404 lines
19 KiB
TypeScript
404 lines
19 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/platform-infra-observability.ts.
|
||
|
||
// Moved mechanically from scripts/src/platform-infra-observability.ts:150-369 for #903.
|
||
|
||
// SPEC: PJ2026-01060501 OTel追踪 draft-2026-06-19-p0.
|
||
// Responsibility: YAML-first platform-infra OpenTelemetry tracing control commands.
|
||
import { Buffer } from "node:buffer";
|
||
import { readFileSync } from "node:fs";
|
||
import type { UniDeskConfig } from "../config";
|
||
import { rootPath } from "../config";
|
||
import type { RenderedCliResult } from "../output";
|
||
import {
|
||
compactCapture,
|
||
createYamlFieldReader,
|
||
numberField,
|
||
parseJsonOutput,
|
||
redactSensitiveUnknown,
|
||
shQuote,
|
||
capture,
|
||
} from "../platform-infra-ops-library";
|
||
|
||
import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, MetricsQueryOptions, SearchOptions, TraceOptions } from "./types";
|
||
import { apply, plan, status, validate } from "./actions";
|
||
import { diagnoseCodeAgent, search, trace } from "./render";
|
||
import { renderObservabilityPlan, renderObservabilityPlanFailure } from "./plan-render";
|
||
import { metricsQuery } from "./prometheus";
|
||
|
||
const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query"] as const;
|
||
|
||
export function observabilityHelp(scope: string | null = null): Record<string, unknown> {
|
||
const scoped = scope === null ? null : observabilityOperationHelp(scope);
|
||
if (scoped !== null) return scoped;
|
||
return {
|
||
command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent|metrics-query",
|
||
output: "默认输出为有界摘要;--full/--raw 显式披露完整结构",
|
||
configTruth: "config/platform-infra/observability.yaml",
|
||
spec: "PJ2026-01060501 OTel追踪 draft-2026-06-19-p0",
|
||
operations: observabilityOperations.map((name) => ({
|
||
name,
|
||
help: `bun scripts/cli.ts platform-infra observability ${name} --help`,
|
||
})),
|
||
scopedHelp: "bun scripts/cli.ts platform-infra observability <operation> --help",
|
||
boundary: "Collector + Tempo 是 trace authority;Prometheus 仅承担 metrics,故障和漂移保持非阻塞 warning。",
|
||
};
|
||
}
|
||
|
||
export async function runPlatformObservabilityCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||
const [action = "plan"] = args;
|
||
if (args.some(isHelpToken)) return observabilityHelp(args.find((item) => !isHelpToken(item)) ?? null);
|
||
if (action === "plan") {
|
||
const options = parseCommonOptions(args.slice(1));
|
||
try {
|
||
const result = plan(options);
|
||
return options.full || options.raw ? result : renderObservabilityPlan(result);
|
||
} catch (error) {
|
||
if (options.full || options.raw) throw error;
|
||
return renderObservabilityPlanFailure(error, options.targetId);
|
||
}
|
||
}
|
||
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(1)));
|
||
if (action === "status") return await status(config, parseCommonOptions(args.slice(1)));
|
||
if (action === "validate") return await validate(config, parseCommonOptions(args.slice(1)));
|
||
if (action === "trace") return await trace(config, parseTraceOptions(args.slice(1)));
|
||
if (action === "search") return await search(config, parseSearchOptions(args.slice(1)));
|
||
if (action === "diagnose-code-agent") return await diagnoseCodeAgent(config, parseDiagnoseCodeAgentOptions(args.slice(1)));
|
||
if (action === "metrics-query") return await metricsQuery(config, parseMetricsQueryOptions(args.slice(1)));
|
||
return { ok: false, error: "unsupported-platform-infra-observability-command", args, help: observabilityHelp() };
|
||
}
|
||
|
||
function isHelpToken(value: string): boolean {
|
||
return value === "help" || value === "--help" || value === "-h";
|
||
}
|
||
|
||
function observabilityOperationHelp(scope: string): Record<string, unknown> | null {
|
||
const common = {
|
||
scope,
|
||
configTruth: "config/platform-infra/observability.yaml",
|
||
disclosure: "默认有界;使用 --full 查看完整计划或诊断结构,--raw 仅用于后端/API 解析排障。",
|
||
};
|
||
if (scope === "plan") return {
|
||
...common,
|
||
command: "platform-infra observability plan",
|
||
usage: ["bun scripts/cli.ts platform-infra observability plan --target <NODE> [--full|--raw]"],
|
||
options: ["--target <NODE>", "--full", "--raw"],
|
||
};
|
||
if (scope === "apply") return {
|
||
...common,
|
||
command: "platform-infra observability apply",
|
||
usage: [
|
||
"bun scripts/cli.ts platform-infra observability apply --target <NODE> --dry-run",
|
||
"bun scripts/cli.ts platform-infra observability apply --target <NODE> --confirm [--wait]",
|
||
],
|
||
options: ["--target <NODE>", "--dry-run", "--confirm", "--wait", "--full", "--raw"],
|
||
};
|
||
if (scope === "status" || scope === "validate") return {
|
||
...common,
|
||
command: `platform-infra observability ${scope}`,
|
||
usage: [`bun scripts/cli.ts platform-infra observability ${scope} --target <NODE> [--full|--raw]`],
|
||
options: ["--target <NODE>", "--full", "--raw"],
|
||
};
|
||
if (scope === "trace") return {
|
||
...common,
|
||
command: "platform-infra observability trace",
|
||
usage: [
|
||
"bun scripts/cli.ts platform-infra observability trace <otel-trace-id> --target <NODE> [--grep <text>] [--limit 40] [--full|--raw]",
|
||
"bun scripts/cli.ts platform-infra observability trace --trace-id <otel-trace-id> --target <NODE> --full",
|
||
],
|
||
options: ["--trace-id <32-hex>", "--target <NODE>", "--grep <text>", "--limit <1..500>", "--full", "--raw"],
|
||
};
|
||
if (scope === "metrics-query") return {
|
||
...common,
|
||
command: "platform-infra observability metrics-query",
|
||
usage: ["bun scripts/cli.ts platform-infra observability metrics-query --target <NODE> --query <promql> [--full|--raw]"],
|
||
options: ["--target <NODE>", "--query <promql>", "--full", "--raw"],
|
||
};
|
||
if (scope === "search") return {
|
||
...common,
|
||
command: "platform-infra observability search",
|
||
usage: [
|
||
"bun scripts/cli.ts platform-infra observability search --target <NODE> --grep <text> [--lookback-minutes 360] [--candidate-limit 80] [--limit 20]",
|
||
"bun scripts/cli.ts platform-infra observability search --target <NODE> --path </route> --status <code> [--full|--raw]",
|
||
],
|
||
options: ["--grep <text>|--query <TraceQL>|--path </route>|--status <code>", "--target <NODE>", "--lookback-minutes <N>", "--end-at <ISO>", "--candidate-limit <N>", "--limit <N>", "--full", "--raw"],
|
||
};
|
||
if (scope === "diagnose-code-agent") return {
|
||
...common,
|
||
command: "platform-infra observability diagnose-code-agent",
|
||
usage: [
|
||
"bun scripts/cli.ts platform-infra observability diagnose-code-agent --target <NODE> --business-trace-id <trc_...> [--full|--raw]",
|
||
"bun scripts/cli.ts platform-infra observability diagnose-code-agent --target <NODE> --run-id <run_...> [--command-id <cmd_...>] [--session-id <ses_...>] [--runner-job-id <rjob_...>]",
|
||
],
|
||
options: ["--business-trace-id <trc_...>|--trace-id <32-hex>|--run-id <run_...>|--command-id <cmd_...>|--runner-job-id <rjob_...>", "--session-id <ses_...>", "--target <NODE>", "--lookback-minutes <N>", "--candidate-limit <N>", "--limit <N>", "--full", "--raw"],
|
||
};
|
||
return null;
|
||
}
|
||
|
||
export function parseMetricsQueryOptions(args: string[]): MetricsQueryOptions {
|
||
const commonArgs: string[] = [];
|
||
let query: string | null = null;
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index];
|
||
if (arg === "--query") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--query requires a value");
|
||
query = value;
|
||
index += 1;
|
||
} else {
|
||
commonArgs.push(arg);
|
||
if (arg === "--target") {
|
||
commonArgs.push(args[index + 1] ?? "");
|
||
index += 1;
|
||
}
|
||
}
|
||
}
|
||
return { ...parseCommonOptions(commonArgs), query };
|
||
}
|
||
|
||
export function parseCommonOptions(args: string[]): CommonOptions {
|
||
let targetId: string | null = null;
|
||
let full = false;
|
||
let raw = false;
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index];
|
||
if (arg === "--target") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
|
||
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error("--target must be a simple target id");
|
||
targetId = value;
|
||
index += 1;
|
||
} else if (arg === "--full") {
|
||
full = true;
|
||
} else if (arg === "--raw") {
|
||
raw = true;
|
||
full = true;
|
||
} else {
|
||
throw new Error(`unsupported observability option: ${arg}`);
|
||
}
|
||
}
|
||
return { targetId, full, raw };
|
||
}
|
||
|
||
export function parseApplyOptions(args: string[]): ApplyOptions {
|
||
const commonArgs: string[] = [];
|
||
let confirm = false;
|
||
let dryRun = false;
|
||
let wait = false;
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index];
|
||
if (arg === "--confirm") confirm = true;
|
||
else if (arg === "--dry-run") dryRun = true;
|
||
else if (arg === "--wait") wait = true;
|
||
else {
|
||
commonArgs.push(arg);
|
||
if (arg === "--target") {
|
||
commonArgs.push(args[index + 1] ?? "");
|
||
index += 1;
|
||
}
|
||
}
|
||
}
|
||
if (confirm && dryRun) throw new Error("observability apply accepts only one of --confirm or --dry-run");
|
||
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
|
||
}
|
||
|
||
export function parseTraceOptions(args: string[]): TraceOptions {
|
||
const commonArgs: string[] = [];
|
||
let traceId: string | null = null;
|
||
let grep: string | null = null;
|
||
let limit = 40;
|
||
const assignTraceId = (value: string) => {
|
||
if (!/^[0-9a-fA-F]{32}$/u.test(value)) throw new Error("trace id must be a 32-character OpenTelemetry trace id encoded as hex");
|
||
if (traceId !== null) throw new Error("observability trace accepts exactly one trace id, either positionally or through --trace-id");
|
||
traceId = value;
|
||
};
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index];
|
||
if (arg === "--trace-id") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--trace-id requires a value");
|
||
assignTraceId(value);
|
||
index += 1;
|
||
} else if (arg === "--grep") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--grep requires a value");
|
||
grep = value;
|
||
index += 1;
|
||
} else if (arg === "--limit") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--limit requires a value");
|
||
const parsed = Number(value);
|
||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 500) throw new Error("--limit must be an integer from 1 to 500");
|
||
limit = parsed;
|
||
index += 1;
|
||
} else if (!arg.startsWith("--")) {
|
||
assignTraceId(arg);
|
||
} else {
|
||
commonArgs.push(arg);
|
||
if (arg === "--target") {
|
||
commonArgs.push(args[index + 1] ?? "");
|
||
index += 1;
|
||
}
|
||
}
|
||
}
|
||
return { ...parseCommonOptions(commonArgs), traceId, grep, limit };
|
||
}
|
||
|
||
export function parseSearchOptions(args: string[]): SearchOptions {
|
||
const commonArgs: string[] = [];
|
||
let grep: string | null = null;
|
||
let query: string | null = null;
|
||
let path: string | null = null;
|
||
let status: number | null = null;
|
||
let limit = 20;
|
||
let candidateLimit = 80;
|
||
let lookbackMinutes = 360;
|
||
let endAt: string | null = null;
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index];
|
||
if (arg === "--grep") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--grep requires a value");
|
||
grep = value;
|
||
index += 1;
|
||
} else if (arg === "--query" || arg === "--q") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||
query = value;
|
||
index += 1;
|
||
} else if (arg === "--path") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--path requires a value");
|
||
if (!value.startsWith("/") || value.includes("\n") || value.length > 300) throw new Error("--path must be an absolute HTTP path up to 300 characters");
|
||
path = value;
|
||
index += 1;
|
||
} else if (arg === "--status") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--status requires a value");
|
||
const parsed = Number(value);
|
||
if (!Number.isInteger(parsed) || parsed < 100 || parsed > 599) throw new Error("--status must be an integer HTTP status from 100 to 599");
|
||
status = parsed;
|
||
index += 1;
|
||
} else if (arg === "--limit") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--limit requires a value");
|
||
const parsed = Number(value);
|
||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) throw new Error("--limit must be an integer from 1 to 100");
|
||
limit = parsed;
|
||
index += 1;
|
||
} else if (arg === "--candidate-limit") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--candidate-limit requires a value");
|
||
const parsed = Number(value);
|
||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 500) throw new Error("--candidate-limit must be an integer from 1 to 500");
|
||
candidateLimit = parsed;
|
||
index += 1;
|
||
} else if (arg === "--lookback-minutes" || arg === "--since-minutes") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||
const parsed = Number(value);
|
||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10080) throw new Error(`${arg} must be an integer from 1 to 10080`);
|
||
lookbackMinutes = parsed;
|
||
index += 1;
|
||
} else if (arg === "--end-at") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--end-at requires an ISO timestamp value");
|
||
const parsed = Date.parse(value);
|
||
if (!Number.isFinite(parsed)) throw new Error("--end-at must be an ISO timestamp parseable by Date.parse");
|
||
endAt = new Date(parsed).toISOString();
|
||
index += 1;
|
||
} else {
|
||
commonArgs.push(arg);
|
||
if (arg === "--target") {
|
||
commonArgs.push(args[index + 1] ?? "");
|
||
index += 1;
|
||
}
|
||
}
|
||
}
|
||
if (grep === null && query === null && path === null && status === null) throw new Error("observability search requires --grep <text>, --query <tempo-query>, --path <route>, or --status <code>");
|
||
return { ...parseCommonOptions(commonArgs), grep, query, path, status, limit, candidateLimit, lookbackMinutes, endAt };
|
||
}
|
||
|
||
export function parseDiagnoseCodeAgentOptions(args: string[]): DiagnoseCodeAgentOptions {
|
||
const commonArgs: string[] = [];
|
||
let businessTraceId: string | null = null;
|
||
let traceId: string | null = null;
|
||
let runId: string | null = null;
|
||
let commandId: string | null = null;
|
||
let sessionId: string | null = null;
|
||
let runnerJobId: string | null = null;
|
||
let limit = 40;
|
||
let candidateLimit = 300;
|
||
let lookbackMinutes = 10080;
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const arg = args[index];
|
||
if (arg === "--business-trace-id" || arg === "--trace") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||
if (!/^trc_[A-Za-z0-9_-]+$/u.test(value)) throw new Error(`${arg} must look like trc_<id>`);
|
||
businessTraceId = value;
|
||
index += 1;
|
||
} else if (arg === "--trace-id" || arg === "--otel-trace-id") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||
if (!/^[0-9a-fA-F]{32}$/u.test(value)) throw new Error(`${arg} must be a 32-character OpenTelemetry trace id encoded as hex`);
|
||
traceId = value.toLowerCase();
|
||
index += 1;
|
||
} else if (arg === "--run-id" || arg === "--run") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||
if (!/^run_[A-Za-z0-9_-]+$/u.test(value)) throw new Error(`${arg} must look like run_<id>`);
|
||
runId = value;
|
||
index += 1;
|
||
} else if (arg === "--command-id" || arg === "--command") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||
if (!/^cmd_[A-Za-z0-9_-]+$/u.test(value)) throw new Error(`${arg} must look like cmd_<id>`);
|
||
commandId = value;
|
||
index += 1;
|
||
} else if (arg === "--session-id" || arg === "--session") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||
if (!/^ses_[A-Za-z0-9_-]+$/u.test(value)) throw new Error(`${arg} must look like ses_<id>`);
|
||
sessionId = value;
|
||
index += 1;
|
||
} else if (arg === "--runner-job-id" || arg === "--runner-job" || arg === "--runnerjob") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||
if (!/^rjob_[A-Za-z0-9_-]+$/u.test(value)) throw new Error(`${arg} must look like rjob_<id>`);
|
||
runnerJobId = value;
|
||
index += 1;
|
||
} else if (arg === "--limit") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--limit requires a value");
|
||
const parsed = Number(value);
|
||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 200) throw new Error("--limit must be an integer from 1 to 200");
|
||
limit = parsed;
|
||
index += 1;
|
||
} else if (arg === "--candidate-limit") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error("--candidate-limit requires a value");
|
||
const parsed = Number(value);
|
||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 500) throw new Error("--candidate-limit must be an integer from 1 to 500");
|
||
candidateLimit = parsed;
|
||
index += 1;
|
||
} else if (arg === "--lookback-minutes" || arg === "--since-minutes") {
|
||
const value = args[index + 1];
|
||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||
const parsed = Number(value);
|
||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10080) throw new Error(`${arg} must be an integer from 1 to 10080`);
|
||
lookbackMinutes = parsed;
|
||
index += 1;
|
||
} else {
|
||
commonArgs.push(arg);
|
||
if (arg === "--target") {
|
||
commonArgs.push(args[index + 1] ?? "");
|
||
index += 1;
|
||
}
|
||
}
|
||
}
|
||
if (businessTraceId === null && traceId === null && runId === null && commandId === null && runnerJobId === null) {
|
||
throw new Error("observability diagnose-code-agent requires --business-trace-id <trc_...>, --trace-id <otelTraceId>, --run-id <run_...>, --command-id <cmd_...>, or --runner-job-id <rjob_...>");
|
||
}
|
||
return { ...parseCommonOptions(commonArgs), businessTraceId, traceId, runId, commandId, sessionId, runnerJobId, limit, candidateLimit, lookbackMinutes };
|
||
}
|