From 692aa3a6f365b2862fdd63c884e3afff015ffbe3 Mon Sep 17 00:00:00 2001 From: liying <422107224@qq.com> Date: Tue, 13 Oct 2026 12:08:26 +0800 Subject: [PATCH 1/4] Add recorded Token activity and interval evidence --- apps/web/e2e/session-analytics.pw.ts | 8 +- apps/web/e2e/session-token-activity.pw.ts | 142 +++++++ apps/web/src/i18n/en.json | 39 +- apps/web/src/i18n/zh-CN.json | 39 +- apps/web/src/runtime/http.ts | 6 +- .../runtime/sessionAnalyticsGateway.test.ts | 35 +- .../src/runtime/sessionAnalyticsGateway.ts | 2 +- apps/web/src/view/SessionAnalyticsView.tsx | 27 +- .../view/SessionTokenActivityView.test.tsx | 103 +++++ .../web/src/view/SessionTokenActivityView.tsx | 126 ++++++ apps/web/src/workspace.css | 11 + apps/web/test/sessionTokenActivityFixture.ts | 69 ++++ docs/api/openapi-v1.yaml | 126 +++++- .../adr/0127-recorded-token-activity.md | 72 ++++ docs/architecture/adr/README.md | 1 + docs/session-analytics.md | 76 +++- .../application/src/sessionAnalytics.test.ts | 89 +++++ packages/application/src/sessionAnalytics.ts | 55 ++- packages/domain/src/sessionAnalytics.test.ts | 47 ++- packages/domain/src/sessionAnalytics.ts | 59 ++- .../httpapi/activity_handlers_test.go | 6 +- .../adapters/httpapi/analytics_handlers.go | 8 +- .../httpapi/token_activity_handlers_test.go | 129 +++++++ .../analytics_activity_integration_test.go | 67 +++- server/internal/sessionanalytics/analytics.go | 108 ++++-- .../sessionanalytics/token_activity.go | 358 ++++++++++++++++++ .../sessionanalytics/token_activity_test.go | 351 +++++++++++++++++ 27 files changed, 2053 insertions(+), 106 deletions(-) create mode 100644 apps/web/e2e/session-token-activity.pw.ts create mode 100644 apps/web/src/view/SessionTokenActivityView.test.tsx create mode 100644 apps/web/src/view/SessionTokenActivityView.tsx create mode 100644 apps/web/test/sessionTokenActivityFixture.ts create mode 100644 docs/architecture/adr/0127-recorded-token-activity.md create mode 100644 server/internal/adapters/httpapi/token_activity_handlers_test.go create mode 100644 server/internal/sessionanalytics/token_activity.go create mode 100644 server/internal/sessionanalytics/token_activity_test.go diff --git a/apps/web/e2e/session-analytics.pw.ts b/apps/web/e2e/session-analytics.pw.ts index fcd1b85..7da5e27 100644 --- a/apps/web/e2e/session-analytics.pw.ts +++ b/apps/web/e2e/session-analytics.pw.ts @@ -265,7 +265,7 @@ const activityAPI = async (page: Page) => { await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { const url = new URL(route.request().url()); reads.push(url) expect(route.request().method()).toBe("GET") - expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") expect(route.request().headers()).not.toHaveProperty("atape-accept-analytics-version") await hold?.(url) if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "The selected snapshot changed." } }); return } @@ -458,7 +458,7 @@ test("old v1 and v2 Servers expose unavailable activity while retaining their ex const reads: URL[] = [] await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { const url = new URL(route.request().url()); reads.push(url) - expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") expect(url.searchParams.has("range")).toBe(false) expect(url.searchParams.get("metric")).not.toBe("records") await route.fulfill({ json: version === 1 ? analyticsFixture : capabilitiesFixture }) @@ -540,7 +540,7 @@ const stableToolAPI = async (page: Page) => { let mode: "ready" | "changed" | "replacement" | "denied" = "ready" await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { const url = new URL(route.request().url()); reads.push(url) - expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "Changed" } }); return } if (mode === "denied") { await route.fulfill({ status: 404, json: { code: "not_found", detail: "Unavailable" } }); return } if (mode === "replacement") { @@ -647,7 +647,7 @@ test("a v3 Server keeps activity and display-label tool filters when it ignores const reads: URL[] = [] await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { const url = new URL(route.request().url()); reads.push(url) - expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") await route.fulfill({ json: activityFixture }) }) await page.goto(path) diff --git a/apps/web/e2e/session-token-activity.pw.ts b/apps/web/e2e/session-token-activity.pw.ts new file mode 100644 index 0000000..2f2fae2 --- /dev/null +++ b/apps/web/e2e/session-token-activity.pw.ts @@ -0,0 +1,142 @@ +import { expect, test, type Page } from "@playwright/test" +import { stableToolsFixture } from "../test/sessionAnalyticsFixture" +import { tokenActivityReport, tokenActivityResponse, tokenBucketLabel, tokenHead, tokenModelKey, tokenSnapshot, tokenUnknownModelKey } from "../test/sessionTokenActivityFixture" + +const path = "/teams/team-id/projects/project-1/sessions/session-reader?thread=root" +test.beforeEach(async ({ context, request }) => { + await request.post("http://127.0.0.1:8080/__fixture/reset") + await context.addCookies([{ name: "fixture_session", value: "1", url: "http://127.0.0.1:4187" }]) +}) + +const tokenAPI = async (page: Page) => { + const reads: URL[] = [], violations: string[] = [] + let mode: "ready" | "changed" | "denied" = "ready" + await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { + const url = new URL(route.request().url()); reads.push(url) + expect(route.request().method()).toBe("GET") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "Changed" } }); return } + if (mode === "denied") { await route.fulfill({ status: 404, json: { code: "not_found", detail: "Unavailable" } }); return } + const response = tokenActivityResponse(url.searchParams) + if (response.violation) violations.push(response.violation) + await route.fulfill({ status: response.status, json: response.body }) + }) + return { reads, violations, mode: (value: typeof mode) => { mode = value } } +} + +const openTokens = async (page: Page) => { + await page.goto(path) + await page.getByRole("button", { name: "Session analysis", exact: true }).click() + const panel = page.getByRole("region", { name: "Session analysis", exact: true }) + await expect(panel.getByRole("heading", { name: "Token consumption", exact: true })).toBeVisible() + return { panel, tokens: panel.getByRole("region", { name: "Token consumption", exact: true }) } +} + +const conditionalReader = async (page: Page) => { + const reads: URL[] = [] + await page.route("**/api/v1/sessions/session-reader?*", async route => { + const url = new URL(route.request().url()); reads.push(url) + const response = await route.fetch({ url: `${url.origin}${url.pathname}?thread=root` }), value = await response.json() + value.thread = { ...value.thread, id: url.searchParams.get("thread") ?? "root" } + value.head = tokenHead; value.snapshot = tokenSnapshot + if (url.searchParams.get("at") === "token-context-21") value.events = [{ id: "token-context-21", kind: "tool_result", author: "Agent", + text: "Contemporaneous context, not a per-response Token attribution", occurredAt: "2026-10-13T08:01:30Z", + tool: { sessionUpdate: "tool_call_update", toolCallId: "context-call", title: "Read file", status: "completed" } }] + await route.fulfill({ json: value }) + }) + return reads +} + +test("Token hotspots retain model, Thread, snapshot and page through conditional Reader focus and browser Back", async ({ page }) => { + const api = await tokenAPI(page), readerReads = await conditionalReader(page), { panel, tokens } = await openTokens(page) + await expect(tokens.getByText("3 reported · 1 missing", { exact: true })).toHaveCount(5) + await expect(tokens.getByLabel("Recorded input cache shares", { exact: true })).toContainText("Cache read share20%Cache write share10%Uncached input share70%") + await tokens.getByLabel("Token model", { exact: true }).selectOption(tokenModelKey) + await tokens.getByLabel("Token thread", { exact: true }).selectOption("child") + await expect(tokens.getByLabel("Token thread", { exact: true })).toHaveValue("child") + await expect(tokens.getByLabel("Token model", { exact: true })).toHaveValue(tokenModelKey) + const hot = tokens.locator(".analytics-token-hotspots").getByRole("button", { name: tokenBucketLabel("child", tokenModelKey), exact: true }) + await hot.click() + const range = tokenActivityReport("child", tokenModelKey).buckets[1]!.range + await expect(panel.getByLabel("Evidence type", { exact: true })).toHaveValue("token_records") + await expect(panel.locator(".analytics-evidence-list").getByRole("link")).toHaveCount(20) + expect(Object.fromEntries(api.reads.at(-1)!.searchParams)).toMatchObject({ metric: "token_records", model: tokenModelKey, + thread: "child", snapshot: tokenSnapshot, tokenRange: range }) + await panel.getByRole("button", { name: "Next evidence page", exact: true }).click() + await expect(panel.locator(".analytics-evidence-list").getByRole("link")).toHaveCount(4) + expect(api.reads.at(-1)!.searchParams.get("tokenRange")).toBe(range) + expect(api.reads.at(-1)!.searchParams.get("cursor")).toBe(`next-${range}`) + const link = panel.getByRole("link", { name: "Token interval context", exact: true }) + await expect(link).toHaveAttribute("href", /snapshot=token-snapshot-one/) + await link.click() + await expect(page.locator("#event-token-context-21")).toBeFocused() + await expect(page.locator(".narrative-activity:has(#event-token-context-21)")).toHaveAttribute("open", "") + expect(Object.fromEntries(readerReads.at(-1)!.searchParams)).toMatchObject({ snapshot: tokenSnapshot, head: tokenHead, + thread: "child", at: "token-context-21" }) + expect(readerReads.at(-1)!.searchParams.has("model")).toBe(false) + await page.goBack() + await expect(page).toHaveURL(/analysis=open/) + await expect(tokens.getByLabel("Token model", { exact: true })).toHaveValue(tokenModelKey) + await expect(tokens.getByLabel("Token thread", { exact: true })).toHaveValue("child") + await expect(hot).toHaveAttribute("aria-pressed", "true") + await expect(panel.getByRole("button", { name: "First evidence page", exact: true })).toBeEnabled() + await expect(panel.locator(".analytics-evidence-list").getByRole("link")).toHaveCount(4) + expect(api.violations).toEqual([]) +}) + +test("changing Token scopes clears interval and cursor, preserves a literal unknown model, and full refresh clears all selections", async ({ page }) => { + const api = await tokenAPI(page), { panel, tokens } = await openTokens(page) + await expect(tokens.getByLabel("Token model", { exact: true }).locator(`option[value="${tokenModelKey}"]`)).toHaveText("unknown") + await expect(tokens.getByLabel("Token model", { exact: true }).locator(`option[value="${tokenUnknownModelKey}"]`)).toHaveText("Model unknown") + await tokens.locator(".analytics-token-hotspots").getByRole("button", { name: tokenBucketLabel(), exact: true }).click() + await panel.getByRole("button", { name: "Next evidence page", exact: true }).click() + await tokens.getByLabel("Token model", { exact: true }).selectOption(tokenUnknownModelKey) + await expect(panel.getByLabel("Evidence type", { exact: true })).toHaveValue("records") + expect(Object.fromEntries(api.reads.at(-1)!.searchParams)).toEqual({ metric: "records", model: tokenUnknownModelKey, snapshot: tokenSnapshot }) + await tokens.getByLabel("Token thread", { exact: true }).selectOption("child") + await expect(tokens.getByLabel("Token thread", { exact: true })).toHaveValue("child") + expect(api.reads.at(-1)!.searchParams.get("model")).toBe(tokenUnknownModelKey) + await panel.getByLabel("Evidence type", { exact: true }).selectOption("thoughts") + await expect(panel.getByLabel("Evidence type", { exact: true })).toHaveValue("thoughts") + expect(api.reads.at(-1)!.searchParams.get("model")).toBe(tokenUnknownModelKey) + await tokens.getByLabel("Token model", { exact: true }).selectOption("") + await expect(tokens.getByLabel("Token model", { exact: true })).toHaveValue("") + expect(api.reads.at(-1)!.searchParams.has("model")).toBe(false) + await tokens.locator(".analytics-token-hotspots").getByRole("button", { name: tokenBucketLabel("child"), exact: true }).click() + api.mode("changed") + await panel.getByRole("button", { name: "Next evidence page", exact: true }).click() + await expect(panel.getByRole("heading", { name: "Conversation has changed", exact: true })).toBeVisible() + await expect(panel.locator(".analytics-token-activity")).toHaveCount(0) + api.mode("ready") + await panel.getByRole("button", { name: "Reload analysis", exact: true }).first().click() + await expect(tokens.getByLabel("Token thread", { exact: true })).toHaveValue("") + await expect(tokens.getByLabel("Token model", { exact: true })).toHaveValue("") + expect(Object.fromEntries(api.reads.at(-1)!.searchParams)).toEqual({ metric: "tools" }) + expect(api.violations).toEqual([]) +}) + +test("zero-context intervals stay visible without a nearest-record jump and Token presentation fits a narrow Reader", async ({ page }) => { + const api = await tokenAPI(page), { tokens } = await openTokens(page) + const empty = tokens.locator(".analytics-token-bars").getByRole("button", { name: tokenBucketLabel(null, null, 2), exact: true }) + await expect(empty).toBeVisible() + await expect(empty).toBeDisabled() + await expect(empty).toHaveAttribute("title", "No contemporaneous records are available in this interval.") + const untimed = tokens.getByText("Known subtotals without a usage time (1 samples)", { exact: true }) + await untimed.click() + await expect(tokens.getByText("Included in subtotals; no interval can be selected without a time.", { exact: true })).toBeVisible() + await page.setViewportSize({ width: 390, height: 844 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + expect(await page.locator(".session-main-reader").evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + expect(api.reads).toHaveLength(1) + expect(api.violations).toEqual([]) +}) + +test("a v4 Server retains its reports and explicitly marks Token distribution unavailable", async ({ page }) => { + await page.route("**/api/v1/sessions/session-reader/analytics*", route => route.fulfill({ json: stableToolsFixture })) + const { panel, tokens } = await openTokens(page) + await expect(tokens.getByText("This Server does not provide Token distribution or cache shares yet.", { exact: true })).toBeVisible() + await expect(tokens.getByLabel("Recorded input cache shares", { exact: true })).toHaveCount(0) + await expect(tokens.locator(".analytics-token-bars")).toHaveCount(0) + await expect(panel.getByRole("heading", { name: "Recorded tools", exact: true })).toBeVisible() + await expect(panel.getByRole("heading", { name: "Recorded file activity", exact: true })).toBeVisible() +}) diff --git a/apps/web/src/i18n/en.json b/apps/web/src/i18n/en.json index dae9557..1d1b9d2 100644 --- a/apps/web/src/i18n/en.json +++ b/apps/web/src/i18n/en.json @@ -551,7 +551,7 @@ "analytics.loading": "Reading session analysis…", "analytics.unavailable": "Analysis is unavailable", "analytics.unknown": "Unknown", - "analytics.scope": "Statistics describe the captured Canonical conversation. Thread selection changes activity and evidence; summary, tools, capabilities and tokens remain Session-wide.", + "analytics.scope": "Counts describe captured history. Thread selection scopes activity and evidence; Thread and model selection scope Token consumption. Other reports keep their existing scope.", "analytics.capture": "Capture: {status}", "analytics.capture.complete": "Complete", "analytics.capture.partial": "Partial", @@ -691,5 +691,40 @@ "analytics.allFileOperations": "All operations", "analytics.fileOperationEvidence": "File evidence: {operation}", "analytics.filePathEvidence": "File evidence: {path}", - "analytics.filePathOperationEvidence": "File evidence: {operation} · {path}" + "analytics.filePathOperationEvidence": "File evidence: {operation} · {path}", + "analytics.allModels": "All models", + "analytics.cacheCohort": "Eligible cache samples", + "analytics.cacheCohortTokens": "Eligible input: {input} · cache read: {read} · cache write: {write} · uncached: {uncached}", + "analytics.cacheReadShare": "Cache read share", + "analytics.cacheShareMeaning": "Shares use samples with all three valid counters. Cache is already included in input. {missing} samples are excluded; insufficient data, zero input or overflow means Unknown.", + "analytics.cacheShares": "Recorded input cache shares", + "analytics.cacheWriteShare": "Cache write share", + "analytics.knownTokenSubtotals": "Known Token subtotals", + "analytics.metric.token_records": "Token interval records", + "analytics.noDatedUsage": "No dated usage samples are available for intervals.", + "analytics.noTokenIntervalRecords": "No contemporaneous records are available in this interval.", + "analytics.selectedModelWithoutSamples": "Selected model (no samples in this Thread)", + "analytics.timedUsageSamples": "Dated usage samples", + "analytics.tokenActivity": "Token consumption", + "analytics.tokenActivityMeaning": "Follows the selected Thread and model. Subtotals include known counters only; missing usage stays unknown. No cost is estimated.", + "analytics.tokenActivityUnavailable": "This Server does not provide Token distribution or cache shares yet.", + "analytics.tokenBarMeaning": "Bars and hotspots use known total subtotals. Unknown values have no bar. Intervals without records cannot be opened.", + "analytics.tokenBucketEvidence": "Read interval records: {tokens} recorded Tokens · {records} records · {from} → {to}", + "analytics.tokenCounterCoverage": "{reported} reported · {missing} missing", + "analytics.tokenCounterOverflow": "Subtotal exceeds the supported numeric range.", + "analytics.tokenDistribution": "Token distribution over recorded usage time", + "analytics.tokenHotspots": "Highest recorded consumption intervals", + "analytics.tokenIntervalCoverage": "{samples} usage samples · {reported} total counters reported · {missing} total counters missing · {records} interval records", + "analytics.tokenIntervals": "Token consumption intervals", + "analytics.tokenModel": "Token model", + "analytics.tokenRangeMeaning": "Read records from the same interval and selected Thread. They provide context, not exact Token attribution to a response or model. Records with unknown or legacy times are excluded.", + "analytics.tokenThread": "Token thread", + "analytics.tokenTimeCoverage": "Usage time coverage", + "analytics.uncachedShare": "Uncached input share", + "analytics.untimedTokenMeaning": "Included in subtotals; no interval can be selected without a time.", + "analytics.untimedTokenSubtotals": "Known subtotals without a usage time ({count} samples)", + "analytics.untimedUsageSamples": "Usage samples without time", + "analytics.usageClockMeaning": "Times are recorded usage values and have not been independently verified.", + "analytics.usageSamples": "Usage samples", + "analytics.noRecordedUsage": "No recorded usage is available to analyze. This does not mean zero consumption." } diff --git a/apps/web/src/i18n/zh-CN.json b/apps/web/src/i18n/zh-CN.json index b80afd6..125e197 100644 --- a/apps/web/src/i18n/zh-CN.json +++ b/apps/web/src/i18n/zh-CN.json @@ -551,7 +551,7 @@ "analytics.loading": "正在读取会话分析…", "analytics.unavailable": "分析暂不可用", "analytics.unknown": "未知", - "analytics.scope": "统计基于已捕获的规范化会话。线程选择影响活动与证据;概览、工具、能力调用与 Tokens 仍覆盖整个会话。", + "analytics.scope": "统计基于已采集的历史。Thread 选择影响活动与证据,Thread 与模型选择影响 Token 消耗。其他报告保持原有范围。", "analytics.capture": "采集状态:{status}", "analytics.capture.complete": "完整", "analytics.capture.partial": "部分", @@ -691,5 +691,40 @@ "analytics.allFileOperations": "所有操作", "analytics.fileOperationEvidence": "文件证据:{operation}", "analytics.filePathEvidence": "文件证据:{path}", - "analytics.filePathOperationEvidence": "文件证据:{operation} · {path}" + "analytics.filePathOperationEvidence": "文件证据:{operation} · {path}", + "analytics.allModels": "所有模型", + "analytics.cacheCohort": "可计算缓存占比的样本", + "analytics.cacheCohortTokens": "有效输入:{input} · 缓存读取:{read} · 缓存写入:{write} · 非缓存:{uncached}", + "analytics.cacheReadShare": "缓存读取占比", + "analytics.cacheShareMeaning": "占比只使用三项计数均有效的样本,缓存已包含在输入中,不重复相加。排除了 {missing} 个样本;数据不足、输入为零或溢出时显示未知。", + "analytics.cacheShares": "已记录输入的缓存占比", + "analytics.cacheWriteShare": "缓存写入占比", + "analytics.knownTokenSubtotals": "已知 Token 小计", + "analytics.metric.token_records": "Token 区间原文", + "analytics.noDatedUsage": "没有可用于时间区间的用量样本。", + "analytics.noTokenIntervalRecords": "此区间没有可用的同期原文记录。", + "analytics.selectedModelWithoutSamples": "所选模型(此 Thread 中无样本)", + "analytics.timedUsageSamples": "有时间的用量样本", + "analytics.tokenActivity": "Token 消耗", + "analytics.tokenActivityMeaning": "按所选 Thread 与模型统计。小计只包含已知计数,缺失用量保持未知。此处不估算费用。", + "analytics.tokenActivityUnavailable": "此 Server 尚不提供 Token 消耗分布与缓存占比。", + "analytics.tokenBarMeaning": "柱高与热点排序使用已知总量小计。未知值不画柱,没有原文的区间无法打开。", + "analytics.tokenBucketEvidence": "查看区间原文:已记录 {tokens} Tokens · {records} 条记录 · {from} → {to}", + "analytics.tokenCounterCoverage": "已报告 {reported} 个 · 缺失 {missing} 个", + "analytics.tokenCounterOverflow": "小计超出支持的数值范围。", + "analytics.tokenDistribution": "按已记录用量时间的 Token 分布", + "analytics.tokenHotspots": "已记录消耗最高的区间", + "analytics.tokenIntervalCoverage": "{samples} 个用量样本 · {reported} 个已报告总量 · {missing} 个缺失总量 · {records} 条区间记录", + "analytics.tokenIntervals": "Token 消耗区间", + "analytics.tokenModel": "Token 模型", + "analytics.tokenRangeMeaning": "查看同一区间、所选 Thread 的原文。它们用于了解上下文,不能把 Tokens 精确归属到某条响应或模型。时间未知或来自旧格式的记录不纳入区间。", + "analytics.tokenThread": "Token Thread", + "analytics.tokenTimeCoverage": "用量时间覆盖情况", + "analytics.uncachedShare": "非缓存输入占比", + "analytics.untimedTokenMeaning": "这些样本计入小计;缺少时间时无法选择区间原文。", + "analytics.untimedTokenSubtotals": "缺少用量时间的已知小计({count} 个样本)", + "analytics.untimedUsageSamples": "无时间的用量样本", + "analytics.usageClockMeaning": "时间来自已记录的用量,未经过独立验证。", + "analytics.usageSamples": "用量样本", + "analytics.noRecordedUsage": "没有记录的用量,无法分析。这不表示消耗为零。" } diff --git a/apps/web/src/runtime/http.ts b/apps/web/src/runtime/http.ts index b610eb2..b760cd2 100644 --- a/apps/web/src/runtime/http.ts +++ b/apps/web/src/runtime/http.ts @@ -28,7 +28,7 @@ export class BrowserHTTPError extends Error { } type RequestOptions = { - readonly acceptAnalyticsVersion?: 3 | 4 + readonly acceptAnalyticsVersion?: 3 | 4 | 5 readonly responseProfile?: "conversation-page" | "raw-content-page" readonly method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE" readonly body?: unknown @@ -166,7 +166,9 @@ export const browserRequest = (path: string, options: RequestOptions = {}): Effe Effect.tryPromise({ try: async (interruption) => { const method = options.method ?? "GET" - const headers = new Headers({ Accept: options.acceptAnalyticsVersion === 4 + const headers = new Headers({ Accept: options.acceptAnalyticsVersion === 5 + ? "application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json" + : options.acceptAnalyticsVersion === 4 ? "application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json" : options.acceptAnalyticsVersion === 3 ? "application/vnd.atape.session-analytics.v3+json, application/json" : "application/json" }) diff --git a/apps/web/src/runtime/sessionAnalyticsGateway.test.ts b/apps/web/src/runtime/sessionAnalyticsGateway.test.ts index 63a9a3f..5fa6cce 100644 --- a/apps/web/src/runtime/sessionAnalyticsGateway.test.ts +++ b/apps/web/src/runtime/sessionAnalyticsGateway.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import { afterEach, describe, expect, it, vi } from "vitest" import { activityFixture, analyticsFixture, capabilitiesFixture, executeToolKey, fileActivityFixture, sourceFileKey, stableToolsFixture } from "../../test/sessionAnalyticsFixture" import { BrowserSessionAnalyticsGatewayLayer } from "./sessionAnalyticsGateway" +import { tokenActivityFixture, tokenModelKey, tokenUnknownModelKey } from "../../test/sessionTokenActivityFixture" const run = (effect: Effect.Effect) => Effect.runPromise(effect.pipe(Effect.provide(BrowserSessionAnalyticsGatewayLayer))) @@ -18,7 +19,7 @@ describe("Session analytics browser Adapter", () => { expect(Object.fromEntries(url.searchParams)).toEqual({ snapshot: "snapshot-one", metric: "failed_tools", thread: "child/one", tool: "read & write", cursor: "opaque+=", limit: "20" }) expect(result.usage.tokens).toMatchObject({ total: null, cacheRead: 0 }) const headers = new Headers(fetchMock.mock.calls[0]![1]?.headers) - expect(headers.get("Accept")).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(headers.get("Accept")).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") expect(headers.has("ATape-Accept-Analytics-Version")).toBe(false) }) it("reads v1 without inventing a capability report, and v2 with a required report", async () => { @@ -72,7 +73,37 @@ describe("Session analytics browser Adapter", () => { expect(Object.fromEntries(url.searchParams)).toEqual({ ...query, limit: "4" }) expect(url.searchParams.get("file")).not.toBe("/work/src/app.ts") expect(new Headers(fetchMock.mock.calls[0]![1]?.headers).get("Accept")) - .toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + .toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + }) + it("negotiates v5 and transports model and Token interval keys without converting them into Event identity", async () => { + const report = { ...tokenActivityFixture, snapshot: "snapshot-one", activity: { ...tokenActivityFixture.activity, scopeThreadId: "child/one" }, tokenActivity: { ...tokenActivityFixture.tokenActivity, + scopeThreadId: "child/one", scopeModelKey: tokenModelKey } } + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(report))) + vi.stubGlobal("fetch", fetchMock) + const query = { metric: "token_records" as const, tokenRange: "opaque+=token-range", model: tokenModelKey, + snapshot: "snapshot-one", thread: "child/one", cursor: "next+=", limit: 4 } + expect(await run(readSessionAnalytics("session-reader", query))).toEqual(report) + const url = new URL(fetchMock.mock.calls[0]![0] as string, "http://localhost") + expect(Object.fromEntries(url.searchParams)).toEqual({ ...query, limit: "4" }) + expect(new Headers(fetchMock.mock.calls[0]![1]?.headers).get("Accept")) + .toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(url.searchParams.has("at")).toBe(false) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + it("requires v5 Token coverage and accepts null counters and an explicitly missing model", async () => { + const { tokenActivity: _activity, ...missing } = tokenActivityFixture + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ ...tokenActivityFixture, tokenActivity: { ...tokenActivityFixture.tokenActivity, scopeModelKey: tokenUnknownModelKey } }))) + .mockResolvedValueOnce(new Response(JSON.stringify(missing))) + .mockResolvedValueOnce(new Response(JSON.stringify({ ...tokenActivityFixture, tokenActivity: { + ...tokenActivityFixture.tokenActivity, cache: { ...tokenActivityFixture.tokenActivity.cache, readShare: 2 } } }))) + vi.stubGlobal("fetch", fetchMock) + const result = await run(readSessionAnalytics("session-reader", { model: tokenUnknownModelKey })) + if (result.analyticsVersion === 5) { + expect(result.tokenActivity.models[1]).toMatchObject({ model: null, tokens: { total: { knownSubtotal: null, reportedSamples: 0, missingSamples: 1 } } }) + } + expect(await run(readSessionAnalytics("session-reader").pipe(Effect.flip))).toMatchObject({ reason: "decode" }) + expect(await run(readSessionAnalytics("session-reader").pipe(Effect.flip))).toMatchObject({ reason: "decode" }) }) it("requires the v4 file report, including explicit empty coverage and returned path keys", async () => { const { fileActivity: _fileActivity, ...missingReport } = fileActivityFixture diff --git a/apps/web/src/runtime/sessionAnalyticsGateway.ts b/apps/web/src/runtime/sessionAnalyticsGateway.ts index e25b458..f59fbc6 100644 --- a/apps/web/src/runtime/sessionAnalyticsGateway.ts +++ b/apps/web/src/runtime/sessionAnalyticsGateway.ts @@ -10,7 +10,7 @@ export const BrowserSessionAnalyticsGatewayLayer = Layer.succeed(SessionAnalytic if (value !== undefined) query.set(key, String(value)) } const suffix = query.size ? `?${query}` : "" - return browserRequest(`/api/v1/sessions/${encodeURIComponent(sessionId)}/analytics${suffix}`, { acceptAnalyticsVersion: 4 }).pipe( + return browserRequest(`/api/v1/sessions/${encodeURIComponent(sessionId)}/analytics${suffix}`, { acceptAnalyticsVersion: 5 }).pipe( Effect.mapError(cause => new SessionAnalyticsGatewayError({ reason: cause.reason, message: cause.message, ...(cause.status === undefined ? {} : { status: cause.status }), diff --git a/apps/web/src/view/SessionAnalyticsView.tsx b/apps/web/src/view/SessionAnalyticsView.tsx index fe38671..dd34cc6 100644 --- a/apps/web/src/view/SessionAnalyticsView.tsx +++ b/apps/web/src/view/SessionAnalyticsView.tsx @@ -8,6 +8,7 @@ import { formatNumber, t, type WebMessageKey } from "../i18n" import { ConversationTime } from "./ConversationTime" import { SessionActivityView } from "./SessionActivityView" import { SessionFileActivityView } from "./SessionFileActivityView" +import { SessionTokenActivityView } from "./SessionTokenActivityView" export type AnalyticsEvidenceTarget = { readonly snapshot: string @@ -33,7 +34,7 @@ const metrics: ReadonlyArray = ["unknown_tools", "analytics.metric.unknown_tools"], ["user_inputs", "analytics.metric.user_inputs"], ["thoughts", "analytics.metric.thoughts"], ["threads", "analytics.metric.threads"], ["mcp_tools", "analytics.metric.mcp_tools"], ["skills", "analytics.metric.skills"], ["records", "analytics.metric.records"], - ["files", "analytics.metric.files"] + ["files", "analytics.metric.files"], ["token_records", "analytics.metric.token_records"] ] const token = (value: number | null) => value === null ? t("analytics.unknown", "Unknown") : formatNumber(value) const capture = (value: string) => value === "healthy" ? t("analytics.capture.healthy", "Capture healthy") : value === "complete" ? t("analytics.capture.complete", "Complete") : @@ -109,7 +110,7 @@ export function SessionAnalyticsView({ state, query, onReload, onFilter, onNext, const title = useRef(null) useEffect(() => { title.current?.focus({ preventScroll: true }) }, []) const selections = state._tag === "Ready" ? sessionAnalyticsToolSelections(state.value) : [] - const stableTools = state._tag === "Ready" && state.value.analyticsVersion === 4 + const stableTools = state._tag === "Ready" && (state.value.analyticsVersion === 4 || state.value.analyticsVersion === 5) const toolFilterLabel = stableTools ? t("analytics.toolName", "Tool name") : t("analytics.toolLabel", "Tool label") return
@@ -124,7 +125,7 @@ export function SessionAnalyticsView({ state, query, onReload, onFilter, onNext, {state.retryable && } } {state._tag === "Ready" && <> -

{t("analytics.scope", "Statistics describe the captured Canonical conversation. Filters only change the evidence list.")}

+

{t("analytics.scope", "Counts describe captured history. Thread selection scopes activity and evidence; Thread and model selection scope Token consumption. Other reports keep their existing scope.")}

{t("analytics.capture", "Capture: {status}", { status: capture(state.value.captureStatus) })}

{!["complete", "healthy"].includes(state.value.captureStatus) &&

{t("analytics.partial", "Capture is incomplete. These counts cover only the events available in this snapshot.")}

}
@@ -140,14 +141,14 @@ export function SessionAnalyticsView({ state, query, onReload, onFilter, onNext, ] as const).map(([label, count]) =>
{t(label)}
{formatNumber(count)}
)}

{t("analytics.countMeaning", "Message fragments are stored pieces, not original message counts or conversation turns. Tool calls are linked by their recorded call ID within each thread.")}

- onFilter({ ...query, thread })} onRange={range => onFilter({ metric: "records", range, ...(query.thread ? { thread: query.thread } : {}) })} />

{stableTools ? t("analytics.stableTools", "Recorded tools") : t("analytics.tools", "Tool call labels")}

- {state.value.analyticsVersion === 4 ? <> + {state.value.analyticsVersion === 4 || state.value.analyticsVersion === 5 ? <>
{t("analytics.identifiedToolCalls", "Calls with tool identity")}
{formatNumber(state.value.summary.identifiedToolCalls)}
{t("analytics.unidentifiedToolCalls", "Calls without tool identity")}
{formatNumber(state.value.summary.unidentifiedToolCalls)}
@@ -169,8 +170,13 @@ export function SessionAnalyticsView({ state, query, onReload, onFilter, onNext, )}}
- + onFilter({ metric: "records", ...scope })} + onRange={tokenRange => onFilter({ metric: "token_records", tokenRange, ...(query.thread ? { thread: query.thread } : {}), ...(query.model ? { model: query.model } : {}) })} />

{t("analytics.recordedTokens", "Recorded tokens")}

{t("analytics.samples", "{recorded} recorded samples · {incomplete} incomplete samples · {samples} usage events", { @@ -197,13 +203,14 @@ export function SessionAnalyticsView({ state, query, onReload, onFilter, onNext,

- {!["mcp_tools", "skills", "files"].includes(query.metric ?? "tools") && } {state.value.analyticsVersion !== 1 && ["mcp_tools", "skills"].includes(query.metric ?? "tools") && <> @@ -219,7 +226,7 @@ export function SessionAnalyticsView({ state, query, onReload, onFilter, onNext, .filter(canFilterSessionAnalyticsTool).map(name => )} } - {state.value.analyticsVersion === 4 && query.metric === "files" && <> + {(state.value.analyticsVersion === 4 || state.value.analyticsVersion === 5) && query.metric === "files" && <> + +
+ +

{t("analytics.tokenActivityMeaning", "Follows the selected Thread and model. Subtotals include known counters only; missing usage stays unknown. No cost is estimated.")}

+ {report.samples === 0 &&

{t("analytics.noRecordedUsage", "No recorded usage is available to analyze. This does not mean zero consumption.")}

} +
+
{t("analytics.usageSamples", "Usage samples")}
{formatNumber(report.samples)}
+
{t("analytics.timedUsageSamples", "Dated usage samples")}
{formatNumber(report.timedSamples)}
+
{t("analytics.untimedUsageSamples", "Usage samples without time")}
{formatNumber(report.untimedSamples)}
+
+

{t("analytics.knownTokenSubtotals", "Known Token subtotals")}

+ +

{t("analytics.cacheShares", "Recorded input cache shares")}

+ +

{t("analytics.tokenDistribution", "Token distribution over recorded usage time")}

+

{t("analytics.usageClockMeaning", "Times are recorded usage values and have not been independently verified.")}

+

{t("analytics.tokenRangeMeaning", "Read records from the same interval and selected Thread. They provide context, not exact Token attribution to a response or model. Records with unknown or legacy times are excluded.")}

+

{t("analytics.tokenBarMeaning", "Bars and hotspots use known total subtotals. Unknown values have no bar. Intervals without records cannot be opened.")}

+ {report.firstRecordedAt !== null && report.lastRecordedAt !== null &&

+ + +

} + {report.buckets.length === 0 ?

{t("analytics.noDatedUsage", "No dated usage samples are available for intervals.")}

:
+ {report.buckets.map(bucket => )} +
} + {hotspots.length > 0 && <> +

{t("analytics.tokenHotspots", "Highest recorded consumption intervals")}

+
    {hotspots.map(bucket =>
  1. + + {bucket.records === 0 && {t("analytics.noTokenIntervalRecords", "No contemporaneous records are available in this interval.")}} + {t("analytics.tokenIntervalCoverage", "{samples} usage samples · {reported} total counters reported · {missing} total counters missing · {records} interval records", { + samples: formatNumber(bucket.samples), reported: formatNumber(bucket.tokens.total.reportedSamples), missing: formatNumber(bucket.tokens.total.missingSamples), records: formatNumber(bucket.records) })} +
  2. )}
+ } + {report.untimedSamples > 0 &&
{t("analytics.untimedTokenSubtotals", "Known subtotals without a usage time ({count} samples)", { count: formatNumber(report.untimedSamples) })} +

{t("analytics.untimedTokenMeaning", "Included in subtotals; no interval can be selected without a time.")}

+ +
} +
+} diff --git a/apps/web/src/workspace.css b/apps/web/src/workspace.css index f3db197..72d4693 100644 --- a/apps/web/src/workspace.css +++ b/apps/web/src/workspace.css @@ -1197,6 +1197,17 @@ button.workspace-team-trigger:hover, .analytics-link:disabled { color: inherit; cursor: default; } .analytics-models { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 20px; } .analytics-models h3 { font-size: 1rem; overflow-wrap: anywhere; } +.analytics-token-scope { display: flex; flex-wrap: wrap; gap: 12px; max-width: 100%; } +.analytics-token-coverage p { margin: 8px 0 0; color: var(--atape-color-text-muted); font-size: 0.8rem; line-height: 1.5; } +.analytics-token-bars .analytics-activity-bar { min-width: 34px; } +.analytics-token-unknown { border-style: dashed; } +.analytics-token-hotspots { padding-inline-start: 24px; } +.analytics-token-hotspots li { margin: 12px 0; } +.analytics-token-hotspots li > span { display: block; margin-top: 4px; color: var(--atape-color-text-muted); font-size: 0.85rem; } +.analytics-token-hotspots .analytics-link[aria-pressed="true"] { text-decoration: underline; } +.analytics-token-hotspots .analytics-link:focus-visible { outline: 2px solid var(--atape-color-accent); outline-offset: 3px; } +.analytics-untimed-tokens { margin-top: 24px; } +.analytics-untimed-tokens summary { cursor: pointer; } .analytics-filters { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; } .analytics-filters label { display: grid; gap: 6px; font-size: 0.85rem; min-width: 150px; max-width: 100%; } .analytics-filters select { max-width: 280px; border: 1px solid var(--atape-color-border-soft); border-radius: 6px; padding: 8px; background: var(--atape-color-paper); color: inherit; } diff --git a/apps/web/test/sessionTokenActivityFixture.ts b/apps/web/test/sessionTokenActivityFixture.ts new file mode 100644 index 0000000..c0ed5a8 --- /dev/null +++ b/apps/web/test/sessionTokenActivityFixture.ts @@ -0,0 +1,69 @@ +import type { SessionAnalytics } from "@atape/domain" +import { stableToolsFixture } from "./sessionAnalyticsFixture" + +export type TokenAnalytics = Extract +type Accounting = TokenAnalytics["tokenActivity"]["tokens"] +type Counter = Accounting["total"] +export const tokenModelKey = `model:${"e".repeat(64)}` +export const tokenUnknownModelKey = `model:${"f".repeat(64)}` +export const tokenSnapshot = "token-snapshot-one" +export const tokenHead = "token-head-one" +const counter = (knownSubtotal: number | null, reportedSamples = 1, missingSamples = 0): Counter => + ({ knownSubtotal, reportedSamples, missingSamples, overflowed: false }) +export const tokenAccounting = (total: number | null, input: number | null, output: number | null, + reportedSamples = 1, missingSamples = 0): Accounting => ({ total: counter(total, reportedSamples, missingSamples), + input: counter(input, reportedSamples, missingSamples), output: counter(output, reportedSamples, missingSamples), + cacheRead: counter(input === null ? null : 20, reportedSamples, missingSamples), + cacheWrite: counter(input === null ? null : 10, reportedSamples, missingSamples) }) +const cache: TokenAnalytics["tokenActivity"]["cache"] = { eligibleSamples: 1, missingSamples: 3, + inputTokens: 100, readTokens: 20, writeTokens: 10, uncachedTokens: 70, readShare: .2, writeShare: .1, uncachedShare: .7 } +const tokens = tokenAccounting(670, 500, 170, 3, 1) +const range = (thread: string | null, model: string | null, interval: string) => `token-${thread ?? "all"}-${model ?? "all"}-${interval}` +export const tokenActivityReport = (thread: string | null = null, model: string | null = null): TokenAnalytics["tokenActivity"] => ({ + scopeThreadId: thread, scopeModelKey: model, samples: 4, timedSamples: 3, untimedSamples: 1, + firstRecordedAt: "2026-10-13T08:00:00Z", lastRecordedAt: "2026-10-13T08:03:00Z", + tokens, untimedTokens: tokenAccounting(70, 50, 20), cache, + models: [{ key: tokenModelKey, model: "unknown", samples: 3, tokens, cache }, + { key: tokenUnknownModelKey, model: null, samples: 1, tokens: tokenAccounting(null, null, null, 0, 1), cache }], + threads: [{ id: "root", samples: 2, tokens, cache }, { id: "child", samples: 2, tokens, cache }], + buckets: [ + { from: "2026-10-13T08:00:00Z", to: "2026-10-13T08:01:00Z", includesEnd: false, + samples: 1, tokens: tokenAccounting(100, 80, 20), records: 2, range: range(thread, model, "first") }, + { from: "2026-10-13T08:01:00Z", to: "2026-10-13T08:02:00Z", includesEnd: false, + samples: 2, tokens: tokenAccounting(500, 370, 130, 1, 1), records: 24, range: range(thread, model, "hot") }, + { from: "2026-10-13T08:02:00Z", to: "2026-10-13T08:03:00Z", includesEnd: true, + samples: 0, tokens: tokenAccounting(null, null, null, 0, 0), records: 0, range: range(thread, model, "empty") } + ] +}) +export const tokenActivityFixture: TokenAnalytics = { ...stableToolsFixture, analyticsVersion: 5, + snapshot: tokenSnapshot, head: tokenHead, tokenActivity: tokenActivityReport() } +export const tokenBucketLabel = (thread: string | null = null, model: string | null = null, index = 1) => { + const bucket = tokenActivityReport(thread, model).buckets[index]! + return `Read interval records: ${bucket.tokens.total.knownSubtotal ?? "Unknown"} recorded Tokens · ${bucket.records} records · ${bucket.from} → ${bucket.to}` +} + +/** Fixed HTTP Test Adapter. It validates caller selections and returns canned + * pages; Server derivation is exercised by Go and PostgreSQL checks. */ +export const tokenActivityResponse = (query: URLSearchParams): { readonly status: number; readonly body: unknown; readonly violation?: string } => { + const invalid = (violation: string) => ({ status: 422, body: { code: "validation_failed", detail: violation }, violation }) + const allowed = new Set(["metric", "snapshot", "thread", "model", "tokenRange", "cursor", "limit", "tool"]) + for (const [key, value] of query) if (!allowed.has(key) || value === "" || query.getAll(key).length !== 1) return invalid(`Invalid query ${key}.`) + const thread = query.get("thread"), model = query.get("model"), metric = query.get("metric") ?? "tools" + if (thread !== null && !["root", "child"].includes(thread)) return invalid("Unknown Thread.") + if (model !== null && ![tokenModelKey, tokenUnknownModelKey].includes(model)) return invalid("Model must be a returned key.") + if (query.has("snapshot") && query.get("snapshot") !== tokenSnapshot) return invalid("Snapshot changed.") + const report = tokenActivityReport(thread, model), selectedRange = query.get("tokenRange") + if (metric === "token_records" && (query.get("snapshot") !== tokenSnapshot || selectedRange === null)) return invalid("Token records require snapshot and range.") + if (metric !== "token_records" && selectedRange !== null) return invalid("Token range requires Token records.") + if (selectedRange !== null && !report.buckets.some(bucket => bucket.range === selectedRange)) return invalid("Range changed scope.") + const pageCursor = `next-${selectedRange ?? metric}` + if (query.has("cursor") && query.get("cursor") !== pageCursor) return invalid("Cursor changed selection.") + const items = metric === "token_records" ? Array.from({ length: selectedRange === report.buckets[1]!.range ? 24 : 2 }, (_, index) => ({ + eventId: `token-context-${String(index + 1).padStart(2, "0")}`, threadId: thread ?? "child", kind: index === 20 ? "tool_result" : "message", + label: index === 20 ? "Token interval context" : `Contemporaneous record ${index + 1}`, + occurredAt: "2026-10-13T08:01:30Z", clockQuality: "recorded" as const + })) : [] + const page = query.has("cursor") ? items.slice(20) : items.slice(0, 20) + return { status: 200, body: { ...tokenActivityFixture, activity: { ...tokenActivityFixture.activity, scopeThreadId: thread }, tokenActivity: report, + evidence: { items: page, ...(!query.has("cursor") && items.length > 20 ? { nextCursor: pageCursor } : {}) } } satisfies TokenAnalytics } +} diff --git a/docs/api/openapi-v1.yaml b/docs/api/openapi-v1.yaml index d306ef3..c3e675f 100644 --- a/docs/api/openapi-v1.yaml +++ b/docs/api/openapi-v1.yaml @@ -1007,20 +1007,22 @@ paths: x-atape-authorization-actions: [conversation.read] tags: [Captured sessions] summary: Analyze one current Canonical Session - description: Statistics and evidence share one authorized snapshot. Thread selects the v3/v4 activity scope; summary, tools, capabilities and usage remain Session-wide. Other filters affect evidence only. Resource bounds fail explicitly with 422 analytics_capacity. + description: Statistics and evidence share one authorized snapshot. Thread selects recorded activity and Token scope; model selects v5 Token activity. Summary, tools, capabilities and original usage totals remain Session-wide. Token interval evidence selects contemporaneous recorded Events, not per-response or per-model attribution. Other filters affect evidence only. Resource bounds fail explicitly with 422 analytics_capacity. x-atape-route: {class: web_only, requestBodyPolicy: none, requestBodyLimit: 0, cachePolicy: no-store, originPolicy: none, freshAuthentication: false, requiresIdempotencyKey: false} security: [{webSession: []}] parameters: - - {name: Accept, in: header, description: "Absent the vendor media range, returns version 2. Acceptable application/vnd.atape.session-analytics.v4+json and v3+json ranges opt into versions 4 and 3; the highest admitted nonzero version wins. q=0 declines that range. Older Servers may ignore newer ranges and return their supported representation. Responses retain application/json.", schema: {type: string, maxLength: 4096, default: application/json}} + - {name: Accept, in: header, description: "Absent the vendor media range, returns version 2. Acceptable application/vnd.atape.session-analytics.v5+json, v4+json and v3+json ranges opt into versions 5, 4 and 3; the highest admitted nonzero version wins. q=0 declines that range. Older Servers may ignore newer ranges and return their supported representation. Responses retain application/json.", schema: {type: string, maxLength: 4096, default: application/json}} - {name: snapshot, in: query, schema: {type: string, minLength: 1, maxLength: 200}} - - {name: metric, in: query, schema: {type: string, default: tools, enum: [tools, failed_tools, unknown_tools, user_inputs, thoughts, threads, mcp_tools, skills, records, files]}} + - {name: metric, in: query, schema: {type: string, default: tools, enum: [tools, failed_tools, unknown_tools, user_inputs, thoughts, threads, mcp_tools, skills, records, files, token_records]}} - {name: thread, in: query, schema: {type: string, maxLength: 500}} - - {name: tool, in: query, description: Exact returned group key in version 4, or display label in version 2/3; only applies to tools/failed_tools/unknown_tools., schema: {type: string, maxLength: 500}} + - {name: model, in: query, description: Version 5 returned opaque model key; selects Token activity only and binds Token interval navigation., schema: {type: string, pattern: "^model:[0-9a-f]{64}$"}} + - {name: tokenRange, in: query, description: Version 5 opaque Usage interval; requires token_records metric and snapshot. Cannot combine with the separate Event range., schema: {type: string, minLength: 1, maxLength: 512}} + - {name: tool, in: query, description: Exact returned group key in version 4/5, or display label in version 2/3; only applies to tools/failed_tools/unknown_tools., schema: {type: string, maxLength: 500}} - {name: capability, in: query, description: Exact normalized MCP tool or Skill name; only applies to mcp_tools/skills., schema: {type: string, maxLength: 500}} - {name: server, in: query, description: Exact normalized MCP server; only applies to mcp_tools and can be selected independently., schema: {type: string, maxLength: 500}} - - {name: file, in: query, description: Version 4 returned opaque file path key; only applies to files and combines with operation., schema: {type: string, maxLength: 500}} - - {name: operation, in: query, description: Version 4 explicit file operation; search paths describe scope rather than matching files., schema: {type: string, enum: [read, edit, search]}} - - {name: range, in: query, description: "Opaque Server-generated dated bucket or unplaced selection; requires version 3 or 4, records metric and snapshot. Bound to Session and Thread scope.", schema: {type: string, minLength: 1, maxLength: 512}} + - {name: file, in: query, description: Version 4/5 returned opaque file path key; only applies to files and combines with operation., schema: {type: string, maxLength: 500}} + - {name: operation, in: query, description: Version 4/5 explicit file operation; search paths describe scope rather than matching files., schema: {type: string, enum: [read, edit, search]}} + - {name: range, in: query, description: "Opaque Server-generated dated bucket or unplaced selection; requires version 3, 4 or 5, records metric and snapshot. Bound to Session and Thread scope.", schema: {type: string, minLength: 1, maxLength: 512}} - {name: cursor, in: query, description: Opaque evidence cursor bound to the Session, snapshot, analytics version and filters; requires snapshot., schema: {type: string, maxLength: 1024}} - {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} responses: @@ -1039,16 +1041,18 @@ paths: x-atape-route: {class: web_only, requestBodyPolicy: none, requestBodyLimit: 0, cachePolicy: no-store, originPolicy: none, freshAuthentication: false, requiresIdempotencyKey: false} security: [{webSession: []}] parameters: - - {name: Accept, in: header, description: "Absent the vendor media range, returns version 2. Acceptable application/vnd.atape.session-analytics.v4+json and v3+json ranges opt into versions 4 and 3; the highest admitted nonzero version wins. q=0 declines that range. Older Servers may ignore newer ranges and return their supported representation. Responses retain application/json.", schema: {type: string, maxLength: 4096, default: application/json}} + - {name: Accept, in: header, description: "Absent the vendor media range, returns version 2. Acceptable application/vnd.atape.session-analytics.v5+json, v4+json and v3+json ranges opt into versions 5, 4 and 3; the highest admitted nonzero version wins. q=0 declines that range. Older Servers may ignore newer ranges and return their supported representation. Responses retain application/json.", schema: {type: string, maxLength: 4096, default: application/json}} - {name: snapshot, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 200}} - - {name: metric, in: query, schema: {type: string, default: tools, enum: [tools, failed_tools, unknown_tools, user_inputs, thoughts, threads, mcp_tools, skills, records, files]}} + - {name: metric, in: query, schema: {type: string, default: tools, enum: [tools, failed_tools, unknown_tools, user_inputs, thoughts, threads, mcp_tools, skills, records, files, token_records]}} - {name: thread, in: query, schema: {type: string, maxLength: 500}} - - {name: tool, in: query, description: Exact returned group key in version 4, or display label in version 2/3; only applies to tools/failed_tools/unknown_tools., schema: {type: string, maxLength: 500}} + - {name: model, in: query, description: Version 5 returned opaque model key; selects Token activity only and binds Token interval navigation., schema: {type: string, pattern: "^model:[0-9a-f]{64}$"}} + - {name: tokenRange, in: query, description: Version 5 opaque Usage interval; requires token_records metric and snapshot. Cannot combine with the separate Event range., schema: {type: string, minLength: 1, maxLength: 512}} + - {name: tool, in: query, description: Exact returned group key in version 4/5, or display label in version 2/3; only applies to tools/failed_tools/unknown_tools., schema: {type: string, maxLength: 500}} - {name: capability, in: query, description: Exact normalized MCP tool or Skill name; only applies to mcp_tools/skills., schema: {type: string, maxLength: 500}} - {name: server, in: query, description: Exact normalized MCP server; only applies to mcp_tools and can be selected independently., schema: {type: string, maxLength: 500}} - - {name: file, in: query, description: Version 4 returned opaque file path key; only applies to files and combines with operation., schema: {type: string, maxLength: 500}} - - {name: operation, in: query, description: Version 4 explicit file operation; search paths describe scope rather than matching files., schema: {type: string, enum: [read, edit, search]}} - - {name: range, in: query, description: "Opaque Server-generated dated bucket or unplaced selection; requires version 3 or 4, records metric and snapshot. Bound to Session and Thread scope.", schema: {type: string, minLength: 1, maxLength: 512}} + - {name: file, in: query, description: Version 4/5 returned opaque file path key; only applies to files and combines with operation., schema: {type: string, maxLength: 500}} + - {name: operation, in: query, description: Version 4/5 explicit file operation; search paths describe scope rather than matching files., schema: {type: string, enum: [read, edit, search]}} + - {name: range, in: query, description: "Opaque Server-generated dated bucket or unplaced selection; requires version 3, 4 or 5, records metric and snapshot. Bound to Session and Thread scope.", schema: {type: string, minLength: 1, maxLength: 512}} - {name: cursor, in: query, description: Opaque cursor for the same snapshot and filters., schema: {type: string, maxLength: 1024}} - {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} responses: @@ -2231,7 +2235,7 @@ components: kind: {type: string} label: {type: string} occurredAt: {type: [string, "null"]} - clockQuality: {type: string, enum: [recorded, unknown, legacy], description: Required in version 3/4; recorded is an admitted source clock and is not independently verified.} + clockQuality: {type: string, enum: [recorded, unknown, legacy], description: Required in version 3/4/5; recorded is an admitted source clock and is not independently verified.} SessionAnalyticsEvidence: type: object additionalProperties: false @@ -2291,6 +2295,90 @@ components: searchCalls: {type: integer, minimum: 0, description: Distinct folded calls with a recorded search scope.} uniquePaths: {type: integer, minimum: 0, maximum: 2000} paths: {type: array, maxItems: 2000, items: {$ref: "#/components/schemas/SessionAnalyticsFilePath"}} + SessionAnalyticsTokenCounter: + type: object + additionalProperties: false + required: [knownSubtotal, reportedSamples, missingSamples, overflowed] + properties: + knownSubtotal: {type: [integer, "null"], minimum: 0, maximum: 9007199254740991, description: Sum of usable recorded classifications; null with no reported samples or arithmetic overflow.} + reportedSamples: {type: integer, minimum: 0} + missingSamples: {type: integer, minimum: 0} + overflowed: {type: boolean} + SessionAnalyticsTokenAccounting: + type: object + additionalProperties: false + required: [total, input, output, cacheRead, cacheWrite] + properties: + total: {$ref: "#/components/schemas/SessionAnalyticsTokenCounter"} + input: {$ref: "#/components/schemas/SessionAnalyticsTokenCounter"} + output: {$ref: "#/components/schemas/SessionAnalyticsTokenCounter"} + cacheRead: {$ref: "#/components/schemas/SessionAnalyticsTokenCounter"} + cacheWrite: {$ref: "#/components/schemas/SessionAnalyticsTokenCounter"} + SessionAnalyticsTokenCache: + type: object + additionalProperties: false + required: [eligibleSamples, missingSamples, inputTokens, readTokens, writeTokens, uncachedTokens, readShare, writeShare, uncachedShare] + description: One cohort with complete valid inclusive input and both cache classifications; fractions share the same denominator. No eligible sample, zero denominator or overflow leaves shares null. + properties: + eligibleSamples: {type: integer, minimum: 0} + missingSamples: {type: integer, minimum: 0} + inputTokens: {type: [integer, "null"], minimum: 0, maximum: 9007199254740991} + readTokens: {type: [integer, "null"], minimum: 0, maximum: 9007199254740991} + writeTokens: {type: [integer, "null"], minimum: 0, maximum: 9007199254740991} + uncachedTokens: {type: [integer, "null"], minimum: 0, maximum: 9007199254740991} + readShare: {type: [number, "null"], minimum: 0, maximum: 1} + writeShare: {type: [number, "null"], minimum: 0, maximum: 1} + uncachedShare: {type: [number, "null"], minimum: 0, maximum: 1} + SessionAnalyticsTokenModel: + type: object + additionalProperties: false + required: [key, model, samples, tokens, cache] + properties: + key: {type: string, pattern: "^model:[0-9a-f]{64}$"} + model: {type: [string, "null"], description: Null is an unreported model, distinct from any literal model name.} + samples: {type: integer, minimum: 0} + tokens: {$ref: "#/components/schemas/SessionAnalyticsTokenAccounting"} + cache: {$ref: "#/components/schemas/SessionAnalyticsTokenCache"} + SessionAnalyticsTokenThread: + type: object + additionalProperties: false + required: [id, samples, tokens, cache] + properties: + id: {type: string} + samples: {type: integer, minimum: 0} + tokens: {$ref: "#/components/schemas/SessionAnalyticsTokenAccounting"} + cache: {$ref: "#/components/schemas/SessionAnalyticsTokenCache"} + SessionAnalyticsTokenBucket: + type: object + additionalProperties: false + required: [from, to, includesEnd, samples, tokens, records, range] + properties: + from: {type: string, format: date-time} + to: {type: string, format: date-time} + includesEnd: {type: boolean} + samples: {type: integer, minimum: 0} + tokens: {$ref: "#/components/schemas/SessionAnalyticsTokenAccounting"} + records: {type: integer, minimum: 0, description: Recorded-quality Events in this interval and Thread scope; not events attributed to this model or Usage sample.} + range: {type: string, minLength: 1, maxLength: 512, description: Opaque tokenRange selector bound to Session, snapshot, version, Thread and model.} + SessionAnalyticsTokenActivity: + type: object + additionalProperties: false + required: [scopeThreadId, scopeModelKey, samples, timedSamples, untimedSamples, firstRecordedAt, lastRecordedAt, tokens, untimedTokens, cache, models, threads, buckets] + description: Scoped distribution by stored Usage timestamps. Usage time is independent of Event clock-quality classification and is not independently verified. Existing Session-wide usage totals retain their nullable semantics. + properties: + scopeThreadId: {type: [string, "null"]} + scopeModelKey: {type: [string, "null"], pattern: "^model:[0-9a-f]{64}$"} + samples: {type: integer, minimum: 0} + timedSamples: {type: integer, minimum: 0} + untimedSamples: {type: integer, minimum: 0} + firstRecordedAt: {type: [string, "null"], format: date-time} + lastRecordedAt: {type: [string, "null"], format: date-time} + tokens: {$ref: "#/components/schemas/SessionAnalyticsTokenAccounting"} + untimedTokens: {$ref: "#/components/schemas/SessionAnalyticsTokenAccounting"} + cache: {$ref: "#/components/schemas/SessionAnalyticsTokenCache"} + models: {type: array, maxItems: 1000, items: {$ref: "#/components/schemas/SessionAnalyticsTokenModel"}} + threads: {type: array, maxItems: 5000, items: {$ref: "#/components/schemas/SessionAnalyticsTokenThread"}} + buckets: {type: array, maxItems: 60, items: {$ref: "#/components/schemas/SessionAnalyticsTokenBucket"}} SessionAnalytics: type: object additionalProperties: false @@ -2298,7 +2386,7 @@ components: properties: snapshot: {type: string, description: Opaque conditional token for the current Canonical input; not historical retention or authorization.} head: {type: string, description: Selected publication head when normalized publication is active.} - analyticsVersion: {type: integer, enum: [2, 3, 4]} + analyticsVersion: {type: integer, enum: [2, 3, 4, 5]} sessionId: {type: string} captureStatus: {type: string} summary: {$ref: "#/components/schemas/SessionAnalyticsSummary"} @@ -2309,8 +2397,9 @@ components: evidence: {$ref: "#/components/schemas/SessionAnalyticsEvidence"} activity: {$ref: "#/components/schemas/SessionAnalyticsActivity"} fileActivity: {$ref: "#/components/schemas/SessionAnalyticsFileActivity"} + tokenActivity: {$ref: "#/components/schemas/SessionAnalyticsTokenActivity"} allOf: - - if: {properties: {analyticsVersion: {enum: [3, 4]}}, required: [analyticsVersion]} + - if: {properties: {analyticsVersion: {enum: [3, 4, 5]}}, required: [analyticsVersion]} then: required: [activity] properties: @@ -2318,7 +2407,7 @@ components: properties: items: items: {required: [clockQuality]} - - if: {properties: {analyticsVersion: {const: 4}}, required: [analyticsVersion]} + - if: {properties: {analyticsVersion: {enum: [4, 5]}}, required: [analyticsVersion]} then: required: [fileActivity] properties: @@ -2338,6 +2427,9 @@ components: - if: {properties: {analyticsVersion: {const: 2}}, required: [analyticsVersion]} then: not: {required: [activity]} + - if: {properties: {analyticsVersion: {const: 5}}, required: [analyticsVersion]} + then: {required: [tokenActivity]} + else: {not: {required: [tokenActivity]}} SearchResult: type: object additionalProperties: false diff --git a/docs/architecture/adr/0127-recorded-token-activity.md b/docs/architecture/adr/0127-recorded-token-activity.md new file mode 100644 index 0000000..a992610 --- /dev/null +++ b/docs/architecture/adr/0127-recorded-token-activity.md @@ -0,0 +1,72 @@ +# ADR-0127: Recorded Token activity and interval evidence + +- Status: Accepted +- Date: 2026-10-13 + +## Context + +Session Analytics already reports inclusive input, output and cache counters by +Session, model and Thread. A missing classification makes its existing total +unknown. Users also need to locate high-consumption intervals and understand +which recorded samples support a cache ratio. Usage has no Canonical Event +association; neither a similar identifier nor a nearby timestamp proves one. + +## Interface alternatives + +| Interface | Depth, Leverage and Locality | Decision | +| --- | --- | --- | +| Add Token counts to the existing Event activity buckets | Reuses the existing view, but leaks Event-based bounds into Usage accounting; samples outside the Event span disappear and model selection changes unrelated record semantics. | Reject. | +| Extend the existing Session Analytics Module with a scoped Token report and separate interval selector | Hides Usage aggregation, coverage, cache cohorts and interval derivation behind the same `Open` Interface. Reuses the authorized current-snapshot Seam and conditional reader Adapter. | Select. | +| Introduce a separate persisted consumption read model | Could reduce read-time work, but adds freshness, authorization, invalidation and worker ownership before measurements justify them. | Defer. | + +## Decision + +Opt-in Analytics v5 requires `tokenActivity` and preserves v2-v4 representations. +Its Thread and opaque model selectors scope this report; other summaries keep +their existing Session-wide meaning. The report provides per-column known +subtotals and sample coverage without changing existing nullable total semantics. +An absent classification is not zero. Total includes only samples with both +input and output; cache counters are already included in input. Overflow makes +the subtotal unavailable and is explicitly reported. + +Cache shares use one cohort: samples with valid input, cache-read and cache-write +counters whose cache subdivisions do not exceed inclusive input. Numerators and +denominator come from those same samples. No eligible sample, zero denominator +or overflow yields an unavailable share. Other samples remain disclosed. + +Usage v1 has always required a timestamp, independently of the nullable Event +profiles. Current Codex and Claude projections emit Usage only with an explicit +source timestamp. This report groups nonzero **stored Usage timestamps**; it does +not assign Event clock quality to Usage, infer provenance from another Event or +independently validate a historic clock. A missing clock remains in an untimed +subtotal. This increment adds no Usage clock-quality classification or durable +format change. + +At most 60 uniform buckets span the selected Usage timestamps, using the same +overflow-safe interval arithmetic as recorded activity. Every timed selected +sample enters exactly one bucket, including samples before or after all Events. +Intervals are `[from,to)`, with an inclusive final endpoint. A single instant +has one point interval. + +Each bucket returns a separate opaque `tokenRange`. `token_records` evidence +requires v5, that range and the current snapshot. The selector and cursor bind +Session, snapshot, analytics version, Thread and model. Evidence contains only +recorded-quality Events in the same Thread scope and interval. The model selects +the Usage-derived interval, not an Event's model or a causal relationship. +An interval can contain no eligible original records. Unknown or legacy Event +clocks are never placed into an interval by upload time or proximity. + +The conditional Reader opens returned real Event anchors. Browser Back restores +the selection and page; scope changes clear incompatible range and cursor state. +Every read rechecks authorization and stale source tokens fail as before. + +## Scope + +Metric rules remain in the Go Module Implementation; Web remains Presentation. +The existing snapshot Seam and PostgreSQL/demo Adapters are sufficient. There is +no new interface for mocking, migration, Provider parser, Raw/Search join, worker, +cache or pricing dependency. Existing fact, response and cancellation bounds +remain in force. Monetary estimates, precise per-response attribution, context +occupancy and inferred efficiency remain outside this increment. + +Behavior and validation are described in [Session Analytics](../../session-analytics.md). diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 5c07a4e..9763b3f 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -152,6 +152,7 @@ amendments for scope; keep delivery status in the relevant feature guide. | [0124](0124-stable-native-tool-identity.md) | Stable native tool identity and Analytics v4 | | [0125](0125-recorded-file-activity.md) | Recorded file activity and conditional evidence | | [0126](0126-target-owned-managed-updates.md) | Target-owned managed update preparation and recovery | +| [0127](0127-recorded-token-activity.md) | Recorded Token activity and interval evidence | When adding a decision, choose an unused number and add it here. Preserve an old diff --git a/docs/session-analytics.md b/docs/session-analytics.md index a2cf5e4..bab484b 100644 --- a/docs/session-analytics.md +++ b/docs/session-analytics.md @@ -13,6 +13,9 @@ v5 and Analytics v4. The 0.5.11 bridge already includes supporting Host, Server and reader behavior while retaining genuine older v3/v4 provider output. Published 0.5.10 does not implement these contracts. See [target-owned updates](cli/setup-and-adapters.md#target-owned-updates). +The Analytics v5 Token distribution below is a subsequent implementation +increment. The 0.5.12 delivery evidence does not establish its deployment. It +uses existing Canonical Usage without a new capture profile. ## Metrics and evidence @@ -42,6 +45,68 @@ historical records can still carry an older Adapter's epoch or outcome; saved non-null clock coverage alone does not establish clock quality. See [Codex source clocks](adapters/codex.md#source-clock-quality-and-replay). +## Token consumption distribution and cache shares + +Opt-in Analytics v5 requires `tokenActivity`. Existing Session `usage` totals and +per-Thread Token totals retain their unfiltered, complete-or-null semantics. The new report +independently selects a Thread and an opaque returned model key. + +| Field | Meaning | +| --- | --- | +| `scopeThreadId`, `scopeModelKey` | Selected scope, or null for all | +| `samples`, `timedSamples`, `untimedSamples` | Recorded Usage samples and their stored timestamp coverage | +| `tokens` | Known subtotals with per-classification reported/missing counts and overflow flags | +| `untimedTokens` | The same accounting for samples without a stored timestamp | +| `cache` | Cache-read, cache-write and uncached input from one complete sample cohort | +| `models` | Model accounting within the selected Thread, before model filtering; an unreported model is null | +| `threads` | Thread accounting within the selected model, before Thread filtering | +| `buckets` | At most 60 intervals spanning selected stored Usage timestamps, with Token accounting and opaque ranges | + +Each counter returns `knownSubtotal`, `reportedSamples`, `missingSamples` and +`overflowed`. Subtotals describe reported classifications, not a complete source +conversation. No reported sample returns null rather than invented zero; a real +reported zero stays zero. Total combines input and output only from the same +sample when both are valid. Cache is already in input and reasoning in output; +neither is added again. Single-sample or aggregate arithmetic beyond JavaScript's +safe integer range makes the subtotal null with `overflowed: true`, retaining +the reported sample count. + +Cache shares use only samples with valid inclusive input, cache read and cache +write, with cache subdivisions not exceeding input. The same cohort supplies +every numerator and denominator. `eligibleSamples` and `missingSamples` disclose +coverage. Shares are fractions from zero to one; no eligible samples, a zero +denominator or overflow makes them unavailable. They do not estimate monetary +savings or cache effectiveness for unreported requests. + +Buckets use **Usage timestamps**, independently of the Event activity span. +Usage v1 requires a timestamp; current Codex and Claude readers emit samples only +when an explicit source timestamp is present. This report describes stored Usage +time, without inferring Event clock quality or independently verifying historical +clocks. Missing dates stay in the untimed subtotal; upload/observation time never +fills them. Samples before or after all Events still enter the distribution. +Intervals are `[from,to)` except the inclusive final endpoint; a single instant +produces one point interval. + +The Web displays known subtotals, coverage, cache shares, model and Thread +selections, all buckets and up to five high-consumption intervals. Hotspots rank +usable known total subtotals, retaining partial-coverage disclosure. This is +recorded consumption, not context occupancy or per-response generation speed. +No recorded Usage means unavailable, including Cursor's current capture scope. + +Select an interval using `metric=token_records`, its returned `tokenRange` and +the same snapshot. The selector binds Session, version, Thread and model. It +returns recorded-quality Events in that Thread scope and interval. **This is +interval navigation, not Usage-to-Event or model attribution.** No nearest Event +is invented. An interval can contain zero eligible records; the Web shows that +count and disables its original-record action. Unknown and legacy Event clocks +remain outside these dated ranges. + +Model and Thread changes clear incompatible interval and page state. Conditional +Reader links and Browser Back retain the analysis selection; full refresh clears +all filters. Older v1-v4 Servers retain existing totals and explicitly mark Token +distribution unavailable. Web negotiates v5, v4, v3 and JSON through the existing +safelisted Accept header. See [ADR-0127](architecture/adr/0127-recorded-token-activity.md). + ## Stable tool grouping Analytics v4 groups structured calls by explicit tool identity and recorded kind. @@ -274,15 +339,17 @@ evidence anchors identify the first stored Event in a captured child Thread. | Query | Contract | | --- | --- | -| `snapshot` | Opaque conditional current-source token, at most 200 bytes; required on `/evidence` and when using `cursor` or `range` | -| `metric` | `tools` (default), `failed_tools`, `unknown_tools`, `user_inputs`, `thoughts`, `threads`, `mcp_tools`, `skills`, or v3/v4-only `records`, or v4-only `files` | -| `thread` | Select evidence and v3 activity from one captured Thread, at most 500 bytes | +| `snapshot` | Opaque conditional current-source token, at most 200 bytes; required on `/evidence` and when using `cursor`, `range` or `tokenRange` | +| `metric` | `tools` (default), `failed_tools`, `unknown_tools`, `user_inputs`, `thoughts`, `threads`, `mcp_tools`, `skills`, v3-v5 `records`, v4/v5 `files`, or v5 `token_records` | +| `thread` | Select evidence, v3-v5 activity and v5 Token distribution from one captured Thread, at most 500 bytes | | `range` | V3/v4-only opaque server interval or unplaced selection, at most 512 bytes; requires `records` and the same snapshot/Thread scope | | `tool` | V4 returned group key; v1–v3 exact displayed label; only the three tool metrics, at most 500 bytes | | `capability` | Exact normalized name for `mcp_tools` or `skills`, at most 500 bytes | | `server` | Exact normalized server for `mcp_tools`, at most 500 bytes | | `file` | V4 returned opaque path key, only with `files` | | `operation` | V4 `read`, `edit` or `search`, only with `files`; combines with `file` and Thread using AND | +| `model` | V5 returned opaque model key; selects Token activity without attributing Events to a model | +| `tokenRange` | V5 returned opaque Usage interval; requires `token_records` and snapshot; cannot combine with Event `range` | | `cursor` | Opaque evidence continuation, at most 1024 bytes, bound to Session, snapshot, analytics version and filters | | `limit` | Evidence items per page, 1–100; default 20 | @@ -296,6 +363,9 @@ V4 file and operation filters also bind it. V3/v4 ranges also bind it. V3/v4 evi `unknown` or `legacy`; `occurredAt` preserves the stored date or explicit null. `evidence.nextCursor` is absent at the end; continuation retains the same source token and filters. Each request rechecks authorization. +V5 retains v4 tools, files, activity and clock quality, adds required +`tokenActivity`, and binds model and Token range into continuation. Invalid range +and model combinations fail rather than silently changing scope. ## Snapshot consistency and reader opening diff --git a/packages/application/src/sessionAnalytics.test.ts b/packages/application/src/sessionAnalytics.test.ts index 9d58b7f..4b7805a 100644 --- a/packages/application/src/sessionAnalytics.test.ts +++ b/packages/application/src/sessionAnalytics.test.ts @@ -20,6 +20,14 @@ const emptyFileActivity = { callsWithActivity: 0, callsWithoutActivity: 0, uniqu const fileKey = `file:${"a".repeat(64)}` const fileValue: Extract = { ...activityValue, analyticsVersion: 4, summary: { ...value.summary, identifiedToolCalls: 0, unidentifiedToolCalls: 0 }, tools: [], fileActivity: emptyFileActivity } +const modelKey = `model:${"b".repeat(64)}` +const emptyCounter = { knownSubtotal: null, reportedSamples: 0, missingSamples: 0, overflowed: false } +const emptyTokens = { total: emptyCounter, input: emptyCounter, output: emptyCounter, cacheRead: emptyCounter, cacheWrite: emptyCounter } +const tokenValue: Extract = { ...fileValue, analyticsVersion: 5, + tokenActivity: { scopeThreadId: null, scopeModelKey: null, samples: 0, timedSamples: 0, untimedSamples: 0, + firstRecordedAt: null, lastRecordedAt: null, tokens: emptyTokens, untimedTokens: emptyTokens, + cache: { eligibleSamples: 0, missingSamples: 0, inputTokens: null, readTokens: null, writeTokens: null, + uncachedTokens: null, readShare: null, writeShare: null, uncachedShare: null }, models: [], threads: [], buckets: [] } } describe("SessionAnalytics Module", () => { it("offers tool filters only within the wire UTF-8 byte bound", () => { @@ -41,6 +49,15 @@ describe("SessionAnalytics Module", () => { expect(selectSessionAnalyticsEvidence({ metric: "unknown_tools", tool: key, thread: "root" }, "one")) .toEqual({ metric: "unknown_tools", tool: key, thread: "root", snapshot: "one" }) }) + it("keeps stable tool filtering and file/record evidence available in v5", async () => { + const tool = { key: `tool:${"c".repeat(64)}`, identity: null, name: "Read", kind: "read", + calls: 1, completed: 0, failed: 0, pending: 0, inProgress: 0, unknown: 1 } + expect(sessionAnalyticsToolSelections({ ...tokenValue, tools: [tool] })[0]!.filter).toBe(tool.key) + for (const query of [{ metric: "files" as const, file: fileKey }, { metric: "records" as const, snapshot: "one", range: "old-activity" }]) { + expect(await Effect.runPromise(readSessionAnalytics("session", query).pipe(Effect.provide(provide(tokenValue))))) + .toEqual(tokenValue) + } + }) it("reads through the remote Interface with a snapshot-bound evidence query", async () => { const query: SessionAnalyticsQuery = { snapshot: "one", metric: "failed_tools", thread: "root", tool: "Read", cursor: "next", limit: 20 } const layer = Layer.succeed(SessionAnalyticsGateway, SessionAnalyticsGateway.of({ read: (id, request) => { @@ -84,6 +101,78 @@ describe("SessionAnalytics Module", () => { } }) expect(await Effect.runPromise(readSessionAnalytics("session", query).pipe(Effect.provide(layer)))).toEqual(scoped) }) + it("selects token intervals with their model and Thread scope and clears incompatible evidence filters", () => { + const selected = selectSessionAnalyticsEvidence({ metric: "token_records", model: modelKey, thread: "child", tokenRange: "opaque-token", + range: "old-activity", tool: "Read", capability: "lookup", server: "docs", file: fileKey, operation: "read" }, "one") + expect(selected).toEqual({ metric: "token_records", model: modelKey, thread: "child", tokenRange: "opaque-token", snapshot: "one" }) + for (const changed of [{ ...selected, model: `model:${"c".repeat(64)}` }, { ...selected, thread: "root" }]) { + expect(selectSessionAnalyticsEvidence(changed, "one", selected)) + .toEqual({ metric: "records", model: changed.model, thread: changed.thread, snapshot: "one" }) + } + expect(selectSessionAnalyticsEvidence(selected, "replacement", selected)) + .toEqual({ metric: "records", model: modelKey, thread: "child", snapshot: "replacement" }) + expect(selectSessionAnalyticsEvidence({ ...selected, metric: "tools" }, "one", selected)) + .toEqual({ metric: "tools", model: modelKey, thread: "child", snapshot: "one" }) + expect(selectSessionAnalyticsEvidence({ metric: "tools", thread: "child" }, "one", selected)) + .toEqual({ metric: "tools", model: modelKey, thread: "child", snapshot: "one" }) + expect(selectSessionAnalyticsEvidence({ metric: "token_records", thread: "child", tokenRange: "opaque-token", model: null }, "one", selected)) + .toEqual({ metric: "records", thread: "child", snapshot: "one" }) + expect(selectSessionAnalyticsEvidence({ metric: "token_records", tokenRange: "opaque-token" })) + .toEqual({ metric: "records" }) + expect(selectSessionAnalyticsEvidence({ metric: "records", model: `model:${"c".repeat(64)}`, range: "old-activity" }, "one", + { metric: "records", model: modelKey, snapshot: "one", range: "old-activity" })) + .toEqual({ metric: "records", model: `model:${"c".repeat(64)}`, snapshot: "one" }) + }) + it("preserves the server token interval and pagination without claiming precise Usage-to-Event association", async () => { + const scoped = { ...tokenValue, activity: { ...tokenValue.activity, scopeThreadId: "child" }, + tokenActivity: { ...tokenValue.tokenActivity, scopeThreadId: "child", scopeModelKey: modelKey } } + const query: SessionAnalyticsQuery = { metric: "token_records", tokenRange: "opaque-token", model: modelKey, + thread: "child", snapshot: "one", cursor: "token-page", limit: 4 } + const layer = Layer.succeed(SessionAnalyticsGateway, { read: (id, request) => { + expect(id).toBe("session"); expect(request).toEqual(query); return Effect.succeed(scoped) + } }) + const report = await Effect.runPromise(readSessionAnalytics("session", query).pipe(Effect.provide(layer))) + expect(report).toEqual(scoped) + expect(report.usage.tokens.total).toBeNull() + }) + it.each(["tools", "records", "files", "thoughts"] as const)("retains the model report scope alongside %s evidence", async metric => { + const query = selectSessionAnalyticsEvidence({ metric, model: modelKey }, "one") + expect(query).toEqual({ metric, model: modelKey, snapshot: "one" }) + const scoped = { ...tokenValue, tokenActivity: { ...tokenValue.tokenActivity, scopeModelKey: modelKey } } + expect(await Effect.runPromise(readSessionAnalytics("session", query).pipe(Effect.provide(provide(scoped))))) + .toEqual(scoped) + }) + it.each([1, 2, 3, 4] as const)("retains v%s reports while refusing unsupported token requests", async analyticsVersion => { + const report: SessionAnalytics = analyticsVersion === 1 ? value : analyticsVersion === 2 + ? { ...value, analyticsVersion, capabilities: activityValue.capabilities } : analyticsVersion === 3 ? activityValue : fileValue + expect(await Effect.runPromise(readSessionAnalytics("session").pipe(Effect.provide(provide(report))))).toEqual(report) + for (const query of [{ model: modelKey }, { metric: "token_records" as const, tokenRange: "opaque-token", snapshot: "one" }]) { + expect(await Effect.runPromise(readSessionAnalytics("session", query).pipe(Effect.provide(provide(report)), Effect.flip))) + .toMatchObject({ reason: "http", status: 422 }) + } + }) + it.each([ + { tokenActivity: { ...tokenValue.tokenActivity, scopeThreadId: "other" } }, + { tokenActivity: { ...tokenValue.tokenActivity, scopeModelKey: modelKey } }, + { activity: { ...tokenValue.activity, scopeThreadId: "other" } } + ])("rejects mismatched v5 activity or token scope case %#", async altered => { + expect(await Effect.runPromise(readSessionAnalytics("session").pipe(Effect.provide(provide({ ...tokenValue, ...altered })), Effect.flip))) + .toMatchObject({ reason: "decode" }) + }) + it.each([ + { model: "raw-model-name" }, { model: `model:${"B".repeat(64)}` }, { model: "" }, + { metric: "token_records" as const }, { metric: "token_records" as const, tokenRange: "opaque-token" }, + { metric: "records" as const, snapshot: "one", tokenRange: "opaque-token" }, + { metric: "token_records" as const, snapshot: "one", tokenRange: "" }, + { metric: "token_records" as const, snapshot: "one", tokenRange: "界".repeat(171) }, + ...[{ range: "activity-range" }, { tool: "Read" }, { capability: "lookup" }, { server: "docs" }, + { file: fileKey }, { operation: "read" as const }].map(filter => + ({ metric: "token_records" as const, snapshot: "one", tokenRange: "opaque-token", ...filter })) + ])("rejects invalid token selections before remote IO case %#", async query => { + const layer = Layer.succeed(SessionAnalyticsGateway, { read: () => Effect.die("must not read") }) + expect(await Effect.runPromise(readSessionAnalytics("session", query).pipe(Effect.provide(layer), Effect.flip))) + .toMatchObject({ reason: "http", status: 422 }) + }) it.each([3, 4] as const)("rejects a v%s response for a different activity Thread scope", async analyticsVersion => { const result: SessionAnalytics = analyticsVersion === 3 ? activityValue : { ...activityValue, analyticsVersion, fileActivity: emptyFileActivity, diff --git a/packages/application/src/sessionAnalytics.ts b/packages/application/src/sessionAnalytics.ts index 7d2fe75..5e9719a 100644 --- a/packages/application/src/sessionAnalytics.ts +++ b/packages/application/src/sessionAnalytics.ts @@ -19,35 +19,45 @@ export class SessionAnalyticsGateway extends Context.Service new TextEncoder().encode(label).byteLength <= 500 -// The opaque v4 row key selects identity and kind together. Older Servers +// The opaque v4/v5 row key selects identity and kind together. Older Servers // select displayed labels and retain the original UTF-8 query bound. export const sessionAnalyticsToolSelections = (report: SessionAnalytics): ReadonlyArray<{ readonly tool: SessionAnalytics["tools"][number] readonly filter: string | null -}> => report.analyticsVersion === 4 +}> => report.analyticsVersion === 4 || report.analyticsVersion === 5 ? report.tools.map(tool => ({ tool, filter: tool.key })) : report.tools.map(tool => ({ tool, filter: canFilterSessionAnalyticsTool(tool.name) ? tool.name : null })) -export type SessionAnalyticsFilter = Pick +export type SessionAnalyticsFilter = Pick & { + // Model is an independent report scope: omitted intents retain it, while + // explicit null clears it. Null never enters the transport query. + readonly model?: string | null +} // Changing evidence selection clears incompatible filters and old ranges. -// Thread selection scopes activity and evidence; summary, tools and usage remain -// whole-Session reads. +// Thread selection scopes activity, token activity and evidence. Model selection +// scopes token activity only. Summary, tools and legacy usage stay Session-wide. export const selectSessionAnalyticsEvidence = (filter: SessionAnalyticsFilter, snapshot?: string, previous?: SessionAnalyticsQuery): SessionAnalyticsQuery => { - const metric = filter.metric ?? "tools" + const model = filter.model === undefined ? previous?.model : filter.model ?? undefined + const sameScope = previous === undefined || (previous.thread ?? "") === (filter.thread ?? "") && + (previous.model ?? "") === (model ?? "") && + (previous.snapshot === undefined || previous.snapshot === snapshot) + const tokenRange = filter.tokenRange && filter.metric === "token_records" && snapshot && sameScope + ? filter.tokenRange : undefined + const metric = filter.metric === "token_records" && tokenRange === undefined ? "records" : filter.metric ?? "tools" return { metric, ...(snapshot === undefined ? {} : { snapshot }), ...(filter.thread ? { thread: filter.thread } : {}), + ...(model === undefined ? {} : { model }), ...(filter.tool && ["tools", "failed_tools", "unknown_tools"].includes(metric) ? { tool: filter.tool } : {}), ...(filter.capability && ["mcp_tools", "skills"].includes(metric) ? { capability: filter.capability } : {}), ...(filter.server && metric === "mcp_tools" ? { server: filter.server } : {}), ...(filter.file && metric === "files" ? { file: filter.file } : {}), ...(filter.operation && metric === "files" ? { operation: filter.operation } : {}), - ...(filter.range && metric === "records" && snapshot && - (previous === undefined || (previous.thread ?? "") === (filter.thread ?? "") && - (previous.snapshot === undefined || previous.snapshot === snapshot)) ? { range: filter.range } : {}) + ...(filter.range && filter.metric === "records" && snapshot && sameScope ? { range: filter.range } : {}), + ...(tokenRange === undefined ? {} : { tokenRange }) } } @@ -57,6 +67,18 @@ export const readSessionAnalytics = Effect.fn("SessionAnalytics.read")(function* return yield* Effect.fail(new SessionAnalyticsGatewayError({ reason: "http", status: 422, message: "Analytics pagination requires a snapshot and a valid page size." })) } + if (query.model !== undefined && !/^model:[0-9a-f]{64}$/.test(query.model)) { + return yield* Effect.fail(new SessionAnalyticsGatewayError({ reason: "http", status: 422, + message: "Token activity requires a returned model key." })) + } + if ((query.tokenRange !== undefined || query.metric === "token_records") && + (!query.snapshot || query.metric !== "token_records" || query.tokenRange === undefined || + query.tokenRange.trim() === "" || new TextEncoder().encode(query.tokenRange).byteLength > 512 || + query.range !== undefined || query.tool !== undefined || query.capability !== undefined || query.server !== undefined || + query.file !== undefined || query.operation !== undefined)) { + return yield* Effect.fail(new SessionAnalyticsGatewayError({ reason: "http", status: 422, + message: "Token ranges require a snapshot and token records evidence without other evidence filters." })) + } if (query.range !== undefined && (!query.snapshot || query.metric !== "records" || query.tool !== undefined || query.capability !== undefined || query.server !== undefined || query.file !== undefined || query.operation !== undefined || query.range.trim() === "" || new TextEncoder().encode(query.range).byteLength > 512)) { @@ -80,17 +102,26 @@ export const readSessionAnalytics = Effect.fn("SessionAnalytics.read")(function* return yield* Effect.fail(new SessionAnalyticsGatewayError({ reason: "http", status: 409, code: "refresh_required", message: "The conversation changed. Reload its analytics." })) } - if (query.metric === "records" && result.analyticsVersion !== 3 && result.analyticsVersion !== 4) { + if (query.metric === "records" && result.analyticsVersion !== 3 && result.analyticsVersion !== 4 && result.analyticsVersion !== 5) { return yield* Effect.fail(new SessionAnalyticsGatewayError({ reason: "http", status: 422, message: "This Server does not provide records evidence. Reload its supported analysis." })) } - if (query.metric === "files" && result.analyticsVersion !== 4) { + if (query.metric === "files" && result.analyticsVersion !== 4 && result.analyticsVersion !== 5) { return yield* Effect.fail(new SessionAnalyticsGatewayError({ reason: "http", status: 422, message: "This Server does not provide file activity evidence. Reload its supported analysis." })) } - if ((result.analyticsVersion === 3 || result.analyticsVersion === 4) && result.activity.scopeThreadId !== (query.thread ?? null)) { + if ((query.model !== undefined || query.metric === "token_records") && result.analyticsVersion !== 5) { + return yield* Effect.fail(new SessionAnalyticsGatewayError({ reason: "http", status: 422, + message: "This Server does not provide token activity. Reload its supported analysis." })) + } + if ((result.analyticsVersion === 3 || result.analyticsVersion === 4 || result.analyticsVersion === 5) && result.activity.scopeThreadId !== (query.thread ?? null)) { return yield* Effect.fail(new SessionAnalyticsGatewayError({ reason: "decode", message: "The activity response belongs to a different Thread scope." })) } + if (result.analyticsVersion === 5 && (result.tokenActivity.scopeThreadId !== (query.thread ?? null) || + result.tokenActivity.scopeModelKey !== (query.model ?? null))) { + return yield* Effect.fail(new SessionAnalyticsGatewayError({ reason: "decode", + message: "The token activity response belongs to a different Thread or model scope." })) + } return result }) diff --git a/packages/domain/src/sessionAnalytics.test.ts b/packages/domain/src/sessionAnalytics.test.ts index ba97c0e..984aab3 100644 --- a/packages/domain/src/sessionAnalytics.test.ts +++ b/packages/domain/src/sessionAnalytics.test.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import { describe, expect, it } from "vitest" -import { SessionAnalytics, SessionAnalyticsActivity } from "./sessionAnalytics.ts" +import { SessionAnalytics, SessionAnalyticsActivity, SessionAnalyticsTokenActivity } from "./sessionAnalytics.ts" const activity = { scopeThreadId: null, eventCount: 2, recordedTimeEvents: 1, unknownTimeEvents: 1, legacyTimeEvents: 0, firstRecordedAt: "2026-10-12T00:00:00Z", lastRecordedAt: "2026-10-12T00:00:00Z", recordSpanMilliseconds: 0, @@ -14,6 +14,18 @@ const common = { snapshot: "snapshot", sessionId: "session", captureStatus: "hea label: "Record", occurredAt: null }] } } const capabilities = { mcpCalls: 0, skillCalls: 0, unclassifiedCalls: 0, mcp: [], skills: [] } const fileActivity = { callsWithActivity: 0, callsWithoutActivity: 1, uniquePaths: 0, readCalls: 0, editCalls: 0, searchCalls: 0, paths: [] } +const counter = { knownSubtotal: null, reportedSamples: 0, missingSamples: 1, overflowed: false } +const tokens = { total: counter, input: counter, output: counter, cacheRead: counter, cacheWrite: counter } +const emptyCounter = { ...counter, missingSamples: 0 } +const emptyTokens = { total: emptyCounter, input: emptyCounter, output: emptyCounter, cacheRead: emptyCounter, cacheWrite: emptyCounter } +const cache = { eligibleSamples: 0, missingSamples: 1, inputTokens: null, readTokens: null, writeTokens: null, + uncachedTokens: null, readShare: null, writeShare: null, uncachedShare: null } +const modelKey = `model:${"b".repeat(64)}` +const tokenActivity = { scopeThreadId: null, scopeModelKey: null, samples: 1, timedSamples: 1, untimedSamples: 0, + firstRecordedAt: "2026-10-13T00:00:00Z", lastRecordedAt: "2026-10-13T00:00:00Z", tokens, untimedTokens: emptyTokens, cache, + models: [{ key: modelKey, model: null, samples: 1, tokens, cache }], threads: [{ id: "root", samples: 1, tokens, cache }], + buckets: [{ from: "2026-10-13T00:00:00Z", to: "2026-10-13T00:00:00Z", includesEnd: true, + samples: 1, tokens, records: 0, range: "opaque-token-point" }] } const decode = Schema.decodeUnknownSync(SessionAnalytics) describe("Session Analytics versioned representation", () => { @@ -22,6 +34,7 @@ describe("Session Analytics versioned representation", () => { const v2 = decode({ ...common, analyticsVersion: 2, capabilities }) for (const value of [v1, v2]) { expect("activity" in value).toBe(false) + expect("tokenActivity" in value).toBe(false) expect("clockQuality" in value.evidence.items[0]!).toBe(false) } }) @@ -55,6 +68,38 @@ describe("Session Analytics versioned representation", () => { { ...v4, fileActivity: { ...fileActivity, callsWithoutActivity: -1 } } ]) expect(() => decode(malformed)).toThrow() }) + it("requires the complete v5 token report while keeping missing counters and empty original intervals explicit", () => { + const v5 = { ...common, analyticsVersion: 5, capabilities, activity, fileActivity, tokenActivity, + summary: { ...common.summary, identifiedToolCalls: 0, unidentifiedToolCalls: 0 }, + evidence: { items: common.evidence.items.map(item => ({ ...item, clockQuality: "unknown" })) } } + expect(decode(v5)).toEqual(v5) + const { tokenActivity: _tokens, ...missing } = v5 + expect(() => decode(missing)).toThrow() + expect(() => decode({ ...v5, tokenActivity: { samples: 0 } })).toThrow() + const v4 = decode({ ...missing, analyticsVersion: 4 }) + expect("tokenActivity" in v4).toBe(false) + expect(Schema.decodeUnknownSync(SessionAnalyticsTokenActivity)({ ...tokenActivity, scopeModelKey: modelKey, + tokens: { ...tokens, input: { knownSubtotal: 0, reportedSamples: 1, missingSamples: 0, overflowed: false } }, + cache: { ...cache, eligibleSamples: 1, missingSamples: 0, inputTokens: 0, readTokens: 0, writeTokens: 0, uncachedTokens: 0 } + })).toMatchObject({ scopeModelKey: modelKey, tokens: { input: { knownSubtotal: 0 } }, cache: { readShare: null } }) + }) + it.each([ + { samples: -1 }, { timedSamples: 1.5 }, { untimedSamples: Infinity }, { scopeModelKey: "raw-model" }, + { tokens: { ...tokens, input: { ...counter, knownSubtotal: Number.MAX_SAFE_INTEGER + 1 } } }, + { tokens: { ...tokens, total: { ...counter, missingSamples: -1 } } }, + { tokens: { ...tokens, cacheWrite: { ...counter, overflowed: "false" } } }, + { cache: { ...cache, eligibleSamples: -1 } }, { cache: { ...cache, inputTokens: -1 } }, + { cache: { ...cache, readShare: 1.01 } }, { cache: { ...cache, writeShare: -0.01 } }, + { cache: { ...cache, uncachedShare: Infinity } }, + { models: [{ ...tokenActivity.models[0], key: `model:${"B".repeat(64)}` }] }, + { models: Array.from({ length: 1001 }, () => tokenActivity.models[0]) }, + { threads: Array.from({ length: 5001 }, () => tokenActivity.threads[0]) }, + { buckets: Array.from({ length: 61 }, () => tokenActivity.buckets[0]) }, + { buckets: [{ ...tokenActivity.buckets[0], range: "界".repeat(171) }] }, + { buckets: [{ ...tokenActivity.buckets[0], records: -1 }] } + ])("rejects token activity beyond its transport bounds case %#", invalid => { + expect(() => Schema.decodeUnknownSync(SessionAnalyticsTokenActivity)({ ...tokenActivity, ...invalid })).toThrow() + }) it("preserves zero point spans and null all-unplaced spans", () => { expect(Schema.decodeUnknownSync(SessionAnalyticsActivity)(activity).recordSpanMilliseconds).toBe(0) const unplaced = { ...activity, recordedTimeEvents: 0, unknownTimeEvents: 2, diff --git a/packages/domain/src/sessionAnalytics.ts b/packages/domain/src/sessionAnalytics.ts index 2dff6ed..32e5ebe 100644 --- a/packages/domain/src/sessionAnalytics.ts +++ b/packages/domain/src/sessionAnalytics.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import { AcpToolIdentity, FileActivityOperation, FileActivityPath } from "./collector.ts" -export const SessionAnalyticsMetric = Schema.Literals(["tools", "failed_tools", "unknown_tools", "user_inputs", "thoughts", "threads", "mcp_tools", "skills", "records", "files"]) +export const SessionAnalyticsMetric = Schema.Literals(["tools", "failed_tools", "unknown_tools", "user_inputs", "thoughts", "threads", "mcp_tools", "skills", "records", "files", "token_records"]) export type SessionAnalyticsMetric = typeof SessionAnalyticsMetric.Type const Count = Schema.Number @@ -30,6 +30,43 @@ export const SessionAnalyticsTokens = Schema.Struct({ }) export type SessionAnalyticsTokens = typeof SessionAnalyticsTokens.Type +export const SessionAnalyticsModelKey = Schema.String.check(Schema.makeFilter(value => /^model:[0-9a-f]{64}$/.test(value))) +export const SessionAnalyticsTokenCounter = Schema.Struct({ + knownSubtotal: Schema.NullOr(ActivityCount), reportedSamples: ActivityCount, + missingSamples: ActivityCount, overflowed: Schema.Boolean +}) +export type SessionAnalyticsTokenCounter = typeof SessionAnalyticsTokenCounter.Type +export const SessionAnalyticsTokenAccounting = Schema.Struct({ + total: SessionAnalyticsTokenCounter, input: SessionAnalyticsTokenCounter, output: SessionAnalyticsTokenCounter, + cacheRead: SessionAnalyticsTokenCounter, cacheWrite: SessionAnalyticsTokenCounter +}) +export type SessionAnalyticsTokenAccounting = typeof SessionAnalyticsTokenAccounting.Type +const TokenShare = Schema.NullOr(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))) +export const SessionAnalyticsTokenCache = Schema.Struct({ + eligibleSamples: ActivityCount, missingSamples: ActivityCount, + inputTokens: Schema.NullOr(ActivityCount), readTokens: Schema.NullOr(ActivityCount), + writeTokens: Schema.NullOr(ActivityCount), uncachedTokens: Schema.NullOr(ActivityCount), + readShare: TokenShare, writeShare: TokenShare, uncachedShare: TokenShare +}) +export type SessionAnalyticsTokenCache = typeof SessionAnalyticsTokenCache.Type +export const SessionAnalyticsTokenActivity = Schema.Struct({ + scopeThreadId: Schema.NullOr(Schema.String), scopeModelKey: Schema.NullOr(SessionAnalyticsModelKey), + samples: ActivityCount, timedSamples: ActivityCount, untimedSamples: ActivityCount, + firstRecordedAt: Schema.NullOr(Schema.String), lastRecordedAt: Schema.NullOr(Schema.String), + tokens: SessionAnalyticsTokenAccounting, untimedTokens: SessionAnalyticsTokenAccounting, + cache: SessionAnalyticsTokenCache, + models: Schema.Array(Schema.Struct({ key: SessionAnalyticsModelKey, model: Schema.NullOr(Schema.String), + samples: ActivityCount, tokens: SessionAnalyticsTokenAccounting, cache: SessionAnalyticsTokenCache + })).check(Schema.isMaxLength(1000)), + threads: Schema.Array(Schema.Struct({ id: Schema.String, samples: ActivityCount, + tokens: SessionAnalyticsTokenAccounting, cache: SessionAnalyticsTokenCache + })).check(Schema.isMaxLength(5000)), + buckets: Schema.Array(Schema.Struct({ from: Schema.String, to: Schema.String, includesEnd: Schema.Boolean, + samples: ActivityCount, tokens: SessionAnalyticsTokenAccounting, records: ActivityCount, range: ActivityRange + })).check(Schema.isMaxLength(60)) +}) +export type SessionAnalyticsTokenActivity = typeof SessionAnalyticsTokenActivity.Type + const AnalyticsFields = { snapshot: Schema.String, head: Schema.optionalKey(Schema.String), @@ -78,6 +115,14 @@ const ActivityEvidence = Schema.Struct({ clockQuality: SessionAnalyticsClockQuality })), nextCursor: Schema.optionalKey(Schema.String) }) +const FileAnalyticsFields = { + fileActivity: SessionAnalyticsFileActivity, + summary: Schema.Struct({ ...AnalyticsFields.summary.fields, identifiedToolCalls: Count, unidentifiedToolCalls: Count }), + tools: Schema.Array(Schema.Struct({ key: StableToolKey, identity: Schema.NullOr(AcpToolIdentity), + name: Schema.String, kind: Schema.String, ...Outcomes })), + activity: SessionAnalyticsActivity, + evidence: ActivityEvidence +} export const SessionAnalyticsCapabilities = Schema.Struct({ mcpCalls: Count, skillCalls: Count, @@ -97,12 +142,10 @@ export const SessionAnalytics = Schema.Union([ evidence: ActivityEvidence }), Schema.Struct({ analyticsVersion: Schema.Literal(4), ...AnalyticsFields, capabilities: SessionAnalyticsCapabilities, - fileActivity: SessionAnalyticsFileActivity, - summary: Schema.Struct({ ...AnalyticsFields.summary.fields, identifiedToolCalls: Count, unidentifiedToolCalls: Count }), - tools: Schema.Array(Schema.Struct({ key: StableToolKey, identity: Schema.NullOr(AcpToolIdentity), - name: Schema.String, kind: Schema.String, ...Outcomes })), - activity: SessionAnalyticsActivity, - evidence: ActivityEvidence + ...FileAnalyticsFields + }), + Schema.Struct({ analyticsVersion: Schema.Literal(5), ...AnalyticsFields, capabilities: SessionAnalyticsCapabilities, + ...FileAnalyticsFields, tokenActivity: SessionAnalyticsTokenActivity }) ]) export type SessionAnalytics = typeof SessionAnalytics.Type @@ -115,6 +158,8 @@ export type SessionAnalyticsQuery = { readonly capability?: string readonly server?: string readonly range?: string + readonly model?: string + readonly tokenRange?: string readonly file?: string readonly operation?: typeof FileActivityOperation.Type readonly cursor?: string diff --git a/server/internal/adapters/httpapi/activity_handlers_test.go b/server/internal/adapters/httpapi/activity_handlers_test.go index c5b59e8..9d30c50 100644 --- a/server/internal/adapters/httpapi/activity_handlers_test.go +++ b/server/internal/adapters/httpapi/activity_handlers_test.go @@ -52,7 +52,11 @@ func TestActivityAcceptOptsInWithoutChangingDefaultJSON(t *testing.T) { {"application/vnd.atape.session-analytics.v4+json, " + activityAccept, 4, 200}, {"application/vnd.atape.session-analytics.v4+json;q=0, " + activityAccept, 3, 200}, {"application/vnd.atape.session-analytics.v4+json;q=bad", 0, 400}, - {"application/vnd.atape.session-analytics.v5+json", 2, 200}, + {"application/vnd.atape.session-analytics.v5+json", 5, 200}, + {"application/vnd.atape.session-analytics.v5+json, " + stableToolAccept, 5, 200}, + {"application/vnd.atape.session-analytics.v5+json;q=0, " + stableToolAccept, 4, 200}, + {"application/vnd.atape.session-analytics.v5+json;q=bad", 0, 400}, + {"application/vnd.atape.session-analytics.v6+json", 2, 200}, {"application/vnd.atape.session-analytics.v3+json;q=broken", 0, 400}, {"application/vnd.atape.session-analytics.v3+json;q=NaN", 0, 400}, {"application/vnd.atape.session-analytics.v3+json;q=1.1", 0, 400}, diff --git a/server/internal/adapters/httpapi/analytics_handlers.go b/server/internal/adapters/httpapi/analytics_handlers.go index 31ee880..a962480 100644 --- a/server/internal/adapters/httpapi/analytics_handlers.go +++ b/server/internal/adapters/httpapi/analytics_handlers.go @@ -26,9 +26,12 @@ func (h *Handler) readSessionAnalytics(w http.ResponseWriter, r *http.Request, e return } queryFields := []string{"snapshot", "metric", "thread", "tool", "capability", "server", "cursor", "limit", "range"} - if version == sessionanalytics.StableToolVersion { + if version >= sessionanalytics.StableToolVersion { queryFields = append(queryFields, "file", "operation") } + if version >= sessionanalytics.TokenActivityVersion { + queryFields = append(queryFields, "model", "tokenRange") + } values, ok := strictQuery(w, r, queryFields...) if !ok { return @@ -59,6 +62,7 @@ func (h *Handler) readSessionAnalytics(w http.ResponseWriter, r *http.Request, e Snapshot: values.Get("snapshot"), Metric: values.Get("metric"), Thread: values.Get("thread"), Tool: values.Get("tool"), Capability: values.Get("capability"), Server: values.Get("server"), Cursor: values.Get("cursor"), Limit: limit, AnalyticsVersion: version, Range: values.Get("range"), File: values.Get("file"), Operation: values.Get("operation"), + Model: values.Get("model"), TokenRange: values.Get("tokenRange"), }) if err != nil { writeError(w, r, err) @@ -84,6 +88,8 @@ func analyticsVersion(values []string) (int, bool) { selected = sessionanalytics.ActivityVersion case "application/vnd.atape.session-analytics.v4+json": selected = sessionanalytics.StableToolVersion + case "application/vnd.atape.session-analytics.v5+json": + selected = sessionanalytics.TokenActivityVersion } media, parameters, err := mime.ParseMediaType(strings.TrimSpace(item)) if err != nil { diff --git a/server/internal/adapters/httpapi/token_activity_handlers_test.go b/server/internal/adapters/httpapi/token_activity_handlers_test.go new file mode 100644 index 0000000..35faccb --- /dev/null +++ b/server/internal/adapters/httpapi/token_activity_handlers_test.go @@ -0,0 +1,129 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/SingleMai/ATape/server/internal/canonical" + "github.com/SingleMai/ATape/server/internal/conversation" + "github.com/SingleMai/ATape/server/internal/ingestion" + "github.com/SingleMai/ATape/server/internal/sessionanalytics" + "github.com/SingleMai/ATape/server/internal/testsupport/canonicalcontract" +) + +const tokenActivityAccept = "application/vnd.atape.session-analytics.v5+json, " + stableToolAccept + +func TestTokenActivityHTTPIntervalsCoverageAndConditionalReader(t *testing.T) { + store := canonical.NewMemoryStoreWithControlPlane(canonicalcontract.MemoryControlPlane()) + batch := canonicalcontract.ValidBatch() + batch.CanonicalProfileVersion = ingestion.StableToolCanonicalProfileVersion + first, second, last := batch.Events[0], batch.Events[1], batch.Events[1] + first.OccurredAt, second.OccurredAt = "2026-10-13T00:00:00Z", "2026-10-13T00:00:00Z" + last.SourceEventID, last.SourceOrder, last.OccurredAt = "last", 3, "2026-10-13T00:01:00Z" + unknown := last + unknown.SourceEventID, unknown.SourceOrder, unknown.OccurredAt, unknown.OccurredAtUnknown = "unknown-clock", 4, "", true + batch.Events = []ingestion.Event{first, second, last, unknown} + batch.Session.ReportedEventCount = len(batch.Events) + counter := func(n int64) *int64 { return &n } + usage := func(id, model, at string, input, output, read, write *int64) ingestion.Usage { + return ingestion.Usage{SourceUsageID: id, SourceThreadID: "provider-root", Revision: 1, Model: model, OccurredAt: at, + InputTokens: input, OutputTokens: output, CacheReadTokens: read, CacheWriteTokens: write} + } + batch.Usage = []ingestion.Usage{ + usage("one", "alpha", first.OccurredAt, counter(100), counter(20), counter(60), counter(10)), + usage("partial", "alpha", first.OccurredAt, counter(50), nil, counter(10), counter(0)), + usage("three", "alpha", last.OccurredAt, counter(200), counter(40), nil, counter(0)), + usage("outside-events", "beta", "2026-10-13T00:02:00Z", counter(300), counter(50), counter(0), counter(0)), + usage("unknown-model", "", first.OccurredAt, counter(10), counter(5), counter(0), counter(0)), + } + writer := ingestion.NewIngestor(store) + created, err := writer.ApplyBatch(t.Context(), canonicalcontract.CLIPrincipal(), batch) + if err != nil { + t.Fatal(err) + } + principal := canonicalcontract.WebPrincipal() + h := testHandlerWithConfig(t, Config{InstanceOrigin: "https://web.example.com", WebOrigin: "https://web.example.com", APIOrigin: "https://web.example.com", DevelopmentPrincipal: &principal}) + h.analytics, h.memory = sessionanalytics.New(store), conversation.NewMemory(store) + read := func(path, accept string, status int) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(http.MethodGet, path, nil) + r.Header.Set("Accept", accept) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != status || w.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("%s: %d %s", path, w.Code, w.Body.String()) + } + return w + } + route := "/api/v1/sessions/" + created.SessionID + "/analytics" + var initial sessionanalytics.Result + w := read(route, tokenActivityAccept, 200) + decodeResponse(t, w, &initial) + a := initial.TokenActivity + if initial.AnalyticsVersion != 5 || a == nil || a.Samples != 5 || a.TimedSamples != 5 || len(a.Buckets) != 60 || len(a.Models) != 3 || a.Cache.EligibleSamples != 4 || a.Cache.MissingSamples != 1 { + t.Fatalf("Token HTTP report: %+v", a) + } + if initial.Usage.Tokens.Total != nil || a.Tokens.Total.KnownSubtotal == nil || *a.Tokens.Total.KnownSubtotal != 725 || a.Tokens.Total.ReportedSamples != 4 || a.Tokens.Total.MissingSamples != 1 || *a.Cache.InputTokens != 460 { + t.Fatalf("coverage or old total semantics: %+v / %+v", a, initial.Usage) + } + if strings.Contains(w.Body.String(), "The retry needs") || strings.Contains(w.Body.String(), "sourceUsageId") { + t.Fatal("analysis exposed source contents or unneeded Usage identity") + } + firstBucket, lastBucket := a.Buckets[0], a.Buckets[len(a.Buckets)-1] + if firstBucket.Samples != 3 || firstBucket.Records != 2 || lastBucket.Samples != 1 || lastBucket.Records != 0 || !lastBucket.IncludesEnd { + t.Fatalf("Usage span confused with Event span: first=%+v last=%+v", firstBucket, lastBucket) + } + query := url.Values{"metric": {"token_records"}, "snapshot": {initial.Snapshot}, "tokenRange": {firstBucket.Range}, "limit": {"1"}} + var page sessionanalytics.Result + decodeResponse(t, read(route+"/evidence?"+query.Encode(), tokenActivityAccept, 200), &page) + if len(page.Evidence.Items) != 1 || page.Evidence.NextCursor == "" || page.Evidence.Items[0].ClockQuality != "recorded" { + t.Fatalf("first interval page: %+v", page.Evidence) + } + item := page.Evidence.Items[0] + reader := "/api/v1/sessions/" + created.SessionID + "?" + url.Values{"thread": {item.ThreadID}, "at": {item.EventID}, "snapshot": {initial.Snapshot}, "limit": {"1"}}.Encode() + read(reader, tokenActivityAccept, 200) + query.Set("cursor", page.Evidence.NextCursor) + var next sessionanalytics.Result + decodeResponse(t, read(route+"/evidence?"+query.Encode(), tokenActivityAccept, 200), &next) + if len(next.Evidence.Items) != 1 || next.Evidence.Items[0].EventID == item.EventID || next.Evidence.NextCursor != "" { + t.Fatalf("interval continuation: %+v", next.Evidence) + } + var empty sessionanalytics.Result + decodeResponse(t, read(route+"?"+url.Values{"metric": {"token_records"}, "snapshot": {initial.Snapshot}, "tokenRange": {lastBucket.Range}}.Encode(), tokenActivityAccept, 200), &empty) + if len(empty.Evidence.Items) != 0 { + t.Fatal("fabricated an original record for a Usage-only interval") + } + for _, path := range []string{route + "?metric=token_records", route + "?model=model:" + strings.Repeat("0", 64), route + "?metric=token_records&snapshot=" + url.QueryEscape(initial.Snapshot) + "&tokenRange=bad", route + "?" + query.Encode() + "&range=bad"} { + assertProblemEnvelope(t, read(path, tokenActivityAccept, 422), "validation_failed") + } + for _, path := range []string{route + "?tokenRange=", route + "?model=one&model=two", route + "?model=one"} { + accept := tokenActivityAccept + if path == route+"?model=one" { + accept = stableToolAccept + } + assertProblemEnvelope(t, read(path, accept, 400), "invalid_request") + } + for _, accept := range []string{"application/json", activityAccept, stableToolAccept} { + old := read(route, accept, 200) + var fields map[string]any + if err := json.Unmarshal(old.Body.Bytes(), &fields); err != nil { + t.Fatal(err) + } + if _, exists := fields["tokenActivity"]; exists { + t.Fatal("changed older analytics representation") + } + } + // Usage can change independently of Event and Session revisions. + batch.BatchID = "usage-only-change" + batch.Usage[0].Revision++ + batch.Usage[0].InputTokens = counter(101) + if _, err := writer.ApplyBatch(t.Context(), canonicalcontract.CLIPrincipal(), batch); err != nil { + t.Fatal(err) + } + assertProblemEnvelope(t, read(route+"/evidence?"+query.Encode(), tokenActivityAccept, 409), "refresh_required") + assertProblemEnvelope(t, read(reader, tokenActivityAccept, 409), "refresh_required") +} diff --git a/server/internal/adapters/postgres/analytics_activity_integration_test.go b/server/internal/adapters/postgres/analytics_activity_integration_test.go index da32285..f04abcd 100644 --- a/server/internal/adapters/postgres/analytics_activity_integration_test.go +++ b/server/internal/adapters/postgres/analytics_activity_integration_test.go @@ -84,6 +84,57 @@ func TestRecordedActivityPostgresContract(t *testing.T) { b.Session.ReportedEventCount = len(b.Events) return b } + t.Run("Token Usage scope, same-interval Reader and independent Usage freshness", func(t *testing.T) { + b := clockBatch("token-activity-native") + counter := func(n int64) *int64 { return &n } + b.Usage = []ingestion.Usage{ + {SourceUsageID: "one", SourceThreadID: "provider-root", Revision: 1, OccurredAt: b.Events[0].OccurredAt, Model: "alpha", InputTokens: counter(100), OutputTokens: counter(20), CacheReadTokens: counter(60), CacheWriteTokens: counter(10)}, + {SourceUsageID: "partial", SourceThreadID: "provider-root", Revision: 1, OccurredAt: b.Events[0].OccurredAt, Model: "alpha", InputTokens: counter(50), CacheReadTokens: counter(10), CacheWriteTokens: counter(0)}, + {SourceUsageID: "outside", SourceThreadID: "provider-root", Revision: 1, OccurredAt: "2026-10-12T00:02:00Z", Model: "beta", InputTokens: counter(300), OutputTokens: counter(50), CacheReadTokens: counter(0), CacheWriteTokens: counter(0)}, + } + created, e := ingestor.ApplyBatch(ctx, cli, b) + if e != nil { + t.Fatal(e) + } + view := open(created.SessionID, sessionanalytics.Query{AnalyticsVersion: 5}) + a := view.TokenActivity + if a == nil || a.Samples != 3 || a.TimedSamples != 3 || a.Tokens.Total.KnownSubtotal == nil || *a.Tokens.Total.KnownSubtotal != 470 || a.Tokens.Total.MissingSamples != 1 || view.Usage.Tokens.Total != nil || len(a.Buckets) != 60 || a.Buckets[59].Records != 0 || a.Buckets[59].Samples != 1 { + t.Fatalf("PostgreSQL Token accounting: %+v", a) + } + if a.Cache.EligibleSamples != 3 || a.Cache.ReadShare == nil || math.Abs(*a.Cache.ReadShare-70.0/450.0) > 1e-12 { + t.Fatalf("cache cohort: %+v", a.Cache) + } + q := sessionanalytics.Query{AnalyticsVersion: 5, Metric: "token_records", Snapshot: view.Snapshot, TokenRange: a.Buckets[0].Range} + page := open(created.SessionID, q) + if len(page.Evidence.Items) != 1 || page.Evidence.Items[0].ClockQuality != "recorded" { + t.Fatalf("Token interval evidence: %+v", page.Evidence) + } + item := page.Evidence.Items[0] + if _, ok, e := store.ConversationPage(ctx, web, created.SessionID, item.ThreadID, canonical.ConversationPageRequest{Snapshot: view.Snapshot, AtEventID: item.EventID, Limit: 1}); e != nil || !ok { + t.Fatalf("Token interval conditional Reader: %v %v", ok, e) + } + b.BatchID = "token-usage-only-change" + b.Usage[0].Revision++ + b.Usage[0].InputTokens = counter(101) + if _, e := ingestor.ApplyBatch(ctx, cli, b); e != nil { + t.Fatal(e) + } + _, e = module.Open(ctx, web, created.SessionID, q) + requireStale(e) + _, _, e = store.ConversationPage(ctx, web, created.SessionID, item.ThreadID, canonical.ConversationPageRequest{Snapshot: view.Snapshot, AtEventID: item.EventID, Limit: 1}) + requireStale(e) + fresh := open(created.SessionID, sessionanalytics.Query{AnalyticsVersion: 5}) + if fresh.Snapshot == view.Snapshot || *fresh.TokenActivity.Tokens.Input.KnownSubtotal != 451 { + t.Fatal("Usage-only change did not refresh Token activity") + } + outsider := web + outsider.UserID = "01991b70-4d2b-7c96-a532-5818faba2e72" + _, e = module.Open(ctx, outsider, created.SessionID, sessionanalytics.Query{AnalyticsVersion: 5, Metric: "token_records", Snapshot: fresh.Snapshot, TokenRange: fresh.TokenActivity.Buckets[0].Range}) + var concealed *sessionanalytics.NotFoundError + if !errors.As(e, &concealed) { + t.Fatalf("Token range granted access: %v", e) + } + }) t.Run("native profiles, range paging, conditional Reader and metadata freshness", func(t *testing.T) { legacy := canonicalcontract.ValidBatch() @@ -192,7 +243,7 @@ func TestRecordedActivityPostgresContract(t *testing.T) { if b.CanonicalProfileVersion == ingestion.ToolIdentityCanonicalProfileVersion { target = publication.ToolIdentityTargetProfile } - body, e := json.Marshal(publication.CanonicalPart{Target: publication.Target{Profile: target, Events: len(b.Events), Threads: len(b.Threads), RetainedThreadIDs: []string{}}, Batch: b}) + body, e := json.Marshal(publication.CanonicalPart{Target: publication.Target{Profile: target, Events: len(b.Events), Usage: len(b.Usage), Threads: len(b.Threads), RetainedThreadIDs: []string{}}, Batch: b}) if e != nil { t.Fatal(e) } @@ -215,6 +266,9 @@ func TestRecordedActivityPostgresContract(t *testing.T) { for _, profile := range []string{ingestion.UnknownTimeCanonicalProfileVersion, ingestion.ToolIdentityCanonicalProfileVersion} { b := clockBatch("activity-publication-" + profile) b.CanonicalProfileVersion = profile + input, output, read, write := int64(100), int64(20), int64(60), int64(10) + b.Usage = []ingestion.Usage{{SourceUsageID: "published-usage", SourceThreadID: "provider-root", Revision: 1, + OccurredAt: b.Events[1].OccurredAt, Model: "published-model", InputTokens: &input, OutputTokens: &output, CacheReadTokens: &read, CacheWriteTokens: &write}} attempt := stage(b, "") if _, ok, e := store.SessionAnalytics(ctx, web, attempt.SessionID, ""); e != nil || ok { t.Fatalf("pending candidate visible: %v %v", ok, e) @@ -227,6 +281,15 @@ func TestRecordedActivityPostgresContract(t *testing.T) { if view.Head != head.Head || view.Activity.RecordedTimeEvents != 2 || view.Activity.UnknownTimeEvents != 1 || view.Activity.LegacyTimeEvents != 0 { t.Fatalf("publication lost clock profile: %+v", view.Activity) } + tokens := open(attempt.SessionID, sessionanalytics.Query{AnalyticsVersion: 5}) + if tokens.Head != head.Head || tokens.TokenActivity.Samples != 1 || *tokens.TokenActivity.Tokens.Total.KnownSubtotal != 120 || len(tokens.TokenActivity.Buckets) != 1 || tokens.TokenActivity.Buckets[0].Records != 1 { + t.Fatalf("publication Token facts: %+v", tokens.TokenActivity) + } + tokenQuery := sessionanalytics.Query{AnalyticsVersion: 5, Metric: "token_records", Snapshot: tokens.Snapshot, TokenRange: tokens.TokenActivity.Buckets[0].Range} + tokenPage := open(attempt.SessionID, tokenQuery) + if len(tokenPage.Evidence.Items) != 1 { + t.Fatalf("publication Token interval: %+v", tokenPage.Evidence) + } selected := open(attempt.SessionID, sessionanalytics.Query{AnalyticsVersion: 3, Metric: "records", Snapshot: view.Snapshot, Range: view.Activity.Buckets[len(view.Activity.Buckets)-1].Range}) item := selected.Evidence.Items[0] if item.OccurredAt == nil || *item.OccurredAt != "2026-10-12T00:01:00.000001Z" { @@ -248,6 +311,8 @@ func TestRecordedActivityPostgresContract(t *testing.T) { if _, e = writer.Activate(ctx, cli, replacement.ID); e != nil { t.Fatal(e) } + _, e = module.Open(ctx, web, attempt.SessionID, tokenQuery) + requireStale(e) _, e = module.Open(ctx, web, attempt.SessionID, sessionanalytics.Query{AnalyticsVersion: 3, Metric: "records", Snapshot: view.Snapshot, Range: view.Activity.Buckets[0].Range}) requireStale(e) _, _, e = store.ConversationPage(ctx, web, attempt.SessionID, item.ThreadID, canonical.ConversationPageRequest{Snapshot: view.Snapshot, AtEventID: item.EventID, Limit: 1}) diff --git a/server/internal/sessionanalytics/analytics.go b/server/internal/sessionanalytics/analytics.go index ec94604..da9f6ea 100644 --- a/server/internal/sessionanalytics/analytics.go +++ b/server/internal/sessionanalytics/analytics.go @@ -24,15 +24,16 @@ import ( ) const ( - Version = 2 - ActivityVersion = 3 - StableToolVersion = 4 - MaxFacts = canonical.AnalyticsRecordLimit - MaxThreads = canonical.AnalyticsThreadLimit - MaxTools = 2000 - MaxModels = 1000 - MaxResponseBytes = 2 << 20 - maxSafeInteger int64 = 9007199254740991 + Version = 2 + ActivityVersion = 3 + StableToolVersion = 4 + TokenActivityVersion = 5 + MaxFacts = canonical.AnalyticsRecordLimit + MaxThreads = canonical.AnalyticsThreadLimit + MaxTools = 2000 + MaxModels = 1000 + MaxResponseBytes = 2 << 20 + maxSafeInteger int64 = 9007199254740991 ) var ErrCapacity = errors.New("session analysis exceeds supported capacity") @@ -48,8 +49,8 @@ type Module struct{ store Store } func New(store Store) *Module { return &Module{store: store} } type Query struct { - Snapshot, Metric, Thread, Tool, Capability, Server, Cursor, Range, File, Operation string - Limit, AnalyticsVersion int + Snapshot, Metric, Thread, Tool, Capability, Server, Cursor, Range, File, Operation, Model, TokenRange string + Limit, AnalyticsVersion int } type InvalidQueryError struct{ Field, Reason string } @@ -166,24 +167,26 @@ type Evidence struct { NextCursor string `json:"nextCursor,omitempty"` } type Result struct { - Snapshot string `json:"snapshot"` - Head string `json:"head,omitempty"` - AnalyticsVersion int `json:"analyticsVersion"` - SessionID string `json:"sessionId"` - CaptureStatus string `json:"captureStatus"` - Summary Summary `json:"summary"` - Tools []Tool `json:"tools"` - Capabilities Capabilities `json:"capabilities"` - Threads []Thread `json:"threads"` - Usage Usage `json:"usage"` - Evidence Evidence `json:"evidence"` - Activity *Activity `json:"activity,omitempty"` - FileActivity *FileActivity `json:"fileActivity,omitempty"` + Snapshot string `json:"snapshot"` + Head string `json:"head,omitempty"` + AnalyticsVersion int `json:"analyticsVersion"` + SessionID string `json:"sessionId"` + CaptureStatus string `json:"captureStatus"` + Summary Summary `json:"summary"` + Tools []Tool `json:"tools"` + Capabilities Capabilities `json:"capabilities"` + Threads []Thread `json:"threads"` + Usage Usage `json:"usage"` + Evidence Evidence `json:"evidence"` + Activity *Activity `json:"activity,omitempty"` + FileActivity *FileActivity `json:"fileActivity,omitempty"` + TokenActivity *TokenActivity `json:"tokenActivity,omitempty"` } -// Open always rechecks authorization at the snapshot Seam. Thread selects v3 -// activity scope; summary, tools, capabilities and usage remain Session-wide. -// Other filters select only the evidence page. +// Open always rechecks authorization at the snapshot Seam. Thread selects +// activity and token-activity scope; Model selects only token activity. Summary, +// tools, capabilities and usage remain Session-wide. Other filters select only +// the evidence page. func (m *Module) Open(ctx context.Context, principal authentication.Principal, sessionID string, query Query) (result Result, err error) { ctx, cancel := context.WithTimeout(ctx, 12*time.Second) defer cancel() @@ -247,9 +250,12 @@ func binding(sessionID string, q Query) string { if q.AnalyticsVersion >= ActivityVersion { encoded, _ = json.Marshal([]any{q.AnalyticsVersion, sessionID, q.Snapshot, q.Metric, q.Thread, q.Tool, q.Capability, q.Server, q.Range}) } - if q.AnalyticsVersion == StableToolVersion { + if q.AnalyticsVersion >= StableToolVersion { encoded, _ = json.Marshal([]any{q.AnalyticsVersion, sessionID, q.Snapshot, q.Metric, q.Thread, q.Tool, q.Capability, q.Server, q.Range, q.File, q.Operation}) } + if q.AnalyticsVersion >= TokenActivityVersion { + encoded, _ = json.Marshal([]any{q.AnalyticsVersion, sessionID, q.Snapshot, q.Metric, q.Thread, q.Tool, q.Capability, q.Server, q.Range, q.File, q.Operation, q.Model, q.TokenRange}) + } sum := sha256.Sum256(encoded) return hex.EncodeToString(sum[:]) } @@ -257,15 +263,15 @@ func validateQuery(sessionID string, q Query) (Query, int, error) { if q.AnalyticsVersion == 0 { q.AnalyticsVersion = Version } - if q.AnalyticsVersion != Version && q.AnalyticsVersion != ActivityVersion && q.AnalyticsVersion != StableToolVersion { + if q.AnalyticsVersion != Version && q.AnalyticsVersion != ActivityVersion && q.AnalyticsVersion != StableToolVersion && q.AnalyticsVersion != TokenActivityVersion { return q, 0, &InvalidQueryError{"analyticsVersion", "is not supported"} } - for field, value := range map[string]string{"sessionId": sessionID, "snapshot": q.Snapshot, "thread": q.Thread, "tool": q.Tool, "capability": q.Capability, "server": q.Server, "range": q.Range, "file": q.File, "operation": q.Operation} { + for field, value := range map[string]string{"sessionId": sessionID, "snapshot": q.Snapshot, "thread": q.Thread, "tool": q.Tool, "capability": q.Capability, "server": q.Server, "range": q.Range, "file": q.File, "operation": q.Operation, "model": q.Model, "tokenRange": q.TokenRange} { limit := 500 if field == "snapshot" { limit = 200 } - if field == "range" { + if field == "range" || field == "tokenRange" { limit = 512 } if len(value) > limit || !utf8.ValidString(value) { @@ -282,17 +288,27 @@ func validateQuery(sessionID string, q Query) (Query, int, error) { case "tools", "failed_tools", "unknown_tools", "user_inputs", "thoughts", "threads", "mcp_tools", "skills": case "records": if q.AnalyticsVersion < ActivityVersion { - return q, 0, &InvalidQueryError{"metric", "records requires analytics version 3 or 4"} + return q, 0, &InvalidQueryError{"metric", "records requires analytics version 3, 4 or 5"} } case "files": - if q.AnalyticsVersion != StableToolVersion { - return q, 0, &InvalidQueryError{"metric", "files requires analytics version 4"} + if q.AnalyticsVersion < StableToolVersion { + return q, 0, &InvalidQueryError{"metric", "files requires analytics version 4 or 5"} + } + case "token_records": + if q.AnalyticsVersion != TokenActivityVersion || q.Snapshot == "" || q.TokenRange == "" { + return q, 0, &InvalidQueryError{"metric", "token_records requires analytics version 5, snapshot and tokenRange"} } default: return q, 0, &InvalidQueryError{"metric", "is not supported"} } if q.Range != "" && (q.AnalyticsVersion < ActivityVersion || q.Metric != "records" || q.Snapshot == "") { - return q, 0, &InvalidQueryError{"range", "requires version 3 or 4 records and snapshot"} + return q, 0, &InvalidQueryError{"range", "requires version 3, 4 or 5 records and snapshot"} + } + if q.Model != "" && q.AnalyticsVersion < TokenActivityVersion { + return q, 0, &InvalidQueryError{"model", "requires analytics version 5"} + } + if q.TokenRange != "" && (q.AnalyticsVersion < TokenActivityVersion || q.Metric != "token_records" || q.Snapshot == "" || q.Range != "") { + return q, 0, &InvalidQueryError{"tokenRange", "requires version 5 token_records and snapshot without range"} } if q.Tool != "" && q.Metric != "tools" && q.Metric != "failed_tools" && q.Metric != "unknown_tools" { return q, 0, &InvalidQueryError{"tool", "only applies to tool metrics"} @@ -368,7 +384,7 @@ func derive(ctx context.Context, s canonical.AnalyticsSnapshot, q Query, offset } r := Result{Snapshot: s.SnapshotToken, Head: s.Head, AnalyticsVersion: q.AnalyticsVersion, SessionID: s.Session.ID, CaptureStatus: s.Session.CaptureStatus, Tools: []Tool{}, Capabilities: Capabilities{MCP: []MCPTool{}, Skills: []Skill{}}, Threads: []Thread{}, Usage: Usage{Models: []ModelUsage{}}, Evidence: Evidence{Items: []EvidenceItem{}}} - if q.AnalyticsVersion == StableToolVersion { + if q.AnalyticsVersion >= StableToolVersion { r.Summary.IdentifiedToolCalls, r.Summary.UnidentifiedToolCalls = new(int), new(int) } threads := make(map[string]*Thread, len(s.Threads)) @@ -424,7 +440,7 @@ func derive(ctx context.Context, s canonical.AnalyticsSnapshot, q Query, offset return Result{}, ErrCapacity } thread.EventCount++ - if q.Metric == "records" { + if q.Metric == "records" || q.Metric == "token_records" { candidates[event.ID] = evidence(event, "Record") } if event.OccurredAt.IsZero() { @@ -510,7 +526,7 @@ func derive(ctx context.Context, s canonical.AnalyticsSnapshot, q Query, offset capabilityNames, serverNames := make(map[string]bool), make(map[string]bool) for key, call := range calls { name, selector := call.name, call.name - if q.AnalyticsVersion == StableToolVersion { + if q.AnalyticsVersion >= StableToolVersion { name, selector = stableToolName(call.identity), stableToolKey(call.identity, call.kind) if call.identity == nil { *r.Summary.UnidentifiedToolCalls++ @@ -525,7 +541,7 @@ func derive(ctx context.Context, s canonical.AnalyticsSnapshot, q Query, offset return Result{}, ErrCapacity } group = &Tool{Name: name, Kind: call.kind} - if q.AnalyticsVersion == StableToolVersion { + if q.AnalyticsVersion >= StableToolVersion { group.Key, group.Identity = selector, call.identity } groups[groupKey] = group @@ -642,7 +658,7 @@ func derive(ctx context.Context, s canonical.AnalyticsSnapshot, q Query, offset } } } - if q.AnalyticsVersion == StableToolVersion { + if q.AnalyticsVersion >= StableToolVersion { var err error r.FileActivity, err = deriveFileActivity(ctx, calls, q, candidates) if err != nil { @@ -692,6 +708,15 @@ func derive(ctx context.Context, s canonical.AnalyticsSnapshot, q Query, offset } r.Activity = activity.report } + var tokenActivity *tokenActivitySelection + if q.AnalyticsVersion >= TokenActivityVersion { + var err error + tokenActivity, err = deriveTokenActivity(ctx, s, events, q) + if err != nil { + return Result{}, err + } + r.TokenActivity = tokenActivity.report + } matched := 0 for _, event := range events { item, ok := candidates[event.ID] @@ -701,6 +726,9 @@ func derive(ctx context.Context, s canonical.AnalyticsSnapshot, q Query, offset if activity != nil && !activity.matches(event) { continue } + if q.Metric == "token_records" && !tokenActivity.matches(event) { + continue + } if q.AnalyticsVersion >= ActivityVersion { item.ClockQuality = clockQuality(event) } diff --git a/server/internal/sessionanalytics/token_activity.go b/server/internal/sessionanalytics/token_activity.go new file mode 100644 index 0000000..ca05235 --- /dev/null +++ b/server/internal/sessionanalytics/token_activity.go @@ -0,0 +1,358 @@ +package sessionanalytics + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "math/big" + "sort" + "time" + + "github.com/SingleMai/ATape/server/internal/canonical" +) + +// TokenCounter is the subtotal of reported classifications, not a replacement +// for the complete-or-null Tokens contract. A reported zero remains a zero. +type TokenCounter struct { + KnownSubtotal *int64 `json:"knownSubtotal"` + ReportedSamples int `json:"reportedSamples"` + MissingSamples int `json:"missingSamples"` + Overflowed bool `json:"overflowed"` +} + +type TokenAccounting struct { + Total TokenCounter `json:"total"` + Input TokenCounter `json:"input"` + Output TokenCounter `json:"output"` + CacheRead TokenCounter `json:"cacheRead"` + CacheWrite TokenCounter `json:"cacheWrite"` +} + +// Cache shares use one shared cohort: all three classifications must be +// reported on the same sample. Inclusive input is the denominator. +type TokenCache struct { + EligibleSamples int `json:"eligibleSamples"` + MissingSamples int `json:"missingSamples"` + InputTokens *int64 `json:"inputTokens"` + ReadTokens *int64 `json:"readTokens"` + WriteTokens *int64 `json:"writeTokens"` + UncachedTokens *int64 `json:"uncachedTokens"` + ReadShare *float64 `json:"readShare"` + WriteShare *float64 `json:"writeShare"` + UncachedShare *float64 `json:"uncachedShare"` +} + +type TokenModel struct { + Key string `json:"key"` + Model *string `json:"model"` + Samples int `json:"samples"` + Tokens TokenAccounting `json:"tokens"` + Cache TokenCache `json:"cache"` +} + +type TokenThread struct { + ID string `json:"id"` + Samples int `json:"samples"` + Tokens TokenAccounting `json:"tokens"` + Cache TokenCache `json:"cache"` +} + +type TokenActivityBucket struct { + From string `json:"from"` + To string `json:"to"` + IncludesEnd bool `json:"includesEnd"` + Samples int `json:"samples"` + Tokens TokenAccounting `json:"tokens"` + Records int `json:"records"` + Range string `json:"range"` +} + +type TokenActivity struct { + ScopeThreadID *string `json:"scopeThreadId"` + ScopeModelKey *string `json:"scopeModelKey"` + Samples int `json:"samples"` + TimedSamples int `json:"timedSamples"` + UntimedSamples int `json:"untimedSamples"` + FirstRecordedAt *string `json:"firstRecordedAt"` + LastRecordedAt *string `json:"lastRecordedAt"` + Tokens TokenAccounting `json:"tokens"` + UntimedTokens TokenAccounting `json:"untimedTokens"` + Cache TokenCache `json:"cache"` + Models []TokenModel `json:"models"` + Threads []TokenThread `json:"threads"` + Buckets []TokenActivityBucket `json:"buckets"` +} + +type tokenCounterSum struct { + value int64 + reported, missing int + overflowed bool +} + +func validToken(value *int64) bool { + return value != nil && *value >= 0 && *value <= maxSafeInteger +} + +func (s *tokenCounterSum) add(value *int64) { + if !validToken(value) { + s.missing++ + return + } + s.reported++ + if s.overflowed || s.value > maxSafeInteger-*value { + s.overflowed = true + return + } + s.value += *value +} + +func (s *tokenCounterSum) get() TokenCounter { + result := TokenCounter{ReportedSamples: s.reported, MissingSamples: s.missing, Overflowed: s.overflowed} + if s.reported > 0 && !s.overflowed { + value := s.value + result.KnownSubtotal = &value + } + return result +} + +type tokenAccountingSum struct{ columns [5]tokenCounterSum } + +func (s *tokenAccountingSum) add(usage canonical.UsageRecord) { + switch { + case !validToken(usage.InputTokens) || !validToken(usage.OutputTokens): + s.columns[0].add(nil) + case *usage.InputTokens > maxSafeInteger-*usage.OutputTokens: + // Both classifications are reported. The sum cannot be serialized + // safely, which is arithmetic overflow rather than missing usage. + s.columns[0].reported++ + s.columns[0].overflowed = true + default: + total := *usage.InputTokens + *usage.OutputTokens + s.columns[0].add(&total) + } + for index, value := range []*int64{usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens} { + s.columns[index+1].add(value) + } +} + +func (s *tokenAccountingSum) get() TokenAccounting { + return TokenAccounting{Total: s.columns[0].get(), Input: s.columns[1].get(), Output: s.columns[2].get(), + CacheRead: s.columns[3].get(), CacheWrite: s.columns[4].get()} +} + +type tokenCacheSum struct { + eligible, missing int + columns [4]tokenCounterSum +} + +func (s *tokenCacheSum) add(usage canonical.UsageRecord) { + if !validToken(usage.InputTokens) || !validToken(usage.CacheReadTokens) || !validToken(usage.CacheWriteTokens) || + *usage.CacheReadTokens > *usage.InputTokens || *usage.CacheWriteTokens > *usage.InputTokens-*usage.CacheReadTokens { + s.missing++ + return + } + s.eligible++ + uncached := *usage.InputTokens - *usage.CacheReadTokens - *usage.CacheWriteTokens + for index, value := range []*int64{usage.InputTokens, usage.CacheReadTokens, usage.CacheWriteTokens, &uncached} { + s.columns[index].add(value) + } +} + +func (s *tokenCacheSum) get() TokenCache { + result := TokenCache{EligibleSamples: s.eligible, MissingSamples: s.missing, InputTokens: s.columns[0].get().KnownSubtotal, + ReadTokens: s.columns[1].get().KnownSubtotal, WriteTokens: s.columns[2].get().KnownSubtotal, UncachedTokens: s.columns[3].get().KnownSubtotal} + if result.InputTokens != nil && *result.InputTokens > 0 && result.ReadTokens != nil && result.WriteTokens != nil && result.UncachedTokens != nil { + read, write, uncached := float64(*result.ReadTokens)/float64(*result.InputTokens), float64(*result.WriteTokens)/float64(*result.InputTokens), float64(*result.UncachedTokens)/float64(*result.InputTokens) + result.ReadShare, result.WriteShare, result.UncachedShare = &read, &write, &uncached + } + return result +} + +type tokenGroup struct { + samples int + tokens tokenAccountingSum + cache tokenCacheSum +} + +func (g *tokenGroup) add(usage canonical.UsageRecord) { + g.samples++ + g.tokens.add(usage) + g.cache.add(usage) +} + +type tokenActivitySelection struct { + report *TokenActivity + boundaries []time.Time + selected int +} + +func modelKey(model string) string { + encoded, _ := json.Marshal([]string{"usage-model.v1", model}) + sum := sha256.Sum256(encoded) + return "model:" + hex.EncodeToString(sum[:]) +} + +func deriveTokenActivity(ctx context.Context, snapshot canonical.AnalyticsSnapshot, events []canonical.EventRecord, q Query) (*tokenActivitySelection, error) { + report := &TokenActivity{Models: []TokenModel{}, Threads: []TokenThread{}, Buckets: []TokenActivityBucket{}} + if q.Thread != "" { + thread := q.Thread + report.ScopeThreadID = &thread + } + if q.Model != "" { + model := q.Model + report.ScopeModelKey = &model + } + selection := &tokenActivitySelection{report: report, selected: -1} + var total tokenGroup + var untimed tokenAccountingSum + models, threads := make(map[string]*tokenGroup), make(map[string]*tokenGroup) + modelFound := q.Model == "" + var first, last time.Time + for index, usage := range snapshot.Usage { + if index%256 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } + key := modelKey(usage.Model) + matchesThread, matchesModel := q.Thread == "" || usage.ThreadID == q.Thread, q.Model == "" || q.Model == key + if q.Model == key { + modelFound = true + } + if matchesThread { + if models[usage.Model] == nil { + models[usage.Model] = &tokenGroup{} + } + models[usage.Model].add(usage) + } + if matchesModel { + if threads[usage.ThreadID] == nil { + threads[usage.ThreadID] = &tokenGroup{} + } + threads[usage.ThreadID].add(usage) + } + if !matchesThread || !matchesModel { + continue + } + total.add(usage) + if usage.OccurredAt.IsZero() { + report.UntimedSamples++ + untimed.add(usage) + continue + } + if report.TimedSamples == 0 || usage.OccurredAt.Before(first) { + first = usage.OccurredAt + } + if report.TimedSamples == 0 || usage.OccurredAt.After(last) { + last = usage.OccurredAt + } + report.TimedSamples++ + } + if !modelFound { + return nil, &InvalidQueryError{"model", "is outside this snapshot"} + } + report.Samples, report.Tokens, report.UntimedTokens, report.Cache = total.samples, total.tokens.get(), untimed.get(), total.cache.get() + for model, group := range models { + var label *string + if model != "" { + value := model + label = &value + } + report.Models = append(report.Models, TokenModel{Key: modelKey(model), Model: label, Samples: group.samples, Tokens: group.tokens.get(), Cache: group.cache.get()}) + } + sort.Slice(report.Models, func(i, j int) bool { + a, b := report.Models[i], report.Models[j] + if a.Model == nil || b.Model == nil { + return a.Model == nil && b.Model != nil + } + return *a.Model < *b.Model + }) + for thread, group := range threads { + report.Threads = append(report.Threads, TokenThread{ID: thread, Samples: group.samples, Tokens: group.tokens.get(), Cache: group.cache.get()}) + } + sort.Slice(report.Threads, func(i, j int) bool { return report.Threads[i].ID < report.Threads[j].ID }) + if report.TimedSamples > 0 { + from, to := first.UTC().Format(time.RFC3339Nano), last.UTC().Format(time.RFC3339Nano) + report.FirstRecordedAt, report.LastRecordedAt = &from, &to + firstNanos := epochNanos(first) + span := new(big.Int).Sub(epochNanos(last), firstNanos) + count := 1 + if span.Sign() > 0 { + count = 60 + if span.Cmp(big.NewInt(60)) < 0 { + count = int(span.Int64()) + } + } + for index := 0; index <= count; index++ { + offset := new(big.Int).Quo(new(big.Int).Mul(span, big.NewInt(int64(index))), big.NewInt(int64(count))) + selection.boundaries = append(selection.boundaries, nanosTime(new(big.Int).Add(firstNanos, offset))) + } + for index := 0; index < count; index++ { + report.Buckets = append(report.Buckets, TokenActivityBucket{From: selection.boundaries[index].Format(time.RFC3339Nano), + To: selection.boundaries[index+1].Format(time.RFC3339Nano), IncludesEnd: index == count-1, + Range: tokenRange(snapshot, q, index)}) + } + bucketTokens := make([]tokenAccountingSum, count) + for index, usage := range snapshot.Usage { + if index%256 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } + if q.Thread != "" && usage.ThreadID != q.Thread || q.Model != "" && modelKey(usage.Model) != q.Model || usage.OccurredAt.IsZero() { + continue + } + bucket := selection.bucket(usage.OccurredAt) + report.Buckets[bucket].Samples++ + bucketTokens[bucket].add(usage) + } + for index := range report.Buckets { + report.Buckets[index].Tokens = bucketTokens[index].get() + } + for index, event := range events { + if index%256 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } + if q.Thread != "" && event.ThreadID != q.Thread || clockQuality(event) != "recorded" || event.OccurredAt.Before(first) || event.OccurredAt.After(last) { + continue + } + report.Buckets[selection.bucket(event.OccurredAt)].Records++ + } + } + if q.TokenRange != "" { + for index, bucket := range report.Buckets { + if q.TokenRange == bucket.Range { + selection.selected = index + return selection, nil + } + } + return nil, &InvalidQueryError{"tokenRange", "must select a token interval from this session, snapshot, version, model and thread"} + } + return selection, nil +} + +func tokenRange(snapshot canonical.AnalyticsSnapshot, q Query, index int) string { + encoded, _ := json.Marshal([]any{"token-range.v1", q.AnalyticsVersion, snapshot.Session.ID, snapshot.SnapshotToken, q.Thread, q.Model, index}) + sum := sha256.Sum256(encoded) + return "tokens:" + hex.EncodeToString(sum[:]) +} + +func (s *tokenActivitySelection) bucket(at time.Time) int { + for index := range s.report.Buckets { + if at.Before(s.boundaries[index+1]) { + return index + } + } + return len(s.report.Buckets) - 1 +} + +func (s *tokenActivitySelection) matches(event canonical.EventRecord) bool { + if s.selected < 0 || clockQuality(event) != "recorded" { + return false + } + from, to := s.boundaries[s.selected], s.boundaries[s.selected+1] + return !event.OccurredAt.Before(from) && (event.OccurredAt.Before(to) || s.report.Buckets[s.selected].IncludesEnd && event.OccurredAt.Equal(to)) +} diff --git a/server/internal/sessionanalytics/token_activity_test.go b/server/internal/sessionanalytics/token_activity_test.go new file mode 100644 index 0000000..a83ba29 --- /dev/null +++ b/server/internal/sessionanalytics/token_activity_test.go @@ -0,0 +1,351 @@ +package sessionanalytics_test + +import ( + "context" + "encoding/json" + "errors" + "math" + "reflect" + "regexp" + "strings" + "testing" + "time" + + "github.com/SingleMai/ATape/server/internal/canonical" + "github.com/SingleMai/ATape/server/internal/sessionanalytics" +) + +func tokenUsage(id, thread, model string, at time.Time, input, output, read, write *int64) canonical.UsageRecord { + return canonical.UsageRecord{SourceKey: id, SessionID: "session", ThreadID: thread, Model: model, OccurredAt: at, + InputTokens: input, OutputTokens: output, CacheReadTokens: read, CacheWriteTokens: write} +} + +func tokenSnapshot() canonical.AnalyticsSnapshot { + s := emptySnapshot() + s.Threads = append(s.Threads, canonical.ThreadRecord{ID: "child", SessionID: "session", Label: "Child", ParentThreadID: ptr("root")}) + at := time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC) + s.Usage = []canonical.UsageRecord{ + tokenUsage("a", "root", "model-a", at, ptr(int64(100)), ptr(int64(20)), ptr(int64(30)), ptr(int64(10))), + tokenUsage("b", "root", "model-a", at.Add(60*time.Nanosecond), ptr(int64(50)), nil, nil, ptr(int64(0))), + tokenUsage("c", "child", "model-b", at.Add(30*time.Nanosecond), ptr(int64(200)), ptr(int64(40)), ptr(int64(80)), ptr(int64(20))), + tokenUsage("untimed", "root", "", time.Time{}, ptr(int64(7)), ptr(int64(3)), ptr(int64(0)), ptr(int64(0))), + } + s.Events = []canonical.EventRecord{ + recorded(event("before", "root", "message", 1), at.Add(-time.Nanosecond)), + recorded(event("root-start", "root", "message", 2), at), + recorded(event("child-middle", "child", "thought", 1), at.Add(30*time.Nanosecond)), + recorded(event("root-end", "root", "message", 3), at.Add(60*time.Nanosecond)), + recorded(event("after", "root", "message", 4), at.Add(61*time.Nanosecond)), + event("unknown", "root", "message", 5), + } + legacy := event("legacy", "root", "message", 6) + legacy.OccurredAt, legacy.SchemaVersion = at, "atape.acp-centered.v2" + s.Events = append(s.Events, legacy) + return s +} + +func subtotal(t *testing.T, counter sessionanalytics.TokenCounter, value *int64, reported, missing int, overflowed bool) { + t.Helper() + if !reflect.DeepEqual(counter.KnownSubtotal, value) || counter.ReportedSamples != reported || counter.MissingSamples != missing || counter.Overflowed != overflowed { + t.Fatalf("counter %+v; want subtotal=%v reported=%d missing=%d overflow=%t", counter, value, reported, missing, overflowed) + } +} + +func TestTokenActivityPreservesCompleteTotalsAndReportsKnownCoverage(t *testing.T) { + s := tokenSnapshot() + r := open(t, s, sessionanalytics.Query{AnalyticsVersion: 5}) + a := r.TokenActivity + if a == nil || a.ScopeThreadID != nil || a.ScopeModelKey != nil || a.Samples != 4 || a.TimedSamples != 3 || a.UntimedSamples != 1 { + t.Fatalf("scope/coverage: %+v", a) + } + if r.Usage.Tokens.Total != nil || r.Usage.Tokens.Output != nil || r.Usage.Tokens.CacheRead != nil || *r.Usage.Tokens.Input != 357 { + t.Fatalf("existing complete-or-null totals changed: %+v", r.Usage.Tokens) + } + subtotal(t, a.Tokens.Total, ptr(int64(370)), 3, 1, false) + subtotal(t, a.Tokens.Input, ptr(int64(357)), 4, 0, false) + subtotal(t, a.Tokens.Output, ptr(int64(63)), 3, 1, false) + subtotal(t, a.Tokens.CacheRead, ptr(int64(110)), 3, 1, false) + subtotal(t, a.UntimedTokens.Total, ptr(int64(10)), 1, 0, false) + cache := a.Cache + if cache.EligibleSamples != 3 || cache.MissingSamples != 1 || *cache.InputTokens != 307 || *cache.ReadTokens != 110 || *cache.WriteTokens != 30 || *cache.UncachedTokens != 167 || math.Abs(*cache.ReadShare-110.0/307.0) > 1e-15 || math.Abs(*cache.WriteShare-30.0/307.0) > 1e-15 || math.Abs(*cache.UncachedShare-167.0/307.0) > 1e-15 { + t.Fatalf("cache did not use one paired cohort: %+v", cache) + } + if len(a.Models) != 3 || a.Models[0].Model != nil || a.Models[0].Samples != 1 || len(a.Threads) != 2 { + t.Fatalf("groups: %+v %+v", a.Models, a.Threads) + } + for _, model := range a.Models { + if !regexp.MustCompile(`^model:[0-9a-f]{64}$`).MatchString(model.Key) { + t.Fatalf("model key: %+v", model) + } + } + if *a.FirstRecordedAt != "2026-10-13T00:00:00Z" || *a.LastRecordedAt != "2026-10-13T00:00:00.00000006Z" || len(a.Buckets) != 60 { + t.Fatalf("usage dates/buckets: %+v", a) + } + samples, records := 0, 0 + for _, bucket := range a.Buckets { + samples += bucket.Samples + records += bucket.Records + } + if samples != 3 || records != 3 || a.Buckets[0].Samples != 1 || a.Buckets[30].Samples != 1 || a.Buckets[59].Samples != 1 || a.Buckets[59].Records != 1 { + t.Fatalf("dated usage/Event coverage: %+v", a.Buckets) + } + if !reflect.DeepEqual(open(t, s, sessionanalytics.Query{AnalyticsVersion: 4}).Usage, r.Usage) { + t.Fatal("v5 changed existing Usage") + } +} + +func TestTokenActivityScopesReportAndOptionsWithoutChangingExistingStatistics(t *testing.T) { + s := tokenSnapshot() + all := open(t, s, sessionanalytics.Query{AnalyticsVersion: 5}) + var key string + for _, model := range all.TokenActivity.Models { + if model.Model != nil && *model.Model == "model-a" { + key = model.Key + } + } + root := open(t, s, sessionanalytics.Query{AnalyticsVersion: 5, Thread: "root", Model: key}) + a := root.TokenActivity + if *a.ScopeThreadID != "root" || *a.ScopeModelKey != key || a.Samples != 2 || a.TimedSamples != 2 || a.UntimedSamples != 0 || len(a.Models) != 2 || len(a.Threads) != 1 || a.Threads[0].ID != "root" { + t.Fatalf("AND scope or independent options: %+v", a) + } + if !reflect.DeepEqual(root.Usage, all.Usage) || !reflect.DeepEqual(root.Summary, all.Summary) || !reflect.DeepEqual(root.Tools, all.Tools) || !reflect.DeepEqual(root.FileActivity, all.FileActivity) { + t.Fatal("token scope changed Session-wide statistics") + } + // A model chooses Usage and its intervals; Events have no model identity. + // Contemporaneous child context remains navigable in an all-Thread scope. + modelOnly := open(t, s, sessionanalytics.Query{AnalyticsVersion: 5, Model: key}) + middle := modelOnly.TokenActivity.Buckets[30] + context := open(t, s, sessionanalytics.Query{AnalyticsVersion: 5, Model: key, Metric: "token_records", Snapshot: modelOnly.Snapshot, TokenRange: middle.Range}) + if middle.Samples != 0 || middle.Records != 1 || len(context.Evidence.Items) != 1 || context.Evidence.Items[0].EventID != "child-middle" { + t.Fatalf("model membership guessed for Event context: %+v %+v", middle, context.Evidence) + } + // A globally present model with no usage in this Thread is an honest empty + // AND scope, and both selectors retain their independent option lists. + empty := open(t, s, sessionanalytics.Query{AnalyticsVersion: 5, Thread: "child", Model: key}).TokenActivity + if empty.Samples != 0 || empty.Tokens.Total.KnownSubtotal != nil || len(empty.Buckets) != 0 || len(empty.Models) != 1 || *empty.Models[0].Model != "model-b" || len(empty.Threads) != 1 || empty.Threads[0].ID != "root" { + t.Fatalf("empty combination/options: %+v", empty) + } + for _, model := range []string{"model-a", "model:" + strings.Repeat("0", 64)} { + _, err := sessionanalytics.New(&snapshotStore{snapshot: s}).Open(t.Context(), principal, "session", sessionanalytics.Query{AnalyticsVersion: 5, Model: model}) + var invalid *sessionanalytics.InvalidQueryError + if !errors.As(err, &invalid) || invalid.Field != "model" { + t.Fatalf("nonmember/raw model accepted: %v", err) + } + } +} + +func TestTokenRangesReturnOnlyActualContemporaneousRecordedEvents(t *testing.T) { + s := tokenSnapshot() + module := sessionanalytics.New(&snapshotStore{snapshot: s}) + r, err := module.Open(t.Context(), principal, "session", sessionanalytics.Query{AnalyticsVersion: 5}) + if err != nil { + t.Fatal(err) + } + for index, bucket := range r.TokenActivity.Buckets { + selected, e := module.Open(t.Context(), principal, "session", sessionanalytics.Query{AnalyticsVersion: 5, Metric: "token_records", Snapshot: r.Snapshot, TokenRange: bucket.Range, Limit: 100}) + if e != nil || len(selected.Evidence.Items) != bucket.Records || !reflect.DeepEqual(selected.TokenActivity, r.TokenActivity) { + t.Fatalf("bucket %d count/navigation: %+v %v", index, selected, e) + } + for _, item := range selected.Evidence.Items { + if item.ClockQuality != "recorded" || item.EventID == "before" || item.EventID == "after" || item.EventID == "unknown" || item.EventID == "legacy" { + t.Fatalf("guessed or unqualified anchor: %+v", item) + } + } + } + // Usage can exist entirely outside the Event span, and its interval has no + // nearest-Event fallback. The report still retains its usage. + s.Usage = s.Usage[:1] + s.Usage[0].OccurredAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + store := &snapshotStore{snapshot: s} + module = sessionanalytics.New(store) + r, err = module.Open(t.Context(), principal, "session", sessionanalytics.Query{AnalyticsVersion: 5}) + if err != nil || r.TokenActivity.Buckets[0].Samples != 1 || r.TokenActivity.Buckets[0].Records != 0 { + t.Fatalf("out-of-Event-span usage lost: %+v %v", r.TokenActivity, err) + } + page, err := module.Open(t.Context(), principal, "session", sessionanalytics.Query{AnalyticsVersion: 5, Metric: "token_records", Snapshot: r.Snapshot, TokenRange: r.TokenActivity.Buckets[0].Range}) + if err != nil || len(page.Evidence.Items) != 0 { + t.Fatalf("nearest Event invented: %+v %v", page.Evidence, err) + } +} + +func TestTokenRangePageBindingsAndAuthorizationRemainConditional(t *testing.T) { + s := tokenSnapshot() + s.Usage = s.Usage[:1] + s.Events = nil + for index := 0; index < 25; index++ { + s.Events = append(s.Events, recorded(event(strings.Repeat("x", index+1), "root", "message", int64(index)), s.Usage[0].OccurredAt)) + } + store := &snapshotStore{snapshot: s} + module := sessionanalytics.New(store) + r, err := module.Open(t.Context(), principal, "session", sessionanalytics.Query{AnalyticsVersion: 5}) + if err != nil { + t.Fatal(err) + } + q := sessionanalytics.Query{AnalyticsVersion: 5, Metric: "token_records", Snapshot: r.Snapshot, TokenRange: r.TokenActivity.Buckets[0].Range, Limit: 5} + page, err := module.Open(t.Context(), principal, "session", q) + if err != nil || page.Evidence.NextCursor == "" || len(page.Evidence.Items) != 5 { + t.Fatalf("first page: %+v %v", page.Evidence, err) + } + q.Cursor, q.Limit = page.Evidence.NextCursor, 7 + next, err := module.Open(t.Context(), principal, "session", q) + if err != nil || len(next.Evidence.Items) != 7 || next.Evidence.Items[0].EventID != strings.Repeat("x", 6) { + t.Fatalf("continuation: %+v %v", next.Evidence, err) + } + for _, mutate := range []func(*sessionanalytics.Query){ + func(q *sessionanalytics.Query) { q.Thread = "root" }, + func(q *sessionanalytics.Query) { q.Model = r.TokenActivity.Models[0].Key }, + func(q *sessionanalytics.Query) { q.TokenRange += "x" }, + func(q *sessionanalytics.Query) { q.Metric, q.TokenRange = "records", "" }, + } { + changed := q + mutate(&changed) + _, e := module.Open(t.Context(), principal, "session", changed) + var invalid *sessionanalytics.InvalidQueryError + if !errors.As(e, &invalid) || invalid.Field != "cursor" { + t.Fatalf("changed cursor binding accepted: %+v %v", changed, e) + } + changed.Cursor = "" + if changed.Metric == "token_records" { + if _, e = module.Open(t.Context(), principal, "session", changed); e == nil { + t.Fatalf("changed scope range accepted: %+v", changed) + } + } + } + store.snapshot.SnapshotToken = "changed" + if _, err = module.Open(t.Context(), principal, "session", q); err == nil { + t.Fatal("stale usage snapshot accepted") + } + store.snapshot, store.missing = s, true + if _, err = module.Open(t.Context(), principal, "session", q); err == nil { + t.Fatal("range granted revoked access") + } +} + +func TestTokenCountersKeepZerosMissingAndOverflowDistinct(t *testing.T) { + for _, test := range []struct { + name string + usage []canonical.UsageRecord + check func(*testing.T, *sessionanalytics.TokenActivity) + }{ + {"empty", nil, func(t *testing.T, a *sessionanalytics.TokenActivity) { + subtotal(t, a.Tokens.Total, nil, 0, 0, false) + if a.Cache.InputTokens != nil || a.Cache.ReadShare != nil || a.FirstRecordedAt != nil || len(a.Buckets) != 0 || a.Buckets == nil { + t.Fatalf("empty became zero: %+v", a) + } + }}, + {"missing", []canonical.UsageRecord{tokenUsage("missing", "root", "", time.Time{}, nil, nil, nil, nil)}, func(t *testing.T, a *sessionanalytics.TokenActivity) { + subtotal(t, a.Tokens.Total, nil, 0, 1, false) + if a.Cache.EligibleSamples != 0 || a.Cache.MissingSamples != 1 || a.Cache.ReadShare != nil || a.UntimedSamples != 1 { + t.Fatalf("missing coverage: %+v", a) + } + }}, + {"zero", []canonical.UsageRecord{tokenUsage("zero", "root", "", time.Time{}, ptr(int64(0)), ptr(int64(0)), ptr(int64(0)), ptr(int64(0)))}, func(t *testing.T, a *sessionanalytics.TokenActivity) { + subtotal(t, a.Tokens.Total, ptr(int64(0)), 1, 0, false) + if a.Cache.EligibleSamples != 1 || a.Cache.InputTokens == nil || *a.Cache.InputTokens != 0 || a.Cache.ReadShare != nil || a.Cache.WriteShare != nil || a.Cache.UncachedShare != nil { + t.Fatalf("zero denominator became a ratio: %+v", a.Cache) + } + }}, + {"aggregate-overflow", []canonical.UsageRecord{ + tokenUsage("max", "root", "", time.Time{}, ptr(int64(9007199254740991)), ptr(int64(0)), ptr(int64(0)), ptr(int64(0))), + tokenUsage("one", "root", "", time.Time{}, ptr(int64(1)), ptr(int64(1)), ptr(int64(0)), ptr(int64(0))), + }, func(t *testing.T, a *sessionanalytics.TokenActivity) { + subtotal(t, a.Tokens.Total, nil, 2, 0, true) + subtotal(t, a.Tokens.Input, nil, 2, 0, true) + subtotal(t, a.Tokens.Output, ptr(int64(1)), 2, 0, false) + if a.Cache.EligibleSamples != 2 || a.Cache.InputTokens != nil || a.Cache.ReadShare != nil || a.Cache.UncachedShare != nil { + t.Fatalf("overflow created unsafe cache totals/ratios: %+v", a.Cache) + } + }}, + {"same-sample-total-overflow", []canonical.UsageRecord{ + tokenUsage("overflow", "root", "", time.Time{}, ptr(int64(9007199254740991)), ptr(int64(1)), ptr(int64(0)), ptr(int64(0))), + tokenUsage("zero-after-overflow", "root", "", time.Time{}, ptr(int64(0)), ptr(int64(0)), ptr(int64(0)), ptr(int64(0))), + }, func(t *testing.T, a *sessionanalytics.TokenActivity) { + subtotal(t, a.Tokens.Total, nil, 2, 0, true) + subtotal(t, a.Tokens.Input, ptr(int64(9007199254740991)), 2, 0, false) + subtotal(t, a.Tokens.Output, ptr(int64(1)), 2, 0, false) + }}, + {"invalid-sample", []canonical.UsageRecord{ + tokenUsage("bad", "root", "", time.Time{}, ptr(int64(-1)), ptr(int64(9007199254740992)), ptr(int64(3)), ptr(int64(4))), + tokenUsage("bad-cache", "root", "", time.Time{}, ptr(int64(5)), ptr(int64(1)), ptr(int64(3)), ptr(int64(4))), + }, func(t *testing.T, a *sessionanalytics.TokenActivity) { + subtotal(t, a.Tokens.Total, ptr(int64(6)), 1, 1, false) + subtotal(t, a.Tokens.Input, ptr(int64(5)), 1, 1, false) + if a.Cache.EligibleSamples != 0 || a.Cache.MissingSamples != 2 || a.Cache.ReadShare != nil { + t.Fatalf("invalid sample entered cache cohort: %+v", a.Cache) + } + }}, + } { + t.Run(test.name, func(t *testing.T) { + s := emptySnapshot() + s.Usage = test.usage + test.check(t, open(t, s, sessionanalytics.Query{AnalyticsVersion: 5}).TokenActivity) + }) + } +} + +func TestTokenActivitySupportsFullYearAndNanosecondSpansIndependentlyOfEventProfiles(t *testing.T) { + for _, dates := range [][2]string{{"0000-01-01T00:00:00.000000001Z", "9999-12-31T23:59:59.999999999Z"}, {"2026-10-13T00:00:00.000000001Z", "2026-10-13T00:00:00.000000003Z"}} { + t.Run(dates[0], func(t *testing.T) { + first, _ := time.Parse(time.RFC3339Nano, dates[0]) + last, _ := time.Parse(time.RFC3339Nano, dates[1]) + s := emptySnapshot() + s.Usage = []canonical.UsageRecord{tokenUsage("first", "root", "", first, nil, nil, nil, nil), tokenUsage("last", "root", "", last, nil, nil, nil, nil)} + // Stored usage time is not promoted from any Event profile. Legacy + // Events stay ineligible for contemporaneous-record navigation. + e := event("legacy", "root", "message", 1) + e.OccurredAt, e.SchemaVersion = first, "atape.acp-centered.v1" + s.Events = []canonical.EventRecord{e} + a := open(t, s, sessionanalytics.Query{AnalyticsVersion: 5}).TokenActivity + if *a.FirstRecordedAt != dates[0] || *a.LastRecordedAt != dates[1] || a.TimedSamples != 2 { + t.Fatalf("stored usage times changed: %+v", a) + } + count := 60 + if dates[0][:4] != "0000" { + count = 2 + } + if len(a.Buckets) != count || a.Buckets[0].Samples != 1 || a.Buckets[count-1].Samples != 1 || !a.Buckets[count-1].IncludesEnd { + t.Fatalf("buckets: %+v", a.Buckets) + } + for index, bucket := range a.Buckets { + from, _ := time.Parse(time.RFC3339Nano, bucket.From) + to, _ := time.Parse(time.RFC3339Nano, bucket.To) + if !from.Before(to) || index > 0 && a.Buckets[index-1].To != bucket.From || bucket.Records != 0 { + t.Fatalf("saturated/legacy bucket: %+v", bucket) + } + } + }) + } +} + +func TestTokenActivityVersionedWireAndQueryValidation(t *testing.T) { + s := stableSnapshot() + s.Usage = tokenSnapshot().Usage[:1] + v4 := open(t, s, sessionanalytics.Query{AnalyticsVersion: 4}) + v5 := open(t, s, sessionanalytics.Query{AnalyticsVersion: 5}) + if v5.TokenActivity == nil || !reflect.DeepEqual(v4.Tools, v5.Tools) || !reflect.DeepEqual(v4.FileActivity, v5.FileActivity) || !reflect.DeepEqual(v4.Summary, v5.Summary) { + t.Fatal("v5 lost v4 stable-tool/file behavior") + } + for _, version := range []int{2, 3, 4} { + old := open(t, s, sessionanalytics.Query{AnalyticsVersion: version}) + wire, _ := json.Marshal(old) + if old.TokenActivity != nil || strings.Contains(string(wire), "tokenActivity") { + t.Fatalf("old representation changed: %s", wire) + } + } + for _, q := range []sessionanalytics.Query{ + {AnalyticsVersion: 4, Model: "model"}, {AnalyticsVersion: 4, Metric: "token_records", Snapshot: s.SnapshotToken, TokenRange: "range"}, + {AnalyticsVersion: 5, Metric: "token_records"}, {AnalyticsVersion: 5, Metric: "token_records", Snapshot: s.SnapshotToken}, + {AnalyticsVersion: 5, TokenRange: "range", Snapshot: s.SnapshotToken}, {AnalyticsVersion: 5, Metric: "token_records", Snapshot: s.SnapshotToken, TokenRange: strings.Repeat("x", 513)}, + {AnalyticsVersion: 5, Metric: "token_records", Snapshot: s.SnapshotToken, TokenRange: "range", Range: "range"}, + {AnalyticsVersion: 5, Metric: "token_records", Snapshot: s.SnapshotToken, TokenRange: "range", Tool: "tool"}, + } { + if _, err := sessionanalytics.New(&snapshotStore{snapshot: s}).Open(t.Context(), principal, "session", q); err == nil { + t.Fatalf("accepted invalid query: %+v", q) + } + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := sessionanalytics.New(&snapshotStore{snapshot: s}).Open(ctx, principal, "session", sessionanalytics.Query{AnalyticsVersion: 5}); !errors.Is(err, context.Canceled) { + t.Fatalf("ignored cancellation: %v", err) + } +} From 62743dde8e9e017e16b55e8082105f18c5f6b4bc Mon Sep 17 00:00:00 2001 From: liying <422107224@qq.com> Date: Tue, 13 Oct 2026 12:11:56 +0800 Subject: [PATCH 2/4] Clarify Analytics v5 scope and negotiation --- docs/session-analytics.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/session-analytics.md b/docs/session-analytics.md index bab484b..efcc6ca 100644 --- a/docs/session-analytics.md +++ b/docs/session-analytics.md @@ -196,7 +196,7 @@ See [ADR-0125](architecture/adr/0125-recorded-file-activity.md). ## Recorded activity and range evidence -Analytics versions 3 and 4 require an `activity` report. It selects every Thread or one +Analytics versions 3, 4 and 5 require an `activity` report. It selects every Thread or one Thread using `thread`; summary, tools, capabilities and usage still describe the full Session. Counts describe Canonical records, including fragments and tool updates, rather than inferred conversational turns. @@ -238,8 +238,8 @@ Event opens the existing conditional Reader target. Browser Back returns to the analysis view with its range, Thread and page selection. A full refresh clears range, cursor, snapshot and filters together. -New Web requests v4, then v3, with the standard safelisted header -`Accept: application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json`. +New Web requests v5, then v4 and v3, with the standard safelisted header +`Accept: application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json`. The response remains `application/json`, with `Vary: Accept` and `no-store`. Without opt-in, the Server retains v2 behavior and its cursor contract. A Server supporting v3 can return activity with the original label-based tool rows. Older @@ -262,7 +262,7 @@ A completed Skill tool does not establish that the requested task succeeded. Unclassified calls include ordinary tools, old projections and unsupported source signals. Zero identified calls does not establish that no MCP or Skill was used. The Web accepts an older version-1 Server response and shows this -report as unavailable; version-2, version-3 and version-4 responses must include it, even when empty. +report as unavailable; version-2 through version-5 responses must include it, even when empty. Only distinct structured calls enter these counts. Older Events without a tool call ID remain separately disclosed by `unlinkedToolEvents`. @@ -329,11 +329,13 @@ GET /api/v1/sessions/{sessionId}/analytics/evidence?snapshot={snapshot}&metric=f Both return the named `SessionAnalytics` representation in [OpenAPI](api/openapi-v1.yaml): `snapshot`, optional publication `head`, `analyticsVersion`, `sessionId`, `captureStatus`, `summary`, `tools`, `threads`, -`usage`, version-2/3/4 `capabilities`, version-3/4 `activity` and `evidence`, and -version-4 stable tool rows, coverage and required `fileActivity`. +`usage`, version-2 through version-5 `capabilities`, version-3 through version-5 +`activity` and clock-quality evidence, version-4/5 stable tool rows and +`fileActivity`, and version-5 `tokenActivity`. Collection fields are arrays, including when empty. The selected version is -explicit in the body. All statistics except scoped v3/v4 activity describe the full -selected Session; other filters only select evidence. Each tool evidence anchor is its last structured update. Thought and +explicit in the body. Statistics describe the full selected Session except for +scoped recorded activity and v5 Token distribution. Thread and model select +Token scope; other filters select evidence. Each tool evidence anchor is its last structured update. Thought and user-input evidence anchors identify the matching Canonical fragment; Thread evidence anchors identify the first stored Event in a captured child Thread. @@ -428,7 +430,8 @@ this acceptance entered the Reader directly and does not validate that sidebar. The Web view handles loading, failure, manual refresh and evidence navigation; it preserves analysis state when returning from the reader. Changes to the selected Canonical content require an explicit full refresh. Reloading analysis -resets the metric to tools and clears Thread, tool, range, cursor and snapshot filters +resets the metric to tools and clears Thread, model, tool, range, Token range, +cursor and snapshot filters before reading the latest content, including when a previously selected item was removed. Tool labels over 500 UTF-8 bytes remain visible in full; individual label filtering is disabled on v1–v3, while metric and Thread filters remain From d59c6a6de5df1bf41d4438f36fdc8ebfe0f76666 Mon Sep 17 00:00:00 2001 From: liying <422107224@qq.com> Date: Tue, 13 Oct 2026 12:20:27 +0800 Subject: [PATCH 3/4] Preserve cross-origin analytics negotiation with older Servers --- apps/web/e2e/analytics-cors-probe.ts | 8 +++ apps/web/e2e/analytics-cors.pw.ts | 59 +++++++++++++++++++ apps/web/e2e/session-analytics.pw.ts | 8 +-- apps/web/e2e/session-token-activity.pw.ts | 2 +- apps/web/src/runtime/http.ts | 2 +- .../runtime/sessionAnalyticsGateway.test.ts | 6 +- docs/api/openapi-v1.yaml | 4 +- .../adr/0127-recorded-token-activity.md | 15 +++++ docs/session-analytics.md | 9 ++- .../httpapi/activity_handlers_test.go | 4 ++ .../adapters/httpapi/analytics_handlers.go | 6 ++ .../httpapi/token_activity_handlers_test.go | 19 +++++- 12 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 apps/web/e2e/analytics-cors-probe.ts create mode 100644 apps/web/e2e/analytics-cors.pw.ts diff --git a/apps/web/e2e/analytics-cors-probe.ts b/apps/web/e2e/analytics-cors-probe.ts new file mode 100644 index 0000000..a023a81 --- /dev/null +++ b/apps/web/e2e/analytics-cors-probe.ts @@ -0,0 +1,8 @@ +import { readSessionAnalytics } from "@atape/application" +import { Effect } from "effect" +import { BrowserSessionAnalyticsGatewayLayer } from "../src/runtime/sessionAnalyticsGateway" + +// Browser-only test entry: run the same application Interface and production +// Browser Adapter as the reader, with this Vite server's configured API origin. +export const readAnalyticsAcrossOrigins = () => Effect.runPromise( + readSessionAnalytics("session-reader").pipe(Effect.provide(BrowserSessionAnalyticsGatewayLayer))) diff --git a/apps/web/e2e/analytics-cors.pw.ts b/apps/web/e2e/analytics-cors.pw.ts new file mode 100644 index 0000000..6eb0436 --- /dev/null +++ b/apps/web/e2e/analytics-cors.pw.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test" +import { createServer as createHTTPServer } from "node:http" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { createServer as createViteServer } from "vite" +import { tokenActivityFixture } from "../test/sessionTokenActivityFixture" + +test("production analytics negotiation reads across origins without an OPTIONS preflight", async ({ page }) => { + const requests: Array<{ readonly method: string; readonly accept: string; readonly origin: string }> = [] + let browserOrigin = "" + const api = createHTTPServer((request, response) => { + requests.push({ method: request.method ?? "", accept: request.headers.accept ?? "", origin: request.headers.origin ?? "" }) + // This is deliberately as restrictive as the previous Server: no preflight + // allow-list for Accept. A long unsafe header must fail, not get waived. + if (request.method !== "GET" || request.url !== "/api/v1/sessions/session-reader/analytics" || request.headers.origin !== browserOrigin) { + response.writeHead(403); response.end(); return + } + response.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", + "Access-Control-Allow-Origin": browserOrigin, "Access-Control-Allow-Credentials": "true", "Vary": "Origin" }) + response.end(JSON.stringify(tokenActivityFixture)) + }) + const cacheDirectory = await mkdtemp(join(tmpdir(), "atape-analytics-cors-")) + let vite: Awaited> | undefined + try { + await new Promise((resolve, reject) => { api.once("error", reject); api.listen(0, "127.0.0.1", resolve) }) + const apiAddress = api.address() + if (apiAddress === null || typeof apiAddress === "string") throw new Error("Missing API test listener.") + const apiOrigin = `http://127.0.0.1:${apiAddress.port}` + vite = await createViteServer({ + configFile: false, root: fileURLToPath(new URL("..", import.meta.url)), cacheDir: cacheDirectory, + define: { "import.meta.env.VITE_ATAPE_API_ORIGIN": JSON.stringify(apiOrigin) }, + server: { host: "127.0.0.1", port: 0, strictPort: false, open: false }, + plugins: [{ name: "analytics-cors-test-page", configureServer(server) { + server.middlewares.use("/__analytics-cors-probe", (_request, response) => { + response.setHeader("Content-Type", "text/html") + response.end('Analytics CORS probe') + }) + } }] + }) + await vite.listen() + const address = vite.httpServer?.address() + if (address === null || address === undefined || typeof address === "string") throw new Error("Missing browser test listener.") + browserOrigin = `http://127.0.0.1:${address.port}` + expect(browserOrigin).not.toBe(apiOrigin) + await page.goto(`${browserOrigin}/__analytics-cors-probe`) + await page.waitForFunction(() => "analyticsProbe" in window) + const report = await page.evaluate(async () => await (window as typeof window & { analyticsProbe: Promise }).analyticsProbe) + expect(report).toMatchObject({ analyticsVersion: 5, sessionId: "session-reader", tokenActivity: { samples: 4 } }) + expect(requests).toEqual([{ method: "GET", origin: browserOrigin, + accept: "application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json" }]) + expect(Buffer.byteLength(requests[0]!.accept)).toBeLessThanOrEqual(128) + } finally { + await vite?.close() + await new Promise(resolve => { api.close(() => resolve()); api.closeAllConnections() }) + await rm(cacheDirectory, { recursive: true, force: true }) + } +}) diff --git a/apps/web/e2e/session-analytics.pw.ts b/apps/web/e2e/session-analytics.pw.ts index 7da5e27..a9c7183 100644 --- a/apps/web/e2e/session-analytics.pw.ts +++ b/apps/web/e2e/session-analytics.pw.ts @@ -265,7 +265,7 @@ const activityAPI = async (page: Page) => { await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { const url = new URL(route.request().url()); reads.push(url) expect(route.request().method()).toBe("GET") - expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json") expect(route.request().headers()).not.toHaveProperty("atape-accept-analytics-version") await hold?.(url) if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "The selected snapshot changed." } }); return } @@ -458,7 +458,7 @@ test("old v1 and v2 Servers expose unavailable activity while retaining their ex const reads: URL[] = [] await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { const url = new URL(route.request().url()); reads.push(url) - expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json") expect(url.searchParams.has("range")).toBe(false) expect(url.searchParams.get("metric")).not.toBe("records") await route.fulfill({ json: version === 1 ? analyticsFixture : capabilitiesFixture }) @@ -540,7 +540,7 @@ const stableToolAPI = async (page: Page) => { let mode: "ready" | "changed" | "replacement" | "denied" = "ready" await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { const url = new URL(route.request().url()); reads.push(url) - expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json") if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "Changed" } }); return } if (mode === "denied") { await route.fulfill({ status: 404, json: { code: "not_found", detail: "Unavailable" } }); return } if (mode === "replacement") { @@ -647,7 +647,7 @@ test("a v3 Server keeps activity and display-label tool filters when it ignores const reads: URL[] = [] await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { const url = new URL(route.request().url()); reads.push(url) - expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json") await route.fulfill({ json: activityFixture }) }) await page.goto(path) diff --git a/apps/web/e2e/session-token-activity.pw.ts b/apps/web/e2e/session-token-activity.pw.ts index 2f2fae2..6269d3b 100644 --- a/apps/web/e2e/session-token-activity.pw.ts +++ b/apps/web/e2e/session-token-activity.pw.ts @@ -14,7 +14,7 @@ const tokenAPI = async (page: Page) => { await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { const url = new URL(route.request().url()); reads.push(url) expect(route.request().method()).toBe("GET") - expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json") if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "Changed" } }); return } if (mode === "denied") { await route.fulfill({ status: 404, json: { code: "not_found", detail: "Unavailable" } }); return } const response = tokenActivityResponse(url.searchParams) diff --git a/apps/web/src/runtime/http.ts b/apps/web/src/runtime/http.ts index b760cd2..2f9c812 100644 --- a/apps/web/src/runtime/http.ts +++ b/apps/web/src/runtime/http.ts @@ -167,7 +167,7 @@ export const browserRequest = (path: string, options: RequestOptions = {}): Effe try: async (interruption) => { const method = options.method ?? "GET" const headers = new Headers({ Accept: options.acceptAnalyticsVersion === 5 - ? "application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json" + ? "application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json" : options.acceptAnalyticsVersion === 4 ? "application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json" : options.acceptAnalyticsVersion === 3 diff --git a/apps/web/src/runtime/sessionAnalyticsGateway.test.ts b/apps/web/src/runtime/sessionAnalyticsGateway.test.ts index 5fa6cce..869c672 100644 --- a/apps/web/src/runtime/sessionAnalyticsGateway.test.ts +++ b/apps/web/src/runtime/sessionAnalyticsGateway.test.ts @@ -19,7 +19,7 @@ describe("Session analytics browser Adapter", () => { expect(Object.fromEntries(url.searchParams)).toEqual({ snapshot: "snapshot-one", metric: "failed_tools", thread: "child/one", tool: "read & write", cursor: "opaque+=", limit: "20" }) expect(result.usage.tokens).toMatchObject({ total: null, cacheRead: 0 }) const headers = new Headers(fetchMock.mock.calls[0]![1]?.headers) - expect(headers.get("Accept")).toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(headers.get("Accept")).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json") expect(headers.has("ATape-Accept-Analytics-Version")).toBe(false) }) it("reads v1 without inventing a capability report, and v2 with a required report", async () => { @@ -73,7 +73,7 @@ describe("Session analytics browser Adapter", () => { expect(Object.fromEntries(url.searchParams)).toEqual({ ...query, limit: "4" }) expect(url.searchParams.get("file")).not.toBe("/work/src/app.ts") expect(new Headers(fetchMock.mock.calls[0]![1]?.headers).get("Accept")) - .toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + .toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json") }) it("negotiates v5 and transports model and Token interval keys without converting them into Event identity", async () => { const report = { ...tokenActivityFixture, snapshot: "snapshot-one", activity: { ...tokenActivityFixture.activity, scopeThreadId: "child/one" }, tokenActivity: { ...tokenActivityFixture.tokenActivity, @@ -86,7 +86,7 @@ describe("Session analytics browser Adapter", () => { const url = new URL(fetchMock.mock.calls[0]![0] as string, "http://localhost") expect(Object.fromEntries(url.searchParams)).toEqual({ ...query, limit: "4" }) expect(new Headers(fetchMock.mock.calls[0]![1]?.headers).get("Accept")) - .toBe("application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + .toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json") expect(url.searchParams.has("at")).toBe(false) expect(fetchMock).toHaveBeenCalledTimes(1) }) diff --git a/docs/api/openapi-v1.yaml b/docs/api/openapi-v1.yaml index c3e675f..bef50ea 100644 --- a/docs/api/openapi-v1.yaml +++ b/docs/api/openapi-v1.yaml @@ -1011,7 +1011,7 @@ paths: x-atape-route: {class: web_only, requestBodyPolicy: none, requestBodyLimit: 0, cachePolicy: no-store, originPolicy: none, freshAuthentication: false, requiresIdempotencyKey: false} security: [{webSession: []}] parameters: - - {name: Accept, in: header, description: "Absent the vendor media range, returns version 2. Acceptable application/vnd.atape.session-analytics.v5+json, v4+json and v3+json ranges opt into versions 5, 4 and 3; the highest admitted nonzero version wins. q=0 declines that range. Older Servers may ignore newer ranges and return their supported representation. Responses retain application/json.", schema: {type: string, maxLength: 4096, default: application/json}} + - {name: Accept, in: header, description: "Absent the vendor media range, returns version 2. Acceptable application/vnd.atape.session-analytics.v5+json, v4+json and v3+json ranges opt into versions 5, 4 and 3; the highest admitted nonzero version wins. q=0 declines that range. Web can opt into v5 with v=5 on the v4 media range, retaining v3 and JSON fallback in a 118-byte CORS-safelisted value; older v4 Servers ignore that parameter. Older Servers may ignore newer ranges and return their supported representation. Responses retain application/json.", schema: {type: string, maxLength: 4096, default: application/json}} - {name: snapshot, in: query, schema: {type: string, minLength: 1, maxLength: 200}} - {name: metric, in: query, schema: {type: string, default: tools, enum: [tools, failed_tools, unknown_tools, user_inputs, thoughts, threads, mcp_tools, skills, records, files, token_records]}} - {name: thread, in: query, schema: {type: string, maxLength: 500}} @@ -1041,7 +1041,7 @@ paths: x-atape-route: {class: web_only, requestBodyPolicy: none, requestBodyLimit: 0, cachePolicy: no-store, originPolicy: none, freshAuthentication: false, requiresIdempotencyKey: false} security: [{webSession: []}] parameters: - - {name: Accept, in: header, description: "Absent the vendor media range, returns version 2. Acceptable application/vnd.atape.session-analytics.v5+json, v4+json and v3+json ranges opt into versions 5, 4 and 3; the highest admitted nonzero version wins. q=0 declines that range. Older Servers may ignore newer ranges and return their supported representation. Responses retain application/json.", schema: {type: string, maxLength: 4096, default: application/json}} + - {name: Accept, in: header, description: "Absent the vendor media range, returns version 2. Acceptable application/vnd.atape.session-analytics.v5+json, v4+json and v3+json ranges opt into versions 5, 4 and 3; the highest admitted nonzero version wins. q=0 declines that range. Web can opt into v5 with v=5 on the v4 media range, retaining v3 and JSON fallback in a 118-byte CORS-safelisted value; older v4 Servers ignore that parameter. Older Servers may ignore newer ranges and return their supported representation. Responses retain application/json.", schema: {type: string, maxLength: 4096, default: application/json}} - {name: snapshot, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 200}} - {name: metric, in: query, schema: {type: string, default: tools, enum: [tools, failed_tools, unknown_tools, user_inputs, thoughts, threads, mcp_tools, skills, records, files, token_records]}} - {name: thread, in: query, schema: {type: string, maxLength: 500}} diff --git a/docs/architecture/adr/0127-recorded-token-activity.md b/docs/architecture/adr/0127-recorded-token-activity.md index a992610..4464d0e 100644 --- a/docs/architecture/adr/0127-recorded-token-activity.md +++ b/docs/architecture/adr/0127-recorded-token-activity.md @@ -60,6 +60,21 @@ The conditional Reader opens returned real Event anchors. Browser Back restores the selection and page; scope changes clear incompatible range and cursor state. Every read rechecks authorization and stale source tokens fail as before. +### Browser negotiation + +Listing full v5, v4, v3 and JSON media ranges produces a 163-byte Accept value, +exceeding the [Fetch CORS safelist's 128-byte limit](https://fetch.spec.whatwg.org/#cors-safelisted-request-header). +Old Server preflight policy deliberately does not allow a requested Accept +header. Adding it to new Server policy would not repair older deployments. + +Web therefore sends the 118-byte value +`application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json`. +New Server recognizes only the exact `v=5` parameter on v4 as v5 opt-in, subject +to that range's normal quality value. Older v4 Servers ignore the parameter and +select v4; older v3 Servers select the next range; earlier Servers retain JSON. +The direct full v5 vendor media range remains supported. This preserves one +request and the existing older-Server fallback without widening CORS policy. + ## Scope Metric rules remain in the Go Module Implementation; Web remains Presentation. diff --git a/docs/session-analytics.md b/docs/session-analytics.md index efcc6ca..f38e3c4 100644 --- a/docs/session-analytics.md +++ b/docs/session-analytics.md @@ -238,8 +238,13 @@ Event opens the existing conditional Reader target. Browser Back returns to the analysis view with its range, Thread and page selection. A full refresh clears range, cursor, snapshot and filters together. -New Web requests v5, then v4 and v3, with the standard safelisted header -`Accept: application/vnd.atape.session-analytics.v5+json, application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json`. +New Web negotiates v5, v4, v3 and JSON with the 118-byte safelisted header +`Accept: application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json`. +New Server recognizes `v=5` on the v4 media range. An older v4 Server ignores +that parameter and retains v4; an older v3 Server selects the next range. Direct +clients can also opt in with `application/vnd.atape.session-analytics.v5+json`. +Listing every full vendor range together would exceed the browser's 128-byte +safelist bound and trigger a preflight that older Server policy rejects. The response remains `application/json`, with `Vary: Accept` and `no-store`. Without opt-in, the Server retains v2 behavior and its cursor contract. A Server supporting v3 can return activity with the original label-based tool rows. Older diff --git a/server/internal/adapters/httpapi/activity_handlers_test.go b/server/internal/adapters/httpapi/activity_handlers_test.go index 9d30c50..90fc911 100644 --- a/server/internal/adapters/httpapi/activity_handlers_test.go +++ b/server/internal/adapters/httpapi/activity_handlers_test.go @@ -56,6 +56,10 @@ func TestActivityAcceptOptsInWithoutChangingDefaultJSON(t *testing.T) { {"application/vnd.atape.session-analytics.v5+json, " + stableToolAccept, 5, 200}, {"application/vnd.atape.session-analytics.v5+json;q=0, " + stableToolAccept, 4, 200}, {"application/vnd.atape.session-analytics.v5+json;q=bad", 0, 400}, + {tokenActivityAccept, 5, 200}, + {"application/vnd.atape.session-analytics.v4+json;v=5;q=0, " + activityAccept, 3, 200}, + {"application/vnd.atape.session-analytics.v4+json;v=5;q=bad", 0, 400}, + {"application/vnd.atape.session-analytics.v4+json;v=6, " + activityAccept, 4, 200}, {"application/vnd.atape.session-analytics.v6+json", 2, 200}, {"application/vnd.atape.session-analytics.v3+json;q=broken", 0, 400}, {"application/vnd.atape.session-analytics.v3+json;q=NaN", 0, 400}, diff --git a/server/internal/adapters/httpapi/analytics_handlers.go b/server/internal/adapters/httpapi/analytics_handlers.go index a962480..1a210cd 100644 --- a/server/internal/adapters/httpapi/analytics_handlers.go +++ b/server/internal/adapters/httpapi/analytics_handlers.go @@ -101,6 +101,12 @@ func analyticsVersion(values []string) (int, bool) { if selected == 0 || media != raw { continue } + // Keep the Browser's v5/v4/v3/JSON negotiation below the 128-byte + // CORS safelist bound. Older v4 Servers ignore this additive media + // parameter and still select v4; v3 Servers select the next range. + if selected == sessionanalytics.StableToolVersion && parameters["v"] == "5" { + selected = sessionanalytics.TokenActivityVersion + } quality := 1.0 if value, found := parameters["q"]; found { if !validAnalyticsQuality(value) { diff --git a/server/internal/adapters/httpapi/token_activity_handlers_test.go b/server/internal/adapters/httpapi/token_activity_handlers_test.go index 35faccb..913cfc4 100644 --- a/server/internal/adapters/httpapi/token_activity_handlers_test.go +++ b/server/internal/adapters/httpapi/token_activity_handlers_test.go @@ -15,7 +15,24 @@ import ( "github.com/SingleMai/ATape/server/internal/testsupport/canonicalcontract" ) -const tokenActivityAccept = "application/vnd.atape.session-analytics.v5+json, " + stableToolAccept +const tokenActivityAccept = "application/vnd.atape.session-analytics.v4+json;v=5, " + activityAccept + +func TestTokenAcceptWorksWithExistingSplitOriginCORS(t *testing.T) { + if len(tokenActivityAccept) > 128 { + t.Fatal("Token negotiation would trigger a browser CORS preflight") + } + h, route := activityHTTPFixture(t, true) + r := httptest.NewRequest(http.MethodGet, route, nil) + r.Header.Set("Origin", "https://web.example.com") + r.Header.Set("Accept", tokenActivityAccept) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + var result sessionanalytics.Result + decodeResponse(t, w, &result) + if w.Code != 200 || result.AnalyticsVersion != 5 || w.Header().Get("Access-Control-Allow-Origin") != "https://web.example.com" || w.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Fatalf("Token split-origin request: %d %+v", w.Code, w.Header()) + } +} func TestTokenActivityHTTPIntervalsCoverageAndConditionalReader(t *testing.T) { store := canonical.NewMemoryStoreWithControlPlane(canonicalcontract.MemoryControlPlane()) From bcf300987ea5ee1c83065195a765d91ebd32e823 Mon Sep 17 00:00:00 2001 From: liying <422107224@qq.com> Date: Tue, 13 Oct 2026 12:42:13 +0800 Subject: [PATCH 4/4] Update shared analytics browser compatibility assertions --- apps/web/e2e/session-files.pw.ts | 2 +- apps/web/test/historicalAnalyticsCors.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/session-files.pw.ts b/apps/web/e2e/session-files.pw.ts index ab5eb2e..f198c3f 100644 --- a/apps/web/e2e/session-files.pw.ts +++ b/apps/web/e2e/session-files.pw.ts @@ -14,7 +14,7 @@ const fileAPI = async (page: Page) => { let mode: "ready" | "changed" | "replacement" | "denied" = "ready" await page.route("**/api/v1/sessions/session-reader/analytics*", async route => { const url = new URL(route.request().url()), query = url.searchParams; reads.push(url) - expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json") + expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json") if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "Changed" } }); return } if (mode === "denied") { await route.fulfill({ status: 404, json: { code: "not_found", detail: "Unavailable" } }); return } if (mode === "replacement") { diff --git a/apps/web/test/historicalAnalyticsCors.ts b/apps/web/test/historicalAnalyticsCors.ts index a43ff93..d46341a 100644 --- a/apps/web/test/historicalAnalyticsCors.ts +++ b/apps/web/test/historicalAnalyticsCors.ts @@ -13,7 +13,7 @@ const tag = "v0.5.9" const repository = fileURLToPath(new URL("../../..", import.meta.url)) const sha256 = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex") -export const analyticsAccept = "application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json" +export const analyticsAccept = "application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json" export const corsCookie = { name: "atape_session_dev", value: "activity-cors-test" } export type CorsRequest = { readonly kind: "request"