Skip to content

Commit 0ad058a

Browse files
authored
feat(diagnostics): add trace context carrier (#70924)
1 parent 5f702b4 commit 0ad058a

7 files changed

Lines changed: 267 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- Diagnostics/OTEL: add a lightweight diagnostic trace-context carrier for future span correlation without adding OTEL SDK state to core. Thanks @vincentkoc.
910
- 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.
1011
- 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.
1112
- 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.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
b9c997ae9dba2c534942c1c79e8285f773ab7481c282e8a981e362e8132f944f plugin-sdk-api-baseline.json
2-
c2f8370ae879d4404a9ac7f7aa7f43859e990f04f4872cbd8bc48da05d4bc671 plugin-sdk-api-baseline.jsonl
1+
3ce0dadfe0cac406051ff95ee8201a508d588e634b98ac22659e6b010c3641f6 plugin-sdk-api-baseline.json
2+
69c9058277b146196a3a3ef49fe193e42987a3642a233732370c9ddae60ddf62 plugin-sdk-api-baseline.jsonl

src/infra/diagnostic-events.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
resetDiagnosticEventsForTest,
77
setDiagnosticsEnabledForProcess,
88
} from "./diagnostic-events.js";
9+
import { createDiagnosticTraceContext } from "./diagnostic-trace-context.js";
910

