diff options
| author | Adam Malczewski <[email protected]> | 2026-06-10 08:29:59 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-10 08:29:59 +0900 |
| commit | 6db12ff70acb3333d05a5020ab66da4172a5225a (patch) | |
| tree | de5cc6314a3a6dd966d7c4fdb9b20adb04ae8307 /packages/conversation-store/src | |
| parent | 4248cd1d546a4c1fb4e68940c11b5e309c2c2736 (diff) | |
| download | dispatch-6db12ff70acb3333d05a5020ab66da4172a5225a.tar.gz dispatch-6db12ff70acb3333d05a5020ab66da4172a5225a.zip | |
feat(metrics): durable per-turn/step token+timing metrics (observability spans + persisted replay)
Two-part token-data improvement:
#2 Observability spans (kernel run-turn): turn & step span-close now stamp
ALL four Usage fields — added usage.cacheReadTokens/cacheWriteTokens (were
silently dropped) and normalized usage_* -> usage.* to match the
provider.request span (consistent D9 GROUP BY). No contract change.
#3 Persisted replay metrics (conversation-store + read endpoint): new
StepMetrics/TurnMetrics wire types; conversation-store persists per-turn
metrics in a separate key space (appendMetrics/loadMetrics, turn-append
order); session-orchestrator accumulates per-step+turn metrics from the
event stream (pure metrics.ts) and persists after seal; transport-http
serves GET /conversations/:id/metrics -> ConversationMetricsResponse.
Contracts: @dispatch/wire + @dispatch/transport-contract bumped 0.3.0->0.4.0
(additive). GLOSSARY: turn metrics / step metrics.
typecheck EXIT 0, biome clean, 546 vitest + 89 bun = 635 tests.
Diffstat (limited to 'packages/conversation-store/src')
| -rw-r--r-- | packages/conversation-store/src/keys.ts | 20 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 140 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 43 |
3 files changed, 200 insertions, 3 deletions
diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index 8646a40..0ba25d8 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -25,3 +25,23 @@ export function parseChunkSeq(key: string): number { const n = Number.parseInt(last, 10); return Number.isNaN(n) ? -1 : n; } + +export function metricsSeqKey(conversationId: string): string { + return `conv:${conversationId}:metrics-seq`; +} + +export function metricsKey(conversationId: string, ordinal: number): string { + return `conv:${conversationId}:metrics:${String(ordinal).padStart(SEQ_PAD, "0")}`; +} + +export function metricsPrefix(conversationId: string): string { + return `conv:${conversationId}:metrics:`; +} + +export function parseMetricsOrdinal(key: string): number { + const parts = key.split(":"); + const last = parts[parts.length - 1]; + if (last === undefined) return -1; + const n = Number.parseInt(last, 10); + return Number.isNaN(n) ? -1 : n; +} diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index e00fdc2..c7083e8 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -1,4 +1,4 @@ -import type { ChatMessage, StepId, StorageNamespace } from "@dispatch/kernel"; +import type { ChatMessage, StepId, StorageNamespace, TurnMetrics } from "@dispatch/kernel"; import { beforeEach, describe, expect, it } from "vitest"; import { createConversationStore } from "./store.js"; @@ -425,3 +425,141 @@ describe("ConversationStore", () => { } }); }); + +describe("ConversationStore metrics", () => { + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("appendMetrics → loadMetrics round-trips a TurnMetrics (usage + durationMs + steps)", async () => { + const store = createConversationStore(storage); + const stepId = "step_1" as StepId; + const metrics: TurnMetrics = { + turnId: "turn_abc", + usage: { inputTokens: 100, outputTokens: 50 }, + durationMs: 1234, + steps: [ + { + stepId, + usage: { inputTokens: 100, outputTokens: 50 }, + ttftMs: 200, + decodeMs: 800, + genTotalMs: 1000, + }, + ], + }; + await store.appendMetrics("conv1", metrics); + const result = await store.loadMetrics("conv1"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(metrics); + }); + + it("loadMetrics returns turns in append order", async () => { + const store = createConversationStore(storage); + const metrics1: TurnMetrics = { + turnId: "turn_first", + usage: { inputTokens: 10, outputTokens: 5 }, + steps: [], + }; + const metrics2: TurnMetrics = { + turnId: "turn_second", + usage: { inputTokens: 20, outputTokens: 10 }, + steps: [], + }; + const metrics3: TurnMetrics = { + turnId: "turn_third", + usage: { inputTokens: 30, outputTokens: 15 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics1); + await store.appendMetrics("conv1", metrics2); + await store.appendMetrics("conv1", metrics3); + const result = await store.loadMetrics("conv1"); + expect(result).toHaveLength(3); + expect(result[0]?.turnId).toBe("turn_first"); + expect(result[1]?.turnId).toBe("turn_second"); + expect(result[2]?.turnId).toBe("turn_third"); + }); + + it("loadMetrics returns [] for a conversation with no persisted metrics", async () => { + const store = createConversationStore(storage); + const result = await store.loadMetrics("nonexistent"); + expect(result).toEqual([]); + }); + + it("appendMetrics does not affect chunk load / loadSince", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + + const metrics: TurnMetrics = { + turnId: "turn_iso", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics); + + const messages = await store.load("conv1"); + expect(messages).toEqual([msg]); + + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + }); + + it("TurnMetrics with cache tokens + per-step ttft/decode/genTotal round-trips losslessly", async () => { + const store = createConversationStore(storage); + const stepId1 = "step_a" as StepId; + const stepId2 = "step_b" as StepId; + const metrics: TurnMetrics = { + turnId: "turn_cache", + usage: { + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 300, + cacheWriteTokens: 100, + }, + durationMs: 5000, + steps: [ + { + stepId: stepId1, + usage: { + inputTokens: 300, + outputTokens: 100, + cacheReadTokens: 200, + cacheWriteTokens: 50, + }, + ttftMs: 150, + decodeMs: 600, + genTotalMs: 750, + }, + { + stepId: stepId2, + usage: { + inputTokens: 200, + outputTokens: 100, + cacheReadTokens: 100, + cacheWriteTokens: 50, + }, + ttftMs: 100, + decodeMs: 400, + genTotalMs: 500, + }, + ], + }; + await store.appendMetrics("conv1", metrics); + const result = await store.loadMetrics("conv1"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(metrics); + expect(result[0]?.usage.cacheReadTokens).toBe(300); + expect(result[0]?.usage.cacheWriteTokens).toBe(100); + expect(result[0]?.steps[0]?.ttftMs).toBe(150); + expect(result[0]?.steps[0]?.decodeMs).toBe(600); + expect(result[0]?.steps[0]?.genTotalMs).toBe(750); + expect(result[0]?.steps[1]?.ttftMs).toBe(100); + expect(result[0]?.steps[1]?.decodeMs).toBe(400); + expect(result[0]?.steps[1]?.genTotalMs).toBe(500); + }); +}); diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index df8bd4e..080cb79 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -1,6 +1,21 @@ -import type { ChatMessage, Chunk, Role, StorageNamespace, StoredChunk } from "@dispatch/kernel"; +import type { + ChatMessage, + Chunk, + Role, + StorageNamespace, + StoredChunk, + TurnMetrics, +} from "@dispatch/kernel"; import { defineService } from "@dispatch/kernel"; -import { chunkKey, chunkPrefix, parseSeq, seqKey } from "./keys.js"; +import { + chunkKey, + chunkPrefix, + metricsKey, + metricsPrefix, + metricsSeqKey, + parseSeq, + seqKey, +} from "./keys.js"; import { reconcile } from "./reconcile.js"; export interface ConversationStore { @@ -10,6 +25,8 @@ export interface ConversationStore { conversationId: string, sinceSeq?: number, ) => Promise<readonly StoredChunk[]>; + readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise<void>; + readonly loadMetrics: (conversationId: string) => Promise<readonly TurnMetrics[]>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); @@ -100,5 +117,27 @@ export function createConversationStore(storage: StorageNamespace): Conversation return result; }, + + async appendMetrics(conversationId, metrics) { + const raw = await storage.get(metricsSeqKey(conversationId)); + const ordinal = parseSeq(raw) + 1; + await storage.set(metricsKey(conversationId, ordinal), JSON.stringify(metrics)); + await storage.set(metricsSeqKey(conversationId), String(ordinal)); + }, + + async loadMetrics(conversationId) { + const prefix = metricsPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const result: TurnMetrics[] = []; + for (const key of sorted) { + const value = await storage.get(key); + if (value === null) continue; + result.push(JSON.parse(value) as TurnMetrics); + } + + return result; + }, }; } |
