From 9e64111f620b5c0440d4d87cc67b27b761a15d90 Mon Sep 17 00:00:00 2001 From: Sheffield Leithart Date: Wed, 28 Oct 2026 12:38:01 -0700 Subject: [PATCH 1/2] analytics reads a per-minute rollup, so an open dashboard stops freezing the daemon Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + package.json | 2 +- src/storage.test.ts | 97 ++++++++++++++++++++++++++++++++++ src/storage.ts | 124 +++++++++++++++++++++++++++++++++++++------- 4 files changed, 205 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4920595..0c3155b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- [2026-10-28] analytics reads a per-minute rollup, so an open dashboard stops freezing the daemon - [2026-10-14] the dashboard fits the terminal it runs in - [2026-10-01] [mac app polish](https://github.com/RubricLab/tokenmaxx/commit/675f52cbbc798b7ee8b161fe3794c13bde9363c8) - [2026-09-30] proxy refuses web pages diff --git a/package.json b/package.json index 3df348a..6b761f1 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.79" + "version": "0.0.80" } diff --git a/src/storage.test.ts b/src/storage.test.ts index 3e7ca50..682ffd4 100644 --- a/src/storage.test.ts +++ b/src/storage.test.ts @@ -47,3 +47,100 @@ describe('a database with rows this build cannot read', () => { store.close() }) }) + +describe('token analytics', () => { + const day = 24 * 3_600_000 + const now = Date.UTC(2026, 9, 28, 12, 0, 0) + const accountId = '00000000-0000-4000-8000-000000000401' + const timeframes = [ + { key: '1h', ms: 3_600_000 }, + { key: '7d', ms: 7 * day }, + { key: '31d', ms: 31 * day } + ] + const events = Array.from({ length: 400 }, (_, index) => ({ + accountId: index % 3 === 0 ? null : accountId, + at: now - ((index * 7_919_993) % (30 * day)), + cacheCreationTokens: index % 5, + cacheReadTokens: index * 3, + inputTokens: 100 + index, + model: index % 4 === 0 ? null : `model-${index % 2}`, + outputTokens: index * 2, + provider: index % 2 === 0 ? ('anthropic' as const) : ('openai' as const) + })) + const sorted = (rows: Row[]) => + [...rows].sort((a, b) => `${a.provider}${a.model}`.localeCompare(`${b.provider}${b.model}`)) + + test('long windows read the minute rollup and keep the same totals', () => { + const store = createStateStore( + join(mkdtempSync(join(tmpdir(), 'tokenmaxx-store-')), 'state.sqlite') + ) + for (const event of events) { + store.recordTokenEvent(event) + } + const [hour, week, month] = store.tokenAnalytics(now, timeframes) + const inWindow = (ms: number) => events.filter(event => event.at >= now - ms) + const total = (list: typeof events) => + list.reduce( + (sum, event) => + sum + + event.inputTokens + + event.outputTokens + + event.cacheReadTokens + + event.cacheCreationTokens, + 0 + ) + expect(hour?.buckets.reduce((a, b) => a + b, 0)).toBe(total(inWindow(3_600_000))) + expect(week?.buckets.reduce((a, b) => a + b, 0)).toBe(total(inWindow(7 * day))) + expect(month?.buckets.reduce((a, b) => a + b, 0)).toBe(total(inWindow(31 * day))) + expect(sorted(month?.byModel ?? []).map(row => row.model)).toContain('unknown') + expect((month?.byModel ?? []).reduce((sum, row) => sum + row.input, 0)).toBe( + events.reduce((sum, event) => sum + event.inputTokens, 0) + ) + expect( + store.accountTokens(accountId, now - 31 * day).reduce((sum, row) => sum + row.output, 0) + ).toBe( + events + .filter(event => event.accountId === accountId) + .reduce((sum, event) => sum + event.outputTokens, 0) + ) + store.close() + }) + + test('an existing database backfills the rollup from its raw events', () => { + const path = join(mkdtempSync(join(tmpdir(), 'tokenmaxx-store-')), 'state.sqlite') + const seed = createStateStore(path) + for (const event of events) { + seed.recordTokenEvent(event) + } + const expected = seed.tokenAnalytics(now, timeframes) + seed.close() + + const database = new Database(path) + database.exec('DROP TABLE token_minutes') + database.close() + + const store = createStateStore(path) + const actual = store.tokenAnalytics(now, timeframes) + expect(actual.map(frame => frame.buckets)).toEqual(expected.map(frame => frame.buckets)) + expect(actual.map(frame => sorted(frame.byModel))).toEqual( + expected.map(frame => sorted(frame.byModel)) + ) + store.close() + }) + + test('minutes older than the retention window are pruned with their events', () => { + const path = join(mkdtempSync(join(tmpdir(), 'tokenmaxx-store-')), 'state.sqlite') + const store = createStateStore(path) + const old = { ...(events[1] as (typeof events)[number]), at: now - 40 * day } + store.recordTokenEvent(old) + store.recordTokenEvent({ ...old, at: now }) + store.close() + + const database = new Database(path) + const count = (table: string) => + database.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n + expect(count('token_events')).toBe(1) + expect(count('token_minutes')).toBe(1) + database.close() + }) +}) diff --git a/src/storage.ts b/src/storage.ts index 8bbc2aa..e7bf5ed 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -24,6 +24,11 @@ type PersistedSchema = { parse(value: unknown): Type } const maxTokenEventAgeMs = 31 * 24 * 60 * 60 * 1000 const tokenBucketCount = 120 +const tokenMinuteMs = 60_000 +// Charts whose buckets are at least this wide read the per-minute rollup. A minute +// placed at its start shifts at most 10% of a bucket, and long windows stop +// scanning every raw event on the daemon's only thread. +const rollupMinBucketMs = 10 * tokenMinuteMs export interface TokenTimeframeAggregate { key: string @@ -144,6 +149,43 @@ function migratePolicyDefaults(database: Database): void { } } +// One row per minute, provider, account and model, kept in step with token_events. +// Empty strings stand in for a missing account or model so the key stays unique. +function migrateTokenMinutes(database: Database): void { + const exists = + database + .query<{ name: string }, []>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'token_minutes'" + ) + .get() !== null + if (exists) { + return + } + database.transaction(() => { + database.exec(` + CREATE TABLE token_minutes ( + minute INTEGER NOT NULL, + provider TEXT NOT NULL, + account_id TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (minute, provider, account_id, model) + ) WITHOUT ROWID; + CREATE INDEX token_minutes_account ON token_minutes(account_id, minute); + `) + database + .query( + `INSERT INTO token_minutes (minute, provider, account_id, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) + SELECT (at / ?) * ?, provider, COALESCE(account_id, ''), COALESCE(model, ''), SUM(input_tokens), SUM(output_tokens), SUM(cache_read_tokens), SUM(cache_creation_tokens) + FROM token_events GROUP BY 1, 2, 3, 4` + ) + .run(tokenMinuteMs, tokenMinuteMs) + })() +} + function migrate(database: Database): void { database.exec('PRAGMA journal_mode = WAL') database.exec('PRAGMA foreign_keys = ON') @@ -215,6 +257,7 @@ function migrate(database: Database): void { 'ALTER TABLE token_events ADD COLUMN cache_creation_tokens INTEGER NOT NULL DEFAULT 0' ) } + migrateTokenMinutes(database) const requiresLabelMigration = !accountColumns.has('label') const requiresExternalAccountIdMigration = !accountColumns.has('external_account_id') const requiresExternalUserIdMigration = !accountColumns.has('external_user_id') @@ -509,21 +552,41 @@ export function createStateStore(databasePath: string): StateStore { function recordTokenEvent(event: TokenEvent): void { const parsed = TokenEventSchema.parse(event) - database - .query( - 'INSERT INTO token_events (at, provider, account_id, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' - ) - .run( - parsed.at, - parsed.provider, - parsed.accountId, - parsed.model, - parsed.inputTokens, - parsed.outputTokens, - parsed.cacheReadTokens, - parsed.cacheCreationTokens - ) - database.query('DELETE FROM token_events WHERE at < ?').run(parsed.at - maxTokenEventAgeMs) + const cutoff = parsed.at - maxTokenEventAgeMs + database.transaction(() => { + database + .query( + 'INSERT INTO token_events (at, provider, account_id, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ) + .run( + parsed.at, + parsed.provider, + parsed.accountId, + parsed.model, + parsed.inputTokens, + parsed.outputTokens, + parsed.cacheReadTokens, + parsed.cacheCreationTokens + ) + database + .query( + 'INSERT INTO token_minutes (minute, provider, account_id, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (minute, provider, account_id, model) DO UPDATE SET input_tokens = input_tokens + excluded.input_tokens, output_tokens = output_tokens + excluded.output_tokens, cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens, cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens' + ) + .run( + Math.floor(parsed.at / tokenMinuteMs) * tokenMinuteMs, + parsed.provider, + parsed.accountId ?? '', + parsed.model ?? '', + parsed.inputTokens, + parsed.outputTokens, + parsed.cacheReadTokens, + parsed.cacheCreationTokens + ) + database.query('DELETE FROM token_events WHERE at < ?').run(cutoff) + database + .query('DELETE FROM token_minutes WHERE minute < ?') + .run(Math.floor(cutoff / tokenMinuteMs) * tokenMinuteMs) + })() } function tokenAnalytics( @@ -549,15 +612,40 @@ export function createStateStore(databasePath: string): StateStore { >( 'SELECT model, provider, SUM(input_tokens) AS input, SUM(output_tokens) AS output, SUM(cache_read_tokens) AS cached, SUM(cache_creation_tokens) AS cacheCreation FROM token_events WHERE at >= ? AND at <= ? GROUP BY model, provider' ) + const minuteBucketQuery = database.query< + { b: number; tokens: number }, + [number, number, number, number] + >( + 'SELECT CAST((minute - ?) / ? AS INTEGER) AS b, SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens) AS tokens FROM token_minutes WHERE minute >= ? AND minute <= ? GROUP BY b' + ) + const minuteModelQuery = database.query< + { + model: string | null + provider: string + input: number + output: number + cached: number + cacheCreation: number + }, + [number, number] + >( + "SELECT NULLIF(model, '') AS model, provider, SUM(input_tokens) AS input, SUM(output_tokens) AS output, SUM(cache_read_tokens) AS cached, SUM(cache_creation_tokens) AS cacheCreation FROM token_minutes WHERE minute >= ? AND minute <= ? GROUP BY model, provider" + ) return timeframes.map(({ key, ms }) => { const start = nowMillis - ms const bucketMs = Math.max(1, Math.round(ms / tokenBucketCount)) + const rollup = bucketMs >= rollupMinBucketMs const buckets = new Array(tokenBucketCount).fill(0) - for (const row of bucketQuery.all(start, bucketMs, start, nowMillis)) { + for (const row of (rollup ? minuteBucketQuery : bucketQuery).all( + start, + bucketMs, + start, + nowMillis + )) { const index = Math.min(tokenBucketCount - 1, Math.max(0, row.b)) buckets[index] = (buckets[index] ?? 0) + row.tokens } - const byModel = modelQuery.all(start, nowMillis).map(row => ({ + const byModel = (rollup ? minuteModelQuery : modelQuery).all(start, nowMillis).map(row => ({ cacheCreation: row.cacheCreation, cached: row.cached, input: row.input, @@ -581,7 +669,7 @@ export function createStateStore(databasePath: string): StateStore { }, [string, number] >( - 'SELECT model, SUM(input_tokens) AS input, SUM(output_tokens) AS output, SUM(cache_read_tokens) AS cached, SUM(cache_creation_tokens) AS cacheCreation FROM token_events WHERE account_id = ? AND at >= ? GROUP BY model' + "SELECT NULLIF(model, '') AS model, SUM(input_tokens) AS input, SUM(output_tokens) AS output, SUM(cache_read_tokens) AS cached, SUM(cache_creation_tokens) AS cacheCreation FROM token_minutes WHERE account_id = ? AND minute >= ? GROUP BY model" ) .all(accountId, startMillis) .map(row => ({ ...row, model: row.model ?? 'unknown' })) From f4e46c1d338dec4ac224ff8320ad333399868cdb Mon Sep 17 00:00:00 2001 From: Sheffield Leithart Date: Fri, 30 Oct 2026 17:52:12 -0700 Subject: [PATCH 2/2] the login daemon runs at interactive priority, so the dashboard stops timing out launchd ran the daemon at utility QoS without a ProcessType. On a real 839k-event database each dashboard/analytics call took 0.1-4.9s under utility QoS and 50-90ms at normal priority. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + package.json | 2 +- src/launch-agent.test.ts | 1 + src/launch-agent.ts | 3 +++ 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c3155b..f50fa3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- [2026-10-30] the login daemon runs at interactive priority, so the dashboard stops timing out - [2026-10-28] analytics reads a per-minute rollup, so an open dashboard stops freezing the daemon - [2026-10-14] the dashboard fits the terminal it runs in - [2026-10-01] [mac app polish](https://github.com/RubricLab/tokenmaxx/commit/675f52cbbc798b7ee8b161fe3794c13bde9363c8) diff --git a/package.json b/package.json index 6b761f1..8f57c0e 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.80" + "version": "0.0.81" } diff --git a/src/launch-agent.test.ts b/src/launch-agent.test.ts index 4cd72a0..8509dec 100644 --- a/src/launch-agent.test.ts +++ b/src/launch-agent.test.ts @@ -110,6 +110,7 @@ describe('login startup files', () => { }, KeepAlive: true, Label: 'sh.tokenmaxx.daemon', + ProcessType: 'Interactive', RunAtLoad: true, WorkingDirectory: input.userDirectory }) diff --git a/src/launch-agent.ts b/src/launch-agent.ts index 688bb11..b0ab443 100644 --- a/src/launch-agent.ts +++ b/src/launch-agent.ts @@ -100,6 +100,9 @@ export function launchAgentFiles(input: LaunchAgentOptions) { ExitTimeOut: 10, KeepAlive: true, Label: label, + // Without this launchd runs the daemon at utility QoS on efficiency cores, + // and every dashboard request and proxied call waits several times longer. + ProcessType: 'Interactive', ProgramArguments: [executable, 'daemon', 'run'], RunAtLoad: true, StandardErrorPath: logPath,