summaryrefslogtreecommitdiffhomepage
path: root/packages/conversation-store/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-10 08:29:59 +0900
committerAdam Malczewski <[email protected]>2026-06-10 08:29:59 +0900
commit6db12ff70acb3333d05a5020ab66da4172a5225a (patch)
treede5cc6314a3a6dd966d7c4fdb9b20adb04ae8307 /packages/conversation-store/src
parent4248cd1d546a4c1fb4e68940c11b5e309c2c2736 (diff)
downloaddispatch-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.ts20
-rw-r--r--packages/conversation-store/src/store.test.ts140
-rw-r--r--packages/conversation-store/src/store.ts43
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;
+ },
};
}