// 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, attrs: Record | 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): 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 { 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[] { const rows: Record[] = []; const seen = new Set(); 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 | 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, 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 | 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 | 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); 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 { 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", }, }; }