feat(diagnostics): add trace context carrier (#70924)

This commit is contained in:
Vincent Koc
2026-04-23 22:18:21 -07:00
committed by GitHub
parent 5f702b464b
commit 0ad058a9cb
7 changed files with 267 additions and 2 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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[] = [];

View File

@@ -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 & {

View File

@@ -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();
});
});

View File

@@ -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<DiagnosticTraceContext> & {
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<DiagnosticTraceContextInput, "traceId" | "traceparent"> = {},
): DiagnosticTraceContext {
const parentSpanId = normalizeSpanId(input.parentSpanId) ?? normalizeSpanId(parent.spanId);
return createDiagnosticTraceContext({
traceId: parent.traceId,
spanId: input.spanId,
parentSpanId,
traceFlags: input.traceFlags ?? parent.traceFlags,
});
}

View File

@@ -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";