diff --git a/extensions/posthog-analytics/lib/event-mappers.ts b/extensions/posthog-analytics/lib/event-mappers.ts index 53e8693c48f..6f0783c3844 100644 --- a/extensions/posthog-analytics/lib/event-mappers.ts +++ b/extensions/posthog-analytics/lib/event-mappers.ts @@ -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 | undefined; for (const msg of messages) { if (!msg || typeof msg !== "object") continue; const m = msg as Record; if (m.role !== "assistant") continue; const usage = m.usage as Record | 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 | 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 | undefined; + const totalCostUsd = cost && typeof cost.total === "number" ? cost.total : 0; + return { inputTokens, outputTokens, totalCostUsd }; } diff --git a/src/telemetry/event-mappers.test.ts b/src/telemetry/event-mappers.test.ts index abc1304f9ad..e74ce564253 100644 --- a/src/telemetry/event-mappers.test.ts +++ b/src/telemetry/event-mappers.test.ts @@ -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", () => {