312 lines
13 KiB
TypeScript
312 lines
13 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. manifest module for scripts/src/platform-infra-observability.ts.
|
|
|
|
// Moved mechanically from scripts/src/platform-infra-observability.ts:983-1299 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 { DiagnoseCodeAgentOptions, ObservabilityTarget, SearchOptions, TraceOptions } from "./types";
|
|
import { asPlainRecord, compactSearchTrace } from "./apply-status-scripts";
|
|
import { trace } from "./render";
|
|
import { asArray } from "./trace-script";
|
|
|
|
export function traceSpanDurationMs(span: Record<string, unknown>, attrs: Record<string, unknown> | null): number {
|
|
const topLevel = numberFromUnknown(span.durationMs);
|
|
if (Number.isFinite(topLevel)) return topLevel;
|
|
if (attrs === null) return Number.NaN;
|
|
for (const key of ["workbench.ui.value_ms", "workbench.ui.resource_duration_ms", "durationMs"]) {
|
|
const value = numberFromUnknown(attrs[key]);
|
|
if (Number.isFinite(value)) return value;
|
|
}
|
|
return Number.NaN;
|
|
}
|
|
|
|
export function traceSpanImportanceScore(span: Record<string, unknown>): number {
|
|
const attrs = asPlainRecord(span.attributes);
|
|
const statusCode = textValue(span.statusCode);
|
|
const name = textValue(span.name).toLowerCase();
|
|
const hasFailure = spanColumnAttr(attrs, ["failureKind", "errorSummary", "error.message", "exception.message"]) !== "-";
|
|
const terminalStatus = spanColumnAttr(attrs, ["terminalStatus"]);
|
|
const problemScore = statusCode === "STATUS_CODE_ERROR" || statusCode === "2" || name.includes("error") || hasFailure || terminalStatus === "failed" || terminalStatus === "blocked" ? 1_000_000_000 : 0;
|
|
const durationMs = traceSpanDurationMs(span, attrs);
|
|
const durationScore = Number.isFinite(durationMs) ? durationMs : 0;
|
|
return problemScore + durationScore;
|
|
}
|
|
|
|
export function searchTraceStatus(trace: Record<string, unknown>): string {
|
|
if (trace.queryOk === false) return "query-bad";
|
|
if (trace.parseOk === false) return "parse-bad";
|
|
const errorCount = numberFromUnknown(trace.errorSpanCount);
|
|
if (errorCount > 0) return "error";
|
|
const matchedCount = numberFromUnknown(trace.matchedSpanCount);
|
|
if (matchedCount > 0 || trace.rawMatched === true) return "matched";
|
|
return "ok";
|
|
}
|
|
|
|
export function uniqueSpanRecords(values: unknown[]): Record<string, unknown>[] {
|
|
const rows: Record<string, unknown>[] = [];
|
|
const seen = new Set<string>();
|
|
for (const value of values) {
|
|
const span = asPlainRecord(value) ?? {};
|
|
const key = `${textValue(span.name)}\n${textValue(span.service)}\n${spanDetail(span, 200)}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
rows.push(span);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
export function spanColumnAttr(attrs: Record<string, unknown> | null, keys: string[]): string {
|
|
if (attrs === null) return "-";
|
|
for (const key of keys) {
|
|
const value = attrs[key];
|
|
const text = textValue(value);
|
|
if (text !== "-") return text;
|
|
}
|
|
return "-";
|
|
}
|
|
|
|
export function spanDetail(span: Record<string, unknown>, maxLength: number): string {
|
|
const attrs = asPlainRecord(span.attributes);
|
|
const parts = [
|
|
valuePart("durationMs", span.durationMs),
|
|
attrPart(attrs, "failureKind"),
|
|
attrPart(attrs, "errorSummary"),
|
|
attrPart(attrs, "error.message"),
|
|
attrPart(attrs, "exception.message"),
|
|
attrPart(attrs, "terminalStatus"),
|
|
attrPart(attrs, "status"),
|
|
attrPart(attrs, "toolName"),
|
|
attrPart(attrs, "exitCode"),
|
|
attrPart(attrs, "outputSummary"),
|
|
attrPart(attrs, "outputBytes"),
|
|
attrPart(attrs, "durationMs"),
|
|
attrPart(attrs, "cwd"),
|
|
attrPart(attrs, "command"),
|
|
attrPart(attrs, "waitingFor"),
|
|
attrPart(attrs, "lastEventLabel"),
|
|
attrPart(attrs, "idleMs"),
|
|
attrPart(attrs, "idleSeconds"),
|
|
attrPart(attrs, "commandFingerprint"),
|
|
attrPart(attrs, "http.route"),
|
|
attrPart(attrs, "http.response.status_code"),
|
|
attrPart(attrs, "http.response.status_class"),
|
|
attrPart(attrs, "workbench.ui.event_type"),
|
|
attrPart(attrs, "workbench.ui.scope"),
|
|
attrPart(attrs, "workbench.ui.state"),
|
|
attrPart(attrs, "workbench.ui.reason"),
|
|
attrPart(attrs, "workbench.ui.outcome"),
|
|
attrPart(attrs, "workbench.ui.value_ms"),
|
|
attrPart(attrs, "workbench.ui.activity_idle_ms"),
|
|
attrPart(attrs, "workbench.ui.activity_waiting_for"),
|
|
attrPart(attrs, "workbench.ui.activity_last_event_label"),
|
|
attrPart(attrs, "workbench.ui.resource_duration_ms"),
|
|
attrPart(attrs, "workbench.ui.resource_fetch_to_request_ms"),
|
|
attrPart(attrs, "workbench.ui.resource_request_wait_ms"),
|
|
attrPart(attrs, "workbench.ui.resource_response_transfer_ms"),
|
|
attrPart(attrs, "workbench.ui.resource_next_hop_protocol"),
|
|
attrPart(attrs, "workbench.ui.resource_server_timing"),
|
|
attrPart(attrs, "hwlab.http.stage"),
|
|
attrPart(attrs, "hwlab.http.phase"),
|
|
attrPart(attrs, "hwlab.http.phase.outcome"),
|
|
attrPart(attrs, "hwlab.live_builds.service_id"),
|
|
attrPart(attrs, "hwlab.live_builds.service_kind"),
|
|
attrPart(attrs, "hwlab.live_builds.external"),
|
|
attrPart(attrs, "hwlab.live_builds.deploy_manifest_status"),
|
|
attrPart(attrs, "hwlab.live_builds.artifact_catalog_status"),
|
|
attrPart(attrs, "db.system"),
|
|
attrPart(attrs, "db.operation.name"),
|
|
attrPart(attrs, "db.sql.table"),
|
|
attrPart(attrs, "db.query.arg_count"),
|
|
attrPart(attrs, "db.index.expected"),
|
|
attrPart(attrs, "db.pool.max_open"),
|
|
attrPart(attrs, "db.pool.open_connections"),
|
|
attrPart(attrs, "db.pool.in_use"),
|
|
attrPart(attrs, "db.pool.idle"),
|
|
attrPart(attrs, "db.pool.wait_count"),
|
|
attrPart(attrs, "db.pool.wait_duration_ms"),
|
|
attrPart(attrs, "error.code"),
|
|
attrPart(attrs, "error.category"),
|
|
attrPart(attrs, "error.layer"),
|
|
].filter((item) => item !== null) as string[];
|
|
if (parts.length === 0) return "-";
|
|
return shortenEnd(parts.join(" "), maxLength);
|
|
}
|
|
|
|
export function valuePart(key: string, value: unknown): string | null {
|
|
if (value === null || value === undefined || value === "") return null;
|
|
return `${key}=${textValue(value)}`;
|
|
}
|
|
|
|
export function attrPart(attrs: Record<string, unknown> | null, key: string): string | null {
|
|
if (attrs === null) return null;
|
|
const value = attrs[key];
|
|
if (value === null || value === undefined || value === "") return null;
|
|
return `${key}=${textValue(value)}`;
|
|
}
|
|
|
|
export function httpTableRows(http: Record<string, unknown> | null): string[][] {
|
|
if (http === null) return [];
|
|
const problemRows = asArray(http.problemCounts).map((item) => {
|
|
const row = asPlainRecord(item) ?? {};
|
|
return [
|
|
textValue(row.method),
|
|
shortenEnd(textValue(row.route ?? row.path), 44),
|
|
textValue(row.status ?? row.statusCode),
|
|
textValue(row.count),
|
|
];
|
|
});
|
|
const statusRows = asArray(http.statusCounts).map((item) => {
|
|
const row = asPlainRecord(item) ?? {};
|
|
return [
|
|
textValue(row.method),
|
|
shortenEnd(textValue(row.route ?? row.path), 44),
|
|
textValue(row.status ?? row.statusCode),
|
|
textValue(row.count),
|
|
];
|
|
});
|
|
return [...problemRows, ...statusRows].slice(0, 8);
|
|
}
|
|
|
|
export function buildTraceCommand(target: ObservabilityTarget, options: TraceOptions, full: boolean): string {
|
|
const parts = ["bun", "scripts/cli.ts", "platform-infra", "observability", "trace", "--target", target.id];
|
|
if (options.traceId !== null) parts.push("--trace-id", options.traceId);
|
|
if (options.grep !== null) parts.push("--grep", options.grep);
|
|
parts.push("--limit", String(options.limit));
|
|
if (full) parts.push("--full");
|
|
return parts.map(cliArg).join(" ");
|
|
}
|
|
|
|
export function buildSearchCommand(target: ObservabilityTarget, options: SearchOptions, full: boolean): string {
|
|
const parts = ["bun", "scripts/cli.ts", "platform-infra", "observability", "search", "--target", target.id];
|
|
if (options.grep !== null) parts.push("--grep", options.grep);
|
|
if (options.query !== null) parts.push("--query", options.query);
|
|
if (options.path !== null) parts.push("--path", options.path);
|
|
if (options.status !== null) parts.push("--status", String(options.status));
|
|
if (options.endAt !== null) parts.push("--end-at", options.endAt);
|
|
parts.push("--lookback-minutes", String(options.lookbackMinutes), "--candidate-limit", String(options.candidateLimit), "--limit", String(options.limit));
|
|
if (full) parts.push("--full");
|
|
return parts.map(cliArg).join(" ");
|
|
}
|
|
|
|
export function buildDiagnoseCommand(target: ObservabilityTarget, options: DiagnoseCodeAgentOptions, full: boolean): string {
|
|
const parts = ["bun", "scripts/cli.ts", "platform-infra", "observability", "diagnose-code-agent", "--target", target.id];
|
|
if (options.businessTraceId !== null) parts.push("--business-trace-id", options.businessTraceId);
|
|
if (options.traceId !== null) parts.push("--trace-id", options.traceId);
|
|
if (options.runId !== null) parts.push("--run-id", options.runId);
|
|
if (options.commandId !== null) parts.push("--command-id", options.commandId);
|
|
if (options.runnerJobId !== null) parts.push("--runner-job-id", options.runnerJobId);
|
|
parts.push("--lookback-minutes", String(options.lookbackMinutes), "--candidate-limit", String(options.candidateLimit), "--limit", String(options.limit));
|
|
if (full) parts.push("--full");
|
|
return parts.map(cliArg).join(" ");
|
|
}
|
|
|
|
export function cliArg(value: string): string {
|
|
return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : shQuote(value);
|
|
}
|
|
|
|
export function formatTable(headers: string[], rows: string[][]): string {
|
|
const normalizedRows = rows.map((row) => headers.map((_, index) => row[index] ?? ""));
|
|
const widths = headers.map((header, index) => Math.max(header.length, ...normalizedRows.map((row) => row[index].length)));
|
|
return [
|
|
headers.map((header, index) => header.padEnd(widths[index])).join(" ").trimEnd(),
|
|
...normalizedRows.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd()),
|
|
].join("\n");
|
|
}
|
|
|
|
export function textValue(value: unknown): string {
|
|
if (value === null || value === undefined || value === "") return "-";
|
|
if (typeof value === "string") return value;
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
export function numberValue(value: unknown): string {
|
|
const number = numberFromUnknown(value);
|
|
return Number.isFinite(number) ? String(number) : "-";
|
|
}
|
|
|
|
export function numberFromUnknown(value: unknown): number {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
return Number.NaN;
|
|
}
|
|
|
|
export function isoFromUnixNano(value: unknown): string {
|
|
if (value === null || value === undefined || value === "") return "-";
|
|
try {
|
|
const text = String(value);
|
|
if (!/^\d+$/u.test(text)) return "-";
|
|
const millis = Number(BigInt(text) / 1000000n);
|
|
if (!Number.isFinite(millis) || millis <= 0) return "-";
|
|
return new Date(millis).toISOString().replace(/\.000Z$/u, "Z");
|
|
} catch {
|
|
return "-";
|
|
}
|
|
}
|
|
|
|
export function joinValues(value: unknown, maxLength: number): string {
|
|
const joined = asArray(value).map(textValue).filter((item) => item !== "-").join(",");
|
|
return shortenEnd(joined.length > 0 ? joined : textValue(value), maxLength);
|
|
}
|
|
|
|
export function shortenEnd(value: string, maxLength: number): string {
|
|
if (value.length <= maxLength) return value;
|
|
if (maxLength <= 1) return value.slice(0, maxLength);
|
|
return `${value.slice(0, maxLength - 1)}~`;
|
|
}
|
|
|
|
export function shortenMiddle(value: string, maxLength: number): string {
|
|
if (value.length <= maxLength) return value;
|
|
if (maxLength <= 3) return value.slice(0, maxLength);
|
|
const left = Math.ceil((maxLength - 1) / 2);
|
|
const right = Math.floor((maxLength - 1) / 2);
|
|
return `${value.slice(0, left)}~${value.slice(value.length - right)}`;
|
|
}
|
|
|
|
export function compactSearchResult(value: unknown): Record<string, unknown> {
|
|
const source = asPlainRecord(value) ?? {};
|
|
const traces = asArray(source.traces).map(compactSearchTrace);
|
|
return {
|
|
ok: source.ok === true,
|
|
searchPath: source.searchPath ?? null,
|
|
grep: source.grep ?? null,
|
|
tempoQuery: source.tempoQuery ?? null,
|
|
pathFilter: source.pathFilter ?? null,
|
|
statusFilter: source.statusFilter ?? null,
|
|
limit: source.limit ?? null,
|
|
candidateLimit: source.candidateLimit ?? null,
|
|
searchParseOk: source.searchParseOk ?? null,
|
|
candidateTraceCount: source.candidateTraceCount ?? null,
|
|
scannedTraceCount: source.scannedTraceCount ?? null,
|
|
matchedTraceCount: source.matchedTraceCount ?? null,
|
|
scanStopped: source.scanStopped ?? null,
|
|
matchingActive: source.matchingActive ?? null,
|
|
traces,
|
|
truncated: source.truncated ?? null,
|
|
next: source.next ?? null,
|
|
searchStderrTail: source.searchStderrTail ?? null,
|
|
disclosure: {
|
|
defaultView: "compact trace rows only; span arrays are omitted to keep stdout below config/unidesk-cli.yaml output.maxStdoutBytes",
|
|
expand: "rerun with --full or query a specific trace id with platform-infra observability trace",
|
|
},
|
|
};
|
|
}
|