From 89c52988c54851a5b69d0b1df8e441f089745d72 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 12:55:30 -0700 Subject: [PATCH] fix(diagnostics): gate traceparent propagation on trusted metadata --- CHANGELOG.md | 1 + src/infra/diagnostic-events.test.ts | 27 +++++++++++++++++++++++++ src/infra/diagnostic-events.ts | 31 +++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f753aaa153..2f1412553de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - Diagnostics/OTEL: keep model-usage span GenAI provider attributes aligned with the existing semantic-convention opt-in policy, using legacy `gen_ai.system` unless latest experimental GenAI conventions are enabled. Thanks @vincentkoc. - Diagnostics/OTEL: keep `gen_ai.request.model` present on GenAI token usage metrics with a bounded `unknown` fallback when model usage events do not include a model. Thanks @vincentkoc. - Docs/OTEL: document the GenAI token and model-call duration metrics, model-usage span attributes, and `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental` provider-attribute behavior. Thanks @vincentkoc. +- Diagnostics/trace: add an internal traceparent propagation helper that only formats trusted dispatcher metadata, keeping plugin-emitted diagnostic traces out of outbound propagation by default. Thanks @vincentkoc. - Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#71450) Thanks @vincentkoc and @jlapenna. diff --git a/src/infra/diagnostic-events.test.ts b/src/infra/diagnostic-events.test.ts index 63a3eb4ea13..bb71501443d 100644 --- a/src/infra/diagnostic-events.test.ts +++ b/src/infra/diagnostic-events.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { emitDiagnosticEvent, emitTrustedDiagnosticEvent, + formatDiagnosticTraceparentForPropagation, isDiagnosticsEnabled, onInternalDiagnosticEvent, onDiagnosticEvent, @@ -147,6 +148,32 @@ describe("diagnostic-events", () => { ]); }); + it("formats traceparent for propagation only from dispatcher-trusted metadata", () => { + const trace = createDiagnosticTraceContext({ + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + traceFlags: "01", + }); + const traceparents: Array = []; + onInternalDiagnosticEvent((event, metadata) => { + traceparents.push(formatDiagnosticTraceparentForPropagation(event, metadata)); + }); + + emitDiagnosticEvent({ + type: "message.queued", + source: "plugin", + trace, + }); + emitTrustedDiagnosticEvent({ + type: "model.usage", + usage: { total: 1 }, + trace, + }); + + expect(traceparents).toEqual([undefined, `00-${trace.traceId}-${trace.spanId}-01`]); + expect(formatDiagnosticTraceparentForPropagation({ trace }, { trusted: true })).toBeUndefined(); + }); + it("shares diagnostic state across duplicate module instances", async () => { const events: string[] = []; onDiagnosticEvent((event) => { diff --git a/src/infra/diagnostic-events.ts b/src/infra/diagnostic-events.ts index 2b770239dff..9cc76ddb525 100644 --- a/src/infra/diagnostic-events.ts +++ b/src/infra/diagnostic-events.ts @@ -1,5 +1,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { DiagnosticTraceContext } from "./diagnostic-trace-context.js"; +import { + formatDiagnosticTraceparent, + type DiagnosticTraceContext, +} from "./diagnostic-trace-context.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; export type DiagnosticSessionState = "idle" | "processing" | "waiting"; @@ -413,6 +416,7 @@ type DiagnosticEventsGlobalState = { const MAX_ASYNC_DIAGNOSTIC_EVENTS = 10_000; const DIAGNOSTIC_EVENTS_STATE_KEY = Symbol.for("openclaw.diagnosticEvents.state.v1"); +const dispatchedTrustedDiagnosticMetadata = new WeakSet(); const ASYNC_DIAGNOSTIC_EVENT_TYPES = new Set([ "tool.execution.started", "tool.execution.completed", @@ -500,7 +504,10 @@ function dispatchDiagnosticEvent( try { for (const listener of state.listeners) { try { - listener(cloneDiagnosticEventForListener(enriched), Object.freeze({ ...metadata })); + listener( + cloneDiagnosticEventForListener(enriched), + createDiagnosticMetadataForListener(metadata), + ); } catch (err) { const errorMessage = err instanceof Error @@ -519,6 +526,16 @@ function dispatchDiagnosticEvent( } } +function createDiagnosticMetadataForListener( + metadata: DiagnosticEventMetadata, +): DiagnosticEventMetadata { + const listenerMetadata = Object.freeze({ ...metadata }); + if (listenerMetadata.trusted) { + dispatchedTrustedDiagnosticMetadata.add(listenerMetadata); + } + return listenerMetadata; +} + function cloneDiagnosticEventForListener(event: DiagnosticEventPayload): DiagnosticEventPayload { return deepFreezeDiagnosticValue(structuredClone(event)) as DiagnosticEventPayload; } @@ -623,6 +640,16 @@ export function onDiagnosticEvent(listener: (evt: DiagnosticEventPayload) => voi }); } +export function formatDiagnosticTraceparentForPropagation( + event: { trace?: DiagnosticTraceContext }, + metadata: DiagnosticEventMetadata, +): string | undefined { + if (!metadata.trusted || !dispatchedTrustedDiagnosticMetadata.has(metadata)) { + return undefined; + } + return formatDiagnosticTraceparent(event.trace); +} + export function resetDiagnosticEventsForTest(): void { const state = getDiagnosticEventsState(); state.enabled = true;