toknmtr-selfhost/src/routes/sessions/[host]/[sessionId]/+page.server.ts
megaproxy 71a60ab054 toknmtr — self-hostable Claude Code usage & analytics dashboard
Server (SvelteKit + SQLite Docker container) + per-machine agent that parses
Claude Code JSONL transcripts. docker-compose one-command deploy; raw transcript
view + search gated behind SHOW_TRANSCRIPTS (hidden by default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:33:55 +01:00

273 lines
7.6 KiB
TypeScript

import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { showTranscripts } from '$lib/server/config';
import { costFor, type TokenCounts } from '$lib/server/pricing';
import type { PageServerLoad } from './$types';
/**
* Per-session transcript view. This SQL is session-specific (it drives one page,
* not a dashboard aggregate) so it lives here rather than in `queries.ts` — same
* ownership split the /search route follows. Everything filters on the composite
* `(host, session_id)` identity (indexed by `idx_events_session`), and any `$`
* figure is computed with `costFor()` — only `is_usage_canonical` rows count for
* tokens/$, matching the rest of the app.
*/
export interface TranscriptToolCall {
toolUseId: string;
toolName: string;
inputJson: string | null;
isError: boolean;
resultBytes: number | null;
durationMs: number | null;
tsUtc: string | null;
}
export interface TranscriptTurn {
uuid: string;
tsUtc: string | null;
type: string; // user | assistant | system | summary | tool (synthetic)
role: string | null;
model: string | null;
text: string | null;
countsTowardUsage: boolean;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalTokens: number;
costUsd: number;
toolCalls: TranscriptToolCall[];
}
export interface SessionMeta {
host: string;
sessionId: string;
project: string | null;
gitBranch: string | null;
ccVersion: string | null;
entrypoint: string | null;
startedAt: string | null;
endedAt: string | null;
}
export interface SessionTotals {
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalTokens: number;
costUsd: number;
eventCount: number;
toolCallCount: number;
firstTs: string | null;
lastTs: string | null;
}
interface EventRow {
uuid: string;
ts_utc: string | null;
type: string;
role: string | null;
model: string | null;
is_usage_canonical: number;
input_tokens: number | null;
output_tokens: number | null;
cache_creation_tokens: number | null;
cache_read_tokens: number | null;
}
interface ContentRow {
uuid: string;
role: string | null;
text: string | null;
}
interface ToolRow {
tool_use_id: string;
event_uuid: string | null;
tool_name: string;
input_json: string | null;
is_error: number | null;
result_bytes: number | null;
duration_ms: number | null;
ts_utc: string | null;
}
export const load: PageServerLoad = async ({ params }) => {
// Raw transcript text is hidden while the dashboard is publicly reachable. Never touch
// the DB in this case — refuse before reading any conversation content.
if (!showTranscripts) {
throw error(403, 'Transcript view is disabled');
}
// Route params arrive URL-encoded (session ids are UUIDs, hosts are hostnames).
const host = decodeURIComponent(params.host);
const sessionId = decodeURIComponent(params.sessionId);
const dbh = db();
const session = dbh
.prepare(
`SELECT host, session_id, project, git_branch, cc_version, entrypoint, started_at, ended_at
FROM sessions WHERE host = ? AND session_id = ?`
)
.get(host, sessionId) as
| {
host: string;
session_id: string;
project: string | null;
git_branch: string | null;
cc_version: string | null;
entrypoint: string | null;
started_at: string | null;
ended_at: string | null;
}
| undefined;
const eventRows = dbh
.prepare(
`SELECT uuid, ts_utc, type, role, model, is_usage_canonical,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens
FROM events
WHERE host = ? AND session_id = ?
ORDER BY datetime(ts_utc), uuid`
)
.all(host, sessionId) as EventRow[];
if (!session && eventRows.length === 0) {
throw error(404, 'Session not found');
}
const contentRows = dbh
.prepare(`SELECT uuid, role, text FROM content WHERE host = ? AND session_id = ?`)
.all(host, sessionId) as ContentRow[];
const toolRows = dbh
.prepare(
`SELECT tool_use_id, event_uuid, tool_name, input_json, is_error, result_bytes, duration_ms, ts_utc
FROM tool_calls
WHERE host = ? AND session_id = ?
ORDER BY datetime(ts_utc), tool_use_id`
)
.all(host, sessionId) as ToolRow[];
// content is 1:1 with an event uuid; join on it.
const textByUuid = new Map<string, string | null>(contentRows.map((c) => [c.uuid, c.text]));
// tool calls hang off the assistant event that invoked them (event_uuid).
const toolsByEvent = new Map<string, TranscriptToolCall[]>();
const orphanTools: TranscriptToolCall[] = [];
const eventUuids = new Set(eventRows.map((e) => e.uuid));
for (const t of toolRows) {
const tc: TranscriptToolCall = {
toolUseId: t.tool_use_id,
toolName: t.tool_name,
inputJson: t.input_json,
isError: !!t.is_error,
resultBytes: t.result_bytes,
durationMs: t.duration_ms,
tsUtc: t.ts_utc
};
if (t.event_uuid && eventUuids.has(t.event_uuid)) {
const list = toolsByEvent.get(t.event_uuid) ?? [];
list.push(tc);
toolsByEvent.set(t.event_uuid, list);
} else {
orphanTools.push(tc);
}
}
const totals: SessionTotals = {
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
costUsd: 0,
eventCount: eventRows.length,
toolCallCount: toolRows.length,
firstTs: eventRows.length ? eventRows[0].ts_utc : null,
lastTs: eventRows.length ? eventRows[eventRows.length - 1].ts_utc : null
};
const turns: TranscriptTurn[] = eventRows.map((e) => {
const canonical = e.is_usage_canonical === 1;
const counts: TokenCounts = {
input_tokens: e.input_tokens,
output_tokens: e.output_tokens,
cache_creation_tokens: e.cache_creation_tokens,
cache_read_tokens: e.cache_read_tokens
};
// Only canonical rows count for tokens/$ (dedup of streamed assistant lines).
const inputTokens = canonical ? (e.input_tokens ?? 0) : 0;
const outputTokens = canonical ? (e.output_tokens ?? 0) : 0;
const cacheCreationTokens = canonical ? (e.cache_creation_tokens ?? 0) : 0;
const cacheReadTokens = canonical ? (e.cache_read_tokens ?? 0) : 0;
const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
const costUsd = canonical ? costFor(e.model, counts) : 0;
totals.inputTokens += inputTokens;
totals.outputTokens += outputTokens;
totals.cacheCreationTokens += cacheCreationTokens;
totals.cacheReadTokens += cacheReadTokens;
totals.costUsd += costUsd;
return {
uuid: e.uuid,
tsUtc: e.ts_utc,
type: e.type,
role: e.role,
model: e.model,
text: textByUuid.get(e.uuid) ?? null,
countsTowardUsage: canonical,
inputTokens,
outputTokens,
cacheCreationTokens,
cacheReadTokens,
totalTokens,
costUsd,
toolCalls: toolsByEvent.get(e.uuid) ?? []
};
});
totals.totalTokens =
totals.inputTokens +
totals.outputTokens +
totals.cacheCreationTokens +
totals.cacheReadTokens;
// Preserve any tool calls whose owning event wasn't ingested as synthetic turns.
for (const t of orphanTools) {
turns.push({
uuid: `tool:${t.toolUseId}`,
tsUtc: t.tsUtc,
type: 'tool',
role: null,
model: null,
text: null,
countsTowardUsage: false,
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
costUsd: 0,
toolCalls: [t]
});
}
if (orphanTools.length) {
turns.sort((a, b) => (a.tsUtc ?? '').localeCompare(b.tsUtc ?? ''));
}
const meta: SessionMeta = {
host,
sessionId,
project: session?.project ?? null,
gitBranch: session?.git_branch ?? null,
ccVersion: session?.cc_version ?? null,
entrypoint: session?.entrypoint ?? null,
startedAt: session?.started_at ?? null,
endedAt: session?.ended_at ?? null
};
return { meta, totals, turns };
};