diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bde0f38b0a..cf95d2c2994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Diagnostics/OTEL: add a lightweight diagnostic trace-context carrier for future span correlation without adding OTEL SDK state to core. Thanks @vincentkoc. - Diagnostics/OTEL: attach diagnostic trace context to exported OTEL logs so log records can correlate with future spans without adding retained process state. Thanks @vincentkoc. +- Diagnostics/OTEL: pass immutable per-run diagnostic trace context through agent and tool hook contexts, and parent exported diagnostic spans from validated context without retaining global trace state. 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 e476d320f86..ed46e206784 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -ad7ec565b1702a76a87b1a08904445c9838e10d4d41fb1c58909af886b702d80 plugin-sdk-api-baseline.json -907a07c206dd52ebd910793fab7bca8640c37cf82ff7e7cca88ab1b12b4fbdfe plugin-sdk-api-baseline.jsonl +c0f788d1895ced2ffdad9f82e6afc592171e6651c61c0fc5083f0040437cda6d plugin-sdk-api-baseline.json +70e320157331080b98f9c2acae58e89ad1dc70b48adad265225a7eb76b6ac29f plugin-sdk-api-baseline.jsonl diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index f59ef9afb6e..eefec6b7a40 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -106,7 +106,7 @@ const handler = async (event) => { export default handler; ``` -Each event includes: `type`, `action`, `sessionKey`, `timestamp`, `messages` (push to send to user), and `context` (event-specific data). +Each event includes: `type`, `action`, `sessionKey`, `timestamp`, `messages` (push to send to user), and `context` (event-specific data). Agent and tool plugin hook contexts can also include `trace`, a read-only W3C-compatible diagnostic trace context that plugins may pass into structured logs for OTEL correlation. ### Event context highlights diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index 0d184b7c820..fd2e1c30e1d 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -6,7 +6,7 @@ const telemetryState = vi.hoisted(() => { const counters = new Map }>(); const histograms = new Map }>(); const tracer = { - startSpan: vi.fn((_name: string, _opts?: unknown) => ({ + startSpan: vi.fn((_name: string, _opts?: unknown, _ctx?: unknown) => ({ end: vi.fn(), setStatus: vi.fn(), })), @@ -384,6 +384,64 @@ describe("diagnostics-otel service", () => { }); }); + test("parents diagnostic event spans from trace context", async () => { + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true }); + await service.start(ctx); + + emitDiagnosticEvent({ + type: "model.usage", + trace: { + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: "01", + }, + provider: "openai", + model: "gpt-5.4", + usage: { total: 4 }, + durationMs: 12, + }); + + const modelUsageCall = telemetryState.tracer.startSpan.mock.calls.find( + (call) => call[0] === "openclaw.model.usage", + ); + expect(modelUsageCall?.[2]).toEqual({ + spanContext: expect.objectContaining({ + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: 1, + isRemote: true, + }), + }); + await service.stop?.(ctx); + }); + + test("ignores invalid diagnostic event trace parents", async () => { + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true }); + await service.start(ctx); + + emitDiagnosticEvent({ + type: "model.usage", + trace: { + traceId: "0".repeat(32), + spanId: "not-a-span", + traceFlags: "zz", + }, + provider: "openai", + model: "gpt-5.4", + usage: { total: 4 }, + durationMs: 12, + }); + + const modelUsageCall = telemetryState.tracer.startSpan.mock.calls.find( + (call) => call[0] === "openclaw.model.usage", + ); + expect(telemetryState.tracer.setSpanContext).not.toHaveBeenCalled(); + expect(modelUsageCall?.[2]).toBeUndefined(); + await service.stop?.(ctx); + }); + test("redacts sensitive reason in session.state metric attributes", async () => { const service = createDiagnosticsOtelService(); const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { metrics: true }); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index ae4caffe63d..ee4b8019e28 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -137,22 +137,36 @@ function traceFlagsToOtel(traceFlags: string | undefined): TraceFlags { return (parsed & TraceFlags.SAMPLED) !== 0 ? TraceFlags.SAMPLED : TraceFlags.NONE; } +function contextForTraceContext(traceContext: DiagnosticTraceContext | undefined) { + const normalized = normalizeTraceContext(traceContext); + if (!normalized?.spanId) { + return undefined; + } + return trace.setSpanContext(otelContextApi.active(), { + traceId: normalized.traceId, + spanId: normalized.spanId, + traceFlags: traceFlagsToOtel(normalized.traceFlags), + isRemote: true, + }); +} + function addTraceAttributes( attributes: Record, traceContext: DiagnosticTraceContext | undefined, ): void { - if (!traceContext) { + const normalized = normalizeTraceContext(traceContext); + if (!normalized) { return; } - attributes["openclaw.traceId"] = traceContext.traceId; - if (traceContext.spanId) { - attributes["openclaw.spanId"] = traceContext.spanId; + attributes["openclaw.traceId"] = normalized.traceId; + if (normalized.spanId) { + attributes["openclaw.spanId"] = normalized.spanId; } - if (traceContext.parentSpanId) { - attributes["openclaw.parentSpanId"] = traceContext.parentSpanId; + if (normalized.parentSpanId) { + attributes["openclaw.parentSpanId"] = normalized.parentSpanId; } - if (traceContext.traceFlags) { - attributes["openclaw.traceFlags"] = traceContext.traceFlags; + if (normalized.traceFlags) { + attributes["openclaw.traceFlags"] = normalized.traceFlags; } } @@ -448,13 +462,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { attributes: redactOtelAttributes(attributes), timestamp: meta?.date ?? new Date(), }; - if (traceContext?.spanId) { - logRecord.context = trace.setSpanContext(otelContextApi.active(), { - traceId: traceContext.traceId, - spanId: traceContext.spanId, - traceFlags: traceFlagsToOtel(traceContext.traceFlags), - isRemote: true, - }); + const logContext = contextForTraceContext(traceContext); + if (logContext) { + logRecord.context = logContext; } otelLogger.emit(logRecord); } catch (err) { @@ -467,13 +477,19 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { name: string, attributes: Record, durationMs?: number, + traceContext?: DiagnosticTraceContext, ) => { const startTime = typeof durationMs === "number" ? Date.now() - Math.max(0, durationMs) : undefined; - const span = tracer.startSpan(name, { - attributes, - ...(startTime ? { startTime } : {}), - }); + const parentContext = contextForTraceContext(traceContext); + const span = tracer.startSpan( + name, + { + attributes, + ...(startTime ? { startTime } : {}), + }, + parentContext, + ); return span; }; @@ -537,7 +553,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { "openclaw.tokens.total": usage.total ?? 0, }; - const span = spanWithDuration("openclaw.model.usage", spanAttrs, evt.durationMs); + const span = spanWithDuration("openclaw.model.usage", spanAttrs, evt.durationMs, evt.trace); span.end(); }; @@ -568,7 +584,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { if (evt.chatId !== undefined) { spanAttrs["openclaw.chatId"] = String(evt.chatId); } - const span = spanWithDuration("openclaw.webhook.processed", spanAttrs, evt.durationMs); + const span = spanWithDuration( + "openclaw.webhook.processed", + spanAttrs, + evt.durationMs, + evt.trace, + ); span.end(); }; @@ -591,9 +612,13 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { if (evt.chatId !== undefined) { spanAttrs["openclaw.chatId"] = String(evt.chatId); } - const span = tracer.startSpan("openclaw.webhook.error", { - attributes: spanAttrs, - }); + const span = tracer.startSpan( + "openclaw.webhook.error", + { + attributes: spanAttrs, + }, + contextForTraceContext(evt.trace), + ); span.setStatus({ code: SpanStatusCode.ERROR, message: redactedError }); span.end(); }; @@ -648,7 +673,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { if (evt.reason) { spanAttrs["openclaw.reason"] = redactSensitiveText(evt.reason); } - const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs); + const span = spanWithDuration( + "openclaw.message.processed", + spanAttrs, + evt.durationMs, + evt.trace, + ); if (evt.outcome === "error" && evt.error) { span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) }); } @@ -699,7 +729,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { addSessionIdentityAttrs(spanAttrs, evt); spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0; spanAttrs["openclaw.ageMs"] = evt.ageMs; - const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs }); + const span = tracer.startSpan( + "openclaw.session.stuck", + { attributes: spanAttrs }, + contextForTraceContext(evt.trace), + ); span.setStatus({ code: SpanStatusCode.ERROR, message: "session stuck" }); span.end(); }; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 38200f9a283..8ba5f3f2231 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -7,6 +7,7 @@ import { ensureContextEnginesInitialized } from "../../context-engine/init.js"; import { resolveContextEngine } from "../../context-engine/registry.js"; import { emitAgentPlanEvent } from "../../infra/agent-events.js"; import { sleepWithAbort } from "../../infra/backoff.js"; +import { freezeDiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; @@ -2134,6 +2135,9 @@ export async function runEmbeddedPiAgent( }); return { payloads: payloadsWithToolMedia?.length ? payloadsWithToolMedia : undefined, + ...(attempt.diagnosticTrace + ? { diagnosticTrace: freezeDiagnosticTraceContext(attempt.diagnosticTrace) } + : {}), meta: { durationMs: Date.now() - started, agentMeta, diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-run-context.ts b/src/agents/pi-embedded-runner/run/attempt.tool-run-context.ts index c37dbc2c18f..717e85b33c1 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-run-context.ts +++ b/src/agents/pi-embedded-runner/run/attempt.tool-run-context.ts @@ -1,14 +1,21 @@ +import { + freezeDiagnosticTraceContext, + type DiagnosticTraceContext, +} from "../../../infra/diagnostic-trace-context.js"; import type { EmbeddedRunTrigger } from "./params.js"; export function buildEmbeddedAttemptToolRunContext(params: { trigger?: EmbeddedRunTrigger; memoryFlushWritePath?: string; + trace?: DiagnosticTraceContext; }): { trigger?: EmbeddedRunTrigger; memoryFlushWritePath?: string; + trace?: DiagnosticTraceContext; } { return { trigger: params.trigger, memoryFlushWritePath: params.memoryFlushWritePath, + ...(params.trace ? { trace: freezeDiagnosticTraceContext(params.trace) } : {}), }; } diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b5adfbe1639..5bceb3c0ea8 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -9,6 +9,10 @@ import { } from "@mariozechner/pi-coding-agent"; import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; +import { + createDiagnosticTraceContext, + freezeDiagnosticTraceContext, +} from "../../../infra/diagnostic-trace-context.js"; import { isEmbeddedMode } from "../../../infra/embedded-mode.js"; import { formatErrorMessage } from "../../../infra/errors.js"; import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summary.js"; @@ -504,12 +508,13 @@ export async function runEmbeddedAttempt( const sessionLabel = params.sessionKey ?? params.sessionId; const contextInjectionMode = resolveContextInjectionMode(params.config); const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); + const diagnosticTrace = freezeDiagnosticTraceContext(createDiagnosticTraceContext()); const toolsRaw = params.disableTools ? [] : (() => { const allTools = createOpenClawCodingTools({ agentId: sessionAgentId, - ...buildEmbeddedAttemptToolRunContext(params), + ...buildEmbeddedAttemptToolRunContext({ ...params, trace: diagnosticTrace }), exec: { ...params.execOverrides, elevated: params.bashElevated, @@ -1942,6 +1947,7 @@ export async function runEmbeddedAttempt( } const hookCtx = { runId: params.runId, + trace: freezeDiagnosticTraceContext(diagnosticTrace), agentId: hookAgentId, sessionKey: params.sessionKey, sessionId: params.sessionId, @@ -2173,6 +2179,7 @@ export async function runEmbeddedAttempt( }, { runId: params.runId, + trace: freezeDiagnosticTraceContext(diagnosticTrace), agentId: hookAgentId, sessionKey: params.sessionKey, sessionId: params.sessionId, @@ -2580,6 +2587,7 @@ export async function runEmbeddedAttempt( }, { runId: params.runId, + trace: freezeDiagnosticTraceContext(diagnosticTrace), agentId: hookAgentId, sessionKey: params.sessionKey, sessionId: params.sessionId, @@ -2681,6 +2689,7 @@ export async function runEmbeddedAttempt( }, { runId: params.runId, + trace: freezeDiagnosticTraceContext(diagnosticTrace), agentId: hookAgentId, sessionKey: params.sessionKey, sessionId: params.sessionId, @@ -2768,6 +2777,7 @@ export async function runEmbeddedAttempt( promptErrorSource, preflightRecovery, sessionIdUsed, + diagnosticTrace, bootstrapPromptWarningSignaturesSeen: bootstrapPromptWarning.warningSignaturesSeen, bootstrapPromptWarningSignature: bootstrapPromptWarning.signature, systemPromptReport, diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 2eafaab1bea..b9a74cd1f02 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -4,6 +4,7 @@ import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { ThinkLevel } from "../../../auto-reply/thinking.js"; import type { SessionSystemPromptReport } from "../../../config/sessions/types.js"; import type { ContextEngine, ContextEnginePromptCacheInfo } from "../../../context-engine/types.js"; +import type { DiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js"; import type { PluginHookBeforeAgentStartResult } from "../../../plugins/hook-before-agent-start.types.js"; import type { MessagingToolSend } from "../../pi-embedded-messaging.types.js"; import type { ToolErrorSummary } from "../../tool-error-summary.js"; @@ -72,6 +73,7 @@ export type EmbeddedRunAttemptResult = { handled?: false; }; sessionIdUsed: string; + diagnosticTrace?: DiagnosticTraceContext; agentHarnessId?: string; bootstrapPromptWarningSignaturesSeen?: string[]; bootstrapPromptWarningSignature?: string; diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index c975ce326b5..1b848357602 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -1,4 +1,5 @@ import type { CliSessionBinding, SessionSystemPromptReport } from "../../config/sessions/types.js"; +import type { DiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js"; import type { MessagingToolSend } from "../pi-embedded-messaging.types.js"; export type EmbeddedPiAgentMeta = { @@ -141,6 +142,7 @@ export type EmbeddedPiRunResult = { audioAsVoice?: boolean; }>; meta: EmbeddedPiRunMeta; + diagnosticTrace?: DiagnosticTraceContext; // True if a messaging tool successfully sent a message. // Used to suppress agent's confirmation text. didSendViaMessagingTool?: boolean; diff --git a/src/agents/pi-tools.before-tool-call.e2e.test.ts b/src/agents/pi-tools.before-tool-call.e2e.test.ts index 5c9125a7dfc..da5a2696668 100644 --- a/src/agents/pi-tools.before-tool-call.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.e2e.test.ts @@ -425,6 +425,39 @@ describe("before_tool_call requireApproval handling", () => { ); }); + it("passes diagnostic trace context to before_tool_call hooks", async () => { + const trace = { + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + traceFlags: "01", + }; + hookRunner.runBeforeToolCall.mockResolvedValue(undefined); + + const result = await runBeforeToolCallHook({ + toolName: "bash", + params: { command: "pwd" }, + toolCallId: "tool-1", + ctx: { agentId: "main", sessionKey: "main", runId: "run-1", trace }, + }); + + expect(result.blocked).toBe(false); + const call = hookRunner.runBeforeToolCall.mock.calls[0]; + expect(call?.[0]).toMatchObject({ + toolName: "exec", + runId: "run-1", + toolCallId: "tool-1", + }); + const toolContext = call?.[1] as { trace?: typeof trace } | undefined; + expect(toolContext).toMatchObject({ + toolName: "exec", + runId: "run-1", + toolCallId: "tool-1", + trace, + }); + expect(toolContext?.trace).not.toBe(trace); + expect(Object.isFrozen(toolContext?.trace)).toBe(true); + }); + it("calls gateway RPC and unblocks on allow-once", async () => { hookRunner.runBeforeToolCall.mockResolvedValue({ requireApproval: { diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index f8440195f1b..8dac3bb50e6 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -1,4 +1,8 @@ import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; +import { + freezeDiagnosticTraceContext, + type DiagnosticTraceContext, +} from "../infra/diagnostic-trace-context.js"; import type { SessionState } from "../logging/diagnostic-session-state.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; @@ -17,6 +21,7 @@ export type HookContext = { /** Ephemeral session UUID — regenerated on /new and /reset. */ sessionId?: string; runId?: string; + trace?: DiagnosticTraceContext; loopDetection?: ToolLoopDetectionConfig; }; @@ -197,6 +202,7 @@ export async function runBeforeToolCallHook(args: { ...(args.ctx?.sessionKey && { sessionKey: args.ctx.sessionKey }), ...(args.ctx?.sessionId && { sessionId: args.ctx.sessionId }), ...(args.ctx?.runId && { runId: args.ctx.runId }), + ...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }), ...(args.toolCallId && { toolCallId: args.toolCallId }), }; const hookResult = await hookRunner.runBeforeToolCall( diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index a8b5db88dbe..30c46aa84c0 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -2,6 +2,7 @@ import { createCodingTools, createReadTool } from "@mariozechner/pi-coding-agent import type { ModelCompatConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; +import type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js"; import { logWarn } from "../logger.js"; import { getPluginToolMeta } from "../plugins/tools.js"; @@ -268,6 +269,8 @@ export function createOpenClawCodingTools(options?: { sessionId?: string; /** Stable run identifier for this agent invocation. */ runId?: string; + /** Diagnostic trace context for hook/log correlation during this run. */ + trace?: DiagnosticTraceContext; /** What initiated this run (for trigger-specific tool restrictions). */ trigger?: string; /** Relative workspace path that memory-triggered writes may append to. */ @@ -707,6 +710,7 @@ export function createOpenClawCodingTools(options?: { sessionKey: options?.sessionKey, sessionId: options?.sessionId, runId: options?.runId, + ...(options?.trace ? { trace: options.trace } : {}), loopDetection: resolveToolLoopDetectionConfig({ cfg: options?.config, agentId }), }), ); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 2d003da2c89..9e390022d8c 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -17,6 +17,7 @@ import type { TypingMode } from "../../config/types.js"; import { resolveSessionTranscriptCandidates } from "../../gateway/session-utils.fs.js"; import { emitAgentEvent } from "../../infra/agent-events.js"; import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; +import { freezeDiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -1423,6 +1424,9 @@ export async function runReplyAgent(params: { const costUsd = estimateUsageCost({ usage, cost: costConfig }); emitDiagnosticEvent({ type: "model.usage", + ...(runResult.diagnosticTrace + ? { trace: freezeDiagnosticTraceContext(runResult.diagnosticTrace) } + : {}), sessionKey, sessionId: followupRun.run.sessionId, channel: replyToChannel, diff --git a/src/infra/diagnostic-trace-context.test.ts b/src/infra/diagnostic-trace-context.test.ts index db98767055a..c1660c439f9 100644 --- a/src/infra/diagnostic-trace-context.test.ts +++ b/src/infra/diagnostic-trace-context.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createChildDiagnosticTraceContext, createDiagnosticTraceContext, + freezeDiagnosticTraceContext, formatDiagnosticTraceparent, isValidDiagnosticSpanId, isValidDiagnosticTraceFlags, @@ -113,4 +114,17 @@ describe("diagnostic-trace-context", () => { createChildDiagnosticTraceContext(parent, { spanId: SPAN_ID }).parentSpanId, ).toBeUndefined(); }); + + it("freezes a defensive trace context copy", () => { + const context = createDiagnosticTraceContext({ + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: "01", + }); + const frozen = freezeDiagnosticTraceContext(context); + + expect(frozen).toEqual(context); + expect(frozen).not.toBe(context); + expect(Object.isFrozen(frozen)).toBe(true); + }); }); diff --git a/src/infra/diagnostic-trace-context.ts b/src/infra/diagnostic-trace-context.ts index c3eb11cbd37..9f4f7f0bc5f 100644 --- a/src/infra/diagnostic-trace-context.ts +++ b/src/infra/diagnostic-trace-context.ts @@ -10,13 +10,13 @@ const TRACEPARENT_VERSION_RE = /^[0-9a-f]{2}$/; export type DiagnosticTraceContext = { /** W3C trace id, 32 lowercase hex chars. */ - traceId: string; + readonly traceId: string; /** Current span id, 16 lowercase hex chars. */ - spanId?: string; + readonly spanId?: string; /** Parent span id, 16 lowercase hex chars. */ - parentSpanId?: string; + readonly parentSpanId?: string; /** W3C trace flags, 2 lowercase hex chars. Defaults to sampled. */ - traceFlags?: string; + readonly traceFlags?: string; }; export type DiagnosticTraceContextInput = Partial & { @@ -156,3 +156,14 @@ export function createChildDiagnosticTraceContext( traceFlags: input.traceFlags ?? parent.traceFlags, }); } + +export function freezeDiagnosticTraceContext( + context: DiagnosticTraceContext, +): DiagnosticTraceContext { + return Object.freeze({ + traceId: context.traceId, + ...(context.spanId ? { spanId: context.spanId } : {}), + ...(context.parentSpanId ? { parentSpanId: context.parentSpanId } : {}), + ...(context.traceFlags ? { traceFlags: context.traceFlags } : {}), + }); +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 1812f389082..67bb19a9498 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -95,6 +95,7 @@ export type { ReplyPayload } from "./reply-payload.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export type { ContextEngineFactory } from "../context-engine/registry.js"; export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js"; +export type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; export type { AssembleResult, BootstrapResult, diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 2e798c88433..280d6da360e 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -7,6 +7,7 @@ import type { import type { FinalizedMsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { TtsAutoMode } from "../config/types.tts.js"; +import type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; import { PLUGIN_PROMPT_MUTATION_RESULT_FIELDS, stripPromptMutationFieldsFromLegacyHookResult, @@ -153,6 +154,7 @@ export const isConversationHookName = (hookName: PluginHookName): boolean => export type PluginHookAgentContext = { runId?: string; + trace?: DiagnosticTraceContext; agentId?: string; sessionKey?: string; sessionId?: string; @@ -300,6 +302,7 @@ export type PluginHookToolContext = { sessionKey?: string; sessionId?: string; runId?: string; + trace?: DiagnosticTraceContext; toolName: string; toolCallId?: string; };