1011
describe("diagnostic-events", () => {
1112
beforeEach(() => {
@@ -88,6 +89,31 @@ describe("diagnostic-events", () => {
8889
expect(seen).toEqual(["webhook.received"]);
8990
});
9091

92+
it("carries explicit trace context without creating retained trace state", () => {
93+
const trace = createDiagnosticTraceContext({
94+
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
95+
spanId: "00f067aa0ba902b7",
96+
});
97+
const events: Array<{ trace: typeof trace | undefined; type: string }> = [];
98+
const stop = onDiagnosticEvent((event) => {
99+
events.push({ trace: event.trace, type: event.type });
100+
});
101+
102+
emitDiagnosticEvent({
103+
type: "message.queued",
104+
source: "telegram",
105+
trace,
106+
});
107+
stop();
108+
emitDiagnosticEvent({
109+
type: "message.queued",
110+
source: "telegram",
111+
trace,
112+
});
113+
114+
expect(events).toEqual([{ trace, type: "message.queued" }]);
115+
});
116+
91117
it("skips event enrichment and subscribers when diagnostics are disabled", () => {
92118
const nowSpy = vi.spyOn(Date, "now");
93119
const seen: string[] = [];

src/infra/diagnostic-events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import type { DiagnosticTraceContext } from "./diagnostic-trace-context.js";
23

34
export type DiagnosticSessionState = "idle" | "processing" | "waiting";
45

56
type DiagnosticBaseEvent = {
67
ts: number;
78
seq: number;
9+
trace?: DiagnosticTraceContext;
810
};
911

1012
export type DiagnosticUsageEvent = DiagnosticBaseEvent & {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
createChildDiagnosticTraceContext,
4+
createDiagnosticTraceContext,
5+
formatDiagnosticTraceparent,
6+
isValidDiagnosticSpanId,
7+
isValidDiagnosticTraceFlags,
8+
isValidDiagnosticTraceId,
9+
parseDiagnosticTraceparent,
10+
} from "./diagnostic-trace-context.js";
11+
12+
const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736";
13+
const SPAN_ID = "00f067aa0ba902b7";
14+
const CHILD_SPAN_ID = "7ad6b9a982deb2c9";
15+
16+
describe("diagnostic-trace-context", () => {
17+
it("validates W3C trace ids, span ids, and trace flags", () => {
18+
expect(isValidDiagnosticTraceId(TRACE_ID)).toBe(true);
19+
expect(isValidDiagnosticSpanId(SPAN_ID)).toBe(true);
20+
expect(isValidDiagnosticTraceFlags("01")).toBe(true);
21+
22+
expect(isValidDiagnosticTraceId("0".repeat(32))).toBe(false);
23+
expect(isValidDiagnosticTraceId("xyz")).toBe(false);
24+
expect(isValidDiagnosticSpanId("0".repeat(16))).toBe(false);
25+
expect(isValidDiagnosticSpanId("xyz")).toBe(false);
26+
expect(isValidDiagnosticTraceFlags("xyz")).toBe(false);
27+
});
28+
29+
it("parses and formats traceparent values", () => {
30+
const traceparent = `00-${TRACE_ID}-${SPAN_ID}-01`;
31+
32+
expect(parseDiagnosticTraceparent(traceparent)).toEqual({
33+
traceId: TRACE_ID,
34+
spanId: SPAN_ID,
35+
traceFlags: "01",
36+
});
37+
expect(
38+
formatDiagnosticTraceparent({
39+
traceId: TRACE_ID,
40+
spanId: SPAN_ID,
41+
traceFlags: "01",
42+
}),
43+
).toBe(traceparent);
44+
});
45+
46+
it("rejects malformed traceparent values", () => {
47+
expect(parseDiagnosticTraceparent(undefined)).toBeUndefined();
48+
expect(parseDiagnosticTraceparent(`ff-${TRACE_ID}-${SPAN_ID}-01`)).toBeUndefined();
49+
expect(parseDiagnosticTraceparent(`00-${"0".repeat(32)}-${SPAN_ID}-01`)).toBeUndefined();
50+
expect(parseDiagnosticTraceparent(`00-${TRACE_ID}-${"0".repeat(16)}-01`)).toBeUndefined();
51+
expect(parseDiagnosticTraceparent(`00-${TRACE_ID}-${SPAN_ID}-xyz`)).toBeUndefined();
52+
});
53+
54+
it("creates a normalized context from explicit fields or traceparent", () => {
55+
expect(
56+
createDiagnosticTraceContext({
57+
traceId: TRACE_ID.toUpperCase(),
58+
spanId: SPAN_ID.toUpperCase(),
59+
traceFlags: "00",
60+
}),
61+
).toEqual({
62+
traceId: TRACE_ID,
63+
spanId: SPAN_ID,
64+
traceFlags: "00",
65+
});
66+
67+
expect(createDiagnosticTraceContext({ traceparent: `00-${TRACE_ID}-${SPAN_ID}-01` })).toEqual({
68+
traceId: TRACE_ID,
69+
spanId: SPAN_ID,
70+
traceFlags: "01",
71+
});
72+
});
73+
74+
it("creates child contexts without retaining parent references or self-parenting", () => {
75+
const parent = createDiagnosticTraceContext({
76+
traceId: TRACE_ID,
77+
spanId: SPAN_ID,
78+
});
79+
const child = createChildDiagnosticTraceContext(parent, {
80+
spanId: CHILD_SPAN_ID,
81+
});
82+
83+
expect(child).toEqual({
84+
traceId: TRACE_ID,
85+
spanId: CHILD_SPAN_ID,
86+
parentSpanId: SPAN_ID,
87+
traceFlags: "01",
88+
});
89+
expect(
90+
createChildDiagnosticTraceContext(parent, { spanId: SPAN_ID }).parentSpanId,
91+
).toBeUndefined();
92+
});
93+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { randomBytes } from "node:crypto";
2+
3+
const TRACEPARENT_VERSION = "00";
4+
const DEFAULT_TRACE_FLAGS = "01";
5+
const TRACE_ID_RE = /^[0-9a-f]{32}$/;
6+
const SPAN_ID_RE = /^[0-9a-f]{16}$/;
7+
const TRACE_FLAGS_RE = /^[0-9a-f]{2}$/;
8+
9+
export type DiagnosticTraceContext = {
10+
/** W3C trace id, 32 lowercase hex chars. */
11+
traceId: string;
12+
/** Current span id, 16 lowercase hex chars. */
13+
spanId?: string;
14+
/** Parent span id, 16 lowercase hex chars. */
15+
parentSpanId?: string;
16+
/** W3C trace flags, 2 lowercase hex chars. Defaults to sampled. */
17+
traceFlags?: string;
18+
};
19+
20+
export type DiagnosticTraceContextInput = Partial<DiagnosticTraceContext> & {
21+
traceparent?: string;
22+
};
23+
24+
function randomHex(bytes: number): string {
25+
return randomBytes(bytes).toString("hex");
26+
}
27+
28+
function isNonZeroHex(value: string): boolean {
29+
return !/^0+$/.test(value);
30+
}
31+
32+
export function isValidDiagnosticTraceId(value: unknown): value is string {
33+
return typeof value === "string" && TRACE_ID_RE.test(value) && isNonZeroHex(value);
34+
}
35+
36+
export function isValidDiagnosticSpanId(value: unknown): value is string {
37+
return typeof value === "string" && SPAN_ID_RE.test(value) && isNonZeroHex(value);
38+
}
39+
40+
export function isValidDiagnosticTraceFlags(value: unknown): value is string {
41+
return typeof value === "string" && TRACE_FLAGS_RE.test(value);
42+
}
43+
44+
function normalizeTraceId(value: unknown): string | undefined {
45+
if (typeof value !== "string") {
46+
return undefined;
47+
}
48+
const normalized = value.toLowerCase();
49+
return isValidDiagnosticTraceId(normalized) ? normalized : undefined;
50+
}
51+
52+
function normalizeSpanId(value: unknown): string | undefined {
53+
if (typeof value !== "string") {
54+
return undefined;
55+
}
56+
const normalized = value.toLowerCase();
57+
return isValidDiagnosticSpanId(normalized) ? normalized : undefined;
58+
}
59+
60+
function normalizeTraceFlags(value: unknown): string | undefined {
61+
if (typeof value !== "string") {
62+
return undefined;
63+
}
64+
const normalized = value.toLowerCase();
65+
return isValidDiagnosticTraceFlags(normalized) ? normalized : undefined;
66+
}
67+
68+
export function parseDiagnosticTraceparent(
69+
traceparent: string | undefined,
70+
): DiagnosticTraceContext | undefined {
71+
const parts = traceparent?.trim().toLowerCase().split("-");
72+
if (!parts || parts.length !== 4) {
73+
return undefined;
74+
}
75+
const [version, traceId, spanId, traceFlags] = parts;
76+
if (version !== TRACEPARENT_VERSION) {
77+
return undefined;
78+
}
79+
const normalizedTraceId = normalizeTraceId(traceId);
80+
const normalizedSpanId = normalizeSpanId(spanId);
81+
const normalizedTraceFlags = normalizeTraceFlags(traceFlags);
82+
if (!normalizedTraceId || !normalizedSpanId || !normalizedTraceFlags) {
83+
return undefined;
84+
}
85+
return {
86+
traceId: normalizedTraceId,
87+
spanId: normalizedSpanId,
88+
traceFlags: normalizedTraceFlags,
89+
};
90+
}
91+
92+
export function formatDiagnosticTraceparent(
93+
context: DiagnosticTraceContext | undefined,
94+
): string | undefined {
95+
if (!context?.spanId) {
96+
return undefined;
97+
}
98+
const traceId = normalizeTraceId(context.traceId);
99+
const spanId = normalizeSpanId(context.spanId);
100+
const traceFlags = normalizeTraceFlags(context.traceFlags) ?? DEFAULT_TRACE_FLAGS;
101+
if (!traceId || !spanId) {
102+
return undefined;
103+
}
104+
return `${TRACEPARENT_VERSION}-${traceId}-${spanId}-${traceFlags}`;
105+
}
106+
107+
export function createDiagnosticTraceContext(
108+
input: DiagnosticTraceContextInput = {},
109+
): DiagnosticTraceContext {
110+
const parsed = parseDiagnosticTraceparent(input.traceparent);
111+
const traceId = normalizeTraceId(input.traceId) ?? parsed?.traceId ?? randomHex(16);
112+
const spanId = normalizeSpanId(input.spanId) ?? parsed?.spanId ?? randomHex(8);
113+
const parentSpanId = normalizeSpanId(input.parentSpanId);
114+
return {
115+
traceId,
116+
spanId,
117+
...(parentSpanId && parentSpanId !== spanId ? { parentSpanId } : {}),
118+
traceFlags: normalizeTraceFlags(input.traceFlags) ?? parsed?.traceFlags ?? DEFAULT_TRACE_FLAGS,
119+
};
120+
}
121+
122+
export function createChildDiagnosticTraceContext(
123+
parent: DiagnosticTraceContext,
124+
input: Omit<DiagnosticTraceContextInput, "traceId" | "traceparent"> = {},
125+
): DiagnosticTraceContext {
126+
const parentSpanId = normalizeSpanId(input.parentSpanId) ?? normalizeSpanId(parent.spanId);
127+
return createDiagnosticTraceContext({
128+
traceId: parent.traceId,
129+
spanId: input.spanId,
130+
parentSpanId,
131+
traceFlags: input.traceFlags ?? parent.traceFlags,
132+
});
133+
}

src/plugin-sdk/diagnostics-otel.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22
// Keep this list additive and scoped to the bundled diagnostics-otel surface.
33

44
export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js";
5+
export type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js";
56
export { emitDiagnosticEvent, onDiagnosticEvent } from "../infra/diagnostic-events.js";
7+
export {
8+
createChildDiagnosticTraceContext,
9+
createDiagnosticTraceContext,
10+
formatDiagnosticTraceparent,
11+
isValidDiagnosticSpanId,
12+
isValidDiagnosticTraceFlags,
13+
isValidDiagnosticTraceId,
14+
parseDiagnosticTraceparent,
15+
} from "../infra/diagnostic-trace-context.js";
616
export { registerLogTransport } from "../logging/logger.js";
717
export { redactSensitiveText } from "../logging/redact.js";
818
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";

0 commit comments

Comments
 (0)