fix(telemetry): emit per-turn cost instead of cumulative session total

extractUsageFromMessages was summing ALL assistant messages, so each
$ai_generation event reported a growing cumulative total — PostHog's
sum() then massively over-counted costs (e.g. $634 reported vs $73 actual).
This commit is contained in:
kumarabhirup 2026-03-15 13:09:54 -07:00
parent 920bb1d12a
commit 45acfbb493
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
2 changed files with 33 additions and 13 deletions

View File

@ -3,7 +3,13 @@ import type { TraceContextManager } from "./trace-context.js";
import { readOrCreateAnonymousId, sanitizeMessages, sanitizeOutputChoices, stripSecrets } from "./privacy.js";
/**
* Extract actual token counts and cost from OpenClaw's per-message usage metadata.
* Extract token counts and cost from the LAST assistant message's usage metadata.
*
* Only the last assistant message is used so that each $ai_generation event
* reports the per-turn delta rather than a cumulative session total. The
* previous implementation summed across ALL assistant messages, which meant
* multi-turn sessions emitted growing cumulative values on every turn and
* PostHog's sum() massively over-counted costs.
*/
export function extractUsageFromMessages(messages: unknown): {
inputTokens: number;
@ -11,20 +17,23 @@ export function extractUsageFromMessages(messages: unknown): {
totalCostUsd: number;
} {
if (!Array.isArray(messages)) return { inputTokens: 0, outputTokens: 0, totalCostUsd: 0 };
let inputTokens = 0;
let outputTokens = 0;
let totalCostUsd = 0;
let lastAssistantUsage: Record<string, unknown> | undefined;
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
if (m.role !== "assistant") continue;
const usage = m.usage as Record<string, unknown> | undefined;
if (!usage) continue;
if (typeof usage.input === "number") inputTokens += usage.input;
if (typeof usage.output === "number") outputTokens += usage.output;
const cost = usage.cost as Record<string, unknown> | undefined;
if (cost && typeof cost.total === "number") totalCostUsd += cost.total;
if (usage) lastAssistantUsage = usage;
}
if (!lastAssistantUsage) return { inputTokens: 0, outputTokens: 0, totalCostUsd: 0 };
const inputTokens = typeof lastAssistantUsage.input === "number" ? lastAssistantUsage.input : 0;
const outputTokens = typeof lastAssistantUsage.output === "number" ? lastAssistantUsage.output : 0;
const cost = lastAssistantUsage.cost as Record<string, unknown> | undefined;
const totalCostUsd = cost && typeof cost.total === "number" ? cost.total : 0;
return { inputTokens, outputTokens, totalCostUsd };
}

View File

@ -48,7 +48,7 @@ describe("extractToolNamesFromMessages", () => {
});
describe("extractUsageFromMessages", () => {
it("sums input/output tokens and cost across assistant messages", () => {
it("extracts usage from only the LAST assistant message (per-turn, not cumulative)", () => {
const messages = [
{ role: "user", content: "hello" },
{ role: "assistant", content: "hi", usage: { input: 10, output: 50, cost: { total: 0.001 } } },
@ -56,9 +56,9 @@ describe("extractUsageFromMessages", () => {
{ role: "assistant", content: "sure", usage: { input: 20, output: 100, cost: { total: 0.002 } } },
];
const result = extractUsageFromMessages(messages);
expect(result.inputTokens).toBe(30);
expect(result.outputTokens).toBe(150);
expect(result.totalCostUsd).toBeCloseTo(0.003);
expect(result.inputTokens).toBe(20);
expect(result.outputTokens).toBe(100);
expect(result.totalCostUsd).toBeCloseTo(0.002);
});
it("skips non-assistant messages (user, tool, system)", () => {
@ -83,6 +83,17 @@ describe("extractUsageFromMessages", () => {
it("returns zeros for non-array input", () => {
expect(extractUsageFromMessages(null)).toEqual({ inputTokens: 0, outputTokens: 0, totalCostUsd: 0 });
});
it("uses the last assistant with usage even if a later assistant lacks it", () => {
const messages = [
{ role: "assistant", content: "first", usage: { input: 50, output: 10, cost: { total: 0.01 } } },
{ role: "assistant", content: "second (no usage)" },
];
const result = extractUsageFromMessages(messages);
expect(result.inputTokens).toBe(50);
expect(result.outputTokens).toBe(10);
expect(result.totalCostUsd).toBe(0.01);
});
});
describe("normalizeOutputForPostHog", () => {