From 0ad058a9cbc67842d64eff6f4c1a89bf1f44779c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 23 Apr 2026 22:18:21 -0700 Subject: [PATCH] feat(diagnostics): add trace context carrier (#70924) --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- src/infra/diagnostic-events.test.ts | 26 ++++ src/infra/diagnostic-events.ts | 2 + src/infra/diagnostic-trace-context.test.ts | 93 ++++++++++++ src/infra/diagnostic-trace-context.ts | 133 ++++++++++++++++++ src/plugin-sdk/diagnostics-otel.ts | 10 ++ 7 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 src/infra/diagnostic-trace-context.test.ts create mode 100644 src/infra/diagnostic-trace-context.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a2355d3c70f..5aab345caf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Diagnostics/OTEL: add a lightweight diagnostic trace-context carrier for future span correlation without adding OTEL SDK state to core. Thanks @vincentkoc. - Control UI/chat: add a Steer action on queued messages so a browser follow-up can be injected into the active run without retyping it. - Control UI/Talk: add browser WebRTC realtime voice sessions backed by OpenAI Realtime, with Gateway-minted ephemeral client secrets and `openclaw_agent_consult` handoff to the full OpenClaw agent. - Agents/tools: add optional per-call `timeoutMs` support for image, video, music, and TTS generation tools so agents can extend provider request timeouts only when a specific generation needs it. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index de03d5b46ff..2ee0b745120 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -b9c997ae9dba2c534942c1c79e8285f773ab7481c282e8a981e362e8132f944f plugin-sdk-api-baseline.json -c2f8370ae879d4404a9ac7f7aa7f43859e990f04f4872cbd8bc48da05d4bc671 plugin-sdk-api-baseline.jsonl +3ce0dadfe0cac406051ff95ee8201a508d588e634b98ac22659e6b010c3641f6 plugin-sdk-api-baseline.json +69c9058277b146196a3a3ef49fe193e42987a3642a233732370c9ddae60ddf62 plugin-sdk-api-baseline.jsonl diff --git a/src/infra/diagnostic-events.test.ts b/src/infra/diagnostic-events.test.ts index f962f15b565..34727da81ba 100644 --- a/src/infra/diagnostic-events.test.ts +++ b/src/infra/diagnostic-events.test.ts @@ -6,6 +6,7 @@ import { resetDiagnosticEventsForTest, setDiagnosticsEnabledForProcess, } from "./diagnostic-events.js"; +import { createDiagnosticTraceContext } from "./diagnostic-trace-context.js"; describe("diagnostic-events", () => { beforeEach(() => { @@ -88,6 +89,31 @@ describe("diagnostic-events", () => { expect(seen).toEqual(["webhook.received"]); }); + it("carries explicit trace context without creating retained trace state", () => { + const trace = createDiagnosticTraceContext({ + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + }); + const events: Array<{ trace: typeof trace | undefined; type: string }> = []; + const stop = onDiagnosticEvent((event) => { + events.push({ trace: event.trace, type: event.type }); + }); + + emitDiagnosticEvent({ + type: "message.queued", + source: "telegram", + trace, + }); + stop(); + emitDiagnosticEvent({ + type: "message.queued", + source: "telegram", + trace, + }); + + expect(events).toEqual([{ trace, type: "message.queued" }]); + }); + it("skips event enrichment and subscribers when diagnostics are disabled", () => { const nowSpy = vi.spyOn(Date, "now"); const seen: string[] = []; diff --git a/src/infra/diagnostic-events.ts b/src/infra/diagnostic-events.ts index 0a169549aa3..77ba1b7f51b 100644 --- a/src/infra/diagnostic-events.ts +++ b/src/infra/diagnostic-events.ts @@ -1,10 +1,12 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { DiagnosticTraceContext } from "./diagnostic-trace-context.js"; export type DiagnosticSessionState = "idle" | "processing" | "waiting"; type DiagnosticBaseEvent = { ts: number; seq: number; + trace?: DiagnosticTraceContext; }; export type DiagnosticUsageEvent = DiagnosticBaseEvent & { diff --git a/src/infra/diagnostic-trace-context.test.ts b/src/infra/diagnostic-trace-context.test.ts new file mode 100644 index 00000000000..7e4cbb8612a --- /dev/null +++ b/src/infra/diagnostic-trace-context.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + createChildDiagnosticTraceContext, + createDiagnosticTraceContext, + formatDiagnosticTraceparent, + isValidDiagnosticSpanId, + isValidDiagnosticTraceFlags, + isValidDiagnosticTraceId, + parseDiagnosticTraceparent, +} from "./diagnostic-trace-context.js"; + +const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"; +const SPAN_ID = "00f067aa0ba902b7"; +const CHILD_SPAN_ID = "7ad6b9a982deb2c9"; + +describe("diagnostic-trace-context", () => { + it("validates W3C trace ids, span ids, and trace flags", () => { + expect(isValidDiagnosticTraceId(TRACE_ID)).toBe(true); + expect(isValidDiagnosticSpanId(SPAN_ID)).toBe(true); + expect(isValidDiagnosticTraceFlags("01")).toBe(true); + + expect(isValidDiagnosticTraceId("0".repeat(32))).toBe(false); + expect(isValidDiagnosticTraceId("xyz")).toBe(false); + expect(isValidDiagnosticSpanId("0".repeat(16))).toBe(false); + expect(isValidDiagnosticSpanId("xyz")).toBe(false); + expect(isValidDiagnosticTraceFlags("xyz")).toBe(false); + }); + + it("parses and formats traceparent values", () => { + const traceparent = `00-${TRACE_ID}-${SPAN_ID}-01`; + + expect(parseDiagnosticTraceparent(traceparent)).toEqual({ + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: "01", + }); + expect( + formatDiagnosticTraceparent({ + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: "01", + }), + ).toBe(traceparent); + }); + + it("rejects malformed traceparent values", () => { + expect(parseDiagnosticTraceparent(undefined)).toBeUndefined(); + expect(parseDiagnosticTraceparent(`ff-${TRACE_ID}-${SPAN_ID}-01`)).toBeUndefined(); + expect(parseDiagnosticTraceparent(`00-${"0".repeat(32)}-${SPAN_ID}-01`)).toBeUndefined(); + expect(parseDiagnosticTraceparent(`00-${TRACE_ID}-${"0".repeat(16)}-01`)).toBeUndefined(); + expect(parseDiagnosticTraceparent(`00-${TRACE_ID}-${SPAN_ID}-xyz`)).toBeUndefined(); + }); + + it("creates a normalized context from explicit fields or traceparent", () => { + expect( + createDiagnosticTraceContext({ + traceId: TRACE_ID.toUpperCase(), + spanId: SPAN_ID.toUpperCase(), + traceFlags: "00", + }), + ).toEqual({ + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: "00", + }); + + expect(createDiagnosticTraceContext({ traceparent: `00-${TRACE_ID}-${SPAN_ID}-01` })).toEqual({ + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: "01", + }); + }); + + it("creates child contexts without retaining parent references or self-parenting", () => { + const parent = createDiagnosticTraceContext({ + traceId: TRACE_ID, + spanId: SPAN_ID, + }); + const child = createChildDiagnosticTraceContext(parent, { + spanId: CHILD_SPAN_ID, + }); + + expect(child).toEqual({ + traceId: TRACE_ID, + spanId: CHILD_SPAN_ID, + parentSpanId: SPAN_ID, + traceFlags: "01", + }); + expect( + createChildDiagnosticTraceContext(parent, { spanId: SPAN_ID }).parentSpanId, + ).toBeUndefined(); + }); +}); diff --git a/src/infra/diagnostic-trace-context.ts b/src/infra/diagnostic-trace-context.ts new file mode 100644 index 00000000000..2a25cfb9522 --- /dev/null +++ b/src/infra/diagnostic-trace-context.ts @@ -0,0 +1,133 @@ +import { randomBytes } from "node:crypto"; + +const TRACEPARENT_VERSION = "00"; +const DEFAULT_TRACE_FLAGS = "01"; +const TRACE_ID_RE = /^[0-9a-f]{32}$/; +const SPAN_ID_RE = /^[0-9a-f]{16}$/; +const TRACE_FLAGS_RE = /^[0-9a-f]{2}$/; + +export type DiagnosticTraceContext = { + /** W3C trace id, 32 lowercase hex chars. */ + traceId: string; + /** Current span id, 16 lowercase hex chars. */ + spanId?: string; + /** Parent span id, 16 lowercase hex chars. */ + parentSpanId?: string; + /** W3C trace flags, 2 lowercase hex chars. Defaults to sampled. */ + traceFlags?: string; +}; + +export type DiagnosticTraceContextInput = Partial & { + traceparent?: string; +}; + +function randomHex(bytes: number): string { + return randomBytes(bytes).toString("hex"); +} + +function isNonZeroHex(value: string): boolean { + return !/^0+$/.test(value); +} + +export function isValidDiagnosticTraceId(value: unknown): value is string { + return typeof value === "string" && TRACE_ID_RE.test(value) && isNonZeroHex(value); +} + +export function isValidDiagnosticSpanId(value: unknown): value is string { + return typeof value === "string" && SPAN_ID_RE.test(value) && isNonZeroHex(value); +} + +export function isValidDiagnosticTraceFlags(value: unknown): value is string { + return typeof value === "string" && TRACE_FLAGS_RE.test(value); +} + +function normalizeTraceId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.toLowerCase(); + return isValidDiagnosticTraceId(normalized) ? normalized : undefined; +} + +function normalizeSpanId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.toLowerCase(); + return isValidDiagnosticSpanId(normalized) ? normalized : undefined; +} + +function normalizeTraceFlags(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.toLowerCase(); + return isValidDiagnosticTraceFlags(normalized) ? normalized : undefined; +} + +export function parseDiagnosticTraceparent( + traceparent: string | undefined, +): DiagnosticTraceContext | undefined { + const parts = traceparent?.trim().toLowerCase().split("-"); + if (!parts || parts.length !== 4) { + return undefined; + } + const [version, traceId, spanId, traceFlags] = parts; + if (version !== TRACEPARENT_VERSION) { + return undefined; + } + const normalizedTraceId = normalizeTraceId(traceId); + const normalizedSpanId = normalizeSpanId(spanId); + const normalizedTraceFlags = normalizeTraceFlags(traceFlags); + if (!normalizedTraceId || !normalizedSpanId || !normalizedTraceFlags) { + return undefined; + } + return { + traceId: normalizedTraceId, + spanId: normalizedSpanId, + traceFlags: normalizedTraceFlags, + }; +} + +export function formatDiagnosticTraceparent( + context: DiagnosticTraceContext | undefined, +): string | undefined { + if (!context?.spanId) { + return undefined; + } + const traceId = normalizeTraceId(context.traceId); + const spanId = normalizeSpanId(context.spanId); + const traceFlags = normalizeTraceFlags(context.traceFlags) ?? DEFAULT_TRACE_FLAGS; + if (!traceId || !spanId) { + return undefined; + } + return `${TRACEPARENT_VERSION}-${traceId}-${spanId}-${traceFlags}`; +} + +export function createDiagnosticTraceContext( + input: DiagnosticTraceContextInput = {}, +): DiagnosticTraceContext { + const parsed = parseDiagnosticTraceparent(input.traceparent); + const traceId = normalizeTraceId(input.traceId) ?? parsed?.traceId ?? randomHex(16); + const spanId = normalizeSpanId(input.spanId) ?? parsed?.spanId ?? randomHex(8); + const parentSpanId = normalizeSpanId(input.parentSpanId); + return { + traceId, + spanId, + ...(parentSpanId && parentSpanId !== spanId ? { parentSpanId } : {}), + traceFlags: normalizeTraceFlags(input.traceFlags) ?? parsed?.traceFlags ?? DEFAULT_TRACE_FLAGS, + }; +} + +export function createChildDiagnosticTraceContext( + parent: DiagnosticTraceContext, + input: Omit = {}, +): DiagnosticTraceContext { + const parentSpanId = normalizeSpanId(input.parentSpanId) ?? normalizeSpanId(parent.spanId); + return createDiagnosticTraceContext({ + traceId: parent.traceId, + spanId: input.spanId, + parentSpanId, + traceFlags: input.traceFlags ?? parent.traceFlags, + }); +} diff --git a/src/plugin-sdk/diagnostics-otel.ts b/src/plugin-sdk/diagnostics-otel.ts index 93428b6ee2e..6b1e109a96d 100644 --- a/src/plugin-sdk/diagnostics-otel.ts +++ b/src/plugin-sdk/diagnostics-otel.ts @@ -2,7 +2,17 @@ // Keep this list additive and scoped to the bundled diagnostics-otel surface. export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js"; +export type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; export { emitDiagnosticEvent, onDiagnosticEvent } from "../infra/diagnostic-events.js"; +export { + createChildDiagnosticTraceContext, + createDiagnosticTraceContext, + formatDiagnosticTraceparent, + isValidDiagnosticSpanId, + isValidDiagnosticTraceFlags, + isValidDiagnosticTraceId, + parseDiagnosticTraceparent, +} from "../infra/diagnostic-trace-context.js"; export { registerLogTransport } from "../logging/logger.js"; export { redactSensitiveText } from "../logging/redact.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js";