Skip to content

Commit 7bbd473

Browse files
committed
feat(diagnostics-otel): add genai token usage metric
1 parent 73706ca commit 7bbd473

3 files changed

Lines changed: 73 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
2828
- Diagnostics/OTEL: emit bounded context assembly diagnostics and export `openclaw.context.assembled` spans with prompt/history sizes but no prompt, history, response, or session-key content. Thanks @vincentkoc.
2929
- Diagnostics/OTEL: export existing tool-loop diagnostics as `openclaw.tool.loop` counters and spans without loop messages, session identifiers, params, or tool output. Thanks @vincentkoc.
3030
- Diagnostics/OTEL: export diagnostic memory samples and pressure as bounded memory histograms, counters, and pressure spans to help spot leak regressions without session or payload data. Thanks @vincentkoc.
31+
- Diagnostics/OTEL: add the GenAI `gen_ai.client.token.usage` histogram for input/output model usage while keeping session identifiers and aggregate cache counters out of the semantic metric. Thanks @vincentkoc.
3132
- 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.
3233
- 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.
3334
- 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.

extensions/diagnostics-otel/src/service.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,55 @@ describe("diagnostics-otel service", () => {
691691
await service.stop?.(ctx);
692692
});
693693

694+
test("exports GenAI client token usage histogram for input and output only", async () => {
695+
const service = createDiagnosticsOtelService();
696+
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { metrics: true });
697+
await service.start(ctx);
698+
699+
emitDiagnosticEvent({
700+
type: "model.usage",
701+
sessionKey: "session-key",
702+
channel: "webchat",
703+
provider: "openai",
704+
model: "gpt-5.4",
705+
usage: {
706+
input: 12,
707+
output: 7,
708+
cacheRead: 3,
709+
cacheWrite: 2,
710+
promptTokens: 17,
711+
total: 24,
712+
},
713+
});
714+
await flushDiagnosticEvents();
715+
716+
expect(telemetryState.meter.createHistogram).toHaveBeenCalledWith(
717+
"gen_ai.client.token.usage",
718+
expect.objectContaining({
719+
unit: "{token}",
720+
advice: {
721+
explicitBucketBoundaries: expect.arrayContaining([1, 4, 16, 1024, 67108864]),
722+
},
723+
}),
724+
);
725+
const genAiTokenUsage = telemetryState.histograms.get("gen_ai.client.token.usage");
726+
expect(genAiTokenUsage?.record).toHaveBeenCalledTimes(2);
727+
expect(genAiTokenUsage?.record).toHaveBeenCalledWith(12, {
728+
"gen_ai.operation.name": "chat",
729+
"gen_ai.provider.name": "openai",
730+
"gen_ai.request.model": "gpt-5.4",
731+
"gen_ai.token.type": "input",
732+
});
733+
expect(genAiTokenUsage?.record).toHaveBeenCalledWith(7, {
734+
"gen_ai.operation.name": "chat",
735+
"gen_ai.provider.name": "openai",
736+
"gen_ai.request.model": "gpt-5.4",
737+
"gen_ai.token.type": "output",
738+
});
739+
expect(JSON.stringify(genAiTokenUsage?.record.mock.calls)).not.toContain("session-key");
740+
await service.stop?.(ctx);
741+
});
742+
694743
test("exports run, model call, and tool execution lifecycle spans", async () => {
695744
const service = createDiagnosticsOtelService();
696745
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });

extensions/diagnostics-otel/src/service.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ const BLOCKED_OTEL_LOG_ATTRIBUTE_KEYS = new Set(["__proto__", "prototype", "cons
5252
const PRELOADED_OTEL_SDK_ENV = "OPENCLAW_OTEL_PRELOADED";
5353
const OTEL_SEMCONV_STABILITY_OPT_IN_ENV = "OTEL_SEMCONV_STABILITY_OPT_IN";
5454
const GEN_AI_LATEST_EXPERIMENTAL_OPT_IN = "gen_ai_latest_experimental";
55+
const GEN_AI_TOKEN_USAGE_BUCKETS = [
56+
1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864,
57+
];
5558

5659
type OtelContentCapturePolicy = {
5760
inputMessages: boolean;
@@ -575,6 +578,13 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
575578
unit: "1",
576579
description: "Token usage by type",
577580
});
581+
const genAiTokenUsageHistogram = meter.createHistogram("gen_ai.client.token.usage", {
582+
unit: "{token}",
583+
description: "Number of input and output tokens used by GenAI client operations",
584+
advice: {
585+
explicitBucketBoundaries: GEN_AI_TOKEN_USAGE_BUCKETS,
586+
},
587+
});
578588
const costCounter = meter.createCounter("openclaw.cost.usd", {
579589
unit: "1",
580590
description: "Estimated model cost (USD)",
@@ -854,13 +864,26 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
854864
"openclaw.provider": evt.provider ?? "unknown",
855865
"openclaw.model": evt.model ?? "unknown",
856866
};
867+
const genAiAttrs: Record<string, string> = {
868+
"gen_ai.operation.name": "chat",
869+
"gen_ai.provider.name": lowCardinalityAttr(evt.provider),
870+
...(evt.model ? { "gen_ai.request.model": lowCardinalityAttr(evt.model) } : {}),
871+
};
857872

858873
const usage = evt.usage;
859874
if (usage.input) {
860875
tokensCounter.add(usage.input, { ...attrs, "openclaw.token": "input" });
876+
genAiTokenUsageHistogram.record(usage.input, {
877+
...genAiAttrs,
878+
"gen_ai.token.type": "input",
879+
});
861880
}
862881
if (usage.output) {
863882
tokensCounter.add(usage.output, { ...attrs, "openclaw.token": "output" });
883+
genAiTokenUsageHistogram.record(usage.output, {
884+
...genAiAttrs,
885+
"gen_ai.token.type": "output",
886+
});
864887
}
865888
if (usage.cacheRead) {
866889
tokensCounter.add(usage.cacheRead, { ...attrs, "openclaw.token": "cache_read" });

0 commit comments

Comments
 (0)