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>
This commit is contained in:
megaproxy 2026-07-08 19:33:55 +01:00
commit 71a60ab054
74 changed files with 15613 additions and 0 deletions

111
agent/push.ts Normal file
View file

@ -0,0 +1,111 @@
/**
* toknmtr agent pusher.
*
* POSTs parsed batches to the server's /api/ingest. The ingest endpoint
* (src/routes/api/ingest/+server.ts) accepts `{ host?, events, toolCalls?, sessions? }`
* events are the documented minimal contract; toolCalls/sessions are additional top-level
* keys the ingest agent reads to populate the tool_calls/sessions tables. Idempotent
* upsert on the server means re-pushing (e.g. retrying after a failed request) is always
* safe. Config via env: TOKNMTR_URL, TOKNMTR_TOKEN.
*/
import type { ParsedEvent, SessionMeta, ToolCall } from './parse.ts';
import type { UsageGauges } from './usage.ts';
const URL_BASE = process.env.TOKNMTR_URL ?? 'http://localhost:3001';
const TOKEN = process.env.TOKNMTR_TOKEN ?? '';
/** Max events per ingest request keeps request bodies (and the server's single
* transaction per request) a reasonable size for large backfills. Events carry raw
* prompt/response text, so a batch of 500 can run into many MB; 200 keeps each POST
* comfortably under the server's BODY_SIZE_LIMIT (see ops/README.md set it generously,
* e.g. BODY_SIZE_LIMIT=64M, on the deployed container). */
const CHUNK_SIZE = 200;
export interface PushBatch {
host?: string;
events: ParsedEvent[];
toolCalls?: ToolCall[];
sessions?: SessionMeta[];
}
export interface PushResult {
ok: boolean;
received: number;
requests: number;
}
interface IngestResponse {
ok: boolean;
events?: number;
tool_calls?: number;
sessions?: number;
received?: number; // older/stub server shape
}
async function postChunk(body: Record<string, unknown>): Promise<number> {
const res = await fetch(`${URL_BASE}/api/ingest`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${TOKEN}`
},
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`ingest failed: ${res.status} ${await res.text()}`);
const parsed = (await res.json()) as IngestResponse;
return parsed.events ?? parsed.received ?? 0;
}
/**
* Push a full batch (events + tool calls + session metadata), chunking `events` into
* groups of CHUNK_SIZE per request. toolCalls/sessions (typically much smaller) are sent
* once, attached to the first request.
*/
export async function pushBatch(batch: PushBatch): Promise<PushResult> {
const { host, events, toolCalls = [], sessions = [] } = batch;
if (events.length === 0 && toolCalls.length === 0 && sessions.length === 0) {
return { ok: true, received: 0, requests: 0 };
}
let received = 0;
let requests = 0;
const chunkCount = events.length > 0 ? Math.ceil(events.length / CHUNK_SIZE) : 1;
for (let i = 0; i < chunkCount; i++) {
const chunk = events.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const body: Record<string, unknown> = { events: chunk };
if (host) body.host = host;
if (i === 0) {
if (toolCalls.length > 0) body.toolCalls = toolCalls;
if (sessions.length > 0) body.sessions = sessions;
}
received += await postChunk(body);
requests++;
}
return { ok: true, received, requests };
}
/** Convenience wrapper for pushing just events (no tool calls / session metadata). */
export async function pushEvents(
events: ParsedEvent[]
): Promise<{ ok: boolean; received: number }> {
const result = await pushBatch({ events });
return { ok: result.ok, received: result.received };
}
/**
* POST a single subscription-usage gauge reading to the server's /api/usage endpoint.
* Returns true on a 2xx. Caller decides whether/when to scrape (see agent/usage.ts) this
* just ships whatever it's handed. Idempotent on the server (upsert by host + ts_utc).
*/
export async function pushUsageGauges(gauges: UsageGauges): Promise<boolean> {
const res = await fetch(`${URL_BASE}/api/usage`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${TOKEN}`
},
body: JSON.stringify(gauges)
});
if (!res.ok) throw new Error(`usage push failed: ${res.status} ${await res.text()}`);
return true;
}