@uniquedivine/tg-sai 0.0.4 → 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/README.md +41 -0
- package/RUNBOOK.md +204 -24
- package/actionExecutor.ts +183 -131
- package/adminAudit.ts +60 -60
- package/captureProvisioningGolden.ts +98 -0
- package/cfg.ts +12 -1
- package/custodyWorker.ts +501 -0
- package/custodyWorkerCli.ts +58 -0
- package/health.ts +31 -50
- package/main.ts +860 -214
- package/mtcuteAdapter.ts +266 -10
- package/ops.ts +12 -0
- package/package.json +14 -5
- package/profiles.ts +157 -32
- package/provisioning.ts +805 -0
- package/provisioningCli.ts +147 -0
- package/provisioningCloud.ts +214 -0
- package/provisioningGoldens.ts +70 -0
- package/provisioningRuntime.ts +417 -0
- package/runtimeAssets.d.ts +4 -0
- package/runtimeHealth.ts +132 -0
- package/runtimeHealthCli.ts +48 -0
- package/session_string_generator.py +36 -24
- package/state/cli.ts +641 -0
- package/state/db.ts +72 -0
- package/state/guard.ts +270 -0
- package/state/holds.ts +381 -0
- package/state/migrations/0001-flood-hold.sql +16 -0
- package/telegramRuntime.ts +25 -0
- package/telemetry/cli.ts +698 -0
- package/telemetry/db.ts +471 -0
- package/telemetry/middleware.ts +475 -0
- package/telemetry/migrations/0001-event.sql +33 -0
- package/telemetry/migrations/0002-event-job-index.sql +2 -0
- package/telemetry/migrations/0003-event-log.sql +10 -0
- package/telemetry/queries.ts +475 -0
- package/telemetry/run.ts +491 -0
- package/telemetry/sqlAssets.d.ts +4 -0
- package/telemetry/summarize.ts +219 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only queries over the telemetry `event` table. Every function takes an
|
|
3
|
+
* open `bun:sqlite` Database and plain filter values so tests can run them
|
|
4
|
+
* against a temp database populated with real rows.
|
|
5
|
+
*/
|
|
6
|
+
import { Database } from "bun:sqlite"
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ATTEMPT_EVENT,
|
|
10
|
+
LOG_LEVELS,
|
|
11
|
+
CALL_EVENT,
|
|
12
|
+
FLOOD_WAIT_EVENT,
|
|
13
|
+
RUN_END_EVENT,
|
|
14
|
+
RUN_START_EVENT,
|
|
15
|
+
WORKER_TICK_EVENT,
|
|
16
|
+
} from "./db"
|
|
17
|
+
import type { EventRow, LogLevel } from "./db"
|
|
18
|
+
|
|
19
|
+
/** Outcomes that count as failures. */
|
|
20
|
+
export const ERROR_OUTCOMES = ["error", "timeout", "aborted"] as const
|
|
21
|
+
|
|
22
|
+
const ERROR_OUTCOMES_SQL = ERROR_OUTCOMES.map((o) => `'${o}'`).join(", ")
|
|
23
|
+
|
|
24
|
+
type SqlParam = string | number | null
|
|
25
|
+
|
|
26
|
+
export interface EventFilter {
|
|
27
|
+
/** Lower bound on `ts_ms`, inclusive. */
|
|
28
|
+
sinceMs?: number
|
|
29
|
+
accountId?: number
|
|
30
|
+
method?: string
|
|
31
|
+
runId?: string
|
|
32
|
+
/** Only failed rows and flood waits. */
|
|
33
|
+
errorsOnly?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface WhereParts {
|
|
37
|
+
clauses: string[]
|
|
38
|
+
params: SqlParam[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Builds `AND`-joined filter clauses on table alias `e`. */
|
|
42
|
+
export function filterClauses(filter: EventFilter): WhereParts {
|
|
43
|
+
const clauses: string[] = []
|
|
44
|
+
const params: SqlParam[] = []
|
|
45
|
+
if (filter.sinceMs !== undefined) {
|
|
46
|
+
clauses.push("e.ts_ms >= ?")
|
|
47
|
+
params.push(filter.sinceMs)
|
|
48
|
+
}
|
|
49
|
+
if (filter.accountId !== undefined) {
|
|
50
|
+
clauses.push("e.account_id = ?")
|
|
51
|
+
params.push(filter.accountId)
|
|
52
|
+
}
|
|
53
|
+
if (filter.method !== undefined) {
|
|
54
|
+
clauses.push("e.method = ?")
|
|
55
|
+
params.push(filter.method)
|
|
56
|
+
}
|
|
57
|
+
if (filter.runId !== undefined) {
|
|
58
|
+
clauses.push("e.run_id = ?")
|
|
59
|
+
params.push(filter.runId)
|
|
60
|
+
}
|
|
61
|
+
if (filter.errorsOnly) {
|
|
62
|
+
clauses.push(
|
|
63
|
+
`(e.outcome IN (${ERROR_OUTCOMES_SQL}) ` +
|
|
64
|
+
`OR e.event = '${FLOOD_WAIT_EVENT}')`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
return { clauses, params }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function andAll(clauses: string[]): string {
|
|
71
|
+
return clauses.length === 0 ? "" : ` AND ${clauses.join(" AND ")}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when a completion row exists for call row `e`. */
|
|
75
|
+
const HAS_COMPLETION_SQL = `EXISTS (
|
|
76
|
+
SELECT 1 FROM event c
|
|
77
|
+
WHERE c.request_id = e.request_id
|
|
78
|
+
AND c.event = '${CALL_EVENT}'
|
|
79
|
+
AND c.outcome != 'started'
|
|
80
|
+
)`
|
|
81
|
+
|
|
82
|
+
export interface TailOptions extends EventFilter {
|
|
83
|
+
limit: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Newest-first call, attempt, and flood-wait rows. A call appears once: as
|
|
88
|
+
* its completion row, or as its `started` row while no completion exists.
|
|
89
|
+
*/
|
|
90
|
+
export function tailEvents(db: Database, options: TailOptions): EventRow[] {
|
|
91
|
+
const { clauses, params } = filterClauses(options)
|
|
92
|
+
const sql = `SELECT e.* FROM event e
|
|
93
|
+
WHERE e.event IN ('${CALL_EVENT}', '${ATTEMPT_EVENT}', '${FLOOD_WAIT_EVENT}')
|
|
94
|
+
AND NOT (e.event = '${CALL_EVENT}' AND e.outcome = 'started'
|
|
95
|
+
AND ${HAS_COMPLETION_SQL})${andAll(clauses)}
|
|
96
|
+
ORDER BY e.ts_ms DESC, e.event_id DESC
|
|
97
|
+
LIMIT ?`
|
|
98
|
+
return db.query<EventRow, SqlParam[]>(sql).all(...params, options.limit)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Chronological rows sharing `id` as request, run, job, or event id.
|
|
103
|
+
*/
|
|
104
|
+
export function showTimeline(db: Database, id: string): EventRow[] {
|
|
105
|
+
return db
|
|
106
|
+
.query<EventRow, SqlParam[]>(
|
|
107
|
+
`SELECT * FROM event
|
|
108
|
+
WHERE request_id = ? OR run_id = ? OR job_id = ? OR event_id = ?
|
|
109
|
+
ORDER BY ts_ms ASC, event_id ASC`,
|
|
110
|
+
)
|
|
111
|
+
.all(id, id, id, id)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `--by` names accepted by `countEvents`, mapped to columns. */
|
|
115
|
+
export const COUNT_COLUMNS: Readonly<Record<string, string>> = {
|
|
116
|
+
method: "method",
|
|
117
|
+
outcome: "outcome",
|
|
118
|
+
account: "account_id",
|
|
119
|
+
account_id: "account_id",
|
|
120
|
+
event: "event",
|
|
121
|
+
subsystem: "subsystem",
|
|
122
|
+
command: "command",
|
|
123
|
+
profile: "profile",
|
|
124
|
+
error_code: "error_code",
|
|
125
|
+
err: "err",
|
|
126
|
+
run: "run_id",
|
|
127
|
+
run_id: "run_id",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type CountLayer = "attempt" | "call"
|
|
131
|
+
|
|
132
|
+
export interface CountOptions {
|
|
133
|
+
sinceMs?: number
|
|
134
|
+
accountId?: number
|
|
135
|
+
by: string[]
|
|
136
|
+
layer: CountLayer
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type CountRow = Record<string, string | number | null> & {
|
|
140
|
+
count: number
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parses a comma-separated `--by` list into column names. Throws on an
|
|
145
|
+
* unknown name or an empty list.
|
|
146
|
+
*/
|
|
147
|
+
export function parseCountBy(value: string): string[] {
|
|
148
|
+
const names = value
|
|
149
|
+
.split(",")
|
|
150
|
+
.map((name) => name.trim())
|
|
151
|
+
.filter((name) => name.length > 0)
|
|
152
|
+
if (names.length === 0) throw new Error("--by needs at least one field")
|
|
153
|
+
const columns = names.map((name) => {
|
|
154
|
+
const column = COUNT_COLUMNS[name]
|
|
155
|
+
if (!column) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Unknown --by field "${name}"; use one of: ` +
|
|
158
|
+
Object.keys(COUNT_COLUMNS).join(", "),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
return column
|
|
162
|
+
})
|
|
163
|
+
return [...new Set(columns)]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Row counts grouped by `by` columns. The attempt layer counts requests sent
|
|
168
|
+
* to Telegram; the call layer counts finished logical calls.
|
|
169
|
+
*/
|
|
170
|
+
export function countEvents(db: Database, options: CountOptions): CountRow[] {
|
|
171
|
+
const known = new Set(Object.values(COUNT_COLUMNS))
|
|
172
|
+
for (const column of options.by) {
|
|
173
|
+
if (!known.has(column)) throw new Error(`Unknown count column: ${column}`)
|
|
174
|
+
}
|
|
175
|
+
if (options.by.length === 0) throw new Error("count needs a group column")
|
|
176
|
+
const { clauses, params } = filterClauses({
|
|
177
|
+
sinceMs: options.sinceMs,
|
|
178
|
+
accountId: options.accountId,
|
|
179
|
+
})
|
|
180
|
+
const layer =
|
|
181
|
+
options.layer === "call"
|
|
182
|
+
? `e.event = '${CALL_EVENT}' AND e.outcome != 'started'`
|
|
183
|
+
: `e.event = '${ATTEMPT_EVENT}'`
|
|
184
|
+
const cols = options.by.map((c) => `e.${c} AS ${c}`).join(", ")
|
|
185
|
+
const group = options.by.map((c) => `e.${c}`).join(", ")
|
|
186
|
+
const sql = `SELECT ${cols}, COUNT(*) AS count FROM event e
|
|
187
|
+
WHERE ${layer}${andAll(clauses)}
|
|
188
|
+
GROUP BY ${group}
|
|
189
|
+
ORDER BY count DESC, ${group}`
|
|
190
|
+
return db.query<CountRow, SqlParam[]>(sql).all(...params)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface FloodSummaryRow {
|
|
194
|
+
event: string
|
|
195
|
+
account_id: number | null
|
|
196
|
+
method: string | null
|
|
197
|
+
wait_s: number
|
|
198
|
+
count: number
|
|
199
|
+
latest_ms: number
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface ActiveFloodRow {
|
|
203
|
+
account_id: number | null
|
|
204
|
+
method: string | null
|
|
205
|
+
wait_s: number
|
|
206
|
+
started_ms: number
|
|
207
|
+
ends_ms: number
|
|
208
|
+
request_id: string | null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface FloodReport {
|
|
212
|
+
floods: FloodSummaryRow[]
|
|
213
|
+
active: ActiveFloodRow[]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface FloodOptions {
|
|
217
|
+
sinceMs?: number
|
|
218
|
+
accountId?: number
|
|
219
|
+
nowMs: number
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Flood waits: mtcute sleeps and attempts that returned a wait. */
|
|
223
|
+
const FLOOD_ROWS_SQL = `(e.event = '${FLOOD_WAIT_EVENT}'
|
|
224
|
+
OR (e.event = '${ATTEMPT_EVENT}' AND e.wait_s IS NOT NULL))`
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Flood waits grouped by layer, account, method, and wait, plus each
|
|
228
|
+
* account/method pair whose latest wait has not ended by `nowMs`. Active
|
|
229
|
+
* waits ignore `sinceMs` so a long wait that began earlier still shows.
|
|
230
|
+
*/
|
|
231
|
+
export function floodReport(db: Database, options: FloodOptions): FloodReport {
|
|
232
|
+
const summary = filterClauses({
|
|
233
|
+
sinceMs: options.sinceMs,
|
|
234
|
+
accountId: options.accountId,
|
|
235
|
+
})
|
|
236
|
+
const floods = db
|
|
237
|
+
.query<FloodSummaryRow, SqlParam[]>(
|
|
238
|
+
`SELECT e.event AS event, e.account_id AS account_id,
|
|
239
|
+
e.method AS method, e.wait_s AS wait_s, COUNT(*) AS count,
|
|
240
|
+
MAX(e.ts_ms) AS latest_ms
|
|
241
|
+
FROM event e
|
|
242
|
+
WHERE ${FLOOD_ROWS_SQL} AND e.wait_s IS NOT NULL${andAll(summary.clauses)}
|
|
243
|
+
GROUP BY e.event, e.account_id, e.method, e.wait_s
|
|
244
|
+
ORDER BY latest_ms DESC`,
|
|
245
|
+
)
|
|
246
|
+
.all(...summary.params)
|
|
247
|
+
const activeFilter = filterClauses({ accountId: options.accountId })
|
|
248
|
+
// SQLite takes bare columns from the row that supplied MAX().
|
|
249
|
+
const active = db
|
|
250
|
+
.query<ActiveFloodRow, SqlParam[]>(
|
|
251
|
+
`SELECT e.account_id AS account_id, e.method AS method,
|
|
252
|
+
e.wait_s AS wait_s, e.ts_ms AS started_ms,
|
|
253
|
+
MAX(e.ts_ms + e.wait_s * 1000) AS ends_ms, e.request_id AS request_id
|
|
254
|
+
FROM event e
|
|
255
|
+
WHERE ${FLOOD_ROWS_SQL} AND e.wait_s IS NOT NULL${andAll(activeFilter.clauses)}
|
|
256
|
+
GROUP BY e.account_id, e.method
|
|
257
|
+
HAVING ends_ms > ?
|
|
258
|
+
ORDER BY ends_ms DESC`,
|
|
259
|
+
)
|
|
260
|
+
.all(...activeFilter.params, options.nowMs)
|
|
261
|
+
return { floods, active }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* `tg.rpc.call` rows still `started` with no completion row for their
|
|
266
|
+
* `request_id`: the caller cannot know whether Telegram applied them.
|
|
267
|
+
*/
|
|
268
|
+
export function uncertainCalls(
|
|
269
|
+
db: Database,
|
|
270
|
+
options: { sinceMs?: number } = {},
|
|
271
|
+
): EventRow[] {
|
|
272
|
+
const { clauses, params } = filterClauses({ sinceMs: options.sinceMs })
|
|
273
|
+
return db
|
|
274
|
+
.query<EventRow, SqlParam[]>(
|
|
275
|
+
`SELECT e.* FROM event e
|
|
276
|
+
WHERE e.event = '${CALL_EVENT}' AND e.outcome = 'started'
|
|
277
|
+
AND NOT ${HAS_COMPLETION_SQL}${andAll(clauses)}
|
|
278
|
+
ORDER BY e.ts_ms DESC, e.event_id DESC`,
|
|
279
|
+
)
|
|
280
|
+
.all(...params)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface RunRow {
|
|
284
|
+
run_id: string
|
|
285
|
+
started_ms: number
|
|
286
|
+
command: string | null
|
|
287
|
+
subsystem: string
|
|
288
|
+
account_id: number | null
|
|
289
|
+
profile: string | null
|
|
290
|
+
host: string | null
|
|
291
|
+
pid: number | null
|
|
292
|
+
ended_ms: number | null
|
|
293
|
+
/** `run.end` outcome, or null when the run has no end row. */
|
|
294
|
+
outcome: string | null
|
|
295
|
+
error_code: number | null
|
|
296
|
+
err: string | null
|
|
297
|
+
duration_ms: number | null
|
|
298
|
+
calls: number
|
|
299
|
+
errors: number
|
|
300
|
+
attempts: number
|
|
301
|
+
ticks: number
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Newest `run.start` rows joined to their latest `run.end`, with call,
|
|
306
|
+
* failed-call, attempt, and worker-tick counts. Duration falls back to the
|
|
307
|
+
* gap between the two rows when `run.end` lacks `duration_ms`.
|
|
308
|
+
*/
|
|
309
|
+
export function recentRuns(db: Database, limit: number): RunRow[] {
|
|
310
|
+
return db
|
|
311
|
+
.query<RunRow, SqlParam[]>(
|
|
312
|
+
`SELECT s.run_id AS run_id, s.ts_ms AS started_ms, s.command AS command,
|
|
313
|
+
s.subsystem AS subsystem, s.account_id AS account_id, s.profile AS profile,
|
|
314
|
+
s.host AS host, s.pid AS pid, en.ts_ms AS ended_ms, en.outcome AS outcome,
|
|
315
|
+
en.error_code AS error_code, en.err AS err,
|
|
316
|
+
COALESCE(en.duration_ms, en.ts_ms - s.ts_ms) AS duration_ms,
|
|
317
|
+
(SELECT COUNT(*) FROM event x WHERE x.run_id = s.run_id
|
|
318
|
+
AND x.event = '${CALL_EVENT}' AND x.outcome != 'started') AS calls,
|
|
319
|
+
(SELECT COUNT(*) FROM event x WHERE x.run_id = s.run_id
|
|
320
|
+
AND x.event = '${CALL_EVENT}'
|
|
321
|
+
AND x.outcome IN (${ERROR_OUTCOMES_SQL})) AS errors,
|
|
322
|
+
(SELECT COUNT(*) FROM event x WHERE x.run_id = s.run_id
|
|
323
|
+
AND x.event = '${ATTEMPT_EVENT}') AS attempts,
|
|
324
|
+
(SELECT COUNT(*) FROM event x WHERE x.run_id = s.run_id
|
|
325
|
+
AND x.event = '${WORKER_TICK_EVENT}') AS ticks
|
|
326
|
+
FROM event s
|
|
327
|
+
LEFT JOIN event en ON en.event_id = (
|
|
328
|
+
SELECT y.event_id FROM event y
|
|
329
|
+
WHERE y.run_id = s.run_id AND y.event = '${RUN_END_EVENT}'
|
|
330
|
+
ORDER BY y.ts_ms DESC, y.event_id DESC LIMIT 1
|
|
331
|
+
)
|
|
332
|
+
WHERE s.event = '${RUN_START_EVENT}'
|
|
333
|
+
ORDER BY s.ts_ms DESC, s.event_id DESC
|
|
334
|
+
LIMIT ?`,
|
|
335
|
+
)
|
|
336
|
+
.all(limit)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* True when the `event` table has the 0003 log columns. Read-only opens skip
|
|
341
|
+
* migrations, so an old database may lack them.
|
|
342
|
+
*/
|
|
343
|
+
export function hasLogColumns(db: Database): boolean {
|
|
344
|
+
const columns = db
|
|
345
|
+
.query<{ name: string }, []>("PRAGMA table_info(event)")
|
|
346
|
+
.all()
|
|
347
|
+
.map((row) => row.name)
|
|
348
|
+
return columns.includes("level")
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** `level` and every more severe level, e.g. `warn` → `warn`, `error`. */
|
|
352
|
+
export function levelsAtOrAbove(level: LogLevel): LogLevel[] {
|
|
353
|
+
const index = LOG_LEVELS.indexOf(level)
|
|
354
|
+
if (index === -1) throw new Error(`Unknown log level: ${level}`)
|
|
355
|
+
return LOG_LEVELS.slice(index)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface LogQuery {
|
|
359
|
+
limit: number
|
|
360
|
+
sinceMs?: number
|
|
361
|
+
/** Minimum level; rows at this level or more severe. */
|
|
362
|
+
minLevel?: LogLevel
|
|
363
|
+
subsystem?: string
|
|
364
|
+
runId?: string
|
|
365
|
+
jobId?: string
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Newest-first app log rows (`level IS NOT NULL`). */
|
|
369
|
+
export function logEvents(db: Database, options: LogQuery): EventRow[] {
|
|
370
|
+
if (!hasLogColumns(db)) return []
|
|
371
|
+
const { clauses, params } = filterClauses({
|
|
372
|
+
sinceMs: options.sinceMs,
|
|
373
|
+
runId: options.runId,
|
|
374
|
+
})
|
|
375
|
+
if (options.minLevel !== undefined) {
|
|
376
|
+
const levels = levelsAtOrAbove(options.minLevel)
|
|
377
|
+
clauses.push(`e.level IN (${levels.map(() => "?").join(", ")})`)
|
|
378
|
+
params.push(...levels)
|
|
379
|
+
}
|
|
380
|
+
if (options.subsystem !== undefined) {
|
|
381
|
+
clauses.push("e.subsystem = ?")
|
|
382
|
+
params.push(options.subsystem)
|
|
383
|
+
}
|
|
384
|
+
if (options.jobId !== undefined) {
|
|
385
|
+
clauses.push("e.job_id = ?")
|
|
386
|
+
params.push(options.jobId)
|
|
387
|
+
}
|
|
388
|
+
const sql = `SELECT e.* FROM event e
|
|
389
|
+
WHERE e.level IS NOT NULL${andAll(clauses)}
|
|
390
|
+
ORDER BY e.ts_ms DESC, e.event_id DESC
|
|
391
|
+
LIMIT ?`
|
|
392
|
+
return db.query<EventRow, SqlParam[]>(sql).all(...params, options.limit)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export interface LevelCount {
|
|
396
|
+
level: LogLevel
|
|
397
|
+
rows: number
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface KindCount {
|
|
401
|
+
event: string
|
|
402
|
+
rows: number
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export interface EventStats {
|
|
406
|
+
rows: number
|
|
407
|
+
oldest_ms: number | null
|
|
408
|
+
newest_ms: number | null
|
|
409
|
+
/** App log rows (`level IS NOT NULL`). */
|
|
410
|
+
log_rows: number
|
|
411
|
+
kinds: KindCount[]
|
|
412
|
+
levels: LevelCount[]
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function eventStats(db: Database): EventStats {
|
|
416
|
+
const totals = db
|
|
417
|
+
.query<
|
|
418
|
+
{ rows: number; oldest_ms: number | null; newest_ms: number | null },
|
|
419
|
+
[]
|
|
420
|
+
>(
|
|
421
|
+
"SELECT COUNT(*) AS rows, MIN(ts_ms) AS oldest_ms, " +
|
|
422
|
+
"MAX(ts_ms) AS newest_ms FROM event",
|
|
423
|
+
)
|
|
424
|
+
.get()
|
|
425
|
+
const kinds = db
|
|
426
|
+
.query<KindCount, []>(
|
|
427
|
+
"SELECT event, COUNT(*) AS rows FROM event GROUP BY event " +
|
|
428
|
+
"ORDER BY rows DESC, event",
|
|
429
|
+
)
|
|
430
|
+
.all()
|
|
431
|
+
const levels = hasLogColumns(db)
|
|
432
|
+
? db
|
|
433
|
+
.query<LevelCount, []>(
|
|
434
|
+
"SELECT level, COUNT(*) AS rows FROM event " +
|
|
435
|
+
"WHERE level IS NOT NULL GROUP BY level ORDER BY rows DESC, level",
|
|
436
|
+
)
|
|
437
|
+
.all()
|
|
438
|
+
: []
|
|
439
|
+
return {
|
|
440
|
+
rows: totals?.rows ?? 0,
|
|
441
|
+
oldest_ms: totals?.oldest_ms ?? null,
|
|
442
|
+
newest_ms: totals?.newest_ms ?? null,
|
|
443
|
+
log_rows: levels.reduce((sum, row) => sum + row.rows, 0),
|
|
444
|
+
kinds,
|
|
445
|
+
levels,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Oldest-first rows since `sinceMs`, streamed. */
|
|
450
|
+
export function* exportEvents(
|
|
451
|
+
db: Database,
|
|
452
|
+
options: { sinceMs?: number } = {},
|
|
453
|
+
): Generator<EventRow> {
|
|
454
|
+
const { clauses, params } = filterClauses({ sinceMs: options.sinceMs })
|
|
455
|
+
const where = clauses.length === 0 ? "" : ` WHERE ${clauses.join(" AND ")}`
|
|
456
|
+
const statement = db.query<EventRow, SqlParam[]>(
|
|
457
|
+
`SELECT e.* FROM event e${where} ORDER BY e.ts_ms ASC, e.event_id ASC`,
|
|
458
|
+
)
|
|
459
|
+
for (const row of statement.iterate(...params)) yield row
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Opens an existing telemetry database read-only: no create, no pragmas
|
|
464
|
+
* that write, no migrations.
|
|
465
|
+
*/
|
|
466
|
+
export function openTelemetryReadOnly(path: string): Database {
|
|
467
|
+
const db = new Database(path, { readonly: true, strict: true })
|
|
468
|
+
try {
|
|
469
|
+
db.run("PRAGMA busy_timeout = 5000")
|
|
470
|
+
return db
|
|
471
|
+
} catch (error) {
|
|
472
|
+
db.close()
|
|
473
|
+
throw error
|
|
474
|
+
}
|
|
475
|
+
}
|