diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac07fd9073..dd173921a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai to the state-managed `plugins/installs.json` ledger, with legacy config reads kept as a deprecated compatibility fallback. Thanks @vincentkoc. - Diagnostics/OTEL: add the GenAI `gen_ai.client.operation.duration` histogram for model-call latency in seconds with bounded provider/model/API and error attributes. Thanks @vincentkoc. +- Diagnostics/OTEL: add GenAI usage token attributes to model-usage spans, including cache read/write input token counts without session identifiers or prompt/response content. 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/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index dda4fd4704c..7804cd820df 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -740,6 +740,53 @@ describe("diagnostics-otel service", () => { await service.stop?.(ctx); }); + test("exports GenAI usage attributes on model usage spans without diagnostic identifiers", async () => { + const service = createDiagnosticsOtelService(); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true }); + await service.start(ctx); + + emitDiagnosticEvent({ + type: "model.usage", + sessionKey: "session-key", + sessionId: "session-id", + provider: "anthropic", + model: "claude-sonnet-4.6", + usage: { + input: 100, + output: 40, + cacheRead: 30, + cacheWrite: 20, + promptTokens: 150, + total: 190, + }, + durationMs: 25, + }); + await flushDiagnosticEvents(); + + const modelUsageCall = telemetryState.tracer.startSpan.mock.calls.find( + (call) => call[0] === "openclaw.model.usage", + ); + expect(modelUsageCall?.[1]).toMatchObject({ + attributes: { + "gen_ai.usage.input_tokens": 150, + "gen_ai.usage.output_tokens": 40, + "gen_ai.usage.cache_read.input_tokens": 30, + "gen_ai.usage.cache_creation.input_tokens": 20, + }, + }); + expect(modelUsageCall?.[1]).toEqual({ + attributes: expect.not.objectContaining({ + "openclaw.sessionKey": expect.anything(), + "openclaw.sessionId": expect.anything(), + "gen_ai.input.messages": expect.anything(), + "gen_ai.output.messages": expect.anything(), + }), + startTime: expect.any(Number), + }); + expect(JSON.stringify(modelUsageCall)).not.toContain("session-key"); + await service.stop?.(ctx); + }); + test("exports GenAI client operation duration histogram without diagnostic identifiers", 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 46a61043059..a7157857300 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -177,6 +177,21 @@ function genAiOperationName( return "chat"; } +function positiveFiniteNumber(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function assignPositiveNumberAttr( + attrs: Record, + key: string, + value: number | undefined, +): void { + const normalized = positiveFiniteNumber(value); + if (normalized !== undefined) { + attrs[key] = normalized; + } +} + function assignGenAiModelCallAttrs( attrs: Record, evt: ModelCallLifecycleDiagnosticEvent, @@ -933,6 +948,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { if (!tracesEnabled) { return; } + const genAiInputTokens = + usage.promptTokens ?? + (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); const spanAttrs: Record = { ...attrs, "openclaw.tokens.input": usage.input ?? 0, @@ -941,6 +959,18 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { "openclaw.tokens.cache_write": usage.cacheWrite ?? 0, "openclaw.tokens.total": usage.total ?? 0, }; + assignPositiveNumberAttr(spanAttrs, "gen_ai.usage.input_tokens", genAiInputTokens); + assignPositiveNumberAttr(spanAttrs, "gen_ai.usage.output_tokens", usage.output); + assignPositiveNumberAttr( + spanAttrs, + "gen_ai.usage.cache_read.input_tokens", + usage.cacheRead, + ); + assignPositiveNumberAttr( + spanAttrs, + "gen_ai.usage.cache_creation.input_tokens", + usage.cacheWrite, + ); const span = spanWithDuration("openclaw.model.usage", spanAttrs, evt.durationMs, { parentContext: contextForTrustedDiagnosticSpanParent(evt, metadata),