opencode-global-habit-profile 0.1.0 → 0.2.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/README.md +22 -3
- package/dist/assets/index-CMgH-UX9.js +21 -0
- package/dist/assets/index-CX37tAhW.css +1 -0
- package/dist/index.html +2 -2
- package/docs/metrics.md +200 -0
- package/index.js +50 -14
- package/package.json +2 -2
- package/src/App.vue +464 -6
- package/src/metrics.js +614 -0
- package/src/plugin-stats.js +215 -0
- package/src/store.js +28 -13
- package/src/ui-model.js +76 -0
- package/src/ui-navigation.js +1 -1
- package/src/ui-server.js +36 -9
- package/src/ui.css +56 -1
- package/dist/assets/index-CeYHNSfT.css +0 -1
- package/dist/assets/index-Nel_NS0n.js +0 -21
package/src/metrics.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync } from "node:fs"
|
|
3
|
+
import { basename, dirname, join } from "node:path"
|
|
4
|
+
import { DatabaseSync } from "node:sqlite"
|
|
5
|
+
import { storePath } from "./store.js"
|
|
6
|
+
import { recordCollectorFlush, recordCollectorPending } from "./plugin-stats.js"
|
|
7
|
+
|
|
8
|
+
// F2 contract: OpenCode v2.2.7 events are `{ id, created, type, data }`.
|
|
9
|
+
// normalizeEvent also tolerates `properties` / `createdAt` / `eventID` so a
|
|
10
|
+
// future or alternate runtime does not silently drop data. Live ingestion uses
|
|
11
|
+
// session.step.ended and session.usage.recorded; backfill uses Session.Info
|
|
12
|
+
// (one approximate row per session, source = backfill).
|
|
13
|
+
export const metricsPath = join(dirname(storePath), "metrics.db")
|
|
14
|
+
export const retentionDays = 90
|
|
15
|
+
const stepKinds = ["primary", "subagent", "title", "compaction", "generate", "other"]
|
|
16
|
+
const sources = ["live", "backfill"]
|
|
17
|
+
const defaultRangeDays = 30
|
|
18
|
+
const maxRangeDays = 90
|
|
19
|
+
const topLimit = 50
|
|
20
|
+
|
|
21
|
+
const schema = `
|
|
22
|
+
CREATE TABLE IF NOT EXISTS steps (
|
|
23
|
+
id INTEGER PRIMARY KEY,
|
|
24
|
+
day TEXT NOT NULL,
|
|
25
|
+
created_at INTEGER NOT NULL,
|
|
26
|
+
-- sha256("message:" + assistantMessageID) for steps; sha256("event:" + event.id) for usage events.
|
|
27
|
+
message_hash TEXT NOT NULL UNIQUE,
|
|
28
|
+
session_hash TEXT NOT NULL,
|
|
29
|
+
parent_hash TEXT,
|
|
30
|
+
project_hash TEXT,
|
|
31
|
+
directory TEXT,
|
|
32
|
+
agent TEXT,
|
|
33
|
+
provider TEXT,
|
|
34
|
+
model TEXT,
|
|
35
|
+
variant TEXT,
|
|
36
|
+
kind TEXT NOT NULL CHECK (kind IN (${stepKinds.map((kind) => `'${kind}'`).join(", ")})),
|
|
37
|
+
source TEXT NOT NULL DEFAULT 'live' CHECK (source IN (${sources.map((source) => `'${source}'`).join(", ")})),
|
|
38
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
cost REAL NOT NULL DEFAULT 0,
|
|
44
|
+
-- 1 when the runtime reported a positive cost; 0 means missing/free.
|
|
45
|
+
priced INTEGER NOT NULL DEFAULT 0 CHECK (priced IN (0, 1))
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS steps_day_idx ON steps(day);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS steps_session_idx ON steps(session_hash);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS monthly (
|
|
50
|
+
month TEXT NOT NULL,
|
|
51
|
+
project_hash TEXT NOT NULL DEFAULT '',
|
|
52
|
+
agent TEXT NOT NULL DEFAULT '',
|
|
53
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
54
|
+
model TEXT NOT NULL DEFAULT '',
|
|
55
|
+
kind TEXT NOT NULL,
|
|
56
|
+
steps INTEGER NOT NULL,
|
|
57
|
+
input_tokens INTEGER NOT NULL,
|
|
58
|
+
output_tokens INTEGER NOT NULL,
|
|
59
|
+
reasoning_tokens INTEGER NOT NULL,
|
|
60
|
+
cache_read_tokens INTEGER NOT NULL,
|
|
61
|
+
cache_write_tokens INTEGER NOT NULL,
|
|
62
|
+
cost REAL NOT NULL,
|
|
63
|
+
priced_steps INTEGER NOT NULL,
|
|
64
|
+
PRIMARY KEY (month, project_hash, agent, provider, model, kind)
|
|
65
|
+
);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
67
|
+
key TEXT PRIMARY KEY,
|
|
68
|
+
value TEXT NOT NULL
|
|
69
|
+
);
|
|
70
|
+
`
|
|
71
|
+
|
|
72
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex")
|
|
73
|
+
const isId = (value) => typeof value === "string" && value.length > 0 && value.length <= 512 && !/[\u0000-\u001f\u007f]/.test(value)
|
|
74
|
+
const requiredId = (value, name) => {
|
|
75
|
+
if (!isId(value)) throw new Error(`Invalid metrics ${name}`)
|
|
76
|
+
return value
|
|
77
|
+
}
|
|
78
|
+
const count = (value) => {
|
|
79
|
+
if (value === undefined) return 0
|
|
80
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error("Invalid token count")
|
|
81
|
+
return value
|
|
82
|
+
}
|
|
83
|
+
const money = (value) => {
|
|
84
|
+
if (value === undefined) return 0
|
|
85
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("Invalid cost")
|
|
86
|
+
return value
|
|
87
|
+
}
|
|
88
|
+
const label = (value, max, allowSlash) => {
|
|
89
|
+
if (value === undefined || value === null) return null
|
|
90
|
+
if (typeof value !== "string") throw new Error("Invalid metrics label")
|
|
91
|
+
const trimmed = value.trim()
|
|
92
|
+
if (!trimmed) return null
|
|
93
|
+
if (trimmed.length > max || /[\u0000-\u001f\u007f]/.test(trimmed) || trimmed.includes("://") || trimmed.startsWith("/") || trimmed.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.includes("..")) throw new Error("Invalid metrics label")
|
|
94
|
+
const pattern = allowSlash ? /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/ : /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
95
|
+
if (!pattern.test(trimmed)) throw new Error("Invalid metrics label")
|
|
96
|
+
return trimmed
|
|
97
|
+
}
|
|
98
|
+
const directoryName = (value) => {
|
|
99
|
+
if (typeof value !== "string") return null
|
|
100
|
+
const trimmed = value.trim()
|
|
101
|
+
if (!trimmed || trimmed.length > 4096) return null
|
|
102
|
+
const name = basename(trimmed)
|
|
103
|
+
return name && name !== "." && name !== ".." && name.length <= 128 && !/[\\/\u0000-\u001f\u007f]/.test(name) ? name : null
|
|
104
|
+
}
|
|
105
|
+
const tokenUsage = (value) => {
|
|
106
|
+
if (value === undefined) return { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid token usage")
|
|
108
|
+
const cache = value.cache === undefined ? {} : value.cache
|
|
109
|
+
if (!cache || typeof cache !== "object" || Array.isArray(cache)) throw new Error("Invalid token cache usage")
|
|
110
|
+
return { input: count(value.input), output: count(value.output), reasoning: count(value.reasoning), read: count(cache.read), write: count(cache.write) }
|
|
111
|
+
}
|
|
112
|
+
const eventData = (event) => event?.data ?? event?.properties?.data ?? event?.properties
|
|
113
|
+
|
|
114
|
+
function buildRow({ sessionID, messageHash, created, tokens, cost, kind, source, session }) {
|
|
115
|
+
const dimensions = session && typeof session === "object" && !Array.isArray(session) ? session : {}
|
|
116
|
+
const model = dimensions.model
|
|
117
|
+
const modelID = typeof model === "string" ? model : model?.id
|
|
118
|
+
const providerID = typeof model === "string" ? undefined : model?.providerID
|
|
119
|
+
const variant = typeof model === "string" ? undefined : model?.variant
|
|
120
|
+
return {
|
|
121
|
+
day: new Date(created).toISOString().slice(0, 10),
|
|
122
|
+
created_at: created,
|
|
123
|
+
message_hash: messageHash,
|
|
124
|
+
session_hash: hash(sessionID),
|
|
125
|
+
parent_hash: isId(dimensions.parentID) ? hash(dimensions.parentID) : null,
|
|
126
|
+
project_hash: isId(dimensions.projectID) ? hash(dimensions.projectID) : null,
|
|
127
|
+
directory: directoryName(dimensions.location?.directory),
|
|
128
|
+
agent: label(dimensions.agent, 64, false),
|
|
129
|
+
provider: label(providerID, 64, true),
|
|
130
|
+
model: label(modelID, 128, true),
|
|
131
|
+
variant: label(variant, 64, true),
|
|
132
|
+
kind,
|
|
133
|
+
source,
|
|
134
|
+
input_tokens: tokens.input,
|
|
135
|
+
output_tokens: tokens.output,
|
|
136
|
+
reasoning_tokens: tokens.reasoning,
|
|
137
|
+
cache_read_tokens: tokens.read,
|
|
138
|
+
cache_write_tokens: tokens.write,
|
|
139
|
+
cost,
|
|
140
|
+
priced: cost > 0 ? 1 : 0,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function normalizeEvent(event, { session, now = Date.now } = {}) {
|
|
145
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) throw new Error("Invalid metrics event")
|
|
146
|
+
if (event.type !== "session.step.ended" && event.type !== "session.usage.recorded") return undefined
|
|
147
|
+
const data = eventData(event)
|
|
148
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Invalid metrics event data")
|
|
149
|
+
const sessionID = requiredId(data.sessionID, "sessionID")
|
|
150
|
+
const created = event.created ?? event.createdAt ?? now()
|
|
151
|
+
if (!Number.isSafeInteger(created) || created < 0) throw new Error("Invalid metrics timestamp")
|
|
152
|
+
const tokens = tokenUsage(data.tokens)
|
|
153
|
+
const cost = money(data.cost)
|
|
154
|
+
const step = event.type === "session.step.ended"
|
|
155
|
+
const messageHash = step
|
|
156
|
+
? hash(`message:${requiredId(data.assistantMessageID, "assistantMessageID")}`)
|
|
157
|
+
: hash(`event:${requiredId(event.id ?? event.eventID ?? `${sessionID}:${data.source}:${created}`, "event id")}`)
|
|
158
|
+
const kind = step
|
|
159
|
+
? (isId(session?.parentID) ? "subagent" : "primary")
|
|
160
|
+
: (["title", "compaction", "generate"].includes(data.source) ? data.source : "other")
|
|
161
|
+
return buildRow({ sessionID, messageHash, created, tokens, cost, kind, source: "live", session })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function normalizeSession(session, { now = Date.now } = {}) {
|
|
165
|
+
if (!session || typeof session !== "object" || Array.isArray(session)) throw new Error("Invalid metrics session")
|
|
166
|
+
const sessionID = requiredId(session.id, "session id")
|
|
167
|
+
const created = session.time?.updated ?? session.time?.created ?? now()
|
|
168
|
+
if (!Number.isSafeInteger(created) || created < 0) throw new Error("Invalid metrics timestamp")
|
|
169
|
+
return buildRow({
|
|
170
|
+
sessionID,
|
|
171
|
+
messageHash: hash(`session:${sessionID}`),
|
|
172
|
+
created,
|
|
173
|
+
tokens: tokenUsage(session.tokens),
|
|
174
|
+
cost: money(session.cost),
|
|
175
|
+
kind: isId(session.parentID) ? "subagent" : "primary",
|
|
176
|
+
source: "backfill",
|
|
177
|
+
session,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function migrate(db) {
|
|
182
|
+
const columns = new Set(db.prepare("PRAGMA table_info(steps)").all().map((column) => column.name))
|
|
183
|
+
if (!columns.has("source")) db.exec("ALTER TABLE steps ADD COLUMN source TEXT NOT NULL DEFAULT 'live'")
|
|
184
|
+
db.exec("CREATE INDEX IF NOT EXISTS steps_session_source_idx ON steps(session_hash, source)")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function openDatabase(path) {
|
|
188
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
189
|
+
const db = new DatabaseSync(path)
|
|
190
|
+
db.exec("PRAGMA journal_mode = WAL")
|
|
191
|
+
db.exec("PRAGMA busy_timeout = 5000")
|
|
192
|
+
db.exec("PRAGMA synchronous = NORMAL")
|
|
193
|
+
db.exec(schema)
|
|
194
|
+
migrate(db)
|
|
195
|
+
secure(path)
|
|
196
|
+
return db
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function secure(path) {
|
|
200
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
201
|
+
try { chmodSync(`${path}${suffix}`, 0o600) } catch (error) { if (error.code !== "ENOENT") throw error }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function createMetricsStore({ path = metricsPath, now = Date.now } = {}) {
|
|
206
|
+
if (typeof path !== "string" || !path) throw new Error("metrics path is required")
|
|
207
|
+
if (typeof now !== "function") throw new Error("now must be a function")
|
|
208
|
+
const db = openDatabase(path)
|
|
209
|
+
const insert = db.prepare(`INSERT INTO steps (
|
|
210
|
+
day, created_at, message_hash, session_hash, parent_hash, project_hash, directory,
|
|
211
|
+
agent, provider, model, variant, kind, source, input_tokens, output_tokens, reasoning_tokens,
|
|
212
|
+
cache_read_tokens, cache_write_tokens, cost, priced
|
|
213
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(message_hash) DO NOTHING`)
|
|
214
|
+
const deleteOld = db.prepare("DELETE FROM steps WHERE day < ?")
|
|
215
|
+
const liveSession = db.prepare("SELECT 1 AS found FROM steps WHERE session_hash = ? AND source = 'live' LIMIT 1")
|
|
216
|
+
const getMetaStatement = db.prepare("SELECT value FROM meta WHERE key = ?")
|
|
217
|
+
const setMetaStatement = db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
218
|
+
let closed = false
|
|
219
|
+
|
|
220
|
+
const ensureOpen = () => { if (closed) throw new Error("Metrics store is closed") }
|
|
221
|
+
const cutoffDay = () => {
|
|
222
|
+
const date = new Date(now())
|
|
223
|
+
if (!Number.isSafeInteger(date.getTime())) throw new Error("Invalid metrics clock")
|
|
224
|
+
const cutoff = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
|
225
|
+
cutoff.setUTCDate(cutoff.getUTCDate() - (retentionDays - 1))
|
|
226
|
+
return cutoff.toISOString().slice(0, 10)
|
|
227
|
+
}
|
|
228
|
+
const insertRows = (rows, { skipLive = false } = {}) => {
|
|
229
|
+
ensureOpen()
|
|
230
|
+
if (!rows.length) return 0
|
|
231
|
+
let inserted = 0
|
|
232
|
+
db.exec("BEGIN IMMEDIATE")
|
|
233
|
+
try {
|
|
234
|
+
for (const row of rows) {
|
|
235
|
+
if (skipLive && liveSession.get(row.session_hash)) continue
|
|
236
|
+
inserted += Number(insert.run(
|
|
237
|
+
row.day, row.created_at, row.message_hash, row.session_hash, row.parent_hash, row.project_hash, row.directory,
|
|
238
|
+
row.agent, row.provider, row.model, row.variant, row.kind, row.source, row.input_tokens, row.output_tokens, row.reasoning_tokens,
|
|
239
|
+
row.cache_read_tokens, row.cache_write_tokens, row.cost, row.priced,
|
|
240
|
+
).changes)
|
|
241
|
+
}
|
|
242
|
+
deleteOld.run(cutoffDay())
|
|
243
|
+
db.exec("COMMIT")
|
|
244
|
+
secure(path)
|
|
245
|
+
} catch (error) {
|
|
246
|
+
try { db.exec("ROLLBACK") } catch {}
|
|
247
|
+
throw error
|
|
248
|
+
}
|
|
249
|
+
return inserted
|
|
250
|
+
}
|
|
251
|
+
const normalizeSessions = (sessions) => {
|
|
252
|
+
const rows = []
|
|
253
|
+
for (const session of sessions) {
|
|
254
|
+
try {
|
|
255
|
+
const row = normalizeSession(session, { now })
|
|
256
|
+
if (row) rows.push(row)
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
return rows
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
path,
|
|
264
|
+
ingestEvent(event, { session } = {}) {
|
|
265
|
+
const row = normalizeEvent(event, { session, now })
|
|
266
|
+
return row ? insertRows([row]) : 0
|
|
267
|
+
},
|
|
268
|
+
ingestEvents(events, { sessions } = {}) {
|
|
269
|
+
if (!Array.isArray(events)) throw new Error("events must be an array")
|
|
270
|
+
const rows = []
|
|
271
|
+
for (const event of events) {
|
|
272
|
+
try {
|
|
273
|
+
const data = eventData(event)
|
|
274
|
+
const session = sessions instanceof Map ? sessions.get(data?.sessionID) : undefined
|
|
275
|
+
const row = normalizeEvent(event, { session, now })
|
|
276
|
+
if (row) rows.push(row)
|
|
277
|
+
} catch {}
|
|
278
|
+
}
|
|
279
|
+
return insertRows(rows)
|
|
280
|
+
},
|
|
281
|
+
ingestSessions(sessions) {
|
|
282
|
+
if (!Array.isArray(sessions)) throw new Error("sessions must be an array")
|
|
283
|
+
return insertRows(normalizeSessions(sessions), { skipLive: true })
|
|
284
|
+
},
|
|
285
|
+
getMeta(key) {
|
|
286
|
+
ensureOpen()
|
|
287
|
+
if (typeof key !== "string" || !key || key.length > 128) throw new Error("Invalid metrics meta key")
|
|
288
|
+
return getMetaStatement.get(key)?.value
|
|
289
|
+
},
|
|
290
|
+
setMeta(key, value) {
|
|
291
|
+
ensureOpen()
|
|
292
|
+
if (typeof key !== "string" || !key || key.length > 128) throw new Error("Invalid metrics meta key")
|
|
293
|
+
if (typeof value !== "string" || value.length > 512) throw new Error("Invalid metrics meta value")
|
|
294
|
+
setMetaStatement.run(key, value)
|
|
295
|
+
},
|
|
296
|
+
prune() {
|
|
297
|
+
ensureOpen()
|
|
298
|
+
const changes = Number(deleteOld.run(cutoffDay()).changes)
|
|
299
|
+
secure(path)
|
|
300
|
+
return changes
|
|
301
|
+
},
|
|
302
|
+
rebuildMonthly() {
|
|
303
|
+
ensureOpen()
|
|
304
|
+
db.exec("BEGIN IMMEDIATE")
|
|
305
|
+
try {
|
|
306
|
+
db.exec("DELETE FROM monthly")
|
|
307
|
+
db.exec(`INSERT INTO monthly (
|
|
308
|
+
month, project_hash, agent, provider, model, kind, steps, input_tokens, output_tokens,
|
|
309
|
+
reasoning_tokens, cache_read_tokens, cache_write_tokens, cost, priced_steps
|
|
310
|
+
) SELECT substr(day, 1, 7), coalesce(project_hash, ''), coalesce(agent, ''), coalesce(provider, ''),
|
|
311
|
+
coalesce(model, ''), kind, count(*), sum(input_tokens), sum(output_tokens), sum(reasoning_tokens),
|
|
312
|
+
sum(cache_read_tokens), sum(cache_write_tokens), sum(cost), sum(priced)
|
|
313
|
+
FROM steps
|
|
314
|
+
GROUP BY substr(day, 1, 7), coalesce(project_hash, ''), coalesce(agent, ''), coalesce(provider, ''), coalesce(model, ''), kind`)
|
|
315
|
+
db.exec("COMMIT")
|
|
316
|
+
} catch (error) {
|
|
317
|
+
try { db.exec("ROLLBACK") } catch {}
|
|
318
|
+
throw error
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
close() {
|
|
322
|
+
if (closed) return
|
|
323
|
+
closed = true
|
|
324
|
+
db.close()
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function createMetricsCollector({
|
|
330
|
+
store,
|
|
331
|
+
subscribe,
|
|
332
|
+
getSession,
|
|
333
|
+
now = Date.now,
|
|
334
|
+
flushInterval = 15_000,
|
|
335
|
+
flushSize = 500,
|
|
336
|
+
cacheSize = 512,
|
|
337
|
+
} = {}) {
|
|
338
|
+
if (!store || typeof store.ingestEvents !== "function" || typeof store.getMeta !== "function" || typeof store.setMeta !== "function") throw new Error("metrics store is required")
|
|
339
|
+
if (typeof subscribe !== "function") throw new Error("subscribe is required")
|
|
340
|
+
if (!Number.isSafeInteger(flushInterval) || flushInterval < 0) throw new Error("flushInterval must be a non-negative integer")
|
|
341
|
+
if (!Number.isSafeInteger(flushSize) || flushSize < 1) throw new Error("flushSize must be a positive integer")
|
|
342
|
+
if (!Number.isSafeInteger(cacheSize) || cacheSize < 1) throw new Error("cacheSize must be a positive integer")
|
|
343
|
+
|
|
344
|
+
const sessions = new Map()
|
|
345
|
+
const pending = []
|
|
346
|
+
const controller = new AbortController()
|
|
347
|
+
let timer
|
|
348
|
+
let closed = false
|
|
349
|
+
let lastError
|
|
350
|
+
|
|
351
|
+
const remember = (sessionID, session) => {
|
|
352
|
+
if (sessions.has(sessionID)) sessions.delete(sessionID)
|
|
353
|
+
else if (sessions.size >= cacheSize) sessions.delete(sessions.keys().next().value)
|
|
354
|
+
sessions.set(sessionID, session)
|
|
355
|
+
}
|
|
356
|
+
const flush = () => {
|
|
357
|
+
if (timer) { clearTimeout(timer); timer = undefined }
|
|
358
|
+
if (!pending.length) return 0
|
|
359
|
+
const batch = pending.splice(0)
|
|
360
|
+
const sessionMap = new Map()
|
|
361
|
+
for (const { event, session } of batch) {
|
|
362
|
+
const sessionID = eventData(event)?.sessionID
|
|
363
|
+
if (typeof sessionID === "string" && session) sessionMap.set(sessionID, session)
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const inserted = store.ingestEvents(batch.map(({ event }) => event), { sessions: sessionMap })
|
|
367
|
+
recordCollectorFlush(0, false)
|
|
368
|
+
return inserted
|
|
369
|
+
} catch (error) {
|
|
370
|
+
recordCollectorFlush(batch.length, true)
|
|
371
|
+
throw error
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const schedule = () => {
|
|
375
|
+
if (closed) return
|
|
376
|
+
if (timer) clearTimeout(timer)
|
|
377
|
+
timer = setTimeout(() => { timer = undefined; try { flush() } catch {} }, flushInterval)
|
|
378
|
+
timer.unref?.()
|
|
379
|
+
}
|
|
380
|
+
const run = async () => {
|
|
381
|
+
try {
|
|
382
|
+
if (!store.getMeta("source.liveFrom")) store.setMeta("source.liveFrom", new Date(now()).toISOString())
|
|
383
|
+
} catch {}
|
|
384
|
+
try {
|
|
385
|
+
for await (const event of subscribe({ types: ["session.step.ended", "session.usage.recorded"], signal: controller.signal })) {
|
|
386
|
+
if (closed) break
|
|
387
|
+
const sessionID = eventData(event)?.sessionID
|
|
388
|
+
let session
|
|
389
|
+
if (typeof sessionID === "string" && sessionID) {
|
|
390
|
+
if (sessions.has(sessionID)) session = sessions.get(sessionID)
|
|
391
|
+
else if (typeof getSession === "function") {
|
|
392
|
+
try { session = await getSession(sessionID) } catch {}
|
|
393
|
+
}
|
|
394
|
+
if (session) remember(sessionID, session)
|
|
395
|
+
}
|
|
396
|
+
pending.push({ event, session })
|
|
397
|
+
recordCollectorPending(pending.length)
|
|
398
|
+
if (pending.length >= flushSize) { try { flush() } catch {} }
|
|
399
|
+
else schedule()
|
|
400
|
+
}
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (!controller.signal.aborted) lastError = error
|
|
403
|
+
} finally {
|
|
404
|
+
try { flush() } catch {}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const done = run()
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
done,
|
|
411
|
+
flush,
|
|
412
|
+
stop: async () => {
|
|
413
|
+
if (closed) return
|
|
414
|
+
closed = true
|
|
415
|
+
controller.abort()
|
|
416
|
+
if (timer) { clearTimeout(timer); timer = undefined }
|
|
417
|
+
try { flush() } catch {}
|
|
418
|
+
await done
|
|
419
|
+
},
|
|
420
|
+
get error() { return lastError },
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function backfillMetrics({ store, listSessions, now = Date.now, limit = 5000 } = {}) {
|
|
425
|
+
if (!store || typeof store.ingestSessions !== "function" || typeof store.getMeta !== "function" || typeof store.setMeta !== "function") throw new Error("metrics store is required")
|
|
426
|
+
if (typeof listSessions !== "function") throw new Error("listSessions is required")
|
|
427
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("limit must be a positive integer")
|
|
428
|
+
let inserted = 0
|
|
429
|
+
let seen = 0
|
|
430
|
+
try {
|
|
431
|
+
if (store.getMeta("source.backfillAt")) return { skipped: true, inserted: 0, seen: 0 }
|
|
432
|
+
const current = now()
|
|
433
|
+
if (!Number.isSafeInteger(current) || current < 0) throw new Error("Invalid metrics clock")
|
|
434
|
+
const cutoff = current - retentionDays * 86_400_000
|
|
435
|
+
let cursor
|
|
436
|
+
while (seen < limit) {
|
|
437
|
+
const result = await listSessions({ limit: 100, order: "desc", cursor })
|
|
438
|
+
const sessions = Array.isArray(result?.data) ? result.data : Array.isArray(result) ? result : []
|
|
439
|
+
if (!sessions.length) break
|
|
440
|
+
const page = []
|
|
441
|
+
let stop = false
|
|
442
|
+
for (const session of sessions) {
|
|
443
|
+
seen++
|
|
444
|
+
const updated = session?.time?.updated ?? session?.time?.created
|
|
445
|
+
if (Number.isFinite(updated) && updated < cutoff) { stop = true; break }
|
|
446
|
+
if (session?.id) page.push(session)
|
|
447
|
+
if (seen >= limit) { stop = true; break }
|
|
448
|
+
}
|
|
449
|
+
if (page.length) inserted += store.ingestSessions(page)
|
|
450
|
+
if (stop) break
|
|
451
|
+
cursor = result?.cursor?.next
|
|
452
|
+
if (!cursor) break
|
|
453
|
+
}
|
|
454
|
+
store.setMeta("source.backfillAt", new Date(current).toISOString())
|
|
455
|
+
return { skipped: false, inserted, seen }
|
|
456
|
+
} catch (error) {
|
|
457
|
+
return { skipped: false, inserted, seen, error: error instanceof Error ? error.message : "Backfill failed" }
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export class MetricsQueryError extends Error {
|
|
462
|
+
constructor(message) {
|
|
463
|
+
super(message)
|
|
464
|
+
this.name = "MetricsQueryError"
|
|
465
|
+
this.code = "INVALID_METRICS_QUERY"
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const kindSet = new Set(stepKinds)
|
|
470
|
+
const validDay = (value) => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value
|
|
471
|
+
const dayOf = (timestamp) => {
|
|
472
|
+
const date = new Date(timestamp)
|
|
473
|
+
if (!Number.isSafeInteger(date.getTime())) throw new MetricsQueryError("Invalid metrics clock")
|
|
474
|
+
return date.toISOString().slice(0, 10)
|
|
475
|
+
}
|
|
476
|
+
const shiftDay = (day, delta) => {
|
|
477
|
+
const date = new Date(`${day}T00:00:00.000Z`)
|
|
478
|
+
date.setUTCDate(date.getUTCDate() + delta)
|
|
479
|
+
return date.toISOString().slice(0, 10)
|
|
480
|
+
}
|
|
481
|
+
const ratio = (value, total) => total > 0 ? Number((value / total).toFixed(4)) : 0
|
|
482
|
+
const cacheHitRate = (tokens) => ratio(tokens.cacheRead, tokens.input + tokens.cacheRead + tokens.cacheWrite)
|
|
483
|
+
const zeroTokens = () => ({ input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 })
|
|
484
|
+
const tokensFrom = (row) => ({
|
|
485
|
+
input: Number(row.input),
|
|
486
|
+
output: Number(row.output),
|
|
487
|
+
reasoning: Number(row.reasoning),
|
|
488
|
+
cacheRead: Number(row.cache_read),
|
|
489
|
+
cacheWrite: Number(row.cache_write),
|
|
490
|
+
})
|
|
491
|
+
const rowSummary = (row, extra = {}) => {
|
|
492
|
+
const steps = Number(row.steps)
|
|
493
|
+
const tokens = tokensFrom(row)
|
|
494
|
+
const pricedSteps = Number(row.priced_steps)
|
|
495
|
+
return {
|
|
496
|
+
key: row.key || null,
|
|
497
|
+
...extra,
|
|
498
|
+
steps,
|
|
499
|
+
tokens,
|
|
500
|
+
cost: Number(row.cost),
|
|
501
|
+
pricedSteps,
|
|
502
|
+
costCoverage: ratio(pricedSteps, steps),
|
|
503
|
+
cacheHitRate: cacheHitRate(tokens),
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const emptyResult = (from, to) => {
|
|
507
|
+
const days = Math.round((Date.parse(`${to}T00:00:00.000Z`) - Date.parse(`${from}T00:00:00.000Z`)) / 86_400_000) + 1
|
|
508
|
+
const series = []
|
|
509
|
+
for (let day = from; day <= to; day = shiftDay(day, 1)) {
|
|
510
|
+
series.push({ day, steps: 0, tokens: zeroTokens(), cost: 0, pricedSteps: 0, costCoverage: 0, cacheHitRate: 0 })
|
|
511
|
+
if (series.length >= days) break
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
range: { from, to, days },
|
|
515
|
+
totals: { steps: 0, tokens: zeroTokens(), cost: 0, pricedSteps: 0, costCoverage: 0, cacheHitRate: 0 },
|
|
516
|
+
series,
|
|
517
|
+
byAgent: [],
|
|
518
|
+
byModel: [],
|
|
519
|
+
byProject: [],
|
|
520
|
+
byKind: [],
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const filterText = (value, name, allowSlash = true) => {
|
|
524
|
+
if (value === undefined) return undefined
|
|
525
|
+
if (typeof value !== "string") throw new MetricsQueryError(`Invalid ${name}`)
|
|
526
|
+
try { return label(value, name === "agent" ? 64 : 128, allowSlash) ?? undefined } catch { throw new MetricsQueryError(`Invalid ${name}`) }
|
|
527
|
+
}
|
|
528
|
+
const filterProject = (value) => {
|
|
529
|
+
if (value === undefined) return undefined
|
|
530
|
+
if (typeof value !== "string" || !value || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value)) throw new MetricsQueryError("Invalid project")
|
|
531
|
+
return { hash: /^[a-f0-9]{64}$/i.test(value) ? value.toLowerCase() : hash(value), directory: value }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function queryMetrics(filters = {}, { path = metricsPath, now = Date.now } = {}) {
|
|
535
|
+
if (!filters || typeof filters !== "object" || Array.isArray(filters)) throw new MetricsQueryError("Invalid filters")
|
|
536
|
+
const today = dayOf(now())
|
|
537
|
+
const from = filters.from ?? shiftDay(today, -(defaultRangeDays - 1))
|
|
538
|
+
const to = filters.to ?? today
|
|
539
|
+
if (!validDay(from) || !validDay(to)) throw new MetricsQueryError("Invalid date range")
|
|
540
|
+
const fromMs = Date.parse(`${from}T00:00:00.000Z`)
|
|
541
|
+
const toMs = Date.parse(`${to}T00:00:00.000Z`)
|
|
542
|
+
const days = Math.round((toMs - fromMs) / 86_400_000) + 1
|
|
543
|
+
if (days < 1 || days > maxRangeDays) throw new MetricsQueryError("Invalid date range")
|
|
544
|
+
const agent = filterText(filters.agent, "agent", false)
|
|
545
|
+
const provider = filterText(filters.provider, "provider", true)
|
|
546
|
+
const model = filterText(filters.model, "model", true)
|
|
547
|
+
const project = filterProject(filters.project)
|
|
548
|
+
const kind = filters.kind
|
|
549
|
+
if (kind !== undefined && !kindSet.has(kind)) throw new MetricsQueryError("Invalid kind")
|
|
550
|
+
if (!existsSync(path)) return emptyResult(from, to)
|
|
551
|
+
|
|
552
|
+
const params = [from, to]
|
|
553
|
+
let where = "WHERE day >= ? AND day <= ?"
|
|
554
|
+
const add = (clause, ...values) => { where += ` AND ${clause}`; params.push(...values) }
|
|
555
|
+
if (agent !== undefined) add("agent = ?", agent)
|
|
556
|
+
if (provider !== undefined) add("provider = ?", provider)
|
|
557
|
+
if (model !== undefined) add("model = ?", model)
|
|
558
|
+
if (project !== undefined) add("(project_hash = ? OR directory = ?)", project.hash, project.directory)
|
|
559
|
+
if (kind !== undefined) add("kind = ?", kind)
|
|
560
|
+
|
|
561
|
+
const db = new DatabaseSync(path, { readOnly: true })
|
|
562
|
+
try {
|
|
563
|
+
db.exec("PRAGMA busy_timeout = 5000")
|
|
564
|
+
const total = db.prepare(`SELECT count(*) AS steps,
|
|
565
|
+
coalesce(sum(input_tokens), 0) AS input, coalesce(sum(output_tokens), 0) AS output,
|
|
566
|
+
coalesce(sum(reasoning_tokens), 0) AS reasoning, coalesce(sum(cache_read_tokens), 0) AS cache_read,
|
|
567
|
+
coalesce(sum(cache_write_tokens), 0) AS cache_write, coalesce(sum(cost), 0) AS cost,
|
|
568
|
+
coalesce(sum(priced), 0) AS priced_steps FROM steps ${where}`).get(...params)
|
|
569
|
+
const totalTokens = tokensFrom(total)
|
|
570
|
+
const totals = {
|
|
571
|
+
steps: Number(total.steps),
|
|
572
|
+
tokens: totalTokens,
|
|
573
|
+
cost: Number(total.cost),
|
|
574
|
+
pricedSteps: Number(total.priced_steps),
|
|
575
|
+
costCoverage: ratio(Number(total.priced_steps), Number(total.steps)),
|
|
576
|
+
cacheHitRate: cacheHitRate(totalTokens),
|
|
577
|
+
}
|
|
578
|
+
const daily = new Map(db.prepare(`SELECT day, count(*) AS steps,
|
|
579
|
+
coalesce(sum(input_tokens), 0) AS input, coalesce(sum(output_tokens), 0) AS output,
|
|
580
|
+
coalesce(sum(reasoning_tokens), 0) AS reasoning, coalesce(sum(cache_read_tokens), 0) AS cache_read,
|
|
581
|
+
coalesce(sum(cache_write_tokens), 0) AS cache_write, coalesce(sum(cost), 0) AS cost,
|
|
582
|
+
coalesce(sum(priced), 0) AS priced_steps FROM steps ${where} GROUP BY day ORDER BY day`).all(...params).map((row) => {
|
|
583
|
+
const summary = rowSummary(row)
|
|
584
|
+
delete summary.key
|
|
585
|
+
return [row.day, { day: row.day, ...summary }]
|
|
586
|
+
}))
|
|
587
|
+
const series = []
|
|
588
|
+
for (let day = from; day <= to; day = shiftDay(day, 1)) {
|
|
589
|
+
series.push(daily.get(day) ?? { day, steps: 0, tokens: zeroTokens(), cost: 0, pricedSteps: 0, costCoverage: 0, cacheHitRate: 0 })
|
|
590
|
+
if (series.length >= days) break
|
|
591
|
+
}
|
|
592
|
+
const group = (select, groupBy, extra) => db.prepare(`SELECT ${select}, count(*) AS steps,
|
|
593
|
+
coalesce(sum(input_tokens), 0) AS input, coalesce(sum(output_tokens), 0) AS output,
|
|
594
|
+
coalesce(sum(reasoning_tokens), 0) AS reasoning, coalesce(sum(cache_read_tokens), 0) AS cache_read,
|
|
595
|
+
coalesce(sum(cache_write_tokens), 0) AS cache_write, coalesce(sum(cost), 0) AS cost,
|
|
596
|
+
coalesce(sum(priced), 0) AS priced_steps,
|
|
597
|
+
(coalesce(sum(input_tokens), 0) + coalesce(sum(output_tokens), 0) + coalesce(sum(reasoning_tokens), 0)
|
|
598
|
+
+ coalesce(sum(cache_read_tokens), 0) + coalesce(sum(cache_write_tokens), 0)) AS total_tokens
|
|
599
|
+
FROM steps ${where} GROUP BY ${groupBy} ORDER BY total_tokens DESC, steps DESC LIMIT ${topLimit}`)
|
|
600
|
+
.all(...params)
|
|
601
|
+
.map((row) => rowSummary(row, extra?.(row) ?? {}))
|
|
602
|
+
return {
|
|
603
|
+
range: { from, to, days },
|
|
604
|
+
totals,
|
|
605
|
+
series,
|
|
606
|
+
byAgent: group("coalesce(agent, '') AS key", "coalesce(agent, '')"),
|
|
607
|
+
byModel: group("coalesce(model, '') AS key, coalesce(provider, '') AS provider", "coalesce(model, ''), coalesce(provider, '')", (row) => ({ provider: row.provider || null })),
|
|
608
|
+
byProject: group("coalesce(project_hash, '') AS key, max(directory) AS directory", "coalesce(project_hash, '')", (row) => ({ directory: row.directory || null })),
|
|
609
|
+
byKind: group("kind AS key", "kind"),
|
|
610
|
+
}
|
|
611
|
+
} finally {
|
|
612
|
+
db.close()
|
|
613
|
+
}
|
|
614
|
+
}
|