Add session analytics projection

This commit is contained in:
Vegard Stikbakke
2026-06-01 13:09:22 +02:00
Unverified
parent f429ddbffd
commit edaa31eb5a
3 changed files with 629 additions and 0 deletions
+14
View File
@@ -74,4 +74,18 @@ export {
type TurnStartEvent,
type WorkingIndicatorOptions,
} from "./extensions/index.ts";
export {
hashSessionAnalyticsString,
type ProjectSessionAnalyticsOptions,
type ProjectSessionHeaderAnalyticsOptions,
projectSessionEntryForAnalytics,
projectSessionForAnalytics,
projectSessionHeaderForAnalytics,
SESSION_ANALYTICS_SCHEMA_VERSION,
type SessionAnalyticsContentStats,
type SessionAnalyticsEntryRecord,
type SessionAnalyticsRecord,
type SessionAnalyticsSessionRecord,
type SessionAnalyticsUsage,
} from "./session-analytics.ts";
export { createSyntheticSourceInfo } from "./source-info.ts";
@@ -0,0 +1,337 @@
import { createHash } from "crypto";
import type { SessionEntry, SessionHeader } from "./session-manager.ts";
export const SESSION_ANALYTICS_SCHEMA_VERSION = 1;
export interface SessionAnalyticsUsage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
costInput: number;
costOutput: number;
costCacheRead: number;
costCacheWrite: number;
costTotal: number;
}
export interface SessionAnalyticsContentStats {
stringContent: boolean;
textBlocks: number;
imageBlocks: number;
thinkingBlocks: number;
redactedThinkingBlocks: number;
toolCallBlocks: number;
otherBlocks: number;
}
export interface SessionAnalyticsSessionRecord {
recordType: "session";
schemaVersion: typeof SESSION_ANALYTICS_SCHEMA_VERSION;
sessionId: string;
version?: number;
createdAt?: string;
modifiedAt?: string;
parentSessionHash?: string;
cwd?: string;
cwdHash?: string;
hasCwd: boolean;
}
export interface SessionAnalyticsEntryRecord {
recordType: "entry";
schemaVersion: typeof SESSION_ANALYTICS_SCHEMA_VERSION;
sessionId: string;
entryId: string;
parentEntryId: string | null;
entryType: string;
timestamp: string;
// Message-level metadata. Raw content, tool arguments, thinking text, and errors are intentionally omitted.
role?: string;
api?: string;
provider?: string;
model?: string;
responseModel?: string;
stopReason?: string;
hasError?: boolean;
usage?: SessionAnalyticsUsage;
contentStats?: SessionAnalyticsContentStats;
isError?: boolean;
exitCode?: number;
cancelled?: boolean;
truncated?: boolean;
excludeFromContext?: boolean;
// Non-message entry metadata. Raw summaries, labels, names, custom data, and details are intentionally omitted.
modelId?: string;
thinkingLevel?: string;
activeToolCount?: number;
firstKeptEntryId?: string;
tokensBefore?: number;
fromHook?: boolean;
hasDetails?: boolean;
fromId?: string;
customType?: string;
display?: boolean;
hasData?: boolean;
targetId?: string | null;
hasLabel?: boolean;
hasName?: boolean;
}
export type SessionAnalyticsRecord = SessionAnalyticsSessionRecord | SessionAnalyticsEntryRecord;
export interface ProjectSessionHeaderAnalyticsOptions {
modifiedAt?: Date | string;
hashString?: (value: string) => string;
}
export interface ProjectSessionAnalyticsOptions extends ProjectSessionHeaderAnalyticsOptions {}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function maybeString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function maybeNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function asNumber(value: unknown): number {
return maybeNumber(value) ?? 0;
}
function modifiedAtToString(value: Date | string | undefined): string | undefined {
if (value instanceof Date) return value.toISOString();
return maybeString(value);
}
function cleanRecord<T extends object>(record: T): T {
const mutableRecord = record as Record<string, unknown>;
for (const key of Object.keys(mutableRecord)) {
if (mutableRecord[key] === undefined) delete mutableRecord[key];
}
return record;
}
export function hashSessionAnalyticsString(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
export function projectSessionHeaderForAnalytics(
header: SessionHeader,
options: ProjectSessionHeaderAnalyticsOptions = {},
): SessionAnalyticsSessionRecord {
const hash = options.hashString ?? hashSessionAnalyticsString;
return cleanRecord({
recordType: "session",
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
sessionId: header.id,
version: header.version,
createdAt: maybeString(header.timestamp),
modifiedAt: modifiedAtToString(options.modifiedAt),
parentSessionHash: header.parentSession ? hash(header.parentSession) : undefined,
cwd: maybeString(header.cwd),
cwdHash: header.cwd ? hash(header.cwd) : undefined,
hasCwd: Boolean(header.cwd),
});
}
function projectUsageForAnalytics(usage: unknown): SessionAnalyticsUsage | undefined {
if (!isRecord(usage)) return undefined;
const cost = isRecord(usage.cost) ? usage.cost : undefined;
return {
input: asNumber(usage.input),
output: asNumber(usage.output),
cacheRead: asNumber(usage.cacheRead),
cacheWrite: asNumber(usage.cacheWrite),
totalTokens: asNumber(usage.totalTokens),
costInput: asNumber(cost?.input),
costOutput: asNumber(cost?.output),
costCacheRead: asNumber(cost?.cacheRead),
costCacheWrite: asNumber(cost?.cacheWrite),
costTotal: asNumber(cost?.total),
};
}
function projectContentStatsForAnalytics(content: unknown): SessionAnalyticsContentStats {
const stats: SessionAnalyticsContentStats = {
stringContent: false,
textBlocks: 0,
imageBlocks: 0,
thinkingBlocks: 0,
redactedThinkingBlocks: 0,
toolCallBlocks: 0,
otherBlocks: 0,
};
if (typeof content === "string") {
stats.stringContent = true;
if (content.length > 0) stats.textBlocks = 1;
return stats;
}
if (!Array.isArray(content)) {
stats.otherBlocks = 1;
return stats;
}
for (const block of content) {
if (!isRecord(block)) {
stats.otherBlocks++;
continue;
}
switch (block.type) {
case "text":
stats.textBlocks++;
break;
case "image":
stats.imageBlocks++;
break;
case "thinking":
stats.thinkingBlocks++;
if (block.redacted === true) stats.redactedThinkingBlocks++;
break;
case "toolCall":
stats.toolCallBlocks++;
break;
default:
stats.otherBlocks++;
break;
}
}
return stats;
}
function createEntryBase(sessionId: string, entry: SessionEntry): SessionAnalyticsEntryRecord {
return {
recordType: "entry",
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
sessionId,
entryId: entry.id,
parentEntryId: entry.parentId,
entryType: entry.type,
timestamp: entry.timestamp,
};
}
function projectMessageEntryForAnalytics(sessionId: string, entry: Extract<SessionEntry, { type: "message" }>) {
const base = createEntryBase(sessionId, entry);
const message = isRecord(entry.message) ? entry.message : undefined;
const role = maybeString(message?.role) ?? "unknown";
if (role === "assistant") {
return cleanRecord({
...base,
role,
api: maybeString(message?.api),
provider: maybeString(message?.provider),
model: maybeString(message?.model),
responseModel: maybeString(message?.responseModel),
stopReason: maybeString(message?.stopReason),
hasError:
Boolean(message?.errorMessage) || message?.stopReason === "error" || message?.stopReason === "aborted",
usage: projectUsageForAnalytics(message?.usage),
contentStats: projectContentStatsForAnalytics(message?.content),
});
}
if (role === "user") return { ...base, role, contentStats: projectContentStatsForAnalytics(message?.content) };
if (role === "toolResult") {
return {
...base,
role,
isError: message?.isError === true,
contentStats: projectContentStatsForAnalytics(message?.content),
};
}
if (role === "bashExecution") {
return cleanRecord({
...base,
role,
exitCode: maybeNumber(message?.exitCode),
cancelled: message?.cancelled === true,
truncated: message?.truncated === true,
excludeFromContext: message?.excludeFromContext === true,
});
}
if (role === "custom") {
return cleanRecord({
...base,
role,
customType: maybeString(message?.customType),
display: typeof message?.display === "boolean" ? message.display : undefined,
contentStats: projectContentStatsForAnalytics(message?.content),
});
}
return { ...base, role };
}
export function projectSessionEntryForAnalytics(sessionId: string, entry: SessionEntry): SessionAnalyticsEntryRecord {
switch (entry.type) {
case "message":
return projectMessageEntryForAnalytics(sessionId, entry);
case "model_change":
return { ...createEntryBase(sessionId, entry), provider: entry.provider, modelId: entry.modelId };
case "thinking_level_change":
return { ...createEntryBase(sessionId, entry), thinkingLevel: entry.thinkingLevel };
case "compaction":
return cleanRecord({
...createEntryBase(sessionId, entry),
firstKeptEntryId: entry.firstKeptEntryId,
tokensBefore: entry.tokensBefore,
fromHook: entry.fromHook,
hasDetails: entry.details !== undefined,
});
case "branch_summary":
return cleanRecord({
...createEntryBase(sessionId, entry),
fromId: entry.fromId,
fromHook: entry.fromHook,
hasDetails: entry.details !== undefined,
});
case "custom":
return cleanRecord({
...createEntryBase(sessionId, entry),
customType: entry.customType,
hasData: entry.data !== undefined,
});
case "custom_message":
return cleanRecord({
...createEntryBase(sessionId, entry),
customType: entry.customType,
display: entry.display,
hasDetails: entry.details !== undefined,
contentStats: projectContentStatsForAnalytics(entry.content),
});
case "label":
return cleanRecord({
...createEntryBase(sessionId, entry),
targetId: entry.targetId,
hasLabel: typeof entry.label === "string" && entry.label.length > 0,
});
case "session_info":
return { ...createEntryBase(sessionId, entry), hasName: Boolean(entry.name?.trim()) };
default:
return createEntryBase(sessionId, entry);
}
}
export function projectSessionForAnalytics(
header: SessionHeader,
entries: SessionEntry[],
options: ProjectSessionAnalyticsOptions = {},
): SessionAnalyticsRecord[] {
const session = projectSessionHeaderForAnalytics(header, options);
return [session, ...entries.map((entry) => projectSessionEntryForAnalytics(session.sessionId, entry))];
}
@@ -0,0 +1,278 @@
import { describe, expect, it } from "vitest";
import {
projectSessionForAnalytics,
SESSION_ANALYTICS_SCHEMA_VERSION,
type SessionAnalyticsRecord,
} from "../src/core/session-analytics.ts";
import type { SessionEntry, SessionHeader } from "../src/core/session-manager.ts";
const header: SessionHeader = {
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-01-02T03:04:05.000Z",
cwd: "/tmp/project",
parentSession: "/tmp/parent.jsonl",
};
const entries: SessionEntry[] = [
{
type: "model_change",
id: "model-1",
parentId: null,
timestamp: "2026-01-02T03:04:06.000Z",
provider: "anthropic",
modelId: "claude-sonnet-4-5",
},
{
type: "message",
id: "user-1",
parentId: "model-1",
timestamp: "2026-01-02T03:04:07.000Z",
message: {
role: "user",
content: [
{ type: "text", text: "secret user prompt" },
{ type: "image", data: "secret-image-data", mimeType: "image/png" },
],
timestamp: 1767323047000,
},
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: "2026-01-02T03:04:08.000Z",
message: {
role: "assistant",
content: [
{ type: "text", text: "secret assistant answer" },
{ type: "thinking", thinking: "secret reasoning" },
{ type: "thinking", thinking: "", thinkingSignature: "secret-signature", redacted: true },
{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "secret/path.ts" } },
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
responseModel: "claude-sonnet-4-5-20260101",
responseId: "secret-response-id",
usage: {
input: 100,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 190,
cost: {
input: 0.1,
output: 0.2,
cacheRead: 0.03,
cacheWrite: 0.04,
total: 0.37,
},
},
stopReason: "toolUse",
timestamp: 1767323048000,
},
},
{
type: "message",
id: "tool-result-1",
parentId: "assistant-1",
timestamp: "2026-01-02T03:04:09.000Z",
message: {
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "secret tool result" }],
isError: true,
timestamp: 1767323049000,
},
},
{
type: "compaction",
id: "compaction-1",
parentId: "tool-result-1",
timestamp: "2026-01-02T03:04:10.000Z",
summary: "secret compaction summary",
firstKeptEntryId: "user-1",
tokensBefore: 1234,
details: { secret: "compaction details" },
fromHook: true,
},
{
type: "label",
id: "label-1",
parentId: "compaction-1",
timestamp: "2026-01-02T03:04:11.000Z",
targetId: "assistant-1",
label: "secret label",
},
{
type: "session_info",
id: "session-info-1",
parentId: "label-1",
timestamp: "2026-01-02T03:04:12.000Z",
name: "secret session name",
},
];
describe("projectSessionForAnalytics", () => {
it("projects a complete session into ordered analytics records", () => {
const records = projectSessionForAnalytics(header, entries, {
modifiedAt: "2026-01-03T04:05:06.000Z",
hashString: (value) => `hashed:${value}`,
});
expect(records).toEqual([
{
recordType: "session",
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
sessionId: "session-1",
version: 3,
createdAt: "2026-01-02T03:04:05.000Z",
modifiedAt: "2026-01-03T04:05:06.000Z",
parentSessionHash: "hashed:/tmp/parent.jsonl",
cwd: "/tmp/project",
cwdHash: "hashed:/tmp/project",
hasCwd: true,
},
{
recordType: "entry",
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
sessionId: "session-1",
entryId: "model-1",
parentEntryId: null,
entryType: "model_change",
timestamp: "2026-01-02T03:04:06.000Z",
provider: "anthropic",
modelId: "claude-sonnet-4-5",
},
{
recordType: "entry",
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
sessionId: "session-1",
entryId: "user-1",
parentEntryId: "model-1",
entryType: "message",
timestamp: "2026-01-02T03:04:07.000Z",
role: "user",
contentStats: {
stringContent: false,
textBlocks: 1,
imageBlocks: 1,
thinkingBlocks: 0,
redactedThinkingBlocks: 0,
toolCallBlocks: 0,
otherBlocks: 0,
},
},
{
recordType: "entry",
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
sessionId: "session-1",
entryId: "assistant-1",
parentEntryId: "user-1",
entryType: "message",
timestamp: "2026-01-02T03:04:08.000Z",
role: "assistant",
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
responseModel: "claude-sonnet-4-5-20260101",
stopReason: "toolUse",
hasError: false,
usage: {
input: 100,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 190,
costInput: 0.1,
costOutput: 0.2,
costCacheRead: 0.03,
costCacheWrite: 0.04,
costTotal: 0.37,
},
contentStats: {
stringContent: false,
textBlocks: 1,
imageBlocks: 0,
thinkingBlocks: 2,
redactedThinkingBlocks: 1,
toolCallBlocks: 1,
otherBlocks: 0,
},
},
{
recordType: "entry",
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
sessionId: "session-1",
entryId: "tool-result-1",
parentEntryId: "assistant-1",
entryType: "message",
timestamp: "2026-01-02T03:04:09.000Z",
role: "toolResult",
isError: true,
contentStats: {
stringContent: false,
textBlocks: 1,
imageBlocks: 0,
thinkingBlocks: 0,
redactedThinkingBlocks: 0,
toolCallBlocks: 0,
otherBlocks: 0,
},
},
{
recordType: "entry",
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
sessionId: "session-1",
entryId: "compaction-1",
parentEntryId: "tool-result-1",
entryType: "compaction",
timestamp: "2026-01-02T03:04:10.000Z",
firstKeptEntryId: "user-1",
tokensBefore: 1234,
fromHook: true,
hasDetails: true,
},
{
recordType: "entry",
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
sessionId: "session-1",
entryId: "label-1",
parentEntryId: "compaction-1",
entryType: "label",
timestamp: "2026-01-02T03:04:11.000Z",
targetId: "assistant-1",
hasLabel: true,
},
{
recordType: "entry",
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
sessionId: "session-1",
entryId: "session-info-1",
parentEntryId: "label-1",
entryType: "session_info",
timestamp: "2026-01-02T03:04:12.000Z",
hasName: true,
},
] satisfies SessionAnalyticsRecord[]);
});
it("omits raw private payload fields", () => {
const serialized = JSON.stringify(projectSessionForAnalytics(header, entries));
expect(serialized).not.toContain("secret user prompt");
expect(serialized).not.toContain("secret assistant answer");
expect(serialized).not.toContain("secret reasoning");
expect(serialized).not.toContain("secret-image-data");
expect(serialized).not.toContain("secret/path.ts");
expect(serialized).not.toContain("secret tool result");
expect(serialized).not.toContain("secret compaction summary");
expect(serialized).not.toContain("compaction details");
expect(serialized).not.toContain("secret label");
expect(serialized).not.toContain("secret session name");
expect(serialized).not.toContain("secret-response-id");
});
});