@cliftonc/finius 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/branding.js +28 -0
- package/dist/cli/backfill.js +122 -0
- package/dist/cli/claude-settings.js +54 -0
- package/dist/cli/codex-config.js +60 -0
- package/dist/cli/codex.js +97 -0
- package/dist/cli/config.js +41 -0
- package/dist/cli/doctor.js +159 -0
- package/dist/cli/hook.js +70 -0
- package/dist/cli/identity.js +163 -0
- package/dist/cli/import.js +61 -0
- package/dist/cli/index.js +70 -0
- package/dist/cli/install.js +23 -0
- package/dist/cli/password.js +14 -0
- package/dist/cli/serve.js +63 -0
- package/dist/cli/setup.js +314 -0
- package/dist/cli/ui.js +15 -0
- package/dist/client/assets/TranscriptView-CBf7-4Bo.css +1 -0
- package/dist/client/assets/TranscriptView-CLCPX5bI.js +194 -0
- package/dist/client/assets/TranscriptView-D056GDHO.js +194 -0
- package/dist/client/assets/TranscriptView-MIgsAwMN.js +194 -0
- package/dist/client/assets/index-6OIY_8fO.css +1 -0
- package/dist/client/assets/index-9aN8py7_.js +1 -0
- package/dist/client/assets/index-B-sjMmTS.js +1636 -0
- package/dist/client/assets/index-B4HbP3X6.js +1 -0
- package/dist/client/assets/index-B9wgN1BV.js +1636 -0
- package/dist/client/assets/index-BHlFz1Th.js +1652 -0
- package/dist/client/assets/index-BJyvYca7.js +1636 -0
- package/dist/client/assets/index-BKBTeJLz.js +1 -0
- package/dist/client/assets/index-BN6CbirS.js +1444 -0
- package/dist/client/assets/index-BW4_7xR6.js +1460 -0
- package/dist/client/assets/index-BaLElA30.js +1 -0
- package/dist/client/assets/index-BaQ02V5d.css +1 -0
- package/dist/client/assets/index-Bh0dgUU-.js +1636 -0
- package/dist/client/assets/index-Bie86XRc.js +1 -0
- package/dist/client/assets/index-Bijt5al-.css +1 -0
- package/dist/client/assets/index-BikJP2HS.js +1636 -0
- package/dist/client/assets/index-BkwrvP-J.js +1 -0
- package/dist/client/assets/index-BwVuUJSv.js +1 -0
- package/dist/client/assets/index-BweXI4-D.css +1 -0
- package/dist/client/assets/index-BwqdHcDE.js +1 -0
- package/dist/client/assets/index-C-Z0w-tQ.js +1652 -0
- package/dist/client/assets/index-C2RmKzem.js +1636 -0
- package/dist/client/assets/index-CHz-iKIQ.js +1 -0
- package/dist/client/assets/index-CIGl5oW_.js +1646 -0
- package/dist/client/assets/index-CVYmd4Bm.js +1465 -0
- package/dist/client/assets/index-Ca9UVGK1.js +1 -0
- package/dist/client/assets/index-CeWDkmJN.js +1 -0
- package/dist/client/assets/index-CpsNq0zm.css +1 -0
- package/dist/client/assets/index-CrUS6abD.css +1 -0
- package/dist/client/assets/index-Ctq8vj2Z.js +1 -0
- package/dist/client/assets/index-D1ktp0pp.js +1 -0
- package/dist/client/assets/index-D3BoYpFi.css +1 -0
- package/dist/client/assets/index-D59GxlrT.js +1636 -0
- package/dist/client/assets/index-D5Wkww8x.css +1 -0
- package/dist/client/assets/index-DC94jMGe.js +1 -0
- package/dist/client/assets/index-DFcIBkv1.js +1652 -0
- package/dist/client/assets/index-DmKj5Jqc.css +1 -0
- package/dist/client/assets/index-Dx52i05H.js +1465 -0
- package/dist/client/assets/index-L3GnPzmU.css +1 -0
- package/dist/client/assets/index-OZADsKet.js +1652 -0
- package/dist/client/assets/index-Qt124kj1.js +1652 -0
- package/dist/client/assets/index-nHzwQ3EM.js +1 -0
- package/dist/client/assets/index-s9Mg6LTO.js +1 -0
- package/dist/client/assets/index-ye8oxz8P.js +1 -0
- package/dist/client/assets/index-yqJS7tUY.css +1 -0
- package/dist/client/favicon.svg +35 -0
- package/dist/client/finius-dashboard.png +0 -0
- package/dist/client/index.html +38 -0
- package/dist/server/app.js +285 -0
- package/dist/server/claude.js +124 -0
- package/dist/server/codex.js +94 -0
- package/dist/server/events.js +12 -0
- package/dist/server/index.js +119 -0
- package/dist/server/otel.js +231 -0
- package/dist/server/pricing-backfill.js +41 -0
- package/dist/server/pricing.js +138 -0
- package/dist/server/queue.js +35 -0
- package/dist/server/storage/blob.js +17 -0
- package/dist/server/storage/query-helpers.js +104 -0
- package/dist/server/storage/sqlite.js +1167 -0
- package/dist/server/transcripts.js +46 -0
- package/dist/server/types.js +1 -0
- package/dist/shared/api-types.js +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { LocalBlobStore } from "./blob.js";
|
|
6
|
+
import { detectTranscriptFormat, parseTranscript, shouldReplaceBySession } from "../transcripts.js";
|
|
7
|
+
import { parseOtelLogRecords, parseOtelMetricPoints, parseOtelMetricRecords, preferredIdentity, stableHash } from "../otel.js";
|
|
8
|
+
import { COMPUTED_COST_METRIC, computeCostPoints, indexPrices } from "../pricing.js";
|
|
9
|
+
import { SerialQueue } from "../queue.js";
|
|
10
|
+
import { EFFECTIVE_ROLLUP, GRANULARITY_MS, OTEL_SOURCE, canUseRollup, jsonlWins, pointWhere, rollupWhere } from "./query-helpers.js";
|
|
11
|
+
export class SqliteStorageAdapter {
|
|
12
|
+
db;
|
|
13
|
+
storeRawPayloads;
|
|
14
|
+
blob;
|
|
15
|
+
// In-memory model→price lookup, loaded from model_prices at construction and refreshed on
|
|
16
|
+
// importPricing. The cost-synthesis hot path reads this synchronously.
|
|
17
|
+
priceIndex = new Map();
|
|
18
|
+
// The earliest effective_date we hold any price for; usage before this day has no historical price
|
|
19
|
+
// and triggers a backfill fetch. null = no pricing at all yet.
|
|
20
|
+
earliestPriceDate = null;
|
|
21
|
+
// The single processing queue behind JSONL uploads (parse → metrics → cost → pricing backfill).
|
|
22
|
+
ingestQueue = new SerialQueue();
|
|
23
|
+
// Content hashes whose processing is queued/in-flight — dedups repeat uploads before the persistent
|
|
24
|
+
// source_files dedup row exists (which we only write on success, so a failed job can be retried).
|
|
25
|
+
inFlight = new Set();
|
|
26
|
+
// Day buckets (YYYY-MM-DD) we've already asked the historical fetcher for, so we fetch each once.
|
|
27
|
+
fetchedPriceDays = new Set();
|
|
28
|
+
// Fetches historical pricing for a day (injected by startServer; absent in tests = no network).
|
|
29
|
+
historicalFetcher;
|
|
30
|
+
// Notified when a queued import finishes, so the server can publish the SSE 'ingest' event.
|
|
31
|
+
processingListener;
|
|
32
|
+
constructor(path, options = {}) {
|
|
33
|
+
this.storeRawPayloads = options.storeRawPayloads ?? true;
|
|
34
|
+
this.blob = options.blob ?? new LocalBlobStore(join(dirname(path), "transcripts"));
|
|
35
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
36
|
+
this.db = new DatabaseSync(path);
|
|
37
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
38
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
39
|
+
this.migrate();
|
|
40
|
+
this.loadPricing();
|
|
41
|
+
}
|
|
42
|
+
async ingestOtelMetrics(batch) {
|
|
43
|
+
const hash = stableHash("otlp_metrics", batch);
|
|
44
|
+
const rawBatchId = this.insertRawBatch("otlp_metrics", hash, batch);
|
|
45
|
+
if (rawBatchId === null)
|
|
46
|
+
return { duplicate: true, points: 0 };
|
|
47
|
+
const points = parseOtelMetricPoints(batch);
|
|
48
|
+
// Still parsed (not persisted) so we can warn once if a backend sends CUMULATIVE temporality.
|
|
49
|
+
warnIfCumulative(parseOtelMetricRecords(batch));
|
|
50
|
+
this.db.exec("BEGIN");
|
|
51
|
+
try {
|
|
52
|
+
for (const point of points) {
|
|
53
|
+
this.insertMetricPoint(point, rawBatchId);
|
|
54
|
+
this.upsertRollup(point);
|
|
55
|
+
}
|
|
56
|
+
this.db.exec("COMMIT");
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
this.db.exec("ROLLBACK");
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
return { duplicate: false, points: points.length };
|
|
63
|
+
}
|
|
64
|
+
async ingestOtelLogs(batch) {
|
|
65
|
+
const hash = stableHash("otlp_logs", batch);
|
|
66
|
+
const rawBatchId = this.insertRawBatch("otlp_logs", hash, batch);
|
|
67
|
+
if (rawBatchId === null)
|
|
68
|
+
return { duplicate: true, events: 0 };
|
|
69
|
+
// Logs are indexed into log_events for inspection (GET /api/logs/events) but NOT aggregated into
|
|
70
|
+
// metric_points. Claude's tokens/cost come from its metrics; Codex's come from the authoritative
|
|
71
|
+
// rollout-JSONL path (`codex-cli-jsonl`). Codex's logs-only OTel (`codex.sse_event`) is a partial,
|
|
72
|
+
// cost-less subset of the rollout, so counting it here would undercount and drop cost — we keep it
|
|
73
|
+
// visible as log_events only. raw_batches still holds the verbatim payload for replay.
|
|
74
|
+
const records = parseOtelLogRecords(batch);
|
|
75
|
+
const insert = this.db.prepare(`INSERT INTO log_events (event_name, severity, session_id, timestamp, attributes_json, body_json, raw_batch_id)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
77
|
+
this.db.exec("BEGIN");
|
|
78
|
+
try {
|
|
79
|
+
for (const record of records) {
|
|
80
|
+
insert.run(record.eventName, record.severityText, record.sessionId, record.timestamp, JSON.stringify(record.attributes ?? {}), record.body === undefined ? null : JSON.stringify(record.body ?? null), rawBatchId);
|
|
81
|
+
}
|
|
82
|
+
this.db.exec("COMMIT");
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
this.db.exec("ROLLBACK");
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
return { duplicate: false, events: records.length };
|
|
89
|
+
}
|
|
90
|
+
// Grouped inspection view of captured log records: one row per distinct event name with a count,
|
|
91
|
+
// the latest timestamp, and a single sample (most recent) so we can see what Codex actually emits.
|
|
92
|
+
async getLogEventSummary() {
|
|
93
|
+
const rows = this.db
|
|
94
|
+
.prepare(`SELECT
|
|
95
|
+
COALESCE(event_name, '(unnamed)') AS eventName,
|
|
96
|
+
COUNT(*) AS count,
|
|
97
|
+
MAX(timestamp) AS lastSeenAt,
|
|
98
|
+
(SELECT attributes_json FROM log_events e2
|
|
99
|
+
WHERE COALESCE(e2.event_name, '(unnamed)') = COALESCE(e1.event_name, '(unnamed)')
|
|
100
|
+
ORDER BY e2.timestamp DESC LIMIT 1) AS sampleAttributes,
|
|
101
|
+
(SELECT body_json FROM log_events e2
|
|
102
|
+
WHERE COALESCE(e2.event_name, '(unnamed)') = COALESCE(e1.event_name, '(unnamed)')
|
|
103
|
+
ORDER BY e2.timestamp DESC LIMIT 1) AS sampleBody
|
|
104
|
+
FROM log_events e1
|
|
105
|
+
GROUP BY COALESCE(event_name, '(unnamed)')
|
|
106
|
+
ORDER BY count DESC`)
|
|
107
|
+
.all();
|
|
108
|
+
return rows.map((row) => ({
|
|
109
|
+
eventName: row.eventName,
|
|
110
|
+
count: row.count,
|
|
111
|
+
lastSeenAt: row.lastSeenAt,
|
|
112
|
+
sample: row.sampleAttributes
|
|
113
|
+
? { attributes: safeParse(row.sampleAttributes), body: safeParse(row.sampleBody) }
|
|
114
|
+
: null
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
// Synchronous import: persist + fully process in one call. Used by tests, the CLI backfill (direct),
|
|
118
|
+
// and anywhere a caller wants the ImportResult back. The HTTP upload path uses enqueueImport instead.
|
|
119
|
+
async importJsonl(source, sessionHint, content, format) {
|
|
120
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
121
|
+
if (this.isDuplicateUpload(hash))
|
|
122
|
+
return { duplicate: true, importedLines: 0, malformedLines: 0, metricPoints: 0, rawEvents: 0 };
|
|
123
|
+
await this.blob.save(hash, content);
|
|
124
|
+
return this.processImport(source, sessionHint, content, hash, format);
|
|
125
|
+
}
|
|
126
|
+
// Upload path: save the blob IMMEDIATELY (so the file is durable the moment we accept it) and hand
|
|
127
|
+
// the parsing/metrics/cost work to the single background processing queue. Returns as soon as the
|
|
128
|
+
// job is queued; the SSE 'ingest' event fires from the queue when the job completes.
|
|
129
|
+
async enqueueImport(source, sessionHint, content, format) {
|
|
130
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
131
|
+
if (this.isDuplicateUpload(hash))
|
|
132
|
+
return { duplicate: true, queued: false };
|
|
133
|
+
this.inFlight.add(hash);
|
|
134
|
+
await this.blob.save(hash, content); // persisted immediately, before we return
|
|
135
|
+
this.ingestQueue.enqueue(async () => {
|
|
136
|
+
try {
|
|
137
|
+
const result = await this.processImport(source, sessionHint, content, hash, format);
|
|
138
|
+
this.processingListener?.("jsonl", result);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
this.inFlight.delete(hash);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return { duplicate: false, queued: true };
|
|
145
|
+
}
|
|
146
|
+
// Drains the processing queue — for tests and graceful shutdown.
|
|
147
|
+
async settleIngest() {
|
|
148
|
+
await this.ingestQueue.settle();
|
|
149
|
+
}
|
|
150
|
+
setProcessingListener(listener) {
|
|
151
|
+
this.processingListener = listener;
|
|
152
|
+
}
|
|
153
|
+
// Injected by startServer: how to fetch the LiteLLM price file as it existed on a given day. Absent
|
|
154
|
+
// (e.g. in tests, or FINIUS_PRICING_FETCH=off) means no historical backfill — cost uses what we have.
|
|
155
|
+
setHistoricalPriceFetcher(fetcher) {
|
|
156
|
+
this.historicalFetcher = fetcher;
|
|
157
|
+
}
|
|
158
|
+
// Already-imported (persistent) or queued/in-flight (in-memory) — either way, don't re-process.
|
|
159
|
+
isDuplicateUpload(hash) {
|
|
160
|
+
if (this.inFlight.has(hash))
|
|
161
|
+
return true;
|
|
162
|
+
return !!this.db.prepare("SELECT 1 FROM source_files WHERE hash = ?").get(hash);
|
|
163
|
+
}
|
|
164
|
+
// The actual processing step (queued, or run inline by importJsonl): parse → backfill any missing
|
|
165
|
+
// historical pricing → synthesize cost → insert points + record the source file. The blob is already
|
|
166
|
+
// saved by the caller; this never touches raw_batches.
|
|
167
|
+
async processImport(source, sessionHint, content, hash, format) {
|
|
168
|
+
const lines = content.split(/\r?\n/);
|
|
169
|
+
// Pluggable per-agent parser: explicit format wins, else sniff (Claude vs Codex).
|
|
170
|
+
const resolvedFormat = format ?? detectTranscriptFormat(lines);
|
|
171
|
+
const parsed = parseTranscript(resolvedFormat, source, sessionHint, lines);
|
|
172
|
+
// If this transcript has usage on days we hold no price for, fetch the historical pricing now
|
|
173
|
+
// (serial, deduped) so the cost we synthesize below uses the rate in effect at the time.
|
|
174
|
+
await this.backfillHistoricalPricing(parsed.points);
|
|
175
|
+
this.db.exec("BEGIN");
|
|
176
|
+
try {
|
|
177
|
+
// Some agents (Codex) write ONE append-only file per session and re-upload it as it grows; for
|
|
178
|
+
// those, replace the session's prior points for this source so a longer re-upload doesn't
|
|
179
|
+
// double-count. (Identical re-uploads already short-circuited on the content hash.)
|
|
180
|
+
if (shouldReplaceBySession(resolvedFormat)) {
|
|
181
|
+
const sessionIds = [...new Set(parsed.points.map((p) => p.sessionId))];
|
|
182
|
+
const del = this.db.prepare("DELETE FROM metric_points WHERE source = ? AND signal = 'jsonl' AND session_row_id IN (SELECT id FROM sessions WHERE session_id = ?)");
|
|
183
|
+
for (const sid of sessionIds)
|
|
184
|
+
del.run(source, sid);
|
|
185
|
+
}
|
|
186
|
+
// Synthesize cost when the transcript didn't carry it (Codex never does; Claude JSONL rarely
|
|
187
|
+
// does). The computed points share signal:'jsonl' so jsonlWins/EFFECTIVE_ROLLUP shadow them for
|
|
188
|
+
// any session that also has authoritative OTel cost — no double counting. When the transcript
|
|
189
|
+
// DID report a real cost point, we leave it as-is and synthesize nothing.
|
|
190
|
+
const hasReportedCost = parsed.points.some((p) => p.kind === "cost");
|
|
191
|
+
const pointsToInsert = hasReportedCost
|
|
192
|
+
? parsed.points
|
|
193
|
+
: [...parsed.points, ...computeCostPoints(parsed.points, this.priceIndex)];
|
|
194
|
+
for (const point of pointsToInsert) {
|
|
195
|
+
// JSONL metrics deliberately do NOT feed the rollup: OTel is authoritative, so the rollup
|
|
196
|
+
// stays OTel-only and the JSONL fallback is folded in at read time (see EFFECTIVE_ROLLUP /
|
|
197
|
+
// jsonlWins). This keeps a session that has both signals from being double-counted.
|
|
198
|
+
this.insertMetricPoint(point, null);
|
|
199
|
+
}
|
|
200
|
+
// Link the file to the single session row for its UUID (inserting the points above already
|
|
201
|
+
// upserted it, recording has_jsonl). "View transcript" then surfaces on that one session.
|
|
202
|
+
const sessionId = sessionHint.sessionId ?? parsed.points[0]?.sessionId ?? null;
|
|
203
|
+
const sessionRow = sessionId
|
|
204
|
+
? this.db.prepare("SELECT id FROM sessions WHERE session_id = ?").get(sessionId)
|
|
205
|
+
: undefined;
|
|
206
|
+
this.db
|
|
207
|
+
.prepare(`INSERT INTO source_files (source, session_row_id, session_id, hash, blob_key, byte_size, line_count, imported_at)
|
|
208
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
209
|
+
.run(source, sessionRow?.id ?? null, sessionId, hash, hash, Buffer.byteLength(content), parsed.result.importedLines, Date.now());
|
|
210
|
+
this.db.exec("COMMIT");
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
this.db.exec("ROLLBACK");
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
return parsed.result;
|
|
217
|
+
}
|
|
218
|
+
// For each token-usage day earlier than the earliest price we hold, fetch that day's historical
|
|
219
|
+
// pricing (once per day, ever) and import it; then recompute prior computed cost so older imports get
|
|
220
|
+
// re-priced too. No-op when no fetcher is wired or the days are already covered.
|
|
221
|
+
async backfillHistoricalPricing(points) {
|
|
222
|
+
if (!this.historicalFetcher)
|
|
223
|
+
return;
|
|
224
|
+
const DAY = GRANULARITY_MS.day;
|
|
225
|
+
const earliestDay = this.earliestPriceDate == null ? Infinity : Math.floor(this.earliestPriceDate / DAY) * DAY;
|
|
226
|
+
const days = new Set();
|
|
227
|
+
for (const p of points) {
|
|
228
|
+
if (p.kind !== "tokens")
|
|
229
|
+
continue;
|
|
230
|
+
const day = Math.floor(p.timestamp / DAY) * DAY;
|
|
231
|
+
if (day < earliestDay)
|
|
232
|
+
days.add(day);
|
|
233
|
+
}
|
|
234
|
+
let imported = false;
|
|
235
|
+
for (const dayMs of days) {
|
|
236
|
+
const dayIso = new Date(dayMs).toISOString().slice(0, 10);
|
|
237
|
+
if (this.fetchedPriceDays.has(dayIso))
|
|
238
|
+
continue;
|
|
239
|
+
this.fetchedPriceDays.add(dayIso);
|
|
240
|
+
try {
|
|
241
|
+
const prices = await this.historicalFetcher(dayIso);
|
|
242
|
+
if (prices && prices.length) {
|
|
243
|
+
await this.importPricing(prices);
|
|
244
|
+
imported = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
console.warn(`[finius] historical pricing fetch for ${dayIso} failed: ${err.message}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Re-price previously-imported transcripts now that we have older rates (idempotent).
|
|
252
|
+
if (imported)
|
|
253
|
+
await this.recomputeComputedCost();
|
|
254
|
+
}
|
|
255
|
+
async getSessionTranscript(sessionRowId) {
|
|
256
|
+
const row = this.db
|
|
257
|
+
.prepare("SELECT blob_key AS blobKey, source, imported_at AS importedAt FROM source_files WHERE session_row_id = ? ORDER BY imported_at DESC LIMIT 1")
|
|
258
|
+
.get(sessionRowId);
|
|
259
|
+
if (!row)
|
|
260
|
+
return null;
|
|
261
|
+
const bytes = await this.blob.read(row.blobKey);
|
|
262
|
+
if (!bytes)
|
|
263
|
+
return null;
|
|
264
|
+
return { content: bytes.toString("utf8"), source: row.source, importedAt: row.importedAt };
|
|
265
|
+
}
|
|
266
|
+
// Metadata only (no blob read) so the UI can decide whether to show a "view transcript" link.
|
|
267
|
+
async getSessionTranscriptInfo(sessionRowId) {
|
|
268
|
+
const row = this.db
|
|
269
|
+
.prepare("SELECT source, imported_at AS importedAt, byte_size AS byteSize, line_count AS lineCount FROM source_files WHERE session_row_id = ? ORDER BY imported_at DESC LIMIT 1")
|
|
270
|
+
.get(sessionRowId);
|
|
271
|
+
return row ?? null;
|
|
272
|
+
}
|
|
273
|
+
async getSummary(filters) {
|
|
274
|
+
// The rollup has no session dimension, so a session-filtered summary falls back to metric_points.
|
|
275
|
+
return canUseRollup(filters) ? this.summaryFromRollup(filters) : this.summaryFromPoints(filters);
|
|
276
|
+
}
|
|
277
|
+
// Served from the pre-aggregated rollup. Scalar totals + activeSenders sum cleanly from rollup
|
|
278
|
+
// rows; sessionCount is a distinct count that cannot be summed across buckets, so it stays on
|
|
279
|
+
// metric_points (see upsertRollup note).
|
|
280
|
+
summaryFromRollup(filters) {
|
|
281
|
+
const { where, params } = rollupWhere(filters);
|
|
282
|
+
const totals = this.db
|
|
283
|
+
.prepare(`SELECT
|
|
284
|
+
COALESCE(SUM(CASE WHEN kind = 'cost' THEN sum_value ELSE 0 END), 0) AS totalCost,
|
|
285
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'input' THEN sum_value ELSE 0 END), 0) AS inputTokens,
|
|
286
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'output' THEN sum_value ELSE 0 END), 0) AS outputTokens,
|
|
287
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'cache_creation' THEN sum_value ELSE 0 END), 0) AS cacheCreationTokens,
|
|
288
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'cache_read' THEN sum_value ELSE 0 END), 0) AS cacheReadTokens,
|
|
289
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' THEN sum_value ELSE 0 END), 0) AS totalTokens,
|
|
290
|
+
COALESCE(SUM(CASE WHEN kind = 'lines' AND token_type = 'added' THEN sum_value ELSE 0 END), 0) AS linesAdded,
|
|
291
|
+
COALESCE(SUM(CASE WHEN kind = 'lines' AND token_type = 'removed' THEN sum_value ELSE 0 END), 0) AS linesRemoved,
|
|
292
|
+
COALESCE(SUM(CASE WHEN kind = 'decision' AND token_type = 'accept' THEN sum_value ELSE 0 END), 0) AS editsAccepted,
|
|
293
|
+
COALESCE(SUM(CASE WHEN kind = 'decision' AND token_type = 'reject' THEN sum_value ELSE 0 END), 0) AS editsRejected,
|
|
294
|
+
COALESCE(SUM(CASE WHEN kind = 'pull_request' THEN sum_value ELSE 0 END), 0) AS pullRequests,
|
|
295
|
+
COALESCE(SUM(CASE WHEN kind = 'commit' THEN sum_value ELSE 0 END), 0) AS commits,
|
|
296
|
+
COUNT(DISTINCT user_identity) AS activeSenders
|
|
297
|
+
FROM ${EFFECTIVE_ROLLUP} AS r ${where}`)
|
|
298
|
+
.get(...params);
|
|
299
|
+
const point = pointWhere(filters, { dedupe: true });
|
|
300
|
+
const { sessionCount } = this.db
|
|
301
|
+
.prepare(`SELECT COUNT(DISTINCT session_row_id) AS sessionCount FROM metric_points ${point.where}`)
|
|
302
|
+
.get(...point.params);
|
|
303
|
+
return {
|
|
304
|
+
totalCost: totals.totalCost,
|
|
305
|
+
inputTokens: totals.inputTokens,
|
|
306
|
+
outputTokens: totals.outputTokens,
|
|
307
|
+
cacheCreationTokens: totals.cacheCreationTokens,
|
|
308
|
+
cacheReadTokens: totals.cacheReadTokens,
|
|
309
|
+
totalTokens: totals.totalTokens,
|
|
310
|
+
sessionCount,
|
|
311
|
+
activeSenders: totals.activeSenders,
|
|
312
|
+
linesAdded: totals.linesAdded,
|
|
313
|
+
linesRemoved: totals.linesRemoved,
|
|
314
|
+
editsAccepted: totals.editsAccepted,
|
|
315
|
+
editsRejected: totals.editsRejected,
|
|
316
|
+
pullRequests: totals.pullRequests,
|
|
317
|
+
commits: totals.commits,
|
|
318
|
+
models: this.rollupBreakdown("model", "model", filters),
|
|
319
|
+
users: this.enrichUsers(this.rollupBreakdown("user_identity", "COALESCE(user_email, user_account_id, user_id, 'unknown')", filters, "user")),
|
|
320
|
+
sources: this.rollupBreakdown("source", "source", filters)
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
// Fallback path: aggregate directly over metric_points (used when a session filter is present,
|
|
324
|
+
// which the rollup can't express). Identical results to summaryFromRollup.
|
|
325
|
+
summaryFromPoints(filters) {
|
|
326
|
+
const { where, params } = pointWhere(filters, { dedupe: true });
|
|
327
|
+
const totals = this.db
|
|
328
|
+
.prepare(`SELECT
|
|
329
|
+
COALESCE(SUM(CASE WHEN kind = 'cost' THEN value ELSE 0 END), 0) AS totalCost,
|
|
330
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'input' THEN value ELSE 0 END), 0) AS inputTokens,
|
|
331
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'output' THEN value ELSE 0 END), 0) AS outputTokens,
|
|
332
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'cache_creation' THEN value ELSE 0 END), 0) AS cacheCreationTokens,
|
|
333
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'cache_read' THEN value ELSE 0 END), 0) AS cacheReadTokens,
|
|
334
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' THEN value ELSE 0 END), 0) AS totalTokens,
|
|
335
|
+
COALESCE(SUM(CASE WHEN kind = 'lines' AND token_type = 'added' THEN value ELSE 0 END), 0) AS linesAdded,
|
|
336
|
+
COALESCE(SUM(CASE WHEN kind = 'lines' AND token_type = 'removed' THEN value ELSE 0 END), 0) AS linesRemoved,
|
|
337
|
+
COALESCE(SUM(CASE WHEN kind = 'decision' AND token_type = 'accept' THEN value ELSE 0 END), 0) AS editsAccepted,
|
|
338
|
+
COALESCE(SUM(CASE WHEN kind = 'decision' AND token_type = 'reject' THEN value ELSE 0 END), 0) AS editsRejected,
|
|
339
|
+
COALESCE(SUM(CASE WHEN kind = 'pull_request' THEN value ELSE 0 END), 0) AS pullRequests,
|
|
340
|
+
COALESCE(SUM(CASE WHEN kind = 'commit' THEN value ELSE 0 END), 0) AS commits,
|
|
341
|
+
COUNT(DISTINCT session_row_id) AS sessionCount,
|
|
342
|
+
COUNT(DISTINCT COALESCE(user_email, user_account_id, user_id, 'unknown')) AS activeSenders
|
|
343
|
+
FROM metric_points ${where}`)
|
|
344
|
+
.get(...params);
|
|
345
|
+
return {
|
|
346
|
+
totalCost: totals.totalCost,
|
|
347
|
+
inputTokens: totals.inputTokens,
|
|
348
|
+
outputTokens: totals.outputTokens,
|
|
349
|
+
cacheCreationTokens: totals.cacheCreationTokens,
|
|
350
|
+
cacheReadTokens: totals.cacheReadTokens,
|
|
351
|
+
totalTokens: totals.totalTokens,
|
|
352
|
+
sessionCount: totals.sessionCount,
|
|
353
|
+
activeSenders: totals.activeSenders,
|
|
354
|
+
linesAdded: totals.linesAdded,
|
|
355
|
+
linesRemoved: totals.linesRemoved,
|
|
356
|
+
editsAccepted: totals.editsAccepted,
|
|
357
|
+
editsRejected: totals.editsRejected,
|
|
358
|
+
pullRequests: totals.pullRequests,
|
|
359
|
+
commits: totals.commits,
|
|
360
|
+
models: this.breakdown("model", where, params),
|
|
361
|
+
users: this.enrichUsers(this.breakdown("COALESCE(user_email, user_account_id, user_id, 'unknown')", where, params, "user")),
|
|
362
|
+
sources: this.breakdown("source", where, params)
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
async getTimeseries(filters) {
|
|
366
|
+
const granularity = filters.granularity ?? "hour";
|
|
367
|
+
const bucketMs = GRANULARITY_MS[granularity];
|
|
368
|
+
// Hour/day/week (>= the hourly rollup grain) re-bucket from the rollup; sub-hour grains and
|
|
369
|
+
// session-filtered reads (the live view) fall back to metric_points for exact resolution.
|
|
370
|
+
if (canUseRollup(filters, granularity)) {
|
|
371
|
+
const { where, params } = rollupWhere(filters);
|
|
372
|
+
return this.db
|
|
373
|
+
.prepare(`SELECT
|
|
374
|
+
CAST(bucket / ? AS INTEGER) * ? AS bucket,
|
|
375
|
+
COALESCE(SUM(CASE WHEN kind = 'cost' THEN sum_value ELSE 0 END), 0) AS totalCost,
|
|
376
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'input' THEN sum_value ELSE 0 END), 0) AS inputTokens,
|
|
377
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'output' THEN sum_value ELSE 0 END), 0) AS outputTokens,
|
|
378
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'cache_creation' THEN sum_value ELSE 0 END), 0) AS cacheCreationTokens,
|
|
379
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'cache_read' THEN sum_value ELSE 0 END), 0) AS cacheReadTokens,
|
|
380
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type IN ('cache_creation', 'cache_read') THEN sum_value ELSE 0 END), 0) AS cacheTokens,
|
|
381
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' THEN sum_value ELSE 0 END), 0) AS totalTokens,
|
|
382
|
+
COALESCE(SUM(CASE WHEN kind = 'lines' AND token_type = 'added' THEN sum_value ELSE 0 END), 0) AS linesAdded,
|
|
383
|
+
COALESCE(SUM(CASE WHEN kind = 'lines' AND token_type = 'removed' THEN sum_value ELSE 0 END), 0) AS linesRemoved,
|
|
384
|
+
COALESCE(SUM(CASE WHEN kind = 'decision' AND token_type = 'accept' THEN sum_value ELSE 0 END), 0) AS editsAccepted,
|
|
385
|
+
COALESCE(SUM(CASE WHEN kind = 'decision' AND token_type = 'reject' THEN sum_value ELSE 0 END), 0) AS editsRejected,
|
|
386
|
+
COALESCE(SUM(CASE WHEN kind = 'pull_request' THEN sum_value ELSE 0 END), 0) AS pullRequests,
|
|
387
|
+
COALESCE(SUM(CASE WHEN kind = 'commit' THEN sum_value ELSE 0 END), 0) AS commits
|
|
388
|
+
FROM ${EFFECTIVE_ROLLUP} AS r ${where}
|
|
389
|
+
GROUP BY bucket
|
|
390
|
+
ORDER BY bucket ASC`)
|
|
391
|
+
.all(bucketMs, bucketMs, ...params);
|
|
392
|
+
}
|
|
393
|
+
const { where, params } = pointWhere(filters, { dedupe: true });
|
|
394
|
+
const rows = this.db
|
|
395
|
+
.prepare(`SELECT
|
|
396
|
+
CAST(timestamp / ? AS INTEGER) * ? AS bucket,
|
|
397
|
+
COALESCE(SUM(CASE WHEN kind = 'cost' THEN value ELSE 0 END), 0) AS totalCost,
|
|
398
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'input' THEN value ELSE 0 END), 0) AS inputTokens,
|
|
399
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'output' THEN value ELSE 0 END), 0) AS outputTokens,
|
|
400
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'cache_creation' THEN value ELSE 0 END), 0) AS cacheCreationTokens,
|
|
401
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'cache_read' THEN value ELSE 0 END), 0) AS cacheReadTokens,
|
|
402
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type IN ('cache_creation', 'cache_read') THEN value ELSE 0 END), 0) AS cacheTokens,
|
|
403
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' THEN value ELSE 0 END), 0) AS totalTokens,
|
|
404
|
+
COALESCE(SUM(CASE WHEN kind = 'lines' AND token_type = 'added' THEN value ELSE 0 END), 0) AS linesAdded,
|
|
405
|
+
COALESCE(SUM(CASE WHEN kind = 'lines' AND token_type = 'removed' THEN value ELSE 0 END), 0) AS linesRemoved,
|
|
406
|
+
COALESCE(SUM(CASE WHEN kind = 'decision' AND token_type = 'accept' THEN value ELSE 0 END), 0) AS editsAccepted,
|
|
407
|
+
COALESCE(SUM(CASE WHEN kind = 'decision' AND token_type = 'reject' THEN value ELSE 0 END), 0) AS editsRejected,
|
|
408
|
+
COALESCE(SUM(CASE WHEN kind = 'pull_request' THEN value ELSE 0 END), 0) AS pullRequests,
|
|
409
|
+
COALESCE(SUM(CASE WHEN kind = 'commit' THEN value ELSE 0 END), 0) AS commits
|
|
410
|
+
FROM metric_points ${where}
|
|
411
|
+
GROUP BY bucket
|
|
412
|
+
ORDER BY bucket ASC`)
|
|
413
|
+
.all(bucketMs, bucketMs, ...params);
|
|
414
|
+
return rows;
|
|
415
|
+
}
|
|
416
|
+
// Per-model timeseries: one row per (bucket, model) with summed tokens and a distinct session
|
|
417
|
+
// count. Always reads metric_points (the distinct session count can't be summed across rollup
|
|
418
|
+
// buckets, and only token/cost points carry a model), applying the same jsonlWins precedence so a
|
|
419
|
+
// session with both OTel and a transcript isn't double-counted. The client pivots these flat rows
|
|
420
|
+
// into one line per model for the "tokens / sessions by model" charts.
|
|
421
|
+
async getModelTimeseries(filters) {
|
|
422
|
+
const granularity = filters.granularity ?? "hour";
|
|
423
|
+
const bucketMs = GRANULARITY_MS[granularity];
|
|
424
|
+
const { where, params } = pointWhere(filters, { dedupe: true });
|
|
425
|
+
const scoped = where ? `${where} AND model IS NOT NULL AND kind IN ('tokens', 'cost')` : "WHERE model IS NOT NULL AND kind IN ('tokens', 'cost')";
|
|
426
|
+
return this.db
|
|
427
|
+
.prepare(`SELECT
|
|
428
|
+
CAST(timestamp / ? AS INTEGER) * ? AS bucket,
|
|
429
|
+
model,
|
|
430
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' THEN value ELSE 0 END), 0) AS totalTokens,
|
|
431
|
+
COUNT(DISTINCT session_row_id) AS sessions
|
|
432
|
+
FROM metric_points ${scoped}
|
|
433
|
+
GROUP BY bucket, model
|
|
434
|
+
ORDER BY bucket ASC`)
|
|
435
|
+
.all(bucketMs, bucketMs, ...params);
|
|
436
|
+
}
|
|
437
|
+
async listSessions(filters) {
|
|
438
|
+
return this.buildSessions(filters, "ORDER BY s.last_seen_at DESC LIMIT 100");
|
|
439
|
+
}
|
|
440
|
+
// Direct id lookup (no LIMIT) so drilling into any session — not just the 100 most recent — works.
|
|
441
|
+
async getSession(id) {
|
|
442
|
+
const rows = await this.buildSessions({ session: id }, "");
|
|
443
|
+
return rows[0] ?? null;
|
|
444
|
+
}
|
|
445
|
+
// Shared session-row builder. One row per session UUID. By default the joined metric_points are
|
|
446
|
+
// restricted to the session's authoritative signal (so totals never mix OTel with a shadowed
|
|
447
|
+
// transcript); an explicit `source` filter switches to that source's raw points for the OTel-vs-
|
|
448
|
+
// JSONL comparison view, and limits the list to sessions that actually carry that source.
|
|
449
|
+
buildSessions(filters, tail) {
|
|
450
|
+
const joinParams = [];
|
|
451
|
+
let joinCondition;
|
|
452
|
+
if (filters.source) {
|
|
453
|
+
joinCondition = "p.session_row_id = s.id AND p.source = ?";
|
|
454
|
+
joinParams.push(filters.source);
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
joinCondition = `p.session_row_id = s.id AND ${jsonlWins("p.")}`;
|
|
458
|
+
}
|
|
459
|
+
const clauses = [];
|
|
460
|
+
const params = [];
|
|
461
|
+
if (filters.from) {
|
|
462
|
+
clauses.push("s.last_seen_at >= ?");
|
|
463
|
+
params.push(filters.from);
|
|
464
|
+
}
|
|
465
|
+
if (filters.to) {
|
|
466
|
+
clauses.push("s.first_seen_at <= ?");
|
|
467
|
+
params.push(filters.to);
|
|
468
|
+
}
|
|
469
|
+
if (filters.user) {
|
|
470
|
+
clauses.push("COALESCE(s.user_email, s.user_account_id, s.user_id, 'unknown') = ?");
|
|
471
|
+
params.push(filters.user);
|
|
472
|
+
}
|
|
473
|
+
if (filters.model) {
|
|
474
|
+
clauses.push("p.model = ?");
|
|
475
|
+
params.push(filters.model);
|
|
476
|
+
}
|
|
477
|
+
if (filters.session) {
|
|
478
|
+
clauses.push("s.id = ?");
|
|
479
|
+
params.push(filters.session);
|
|
480
|
+
}
|
|
481
|
+
if (filters.source) {
|
|
482
|
+
clauses.push("EXISTS(SELECT 1 FROM metric_points mp WHERE mp.session_row_id = s.id AND mp.source = ?)");
|
|
483
|
+
params.push(filters.source);
|
|
484
|
+
}
|
|
485
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
486
|
+
const rows = this.db
|
|
487
|
+
.prepare(`SELECT
|
|
488
|
+
s.id, s.session_id AS sessionId, s.user_id AS userId, s.user_email AS userEmail,
|
|
489
|
+
s.user_account_id AS userAccountId, s.first_seen_at AS firstSeenAt, s.last_seen_at AS lastSeenAt,
|
|
490
|
+
s.metric_source AS metricSource, s.has_otel AS hasOtel, s.has_jsonl AS hasJsonl,
|
|
491
|
+
COALESCE(SUM(CASE WHEN p.kind = 'cost' THEN p.value ELSE 0 END), 0) AS totalCost,
|
|
492
|
+
COALESCE(SUM(CASE WHEN p.kind = 'tokens' AND p.token_type = 'input' THEN p.value ELSE 0 END), 0) AS inputTokens,
|
|
493
|
+
COALESCE(SUM(CASE WHEN p.kind = 'tokens' AND p.token_type = 'output' THEN p.value ELSE 0 END), 0) AS outputTokens,
|
|
494
|
+
COALESCE(SUM(CASE WHEN p.kind = 'tokens' AND p.token_type = 'cache_creation' THEN p.value ELSE 0 END), 0) AS cacheCreationTokens,
|
|
495
|
+
COALESCE(SUM(CASE WHEN p.kind = 'tokens' AND p.token_type = 'cache_read' THEN p.value ELSE 0 END), 0) AS cacheReadTokens,
|
|
496
|
+
COALESCE(SUM(CASE WHEN p.kind = 'tokens' THEN p.value ELSE 0 END), 0) AS totalTokens,
|
|
497
|
+
-- Per-signal token totals for the whole session (independent of the authoritative join and
|
|
498
|
+
-- of the model/source filters) so the UI can show how far the two ingest paths disagree when
|
|
499
|
+
-- a session carries both. OTel often misses requests the transcript captured (or vice versa).
|
|
500
|
+
(SELECT COALESCE(SUM(mp.value), 0) FROM metric_points mp
|
|
501
|
+
WHERE mp.session_row_id = s.id AND mp.signal = 'otlp_metrics' AND mp.kind = 'tokens') AS otelTotalTokens,
|
|
502
|
+
(SELECT COALESCE(SUM(mp.value), 0) FROM metric_points mp
|
|
503
|
+
WHERE mp.session_row_id = s.id AND mp.signal = 'jsonl' AND mp.kind = 'tokens') AS jsonlTotalTokens,
|
|
504
|
+
-- Per-signal cost too: OTel-reported vs synthesized JSONL cost, so the UI can show the delta.
|
|
505
|
+
(SELECT COALESCE(SUM(mp.value), 0) FROM metric_points mp
|
|
506
|
+
WHERE mp.session_row_id = s.id AND mp.signal = 'otlp_metrics' AND mp.kind = 'cost') AS otelTotalCost,
|
|
507
|
+
(SELECT COALESCE(SUM(mp.value), 0) FROM metric_points mp
|
|
508
|
+
WHERE mp.session_row_id = s.id AND mp.signal = 'jsonl' AND mp.kind = 'cost') AS jsonlTotalCost,
|
|
509
|
+
-- Same per-signal split for cost. OTel cost is Claude's reported figure; JSONL cost is the
|
|
510
|
+
-- synthesized finius.cost.computed point (kind=cost, signal=jsonl). Independent of the
|
|
511
|
+
-- authoritative join so the UI can show the delta when a session carries both.
|
|
512
|
+
(SELECT COALESCE(SUM(mp.value), 0) FROM metric_points mp
|
|
513
|
+
WHERE mp.session_row_id = s.id AND mp.signal = 'otlp_metrics' AND mp.kind = 'cost') AS otelTotalCost,
|
|
514
|
+
(SELECT COALESCE(SUM(mp.value), 0) FROM metric_points mp
|
|
515
|
+
WHERE mp.session_row_id = s.id AND mp.signal = 'jsonl' AND mp.kind = 'cost') AS jsonlTotalCost,
|
|
516
|
+
-- The actual transcript source for this session (e.g. 'claude-code-jsonl' vs 'codex-cli-jsonl'),
|
|
517
|
+
-- so the UI can tell which agent produced it. Independent of the authoritative join/filters.
|
|
518
|
+
(SELECT mp.source FROM metric_points mp
|
|
519
|
+
WHERE mp.session_row_id = s.id AND mp.signal = 'jsonl' LIMIT 1) AS jsonlSource,
|
|
520
|
+
GROUP_CONCAT(DISTINCT p.model) AS models,
|
|
521
|
+
EXISTS(SELECT 1 FROM source_files sf WHERE sf.session_row_id = s.id) AS hasTranscript
|
|
522
|
+
FROM sessions s
|
|
523
|
+
LEFT JOIN metric_points p ON ${joinCondition}
|
|
524
|
+
${where}
|
|
525
|
+
GROUP BY s.id
|
|
526
|
+
${tail}`)
|
|
527
|
+
.all(...joinParams, ...params);
|
|
528
|
+
// Resolve each session's friendly identity (GitHub login / display name) the same way People does,
|
|
529
|
+
// keyed by the session's canonical identity string, so the sessions list can prefer it over email.
|
|
530
|
+
const directory = this.userDirectory();
|
|
531
|
+
return Promise.resolve(rows.map(({ jsonlSource, ...row }) => {
|
|
532
|
+
const u = directory.get(row.userEmail ?? row.userAccountId ?? row.userId ?? "unknown");
|
|
533
|
+
return {
|
|
534
|
+
...row,
|
|
535
|
+
// The authoritative source string. OTel only comes from Claude today; for a transcript we
|
|
536
|
+
// surface its real source (e.g. 'codex-cli-jsonl') so the UI can identify the agent.
|
|
537
|
+
source: row.metricSource === "otel" ? OTEL_SOURCE : jsonlSource ?? "claude-code-jsonl",
|
|
538
|
+
metricSource: row.metricSource,
|
|
539
|
+
hasOtel: row.hasOtel === 1,
|
|
540
|
+
hasJsonl: row.hasJsonl === 1,
|
|
541
|
+
githubLogin: u?.githubLogin ?? null,
|
|
542
|
+
displayName: u?.displayName ?? null,
|
|
543
|
+
models: row.models?.split(",").filter(Boolean) ?? [],
|
|
544
|
+
hasTranscript: row.hasTranscript === 1
|
|
545
|
+
};
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
async listPeople(filters) {
|
|
549
|
+
const identity = "COALESCE(user_email, user_account_id, user_id, 'unknown')";
|
|
550
|
+
const { where, params } = pointWhere(filters, { dedupe: true });
|
|
551
|
+
const rows = this.db
|
|
552
|
+
.prepare(`SELECT
|
|
553
|
+
${identity} AS user,
|
|
554
|
+
COUNT(DISTINCT session_row_id) AS sessions,
|
|
555
|
+
COALESCE(SUM(CASE WHEN kind = 'cost' THEN value ELSE 0 END), 0) AS totalCost,
|
|
556
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'input' THEN value ELSE 0 END), 0) AS inputTokens,
|
|
557
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'output' THEN value ELSE 0 END), 0) AS outputTokens,
|
|
558
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type IN ('cache_creation', 'cache_read') THEN value ELSE 0 END), 0) AS cacheTokens,
|
|
559
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' THEN value ELSE 0 END), 0) AS totalTokens,
|
|
560
|
+
MAX(timestamp) AS lastSeenAt,
|
|
561
|
+
GROUP_CONCAT(DISTINCT model) AS models
|
|
562
|
+
FROM metric_points ${where}
|
|
563
|
+
GROUP BY ${identity}
|
|
564
|
+
ORDER BY totalCost DESC, totalTokens DESC`)
|
|
565
|
+
.all(...params);
|
|
566
|
+
// Enrich each identity-string group with friendly fields from the users registry. JS-side join (the
|
|
567
|
+
// table is tiny — one row per person) keeps the aggregate SQL and the `user` filter untouched.
|
|
568
|
+
return this.enrichUsers(rows).map((row) => ({
|
|
569
|
+
...row,
|
|
570
|
+
models: row.models?.split(",").filter(Boolean) ?? []
|
|
571
|
+
}));
|
|
572
|
+
}
|
|
573
|
+
// Attach friendly identity fields (email / display name / GitHub login) from the `users` registry to
|
|
574
|
+
// rows keyed by their canonical identity string (`user`), so every list can prefer a GitHub login or
|
|
575
|
+
// display name over the raw email. Shared by People, the summary Users breakdown, and sessions.
|
|
576
|
+
enrichUsers(rows) {
|
|
577
|
+
const directory = this.userDirectory();
|
|
578
|
+
return rows.map((row) => {
|
|
579
|
+
const u = directory.get(row.user);
|
|
580
|
+
return {
|
|
581
|
+
...row,
|
|
582
|
+
email: u?.email ?? null,
|
|
583
|
+
displayName: u?.displayName ?? null,
|
|
584
|
+
githubLogin: u?.githubLogin ?? null
|
|
585
|
+
};
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
// Map every identity value (email / account_id / user_id) to its users-registry row, so a People
|
|
589
|
+
// group keyed by any of those strings can be resolved to one canonical person for display.
|
|
590
|
+
userDirectory() {
|
|
591
|
+
const users = this.db
|
|
592
|
+
.prepare("SELECT email, account_id AS accountId, user_id AS userId, display_name AS displayName, github_login AS githubLogin FROM users")
|
|
593
|
+
.all();
|
|
594
|
+
const map = new Map();
|
|
595
|
+
for (const u of users) {
|
|
596
|
+
const value = { email: u.email, displayName: u.displayName, githubLogin: u.githubLogin };
|
|
597
|
+
for (const key of [u.email, u.accountId, u.userId])
|
|
598
|
+
if (key)
|
|
599
|
+
map.set(key, value);
|
|
600
|
+
}
|
|
601
|
+
return map;
|
|
602
|
+
}
|
|
603
|
+
async listModels(filters) {
|
|
604
|
+
const identity = "COALESCE(user_email, user_account_id, user_id, 'unknown')";
|
|
605
|
+
const { where, params } = pointWhere(filters, { dedupe: true });
|
|
606
|
+
// Only token/cost points carry a model; other kinds (lines/decision) have NULL model.
|
|
607
|
+
const scoped = where ? `${where} AND model IS NOT NULL` : "WHERE model IS NOT NULL";
|
|
608
|
+
const rows = this.db
|
|
609
|
+
.prepare(`SELECT
|
|
610
|
+
model,
|
|
611
|
+
COUNT(DISTINCT session_row_id) AS sessions,
|
|
612
|
+
COUNT(DISTINCT ${identity}) AS users,
|
|
613
|
+
COALESCE(SUM(CASE WHEN kind = 'cost' THEN value ELSE 0 END), 0) AS totalCost,
|
|
614
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'input' THEN value ELSE 0 END), 0) AS inputTokens,
|
|
615
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type = 'output' THEN value ELSE 0 END), 0) AS outputTokens,
|
|
616
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' AND token_type IN ('cache_creation', 'cache_read') THEN value ELSE 0 END), 0) AS cacheTokens,
|
|
617
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' THEN value ELSE 0 END), 0) AS totalTokens,
|
|
618
|
+
MAX(timestamp) AS lastSeenAt
|
|
619
|
+
FROM metric_points ${scoped}
|
|
620
|
+
GROUP BY model
|
|
621
|
+
ORDER BY totalCost DESC, totalTokens DESC`)
|
|
622
|
+
.all(...params);
|
|
623
|
+
return rows;
|
|
624
|
+
}
|
|
625
|
+
async getFilterOptions() {
|
|
626
|
+
// The rollup is OTel-only, so union its distinct dimensions with metric_points (all signals) to
|
|
627
|
+
// keep the transcript-derived source ('claude-code-jsonl') and any jsonl-only users/models
|
|
628
|
+
// selectable — the comparison view filters on the JSONL source even when every JSONL session is
|
|
629
|
+
// also covered by OTel (and therefore absent from the OTel rollup).
|
|
630
|
+
const sources = this.db
|
|
631
|
+
.prepare(`SELECT source FROM metric_rollup UNION SELECT source FROM metric_points ORDER BY source`)
|
|
632
|
+
.all();
|
|
633
|
+
const users = this.db
|
|
634
|
+
.prepare(`SELECT user_identity AS user FROM metric_rollup
|
|
635
|
+
UNION SELECT COALESCE(user_email, user_account_id, user_id, 'unknown') AS user FROM metric_points
|
|
636
|
+
ORDER BY user`)
|
|
637
|
+
.all();
|
|
638
|
+
const models = this.db
|
|
639
|
+
.prepare(`SELECT model FROM metric_rollup WHERE model <> ''
|
|
640
|
+
UNION SELECT model FROM metric_points WHERE model IS NOT NULL AND model <> ''
|
|
641
|
+
ORDER BY model`)
|
|
642
|
+
.all();
|
|
643
|
+
return { sources: sources.map((r) => r.source), users: users.map((r) => r.user), models: models.map((r) => r.model) };
|
|
644
|
+
}
|
|
645
|
+
close() {
|
|
646
|
+
this.db.close();
|
|
647
|
+
}
|
|
648
|
+
migrate() {
|
|
649
|
+
this.db.exec(`
|
|
650
|
+
CREATE TABLE IF NOT EXISTS raw_batches (
|
|
651
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
652
|
+
signal TEXT NOT NULL,
|
|
653
|
+
hash TEXT NOT NULL UNIQUE,
|
|
654
|
+
payload_json TEXT NOT NULL,
|
|
655
|
+
received_at INTEGER NOT NULL
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
-- One row per logical session (keyed by the Claude Code session UUID), NOT one per source.
|
|
659
|
+
-- A session can carry OTel metrics, a JSONL transcript, or both; has_otel/has_jsonl record
|
|
660
|
+
-- which signals have arrived and metric_source is the authoritative one we present by default
|
|
661
|
+
-- ('otel' whenever OTel is present, else 'jsonl'). Maintained on ingest by upsertSession, so
|
|
662
|
+
-- reads consult stored state instead of recomputing which sessions have OTel.
|
|
663
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
664
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
665
|
+
session_id TEXT NOT NULL UNIQUE,
|
|
666
|
+
user_id TEXT,
|
|
667
|
+
user_email TEXT,
|
|
668
|
+
user_account_id TEXT,
|
|
669
|
+
-- Canonical user this session belongs to (FK into users). Set on ingest by upsertSession via
|
|
670
|
+
-- upsertUser; lets /api/people group by a deduped person and show a friendly name/handle.
|
|
671
|
+
user_row_id INTEGER REFERENCES users(id),
|
|
672
|
+
has_otel INTEGER NOT NULL DEFAULT 0,
|
|
673
|
+
has_jsonl INTEGER NOT NULL DEFAULT 0,
|
|
674
|
+
metric_source TEXT NOT NULL DEFAULT 'jsonl',
|
|
675
|
+
first_seen_at INTEGER NOT NULL,
|
|
676
|
+
last_seen_at INTEGER NOT NULL
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
-- One row per distinct person, populated from any identity we see (OTel or JSONL/rollout) and
|
|
680
|
+
-- DEDUPED BY EMAIL (same email ⇒ same user), with account_id/user_id/github_login as secondary
|
|
681
|
+
-- link keys. Maintained incrementally by upsertUser; back-filled once from sessions on first run.
|
|
682
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
683
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
684
|
+
email TEXT UNIQUE,
|
|
685
|
+
account_id TEXT,
|
|
686
|
+
user_id TEXT,
|
|
687
|
+
github_login TEXT,
|
|
688
|
+
display_name TEXT,
|
|
689
|
+
first_seen_at INTEGER NOT NULL,
|
|
690
|
+
last_seen_at INTEGER NOT NULL
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
CREATE TABLE IF NOT EXISTS metric_points (
|
|
694
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
695
|
+
source TEXT NOT NULL,
|
|
696
|
+
signal TEXT NOT NULL,
|
|
697
|
+
session_row_id INTEGER NOT NULL,
|
|
698
|
+
session_id TEXT NOT NULL,
|
|
699
|
+
user_id TEXT,
|
|
700
|
+
user_email TEXT,
|
|
701
|
+
user_account_id TEXT,
|
|
702
|
+
model TEXT,
|
|
703
|
+
metric_name TEXT NOT NULL,
|
|
704
|
+
kind TEXT NOT NULL,
|
|
705
|
+
token_type TEXT,
|
|
706
|
+
value REAL NOT NULL,
|
|
707
|
+
unit TEXT,
|
|
708
|
+
timestamp INTEGER NOT NULL,
|
|
709
|
+
attributes_json TEXT,
|
|
710
|
+
raw_batch_id INTEGER,
|
|
711
|
+
FOREIGN KEY (session_row_id) REFERENCES sessions(id),
|
|
712
|
+
FOREIGN KEY (raw_batch_id) REFERENCES raw_batches(id)
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
CREATE TABLE IF NOT EXISTS metric_rollup (
|
|
716
|
+
bucket INTEGER NOT NULL,
|
|
717
|
+
source TEXT NOT NULL,
|
|
718
|
+
user_identity TEXT NOT NULL,
|
|
719
|
+
model TEXT NOT NULL,
|
|
720
|
+
kind TEXT NOT NULL,
|
|
721
|
+
token_type TEXT NOT NULL,
|
|
722
|
+
sum_value REAL NOT NULL DEFAULT 0,
|
|
723
|
+
cnt INTEGER NOT NULL DEFAULT 0,
|
|
724
|
+
PRIMARY KEY (bucket, source, user_identity, model, kind, token_type)
|
|
725
|
+
) WITHOUT ROWID;
|
|
726
|
+
|
|
727
|
+
CREATE TABLE IF NOT EXISTS source_files (
|
|
728
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
729
|
+
source TEXT NOT NULL,
|
|
730
|
+
session_row_id INTEGER,
|
|
731
|
+
session_id TEXT,
|
|
732
|
+
hash TEXT NOT NULL UNIQUE,
|
|
733
|
+
blob_key TEXT NOT NULL,
|
|
734
|
+
byte_size INTEGER NOT NULL,
|
|
735
|
+
line_count INTEGER NOT NULL,
|
|
736
|
+
imported_at INTEGER NOT NULL,
|
|
737
|
+
FOREIGN KEY (session_row_id) REFERENCES sessions(id)
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
-- Client auth session tokens minted by POST /api/auth/login (Secure Mode). We store only the
|
|
741
|
+
-- sha256 of the token so the DB never holds a usable credential; a future admin GUI lists/revokes
|
|
742
|
+
-- these. The master password itself is NOT stored here — it lives in the server config.
|
|
743
|
+
CREATE TABLE IF NOT EXISTS auth_tokens (
|
|
744
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
745
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
746
|
+
label TEXT,
|
|
747
|
+
created_at INTEGER NOT NULL,
|
|
748
|
+
last_used_at INTEGER,
|
|
749
|
+
revoked INTEGER NOT NULL DEFAULT 0
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
-- One row per captured OTLP log record (Codex telemetry is logs-only). Not aggregated into
|
|
753
|
+
-- metric_points yet — this is the inspection surface (GET /api/logs/events) we use to learn the
|
|
754
|
+
-- real event shapes before writing a parser. raw_batch_id back-references the verbatim payload.
|
|
755
|
+
CREATE TABLE IF NOT EXISTS log_events (
|
|
756
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
757
|
+
event_name TEXT,
|
|
758
|
+
severity TEXT,
|
|
759
|
+
session_id TEXT,
|
|
760
|
+
timestamp INTEGER NOT NULL,
|
|
761
|
+
attributes_json TEXT,
|
|
762
|
+
body_json TEXT,
|
|
763
|
+
raw_batch_id INTEGER,
|
|
764
|
+
FOREIGN KEY (raw_batch_id) REFERENCES raw_batches(id)
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
-- Per-model token pricing, used to compute cost ourselves for agents that don't report it.
|
|
768
|
+
-- Keyed (model, effective_date) so dated rows accrete and historical usage is priced by the
|
|
769
|
+
-- rate in effect at the time (LiteLLM publishes only current pricing). Loaded into an in-memory
|
|
770
|
+
-- index at startup / on importPricing.
|
|
771
|
+
CREATE TABLE IF NOT EXISTS model_prices (
|
|
772
|
+
model TEXT NOT NULL,
|
|
773
|
+
provider TEXT,
|
|
774
|
+
input_per_token REAL NOT NULL DEFAULT 0,
|
|
775
|
+
output_per_token REAL NOT NULL DEFAULT 0,
|
|
776
|
+
cache_read_per_token REAL NOT NULL DEFAULT 0,
|
|
777
|
+
cache_creation_per_token REAL NOT NULL DEFAULT 0,
|
|
778
|
+
effective_date INTEGER NOT NULL DEFAULT 0,
|
|
779
|
+
PRIMARY KEY (model, effective_date)
|
|
780
|
+
) WITHOUT ROWID;
|
|
781
|
+
|
|
782
|
+
CREATE INDEX IF NOT EXISTS idx_metric_points_timestamp ON metric_points(timestamp);
|
|
783
|
+
CREATE INDEX IF NOT EXISTS idx_metric_points_session ON metric_points(session_row_id);
|
|
784
|
+
CREATE INDEX IF NOT EXISTS idx_metric_points_model ON metric_points(model);
|
|
785
|
+
CREATE INDEX IF NOT EXISTS idx_metric_points_signal_session ON metric_points(signal, session_id);
|
|
786
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_seen ON sessions(last_seen_at);
|
|
787
|
+
CREATE INDEX IF NOT EXISTS idx_rollup_bucket ON metric_rollup(bucket);
|
|
788
|
+
CREATE INDEX IF NOT EXISTS idx_raw_batches_received_at ON raw_batches(received_at);
|
|
789
|
+
CREATE INDEX IF NOT EXISTS idx_source_files_session ON source_files(session_row_id);
|
|
790
|
+
CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(token_hash);
|
|
791
|
+
CREATE INDEX IF NOT EXISTS idx_log_events_name ON log_events(event_name);
|
|
792
|
+
CREATE INDEX IF NOT EXISTS idx_log_events_batch ON log_events(raw_batch_id);
|
|
793
|
+
CREATE INDEX IF NOT EXISTS idx_metric_points_metric_name ON metric_points(metric_name);
|
|
794
|
+
CREATE INDEX IF NOT EXISTS idx_users_account_id ON users(account_id);
|
|
795
|
+
CREATE INDEX IF NOT EXISTS idx_users_user_id ON users(user_id);
|
|
796
|
+
CREATE INDEX IF NOT EXISTS idx_users_github_login ON users(github_login);
|
|
797
|
+
`);
|
|
798
|
+
// Existing databases predate sessions.user_row_id (CREATE TABLE IF NOT EXISTS won't add it). Ensure
|
|
799
|
+
// the column exists BEFORE indexing it — the index must not be in the exec block above, or it would
|
|
800
|
+
// fail on an old DB whose sessions table hasn't been ALTERed yet.
|
|
801
|
+
this.ensureColumn("sessions", "user_row_id", "INTEGER");
|
|
802
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_user_row ON sessions(user_row_id)");
|
|
803
|
+
// Back-fill the users registry from any identities already on disk (runs once, when users is empty).
|
|
804
|
+
this.migrateUsers();
|
|
805
|
+
}
|
|
806
|
+
// Add a column to a table if it isn't already present (idempotent ALTER for pre-existing DBs).
|
|
807
|
+
ensureColumn(table, column, decl) {
|
|
808
|
+
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
809
|
+
if (cols.some((c) => c.name === column))
|
|
810
|
+
return;
|
|
811
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
|
|
812
|
+
}
|
|
813
|
+
insertRawBatch(signal, hash, batch) {
|
|
814
|
+
// payload_json is NOT NULL in the schema; store '' (treated as "no payload") when payload
|
|
815
|
+
// storage is disabled, so we avoid a table rebuild while still keeping the dedup hash.
|
|
816
|
+
const payload = this.storeRawPayloads ? JSON.stringify(batch) : "";
|
|
817
|
+
try {
|
|
818
|
+
const result = this.db
|
|
819
|
+
.prepare("INSERT INTO raw_batches (signal, hash, payload_json, received_at) VALUES (?, ?, ?, ?)")
|
|
820
|
+
.run(signal, hash, payload, Date.now());
|
|
821
|
+
return Number(result.lastInsertRowid);
|
|
822
|
+
}
|
|
823
|
+
catch (error) {
|
|
824
|
+
if (error instanceof Error && error.message.includes("UNIQUE"))
|
|
825
|
+
return null;
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async pruneRawBatches(beforeTimestampMs) {
|
|
830
|
+
this.db.exec("BEGIN");
|
|
831
|
+
try {
|
|
832
|
+
// Orphan any metric_points that reference the batches we're about to delete — they keep their
|
|
833
|
+
// aggregated data, just lose the back-reference to the now-gone raw payload (FK would block).
|
|
834
|
+
this.db
|
|
835
|
+
.prepare("UPDATE metric_points SET raw_batch_id = NULL WHERE raw_batch_id IN (SELECT id FROM raw_batches WHERE received_at < ?)")
|
|
836
|
+
.run(beforeTimestampMs);
|
|
837
|
+
// log_events also back-reference raw_batches; orphan them too (they keep the indexed record).
|
|
838
|
+
this.db
|
|
839
|
+
.prepare("UPDATE log_events SET raw_batch_id = NULL WHERE raw_batch_id IN (SELECT id FROM raw_batches WHERE received_at < ?)")
|
|
840
|
+
.run(beforeTimestampMs);
|
|
841
|
+
const result = this.db.prepare("DELETE FROM raw_batches WHERE received_at < ?").run(beforeTimestampMs);
|
|
842
|
+
this.db.exec("COMMIT");
|
|
843
|
+
return { deleted: Number(result.changes) };
|
|
844
|
+
}
|
|
845
|
+
catch (error) {
|
|
846
|
+
this.db.exec("ROLLBACK");
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
createAuthToken(tokenHash, label, now) {
|
|
851
|
+
this.db
|
|
852
|
+
.prepare("INSERT INTO auth_tokens (token_hash, label, created_at) VALUES (?, ?, ?)")
|
|
853
|
+
.run(tokenHash, label, now);
|
|
854
|
+
}
|
|
855
|
+
findAuthToken(tokenHash) {
|
|
856
|
+
const row = this.db
|
|
857
|
+
.prepare("SELECT id, revoked FROM auth_tokens WHERE token_hash = ?")
|
|
858
|
+
.get(tokenHash);
|
|
859
|
+
if (!row)
|
|
860
|
+
return null;
|
|
861
|
+
// Best-effort touch so the admin GUI can show recency; failures here must not block auth.
|
|
862
|
+
try {
|
|
863
|
+
this.db.prepare("UPDATE auth_tokens SET last_used_at = ? WHERE id = ?").run(Date.now(), row.id);
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
/* ignore */
|
|
867
|
+
}
|
|
868
|
+
return row;
|
|
869
|
+
}
|
|
870
|
+
listAuthTokens() {
|
|
871
|
+
return this.db
|
|
872
|
+
.prepare("SELECT id, label, created_at AS createdAt, last_used_at AS lastUsedAt, revoked FROM auth_tokens ORDER BY created_at DESC")
|
|
873
|
+
.all();
|
|
874
|
+
}
|
|
875
|
+
revokeAuthToken(id) {
|
|
876
|
+
this.db.prepare("UPDATE auth_tokens SET revoked = 1 WHERE id = ?").run(id);
|
|
877
|
+
}
|
|
878
|
+
// Load model_prices into the in-memory index that cost synthesis reads. Called at construction and
|
|
879
|
+
// after every importPricing so the hot path never touches the DB.
|
|
880
|
+
loadPricing() {
|
|
881
|
+
const rows = this.db
|
|
882
|
+
.prepare(`SELECT model, provider,
|
|
883
|
+
input_per_token AS inputPerToken, output_per_token AS outputPerToken,
|
|
884
|
+
cache_read_per_token AS cacheReadPerToken, cache_creation_per_token AS cacheCreationPerToken,
|
|
885
|
+
effective_date AS effectiveDate
|
|
886
|
+
FROM model_prices`)
|
|
887
|
+
.all();
|
|
888
|
+
this.priceIndex = indexPrices(rows);
|
|
889
|
+
this.earliestPriceDate = rows.reduce((min, r) => (min == null || r.effectiveDate < min ? r.effectiveDate : min), null);
|
|
890
|
+
}
|
|
891
|
+
async getPricing() {
|
|
892
|
+
return this.db
|
|
893
|
+
.prepare(`SELECT model, provider,
|
|
894
|
+
input_per_token AS inputPerToken, output_per_token AS outputPerToken,
|
|
895
|
+
cache_read_per_token AS cacheReadPerToken, cache_creation_per_token AS cacheCreationPerToken,
|
|
896
|
+
effective_date AS effectiveDate
|
|
897
|
+
FROM model_prices
|
|
898
|
+
ORDER BY model, effective_date DESC`)
|
|
899
|
+
.all();
|
|
900
|
+
}
|
|
901
|
+
// Upsert dated price rows (newer effective_date wins for a given model) and reload the index.
|
|
902
|
+
async importPricing(prices) {
|
|
903
|
+
const stmt = this.db.prepare(`INSERT INTO model_prices (model, provider, input_per_token, output_per_token, cache_read_per_token, cache_creation_per_token, effective_date)
|
|
904
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
905
|
+
ON CONFLICT(model, effective_date) DO UPDATE SET
|
|
906
|
+
provider = excluded.provider,
|
|
907
|
+
input_per_token = excluded.input_per_token,
|
|
908
|
+
output_per_token = excluded.output_per_token,
|
|
909
|
+
cache_read_per_token = excluded.cache_read_per_token,
|
|
910
|
+
cache_creation_per_token = excluded.cache_creation_per_token`);
|
|
911
|
+
this.db.exec("BEGIN");
|
|
912
|
+
try {
|
|
913
|
+
for (const p of prices) {
|
|
914
|
+
stmt.run(p.model, p.provider ?? null, p.inputPerToken, p.outputPerToken, p.cacheReadPerToken, p.cacheCreationPerToken, p.effectiveDate);
|
|
915
|
+
}
|
|
916
|
+
this.db.exec("COMMIT");
|
|
917
|
+
}
|
|
918
|
+
catch (error) {
|
|
919
|
+
this.db.exec("ROLLBACK");
|
|
920
|
+
throw error;
|
|
921
|
+
}
|
|
922
|
+
this.loadPricing();
|
|
923
|
+
return { imported: prices.length };
|
|
924
|
+
}
|
|
925
|
+
// Rebuild the synthesized `finius.cost.computed` points from the token points already in
|
|
926
|
+
// metric_points — no transcript re-parse needed. Idempotent: delete then re-derive. Sessions that
|
|
927
|
+
// carry an agent-reported cost are skipped so we never stack computed cost on top of real cost.
|
|
928
|
+
async recomputeComputedCost() {
|
|
929
|
+
this.db.exec("BEGIN");
|
|
930
|
+
try {
|
|
931
|
+
this.db.prepare("DELETE FROM metric_points WHERE metric_name = ?").run(COMPUTED_COST_METRIC);
|
|
932
|
+
// After the delete, every remaining kind='cost' row is an agent-reported cost.
|
|
933
|
+
const reported = new Set(this.db.prepare("SELECT DISTINCT session_row_id AS id FROM metric_points WHERE kind = 'cost'").all().map((r) => r.id));
|
|
934
|
+
const tokenRows = this.db
|
|
935
|
+
.prepare(`SELECT source, signal, session_id AS sessionId, session_row_id AS sessionRowId, user_id AS userId,
|
|
936
|
+
user_email AS userEmail, user_account_id AS userAccountId, model, metric_name AS metricName,
|
|
937
|
+
token_type AS tokenType, value, timestamp
|
|
938
|
+
FROM metric_points WHERE signal = 'jsonl' AND kind = 'tokens'`)
|
|
939
|
+
.all();
|
|
940
|
+
const tokenPoints = tokenRows
|
|
941
|
+
.filter((r) => !reported.has(r.sessionRowId))
|
|
942
|
+
.map((r) => ({
|
|
943
|
+
source: r.source,
|
|
944
|
+
signal: r.signal,
|
|
945
|
+
sessionId: r.sessionId,
|
|
946
|
+
userId: r.userId,
|
|
947
|
+
userEmail: r.userEmail,
|
|
948
|
+
userAccountId: r.userAccountId,
|
|
949
|
+
model: r.model,
|
|
950
|
+
metricName: r.metricName,
|
|
951
|
+
kind: "tokens",
|
|
952
|
+
tokenType: r.tokenType,
|
|
953
|
+
value: r.value,
|
|
954
|
+
timestamp: r.timestamp
|
|
955
|
+
}));
|
|
956
|
+
const costPoints = computeCostPoints(tokenPoints, this.priceIndex);
|
|
957
|
+
for (const point of costPoints)
|
|
958
|
+
this.insertMetricPoint(point, null);
|
|
959
|
+
this.db.exec("COMMIT");
|
|
960
|
+
return { costPoints: costPoints.length };
|
|
961
|
+
}
|
|
962
|
+
catch (error) {
|
|
963
|
+
this.db.exec("ROLLBACK");
|
|
964
|
+
throw error;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
insertMetricPoint(point, rawBatchId) {
|
|
968
|
+
const sessionRowId = this.upsertSession(point);
|
|
969
|
+
this.db
|
|
970
|
+
.prepare(`INSERT INTO metric_points (
|
|
971
|
+
source, signal, session_row_id, session_id, user_id, user_email, user_account_id, model,
|
|
972
|
+
metric_name, kind, token_type, value, unit, timestamp, attributes_json, raw_batch_id
|
|
973
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
974
|
+
.run(point.source, point.signal, sessionRowId, point.sessionId, point.userId ?? null, point.userEmail ?? null, point.userAccountId ?? null, point.model ?? null, point.metricName, point.kind, point.tokenType ?? null, point.value, point.unit ?? null, point.timestamp, JSON.stringify(point.attributes ?? {}), rawBatchId);
|
|
975
|
+
}
|
|
976
|
+
// Maintain the pre-aggregated hourly rollup that serves the home view. Runs inside the same
|
|
977
|
+
// ingest transaction as insertMetricPoint, so the rollup is always consistent with metric_points.
|
|
978
|
+
// NOTE: sum_value/cnt are additive only — distinct counts (sessions/users) cannot be derived from
|
|
979
|
+
// here because an entity spans many buckets; those stay on metric_points.
|
|
980
|
+
upsertRollup(point) {
|
|
981
|
+
const bucket = Math.floor(point.timestamp / 3_600_000) * 3_600_000;
|
|
982
|
+
this.db
|
|
983
|
+
.prepare(`INSERT INTO metric_rollup (bucket, source, user_identity, model, kind, token_type, sum_value, cnt)
|
|
984
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
985
|
+
ON CONFLICT(bucket, source, user_identity, model, kind, token_type)
|
|
986
|
+
DO UPDATE SET sum_value = sum_value + excluded.sum_value, cnt = cnt + excluded.cnt`)
|
|
987
|
+
.run(bucket, point.source, preferredIdentity(point), point.model ?? "", point.kind, point.tokenType ?? "", point.value);
|
|
988
|
+
}
|
|
989
|
+
// Upsert the single session row for this point's UUID and fold in which signal it came from. The
|
|
990
|
+
// OR (via MAX over the 0/1 flags) makes presence sticky, and metric_source resolves to 'otel'
|
|
991
|
+
// whenever OTel has ever been seen for the session — that stored flag IS the read-time precedence.
|
|
992
|
+
upsertSession(point) {
|
|
993
|
+
const isOtel = point.signal === "otlp_metrics" ? 1 : 0;
|
|
994
|
+
const isJsonl = point.signal === "jsonl" ? 1 : 0;
|
|
995
|
+
// Resolve (find-or-create) the canonical user for this point's identity so the session can point at
|
|
996
|
+
// a deduped person; null when the point carries no identity at all (stays NULL → "unknown").
|
|
997
|
+
const userRowId = this.upsertUser(point, point.timestamp);
|
|
998
|
+
this.db
|
|
999
|
+
.prepare(`INSERT INTO sessions (session_id, user_id, user_email, user_account_id, user_row_id, has_otel, has_jsonl, metric_source, first_seen_at, last_seen_at)
|
|
1000
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1001
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
1002
|
+
user_id = COALESCE(excluded.user_id, sessions.user_id),
|
|
1003
|
+
user_email = COALESCE(excluded.user_email, sessions.user_email),
|
|
1004
|
+
user_account_id = COALESCE(excluded.user_account_id, sessions.user_account_id),
|
|
1005
|
+
user_row_id = COALESCE(excluded.user_row_id, sessions.user_row_id),
|
|
1006
|
+
has_otel = MAX(sessions.has_otel, excluded.has_otel),
|
|
1007
|
+
has_jsonl = MAX(sessions.has_jsonl, excluded.has_jsonl),
|
|
1008
|
+
metric_source = CASE WHEN MAX(sessions.has_otel, excluded.has_otel) = 1 THEN 'otel' ELSE 'jsonl' END,
|
|
1009
|
+
first_seen_at = MIN(sessions.first_seen_at, excluded.first_seen_at),
|
|
1010
|
+
last_seen_at = MAX(sessions.last_seen_at, excluded.last_seen_at)`)
|
|
1011
|
+
.run(point.sessionId, point.userId ?? null, point.userEmail ?? null, point.userAccountId ?? null, userRowId, isOtel, isJsonl, isOtel ? "otel" : "jsonl", point.timestamp, point.timestamp);
|
|
1012
|
+
const row = this.db.prepare("SELECT id FROM sessions WHERE session_id = ?").get(point.sessionId);
|
|
1013
|
+
return row.id;
|
|
1014
|
+
}
|
|
1015
|
+
// Find-or-enrich the user for an identity, deduping by the strongest available key (email is the
|
|
1016
|
+
// canonical "same user" key; account_id/user_id/github_login are secondary links). On a hit we
|
|
1017
|
+
// COALESCE-fill any columns we didn't know before and widen the seen window; otherwise we insert a
|
|
1018
|
+
// new row. Returns the user row id, or null for a fully-unknown identity.
|
|
1019
|
+
// v1 note: this enriches an existing row but does NOT retroactively merge two pre-existing rows that
|
|
1020
|
+
// later prove to be the same person (e.g. an account-only row and an email-only row seen separately).
|
|
1021
|
+
upsertUser(id, ts) {
|
|
1022
|
+
const email = id.userEmail || null;
|
|
1023
|
+
const accountId = id.userAccountId || null;
|
|
1024
|
+
const userId = id.userId || null;
|
|
1025
|
+
const githubLogin = id.githubLogin || null;
|
|
1026
|
+
const displayName = id.displayName || null;
|
|
1027
|
+
if (!email && !accountId && !userId && !githubLogin)
|
|
1028
|
+
return null;
|
|
1029
|
+
const found = (email && this.findUser("email", email)) ||
|
|
1030
|
+
(accountId && this.findUser("account_id", accountId)) ||
|
|
1031
|
+
(userId && this.findUser("user_id", userId)) ||
|
|
1032
|
+
(githubLogin && this.findUser("github_login", githubLogin)) ||
|
|
1033
|
+
null;
|
|
1034
|
+
if (found !== null) {
|
|
1035
|
+
this.db
|
|
1036
|
+
.prepare(`UPDATE users SET
|
|
1037
|
+
email = COALESCE(email, ?),
|
|
1038
|
+
account_id = COALESCE(account_id, ?),
|
|
1039
|
+
user_id = COALESCE(user_id, ?),
|
|
1040
|
+
github_login = COALESCE(github_login, ?),
|
|
1041
|
+
display_name = COALESCE(display_name, ?),
|
|
1042
|
+
first_seen_at = MIN(first_seen_at, ?),
|
|
1043
|
+
last_seen_at = MAX(last_seen_at, ?)
|
|
1044
|
+
WHERE id = ?`)
|
|
1045
|
+
.run(email, accountId, userId, githubLogin, displayName, ts, ts, found);
|
|
1046
|
+
return found;
|
|
1047
|
+
}
|
|
1048
|
+
const result = this.db
|
|
1049
|
+
.prepare(`INSERT INTO users (email, account_id, user_id, github_login, display_name, first_seen_at, last_seen_at)
|
|
1050
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
1051
|
+
.run(email, accountId, userId, githubLogin, displayName, ts, ts);
|
|
1052
|
+
return Number(result.lastInsertRowid);
|
|
1053
|
+
}
|
|
1054
|
+
findUser(column, value) {
|
|
1055
|
+
const row = this.db.prepare(`SELECT id FROM users WHERE ${column} = ? LIMIT 1`).get(value);
|
|
1056
|
+
return row ? row.id : null;
|
|
1057
|
+
}
|
|
1058
|
+
// Build the users registry once from identities already stored on sessions (existing DBs predate the
|
|
1059
|
+
// table). No-op once users has any rows — from then on upsertSession maintains it incrementally.
|
|
1060
|
+
migrateUsers() {
|
|
1061
|
+
const { n } = this.db.prepare("SELECT COUNT(*) AS n FROM users").get();
|
|
1062
|
+
if (n > 0)
|
|
1063
|
+
return;
|
|
1064
|
+
const sessions = this.db
|
|
1065
|
+
.prepare("SELECT id, user_email, user_account_id, user_id, first_seen_at FROM sessions")
|
|
1066
|
+
.all();
|
|
1067
|
+
if (sessions.length === 0)
|
|
1068
|
+
return;
|
|
1069
|
+
this.db.exec("BEGIN");
|
|
1070
|
+
try {
|
|
1071
|
+
for (const s of sessions) {
|
|
1072
|
+
const uid = this.upsertUser({ userEmail: s.user_email, userAccountId: s.user_account_id, userId: s.user_id }, s.first_seen_at);
|
|
1073
|
+
if (uid !== null)
|
|
1074
|
+
this.db.prepare("UPDATE sessions SET user_row_id = ? WHERE id = ?").run(uid, s.id);
|
|
1075
|
+
}
|
|
1076
|
+
this.db.exec("COMMIT");
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
this.db.exec("ROLLBACK");
|
|
1080
|
+
throw error;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
breakdown(field, where, params, alias = field) {
|
|
1084
|
+
// Breakdowns attribute cost/tokens, so restrict to those rows — otherwise lines/decision/
|
|
1085
|
+
// active_time points (which carry no model) would add a spurious "unknown" group.
|
|
1086
|
+
const scoped = where ? `${where} AND kind IN ('tokens', 'cost')` : "WHERE kind IN ('tokens', 'cost')";
|
|
1087
|
+
const rows = this.db
|
|
1088
|
+
.prepare(`SELECT
|
|
1089
|
+
${field} AS ${alias},
|
|
1090
|
+
COALESCE(SUM(CASE WHEN kind = 'cost' THEN value ELSE 0 END), 0) AS totalCost,
|
|
1091
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' THEN value ELSE 0 END), 0) AS totalTokens,
|
|
1092
|
+
COUNT(DISTINCT session_row_id) AS sessions
|
|
1093
|
+
FROM metric_points ${scoped}
|
|
1094
|
+
GROUP BY ${field}
|
|
1095
|
+
ORDER BY totalCost DESC, totalTokens DESC
|
|
1096
|
+
LIMIT 10`)
|
|
1097
|
+
.all(...params);
|
|
1098
|
+
return rows.map((row) => ({
|
|
1099
|
+
[alias]: String(row[alias] ?? "unknown"),
|
|
1100
|
+
totalCost: Number(row.totalCost),
|
|
1101
|
+
totalTokens: Number(row.totalTokens),
|
|
1102
|
+
sessions: Number(row.sessions)
|
|
1103
|
+
}));
|
|
1104
|
+
}
|
|
1105
|
+
// Rollup equivalent of breakdown(): cost/tokens sum from the rollup, but the per-group distinct
|
|
1106
|
+
// session count must still come from metric_points (distinct counts can't be summed across
|
|
1107
|
+
// buckets). `rollupDim` is the rollup column; `pointDim` is the matching metric_points expression.
|
|
1108
|
+
rollupBreakdown(rollupDim, pointDim, filters, alias = rollupDim) {
|
|
1109
|
+
const { where, params } = rollupWhere(filters);
|
|
1110
|
+
const scoped = where ? `${where} AND kind IN ('tokens', 'cost')` : "WHERE kind IN ('tokens', 'cost')";
|
|
1111
|
+
const rows = this.db
|
|
1112
|
+
.prepare(`SELECT
|
|
1113
|
+
${rollupDim} AS ${alias},
|
|
1114
|
+
COALESCE(SUM(CASE WHEN kind = 'cost' THEN sum_value ELSE 0 END), 0) AS totalCost,
|
|
1115
|
+
COALESCE(SUM(CASE WHEN kind = 'tokens' THEN sum_value ELSE 0 END), 0) AS totalTokens
|
|
1116
|
+
FROM ${EFFECTIVE_ROLLUP} AS r ${scoped}
|
|
1117
|
+
GROUP BY ${rollupDim}
|
|
1118
|
+
ORDER BY totalCost DESC, totalTokens DESC
|
|
1119
|
+
LIMIT 10`)
|
|
1120
|
+
.all(...params);
|
|
1121
|
+
const sessionsByGroup = this.distinctSessionsByGroup(pointDim, filters);
|
|
1122
|
+
return rows.map((row) => {
|
|
1123
|
+
const label = String(row[alias] || "unknown"); // rollup '' sentinel (NULL model) -> "unknown"
|
|
1124
|
+
return {
|
|
1125
|
+
[alias]: label,
|
|
1126
|
+
totalCost: Number(row.totalCost),
|
|
1127
|
+
totalTokens: Number(row.totalTokens),
|
|
1128
|
+
sessions: sessionsByGroup.get(label) ?? 0
|
|
1129
|
+
};
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
// Distinct session counts per dimension group, from metric_points. Keyed by the same "unknown"
|
|
1133
|
+
// normalization rollupBreakdown uses so the maps line up.
|
|
1134
|
+
distinctSessionsByGroup(pointDim, filters) {
|
|
1135
|
+
const { where, params } = pointWhere(filters, { dedupe: true });
|
|
1136
|
+
const scoped = where ? `${where} AND kind IN ('tokens', 'cost')` : "WHERE kind IN ('tokens', 'cost')";
|
|
1137
|
+
const rows = this.db
|
|
1138
|
+
.prepare(`SELECT ${pointDim} AS grp, COUNT(DISTINCT session_row_id) AS sessions
|
|
1139
|
+
FROM metric_points ${scoped}
|
|
1140
|
+
GROUP BY ${pointDim}`)
|
|
1141
|
+
.all(...params);
|
|
1142
|
+
return new Map(rows.map((r) => [String(r.grp || "unknown"), r.sessions]));
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
function safeParse(json) {
|
|
1146
|
+
if (json == null)
|
|
1147
|
+
return null;
|
|
1148
|
+
try {
|
|
1149
|
+
return JSON.parse(json);
|
|
1150
|
+
}
|
|
1151
|
+
catch {
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
let warnedCumulative = false;
|
|
1156
|
+
// Our token/cost aggregation sums data points, which is only correct for DELTA temporality
|
|
1157
|
+
// (Claude Code's default). Warn once if a backend ever sends CUMULATIVE, which would overcount.
|
|
1158
|
+
function warnIfCumulative(records) {
|
|
1159
|
+
if (warnedCumulative)
|
|
1160
|
+
return;
|
|
1161
|
+
const cumulative = records.some((record) => record.temporality === 2 && (record.metricName === "claude_code.token.usage" || record.metricName === "claude_code.cost.usage"));
|
|
1162
|
+
if (cumulative) {
|
|
1163
|
+
warnedCumulative = true;
|
|
1164
|
+
console.warn("[finius] OTLP metrics arrived with CUMULATIVE temporality; token/cost totals assume DELTA and will overcount. Set OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta.");
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
export { preferredIdentity };
|