Skip to content
1,161 linesCodeBlameRaw
1/**
2 * One project's telemetry: a Durable Object named by the project's id,
3 * holding its requests, logs, spans, metrics, error groups, ingest tokens
4 * and exports in its own SQLite (./schema.ts). One object per project
5 * means each project's store grows, and is queried, on its own.
6 *
7 * Writes arrive from the service's two doors (`ingest`); reads are the
8 * service's RPC methods, called once the site or the API has checked the
9 * viewer may read the project. Open live tails are hibernating WebSockets
10 * here, each handed what arrives as it arrives.
11 *
12 * An alarm runs while there is work: each minute it counts requests into
13 * per-minute rollups and sends to exports what is waiting; each hour it
14 * removes what is past the project's retention.
15 */
16import {
17 DEFAULT_TELEMETRY_SETTINGS,
18 RETENTION_CHOICES,
19 TELEMETRY_TOKEN_PREFIX,
20 type Attributes,
21 type ErrorGroup,
22 type IngestToken,
23 type LiveItem,
24 type LogQuery,
25 type MetricInfo,
26 type MetricSeries,
27 type TelemetryOverview,
28 type TelemetryTotals,
29 type Page,
30 type RequestQuery,
31 type LogSeverity,
32 type TelemetryExport,
33 type TelemetryFilter,
34 type TelemetryLog,
35 type TelemetryRequest,
36 type TelemetrySettings,
37 type TelemetrySpan,
38 type TelemetryUsage,
39 type TraceDetail,
40 type TraceQuery,
41 type TraceSummary,
42} from "@g1t/contracts/telemetry";
43
44import type { Batch } from "./batch.ts";
45import { fingerprint } from "./errors.ts";
46import { exportEndpointProblem, otlpBodies, send } from "./export.ts";
47import { addInto, bucketOf, decode, emptyHistogram, encode, percentiles } from "./histogram.ts";
48import { FTS, MIGRATIONS, ROLLUP_DAYS } from "./schema.ts";
49import { SEVERITY_FLOOR, randomId, severityOf } from "./shape.ts";
50import type { RequestHalf } from "./tail.ts";
51
52const MINUTE_US = 60_000_000;
53const DAY_US = 86_400_000_000;
54/** Requests are counted once they are this old, so both halves have arrived. */
55const ROLL_AFTER_US = 90_000_000;
56/** A store larger than this sheds its oldest day until it is not. */
57const MAX_BYTES = 4 * 1024 ** 3;
58/** At most this many items a second go to each live tail; the rest are counted as dropped. */
59const LIVE_PER_BATCH = 200;
60const STEPS = [60, 300, 900, 1800, 3600, 3 * 3600, 6 * 3600, 86400];
61
62type Row = Record<string, SqlStorageValue>;
63
64const json = <T>(text: SqlStorageValue, fallback: T): T => {
65 if (typeof text !== "string" || text === "") return fallback;
66 try {
67 return JSON.parse(text) as T;
68 } catch {
69 return fallback;
70 }
71};
72const strOrNull = (v: SqlStorageValue) => (v === null || v === undefined || v === "" ? null : String(v));
73const numOrNull = (v: SqlStorageValue) => (v === null || v === undefined ? null : Number(v));
74
75async function sha256(text: string): Promise<string> {
76 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
77 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
78}
79
80function month(us = Date.now() * 1000): string {
81 return new Date(us / 1000).toISOString().slice(0, 7);
82}
83
84/** A page's cursor: the last row's time and id, so the next page starts after it. */
85function cursorOf(time: number, id: string): string {
86 return btoa(`${time}:${id}`);
87}
88function readCursor(cursor: string | null | undefined): { time: number; id: string } | null {
89 if (!cursor) return null;
90 try {
91 const [time, id] = atob(cursor).split(":");
92 return Number.isFinite(Number(time)) && id ? { time: Number(time), id } : null;
93 } catch {
94 return null;
95 }
96}
97
98/** A limit within reason. */
99const limitOf = (n: number | null | undefined, fallback = 100, max = 500) => Math.min(max, Math.max(1, Math.trunc(n ?? fallback)));
100
101/**
102 * Search text split into what it says: `key=value` and `key:value` pairs
103 * filter by attribute (or by a column the row has), the rest are words to find.
104 */
105export function parseSearch(text: string | null | undefined): { words: string[]; pairs: [string, string][] } {
106 const words: string[] = [];
107 const pairs: [string, string][] = [];
108 for (const m of (text ?? "").matchAll(/"([^"]+)"|(\S+)/g)) {
109 const token = m[1] ?? m[2];
110 const pair = m[1] ? null : /^([\w.@/-]+)[=:](.+)$/.exec(token);
111 if (pair && !/^https?$/.test(pair[1])) pairs.push([pair[1], pair[2]]);
112 else words.push(token);
113 }
114 return { words, pairs };
115}
116
117/** An FTS5 query in which every word must appear (as a prefix, for the last). */
118export function ftsQuery(words: string[]): string {
119 return words.map((w, i) => `"${w.replace(/"/g, '""')}"${i === words.length - 1 && !/\s/.test(w) ? "*" : ""}`).join(" AND ");
120}
121
122/**
123 * The store's logic, apart from the Durable Object it runs in
124 * (./durable.ts), so tests run it on Node's SQLite.
125 */
126export class Store {
127 state!: DurableObjectState;
128 sql!: SqlStorage;
129 fts = false;
130
131 constructor(state: DurableObjectState) {
132 this.open(state);
133 }
134
135 open(state: DurableObjectState) {
136 this.state = state;
137 this.sql = state.storage.sql;
138 this.migrate();
139 }
140
141 migrate() {
142 this.sql.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
143 const done = Number(this.sql.exec("SELECT value FROM meta WHERE key = 'migrations'").toArray()[0]?.value ?? 0);
144 for (let i = done; i < MIGRATIONS.length; i++) {
145 this.sql.exec(MIGRATIONS[i]);
146 this.sql.exec("INSERT INTO meta (key, value) VALUES ('migrations', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", String(i + 1));
147 }
148 try {
149 this.sql.exec(FTS);
150 this.fts = true;
151 } catch {
152 this.fts = false;
153 }
154 }
155
156 rows(query: string, ...args: SqlStorageValue[]): Row[] {
157 return this.sql.exec(query, ...args).toArray() as Row[];
158 }
159
160 // ---- Settings -----------------------------------------------------------
161
162 settings(): TelemetrySettings {
163 const out: TelemetrySettings = { ...DEFAULT_TELEMETRY_SETTINGS };
164 for (const row of this.rows("SELECT key, value FROM settings")) {
165 if (row.key === "retention_days") out.retention_days = Number(row.value);
166 if (row.key === "deployments") out.deployments = row.value === "1";
167 }
168 return out;
169 }
170
171 setSettings(change: Partial<TelemetrySettings>): TelemetrySettings {
172 const put = (key: string, value: string) =>
173 this.sql.exec("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", key, value);
174 if (change.retention_days !== undefined) {
175 if (!(RETENTION_CHOICES as readonly number[]).includes(change.retention_days)) throw new Error(`Retention is one of ${RETENTION_CHOICES.join(", ")} days.`);
176 put("retention_days", String(change.retention_days));
177 }
178 if (change.deployments !== undefined) put("deployments", change.deployments ? "1" : "0");
179 return this.settings();
180 }
181
182 // ---- Tokens -------------------------------------------------------------
183
184 tokenRow(row: Row): IngestToken {
185 return {
186 id: String(row.id),
187 name: String(row.name),
188 hint: String(row.hint),
189 created_by: strOrNull(row.created_by),
190 created_us: Number(row.created_us),
191 last_used_us: numOrNull(row.last_used_us),
192 managed: row.managed === 1,
193 };
194 }
195
196 /**
197 * The tokens people made. The one g1t's deployments send with is kept
198 * out of sight: it is g1t's, given to the project's apps on their own.
199 */
200 tokens(): IngestToken[] {
201 return this.rows("SELECT * FROM tokens WHERE managed = 0 ORDER BY created_us DESC").map((r) => this.tokenRow(r));
202 }
203
204 /** A new token: `g1to_<project>_<secret>`, so the ingest door knows whose store to ask. */
205 async createToken(projectId: string, name: string, createdBy: string | null, managed = false): Promise<{ token: IngestToken; secret: string }> {
206 const secret = `${TELEMETRY_TOKEN_PREFIX}${projectId}_${randomId(24)}`;
207 const id = randomId(8);
208 const created = Date.now() * 1000;
209 this.sql.exec(
210 "INSERT INTO tokens (id, name, hash, hint, created_by, created_us, managed, secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
211 id,
212 name.trim().slice(0, 80) || "Ingest token",
213 await sha256(secret),
214 `${secret.slice(0, TELEMETRY_TOKEN_PREFIX.length + 4)}…${secret.slice(-4)}`,
215 createdBy,
216 created,
217 managed ? 1 : 0,
218 managed ? secret : null,
219 );
220 return { token: this.tokenRow(this.rows("SELECT * FROM tokens WHERE id = ?", id)[0]), secret };
221 }
222
223 revokeToken(id: string): boolean {
224 return this.sql.exec("DELETE FROM tokens WHERE id = ? AND managed = 0", id).rowsWritten > 0;
225 }
226
227 async deploymentToken(projectId: string): Promise<{ secret: string }> {
228 const row = this.rows("SELECT secret FROM tokens WHERE managed = 1 LIMIT 1")[0];
229 if (row?.secret) return { secret: String(row.secret) };
230 const made = await this.createToken(projectId, "Deployments on g1t.page", null, true);
231 return { secret: made.secret };
232 }
233
234 /** Whether `secret` is one of this project's tokens; marks it used. */
235 async checkToken(secret: string): Promise<boolean> {
236 const hash = await sha256(secret);
237 const row = this.rows("SELECT id, last_used_us FROM tokens WHERE hash = ?", hash)[0];
238 if (!row) return false;
239 const now = Date.now() * 1000;
240 if (!row.last_used_us || now - Number(row.last_used_us) > MINUTE_US) {
241 this.sql.exec("UPDATE tokens SET last_used_us = ? WHERE id = ?", now, row.id);
242 }
243 return true;
244 }
245
246 // ---- Ingest -------------------------------------------------------------
247
248 /**
249 * Adds what arrived. `token` is checked first when given (the OTLP door);
250 * tail events come from Cloudflare and need none. Returns false for a
251 * token that is not this project's, or when this kind of telemetry is off.
252 */
253 async ingest(batch: Batch, halves: RequestHalf[] = [], token?: string): Promise<{ accepted: boolean; reason?: string }> {
254 if (token !== undefined && !(await this.checkToken(token))) return { accepted: false, reason: "token" };
255 if (token === undefined && !this.settings().deployments) return { accepted: false, reason: "off" };
256
257 const live: LiveItem[] = [];
258 for (const h of halves) this.putHalf(h);
259 for (const r of batch.requests) this.putHalf({ ...r, half: "app" });
260 for (const log of batch.logs) {
261 this.sql.exec(
262 `INSERT OR IGNORE INTO logs (id, time_us, severity_number, body, trace_id, span_id, request_id, attributes, service, environment, deployment_id, commit_sha, source)
263 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
264 log.id, log.time_us, log.severity_number, log.body, log.trace_id, log.span_id, log.request_id, JSON.stringify(log.attributes),
265 log.service, log.environment, log.deployment_id, log.commit_sha, log.source,
266 );
267 live.push({ type: "log", log });
268 }
269 for (const s of batch.spans) {
270 this.sql.exec(
271 `INSERT OR REPLACE INTO spans (trace_id, span_id, parent_span_id, name, kind, start_us, duration_us, status, status_message, attributes, resource, events, links,
272 service, environment, deployment_id, commit_sha, source)
273 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
274 s.trace_id, s.span_id, s.parent_span_id, s.name, s.kind, s.start_us, s.duration_us, s.status, s.status_message,
275 JSON.stringify(s.attributes), JSON.stringify(s.resource), JSON.stringify(s.events), JSON.stringify(s.links),
276 s.service, s.environment, s.deployment_id, s.commit_sha, s.source,
277 );
278 live.push({ type: "span", span: s });
279 }
280 for (const m of batch.metrics) {
281 this.sql.exec(
282 `INSERT INTO metric_points (name, kind, time_us, value, count, sum, min, max, bounds, counts, quantiles, monotonic, attributes, service, environment, deployment_id, source)
283 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
284 m.name, m.kind, m.time_us, m.value, m.count, m.sum, m.min, m.max,
285 m.bounds ? JSON.stringify(m.bounds) : null, m.counts ? JSON.stringify(m.counts) : null, m.quantiles ? JSON.stringify(m.quantiles) : null,
286 m.monotonic ? 1 : 0, JSON.stringify(m.attributes), m.service, m.environment, m.deployment_id, m.source,
287 );
288 const known = this.rows("SELECT services FROM metric_names WHERE name = ?", m.name)[0];
289 const services = new Set<string>(json<string[]>(known?.services ?? null, []));
290 services.add(m.service);
291 this.sql.exec(
292 `INSERT INTO metric_names (name, kind, unit, description, services, last_us) VALUES (?, ?, ?, ?, ?, ?)
293 ON CONFLICT (name) DO UPDATE SET kind = excluded.kind, unit = excluded.unit,
294 description = CASE WHEN excluded.description = '' THEN metric_names.description ELSE excluded.description END,
295 services = excluded.services, last_us = MAX(metric_names.last_us, excluded.last_us)`,
296 m.name, m.kind, m.unit, m.description, JSON.stringify([...services].slice(0, 50)), m.time_us,
297 );
298 }
299 for (const e of batch.errors) await this.seeError(e);
300
301 const requestCount = halves.filter((h) => h.half === "edge").length + batch.requests.length + halves.filter((h) => h.half === "app" && h.trigger !== "fetch").length;
302 this.sql.exec(
303 `INSERT INTO usage (month, ingested_bytes, requests, logs, spans, metric_points) VALUES (?, ?, ?, ?, ?, ?)
304 ON CONFLICT (month) DO UPDATE SET ingested_bytes = ingested_bytes + excluded.ingested_bytes, requests = requests + excluded.requests,
305 logs = logs + excluded.logs, spans = spans + excluded.spans, metric_points = metric_points + excluded.metric_points`,
306 month(), batch.bytes, requestCount, batch.logs.length, batch.spans.length, batch.metrics.length,
307 );
308
309 // Requests go live (and to exports) whole: once both halves are in, or
310 // for OTLP, at once.
311 const ids = [...new Set([...halves.map((h) => h.id), ...batch.requests.map((r) => r.id)])];
312 const requests = ids.length ? this.requestsByIds(ids) : [];
313 for (const r of requests) live.unshift({ type: "request", request: r });
314 this.broadcast(live);
315 // Requests are sent on when they are counted (`roll`), once both halves are in.
316 this.state.waitUntil(this.forward({ ...batch, requests: [] }, []));
317 await this.ensureAlarm();
318 return { accepted: true };
319 }
320
321 /** One half of a request (./tail.ts), merged with the other. */
322 putHalf(h: RequestHalf) {
323 const edge = h.half === "edge";
324 this.sql.exec(
325 `INSERT INTO requests (id, time_us, duration_us, trigger, method, url, route, status, outcome, cpu_ms, region, country, trace_id, log_count,
326 error_name, error_message, service, environment, deployment_id, commit_sha, source, edge, app)
327 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
328 ON CONFLICT (id) DO UPDATE SET
329 time_us = MIN(requests.time_us, excluded.time_us),
330 -- The dispatcher's half has the whole request: its time and status win.
331 duration_us = CASE WHEN excluded.edge = 1 OR requests.edge = 0 THEN excluded.duration_us ELSE requests.duration_us END,
332 status = CASE WHEN excluded.edge = 1 OR requests.status IS NULL THEN COALESCE(excluded.status, requests.status) ELSE requests.status END,
333 trigger = COALESCE(excluded.trigger, requests.trigger),
334 method = COALESCE(requests.method, excluded.method),
335 url = COALESCE(requests.url, excluded.url),
336 route = COALESCE(requests.route, excluded.route),
337 region = COALESCE(requests.region, excluded.region),
338 country = COALESCE(requests.country, excluded.country),
339 trace_id = COALESCE(requests.trace_id, excluded.trace_id),
340 outcome = CASE WHEN excluded.app = 1 THEN excluded.outcome ELSE requests.outcome END,
341 cpu_ms = COALESCE(excluded.cpu_ms, requests.cpu_ms),
342 log_count = requests.log_count + excluded.log_count,
343 error_name = COALESCE(requests.error_name, excluded.error_name),
344 error_message = COALESCE(requests.error_message, excluded.error_message),
345 service = CASE WHEN excluded.service != '' THEN excluded.service ELSE requests.service END,
346 environment = COALESCE(excluded.environment, requests.environment),
347 deployment_id = COALESCE(excluded.deployment_id, requests.deployment_id),
348 commit_sha = COALESCE(excluded.commit_sha, requests.commit_sha),
349 edge = MAX(requests.edge, excluded.edge),
350 app = MAX(requests.app, excluded.app)`,
351 h.id, h.time_us ?? Date.now() * 1000, h.duration_us ?? 0, h.trigger ?? "fetch", h.method ?? null, h.url ?? null, h.route ?? null,
352 h.status ?? null, h.outcome ?? "ok", h.cpu_ms ?? null, h.region ?? null, h.country ?? null, h.trace_id ?? null, h.log_count ?? 0,
353 h.error?.name ?? null, h.error?.message ?? null, h.service ?? "", h.environment ?? null, h.deployment_id ?? null, h.commit_sha ?? null,
354 h.source ?? "cloudflare", edge ? 1 : 0, edge ? 0 : 1,
355 );
356 }
357
358 async seeError(e: Batch["errors"][number]) {
359 const fp = await fingerprint(e.name, e.message, e.stack, e.service);
360 const known = this.rows("SELECT state FROM error_groups WHERE fingerprint = ?", fp)[0];
361 if (!known) {
362 this.sql.exec(
363 `INSERT INTO error_groups (fingerprint, name, message, stack, service, environment, first_us, last_us, count, first_deployment_id, last_request_id, last_trace_id)
364 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`,
365 fp, e.name.slice(0, 200), e.message.slice(0, 4096), e.stack?.slice(0, 16384) ?? null, e.service, e.environment, e.time_us, e.time_us,
366 e.deployment_id, e.request_id, e.trace_id,
367 );
368 return;
369 }
370 // A resolved error that happens again is open again; an ignored one stays ignored.
371 this.sql.exec(
372 `UPDATE error_groups SET count = count + 1, last_us = MAX(last_us, ?), last_request_id = COALESCE(?, last_request_id),
373 last_trace_id = COALESCE(?, last_trace_id), stack = COALESCE(stack, ?), state = CASE WHEN state = 'resolved' THEN 'open' ELSE state END
374 WHERE fingerprint = ?`,
375 e.time_us, e.request_id, e.trace_id, e.stack ?? null, fp,
376 );
377 }
378
379 // ---- Live ---------------------------------------------------------------
380
381 async fetch(request: Request): Promise<Response> {
382 if (request.headers.get("upgrade") !== "websocket") return new Response("Expected a WebSocket.\n", { status: 426 });
383 const pair = new WebSocketPair();
384 this.state.acceptWebSocket(pair[1]);
385 pair[1].send(JSON.stringify({ type: "hello" }));
386 return new Response(null, { status: 101, webSocket: pair[0] });
387 }
388
389 async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
390 if (message === "ping") ws.send("pong");
391 }
392
393 async webSocketClose(ws: WebSocket, code: number) {
394 try {
395 ws.close(code === 1005 ? 1000 : code, "closing");
396 } catch {}
397 }
398
399 broadcast(items: LiveItem[]) {
400 const sockets = this.state.getWebSockets();
401 if (!sockets.length || !items.length) return;
402 const shown = items.slice(0, LIVE_PER_BATCH);
403 const dropped = items.length - shown.length;
404 const message = JSON.stringify({ type: "items", items: shown, dropped });
405 for (const ws of sockets) {
406 try {
407 ws.send(message);
408 } catch {}
409 }
410 }
411
412 // ---- Exports ------------------------------------------------------------
413
414 exportRows(): (TelemetryExport & { headers: Record<string, string> })[] {
415 return this.rows("SELECT * FROM exports ORDER BY name").map((r) => {
416 const headers = json<Record<string, string>>(r.headers, {});
417 return {
418 id: String(r.id),
419 name: String(r.name),
420 endpoint: String(r.endpoint),
421 signals: json(r.signals, ["traces", "logs", "metrics"]),
422 header_names: Object.keys(headers),
423 headers,
424 enabled: r.enabled === 1,
425 last_error: strOrNull(r.last_error),
426 last_sent_us: numOrNull(r.last_sent_us),
427 };
428 });
429 }
430
431 exports(): TelemetryExport[] {
432 return this.exportRows().map(({ headers: _, ...rest }) => rest);
433 }
434
435 putExport(input: { id?: string | null; name: string; endpoint: string; signals: TelemetryExport["signals"]; headers?: Record<string, string> | null; enabled: boolean }): TelemetryExport {
436 const problem = exportEndpointProblem(input.endpoint);
437 if (problem) throw new Error(problem);
438 const signals = input.signals.filter((s) => s === "traces" || s === "logs" || s === "metrics");
439 if (!signals.length) throw new Error("Choose at least one signal to send.");
440 const id = input.id || randomId(8);
441 const before = this.rows("SELECT headers FROM exports WHERE id = ?", id)[0];
442 // Header values are written, never read back: keep those not given again.
443 const headers = { ...json<Record<string, string>>(before?.headers ?? null, {}) };
444 for (const [k, v] of Object.entries(input.headers ?? {})) {
445 if (v === "") delete headers[k];
446 else headers[k] = v;
447 }
448 this.sql.exec(
449 `INSERT INTO exports (id, name, endpoint, signals, headers, enabled) VALUES (?, ?, ?, ?, ?, ?)
450 ON CONFLICT (id) DO UPDATE SET name = excluded.name, endpoint = excluded.endpoint, signals = excluded.signals,
451 headers = excluded.headers, enabled = excluded.enabled, last_error = NULL`,
452 id, input.name.trim().slice(0, 80) || "Export", input.endpoint.trim(), JSON.stringify(signals), JSON.stringify(headers), input.enabled ? 1 : 0,
453 );
454 return this.exports().find((e) => e.id === id)!;
455 }
456
457 deleteExport(id: string): boolean {
458 return this.sql.exec("DELETE FROM exports WHERE id = ?", id).rowsWritten > 0;
459 }
460
461 async forward(batch: Batch, requests: TelemetryRequest[]) {
462 const targets = this.exportRows().filter((e) => e.enabled);
463 if (!targets.length || (!requests.length && !batch.logs.length && !batch.spans.length && !batch.metrics.length)) return;
464 const bodies = otlpBodies(batch, requests);
465 for (const target of targets) {
466 let error: string | null = null;
467 for (const signal of target.signals) {
468 const body = bodies[signal];
469 if (body) error = (await send(target, signal, body)) ?? error;
470 }
471 this.sql.exec("UPDATE exports SET last_error = ?, last_sent_us = ? WHERE id = ?", error, Date.now() * 1000, target.id);
472 }
473 }
474
475 // ---- Upkeep -------------------------------------------------------------
476
477 async ensureAlarm() {
478 if ((await this.state.storage.getAlarm()) === null) await this.state.storage.setAlarm(Date.now() + 60_000);
479 }
480
481 async alarm() {
482 const rolled = this.roll();
483 if (rolled.length) await this.forward({ requests: [], logs: [], spans: [], metrics: [], errors: [], bytes: 0 }, rolled);
484 const last = Number(this.rows("SELECT value FROM meta WHERE key = 'pruned'")[0]?.value ?? 0);
485 if (Date.now() - last > 3_600_000) {
486 this.prune();
487 this.sql.exec("INSERT INTO meta (key, value) VALUES ('pruned', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", String(Date.now()));
488 }
489 // Keep going while there is anything left to count or keep.
490 const pending = this.rows("SELECT 1 FROM requests LIMIT 1").length || this.rows("SELECT 1 FROM logs LIMIT 1").length || this.rows("SELECT 1 FROM spans LIMIT 1").length;
491 if (pending) await this.state.storage.setAlarm(Date.now() + 60_000);
492 }
493
494 /** Counts settled requests into per-minute rollups, and sends them to exports. */
495 roll(now = Date.now() * 1000): TelemetryRequest[] {
496 const rolled: TelemetryRequest[] = [];
497 for (;;) {
498 const rows = this.rows("SELECT * FROM requests WHERE rolled = 0 AND time_us < ? ORDER BY time_us, id LIMIT 2000", now - ROLL_AFTER_US);
499 if (!rows.length) break;
500 for (const a of aggregate(rows)) {
501 const before = this.rows(
502 "SELECT latency, cpu FROM rollups WHERE minute = ? AND service = ? AND environment = ? AND deployment_id = ? AND method = ? AND route = ?",
503 a.minute, a.service, a.environment, a.deployment_id, a.method, a.route,
504 )[0];
505 const latency = encode(before ? addInto(decode(String(before.latency)), decode(a.latency)) : decode(a.latency));
506 const cpu = encode(before ? addInto(decode(String(before.cpu)), decode(a.cpu)) : decode(a.cpu));
507 this.sql.exec(
508 `INSERT INTO rollups (minute, service, environment, deployment_id, commit_sha, source, method, route, requests, errors, s2, s3, s4, s5, latency, cpu)
509 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
510 ON CONFLICT (minute, service, environment, deployment_id, method, route) DO UPDATE SET
511 requests = requests + excluded.requests, errors = errors + excluded.errors, s2 = s2 + excluded.s2, s3 = s3 + excluded.s3,
512 s4 = s4 + excluded.s4, s5 = s5 + excluded.s5, latency = excluded.latency, cpu = excluded.cpu,
513 commit_sha = COALESCE(excluded.commit_sha, rollups.commit_sha)`,
514 a.minute, a.service, a.environment, a.deployment_id, a.commit_sha, a.source, a.method, a.route, a.requests, a.errors,
515 a.s2, a.s3, a.s4, a.s5, latency, cpu,
516 );
517 }
518 const last = rows[rows.length - 1];
519 this.sql.exec("UPDATE requests SET rolled = 1 WHERE rolled = 0 AND (time_us < ? OR (time_us = ? AND id <= ?))", last.time_us, last.time_us, last.id);
520 rolled.push(...rows.map((r) => this.requestOf(r)));
521 if (rows.length < 2000) break;
522 }
523 return rolled;
524 }
525
526 prune(now = Date.now() * 1000) {
527 const keep = now - this.settings().retention_days * DAY_US;
528 // Requests are counted before they go.
529 this.roll(now);
530 this.sql.exec("DELETE FROM requests WHERE time_us < ?", keep);
531 this.sql.exec("DELETE FROM logs WHERE time_us < ?", keep);
532 this.sql.exec("DELETE FROM spans WHERE start_us < ?", keep);
533 this.sql.exec("DELETE FROM metric_points WHERE time_us < ?", keep);
534 this.sql.exec("DELETE FROM rollups WHERE minute < ?", Math.floor((now - ROLLUP_DAYS * DAY_US) / MINUTE_US));
535 this.sql.exec("DELETE FROM error_groups WHERE last_us < ? AND issue_number IS NULL", now - ROLLUP_DAYS * DAY_US);
536 // Over its size, the store sheds a day at a time from the oldest.
537 for (let i = 0; i < 30 && this.state.storage.sql.databaseSize > MAX_BYTES; i++) {
538 const oldest = Number(this.rows("SELECT MIN(time_us) AS t FROM logs")[0]?.t ?? now);
539 const cut = oldest + DAY_US;
540 this.sql.exec("DELETE FROM logs WHERE time_us < ?", cut);
541 this.sql.exec("DELETE FROM spans WHERE start_us < ?", cut);
542 this.sql.exec("DELETE FROM requests WHERE time_us < ?", cut);
543 this.sql.exec("DELETE FROM metric_points WHERE time_us < ?", cut);
544 }
545 }
546
547 async purge(): Promise<boolean> {
548 for (const ws of this.state.getWebSockets()) {
549 try {
550 ws.close(1000, "gone");
551 } catch {}
552 }
553 await this.state.storage.deleteAlarm();
554 await this.state.storage.deleteAll();
555 return true;
556 }
557
558 // ---- Reading ------------------------------------------------------------
559
560 /** WHERE clauses for what every list narrows by. */
561 filter(f: TelemetryFilter, time: string, prefix = ""): { clauses: string[]; args: SqlStorageValue[] } {
562 const clauses: string[] = [];
563 const args: SqlStorageValue[] = [];
564 const col = (c: string) => `${prefix}${c}`;
565 if (f.from_us) clauses.push(`${col(time)} >= ?`), args.push(f.from_us);
566 if (f.to_us) clauses.push(`${col(time)} <= ?`), args.push(f.to_us);
567 if (f.services?.length) clauses.push(`${col("service")} IN (${f.services.map(() => "?").join(",")})`), args.push(...f.services);
568 if (f.environments?.length) clauses.push(`${col("environment")} IN (${f.environments.map(() => "?").join(",")})`), args.push(...f.environments);
569 if (f.deployment_id) clauses.push(`${col("deployment_id")} = ?`), args.push(f.deployment_id);
570 if (f.source) clauses.push(`${col("source")} = ?`), args.push(f.source);
571 return { clauses, args };
572 }
573
574 /** `key=value` pairs: a column the table has, or an attribute. */
575 pairs(pairs: [string, string][], columns: string[], clauses: string[], args: SqlStorageValue[], attributes: string) {
576 for (const [key, value] of pairs) {
577 if (columns.includes(key)) {
578 clauses.push(`CAST(${attributes.includes(".") ? `${attributes.split(".")[0]}.` : ""}${key} AS TEXT) = ?`);
579 args.push(value);
580 } else if (attributes) {
581 clauses.push(`CAST(json_extract(${attributes}, ?) AS TEXT) = ?`);
582 args.push(`$."${key.replace(/"/g, "")}"`, value);
583 }
584 }
585 }
586
587 requestOf(r: Row): TelemetryRequest {
588 return {
589 id: String(r.id),
590 time_us: Number(r.time_us),
591 duration_us: Number(r.duration_us),
592 trigger: String(r.trigger),
593 method: strOrNull(r.method),
594 url: strOrNull(r.url),
595 route: strOrNull(r.route),
596 status: numOrNull(r.status),
597 outcome: String(r.outcome),
598 cpu_ms: numOrNull(r.cpu_ms),
599 region: strOrNull(r.region),
600 country: strOrNull(r.country),
601 trace_id: strOrNull(r.trace_id),
602 log_count: Number(r.log_count ?? 0),
603 error: r.error_name ? { name: String(r.error_name), message: String(r.error_message ?? "") } : null,
604 service: String(r.service ?? ""),
605 environment: strOrNull(r.environment),
606 deployment_id: strOrNull(r.deployment_id),
607 commit_sha: strOrNull(r.commit_sha),
608 source: r.source === "otlp" ? "otlp" : "cloudflare",
609 };
610 }
611
612 requestsByIds(ids: string[]): TelemetryRequest[] {
613 return this.rows(`SELECT * FROM requests WHERE id IN (${ids.map(() => "?").join(",")})`, ...ids).map((r) => this.requestOf(r));
614 }
615
616 logOf(r: Row): TelemetryLog {
617 const n = Number(r.severity_number);
618 return {
619 id: String(r.id),
620 time_us: Number(r.time_us),
621 severity: severityOf(n),
622 severity_number: n,
623 body: String(r.body),
624 trace_id: strOrNull(r.trace_id),
625 span_id: strOrNull(r.span_id),
626 request_id: strOrNull(r.request_id),
627 attributes: json<Attributes>(r.attributes, {}),
628 service: String(r.service ?? ""),
629 environment: strOrNull(r.environment),
630 deployment_id: strOrNull(r.deployment_id),
631 commit_sha: strOrNull(r.commit_sha),
632 source: r.source === "otlp" ? "otlp" : "cloudflare",
633 };
634 }
635
636 spanOf(r: Row): TelemetrySpan {
637 return {
638 trace_id: String(r.trace_id),
639 span_id: String(r.span_id),
640 parent_span_id: strOrNull(r.parent_span_id),
641 name: String(r.name),
642 kind: String(r.kind) as TelemetrySpan["kind"],
643 start_us: Number(r.start_us),
644 duration_us: Number(r.duration_us),
645 status: String(r.status) as TelemetrySpan["status"],
646 status_message: strOrNull(r.status_message),
647 attributes: json(r.attributes, {}),
648 resource: json(r.resource, {}),
649 events: json(r.events, []),
650 links: json(r.links, []),
651 service: String(r.service ?? ""),
652 environment: strOrNull(r.environment),
653 deployment_id: strOrNull(r.deployment_id),
654 commit_sha: strOrNull(r.commit_sha),
655 source: r.source === "otlp" ? "otlp" : "cloudflare",
656 };
657 }
658
659 requests(q: RequestQuery): Page<TelemetryRequest> {
660 const { clauses, args } = this.filter(q, "time_us");
661 const { words, pairs } = parseSearch(q.text);
662 this.pairs(pairs, ["method", "route", "status", "outcome", "region", "country", "trace_id", "service", "environment", "deployment_id", "commit_sha", "trigger"], clauses, args, "");
663 // Software outside g1t.page often reports a route and no address: both are searched.
664 for (const w of words) clauses.push("(url LIKE ? OR route LIKE ? OR error_message LIKE ? OR error_name LIKE ?)"), args.push(`%${w}%`, `%${w}%`, `%${w}%`, `%${w}%`);
665 if (q.status?.length) {
666 const parts: string[] = [];
667 for (const s of q.status) {
668 const cls = /^([1-5])xx$/i.exec(s);
669 if (cls) parts.push("(status >= ? AND status < ?)"), args.push(Number(cls[1]) * 100, Number(cls[1]) * 100 + 100);
670 else if (/^\d{3}$/.test(s)) parts.push("status = ?"), args.push(Number(s));
671 }
672 if (parts.length) clauses.push(`(${parts.join(" OR ")})`);
673 }
674 if (q.errors_only) clauses.push(ERROR_SQL);
675 if (q.route) clauses.push("route = ?"), args.push(q.route);
676 if (q.min_duration_ms) clauses.push("duration_us >= ?"), args.push(q.min_duration_ms * 1000);
677 const after = readCursor(q.cursor);
678 if (after) clauses.push("(time_us < ? OR (time_us = ? AND id < ?))"), args.push(after.time, after.time, after.id);
679 const limit = limitOf(q.limit);
680 const rows = this.rows(
681 `SELECT * FROM requests ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY time_us DESC, id DESC LIMIT ?`,
682 ...args,
683 limit + 1,
684 );
685 const items = rows.slice(0, limit).map((r) => this.requestOf(r));
686 const last = items[items.length - 1];
687 return { items, next: rows.length > limit && last ? cursorOf(last.time_us, last.id) : null };
688 }
689
690 request(id: string): { request: TelemetryRequest; logs: TelemetryLog[] } | null {
691 const row = this.rows("SELECT * FROM requests WHERE id = ?", id)[0];
692 if (!row) return null;
693 const logs = this.rows("SELECT * FROM logs WHERE request_id = ? ORDER BY time_us, rowid LIMIT 1000", id).map((r) => this.logOf(r));
694 return { request: this.requestOf(row), logs };
695 }
696
697 logs(q: LogQuery): Page<TelemetryLog> {
698 const { clauses, args } = this.filter(q, "time_us", "logs.");
699 const { words, pairs } = parseSearch(q.text);
700 const isLevel = ([k]: [string, string]) => k === "level" || k === "severity";
701 this.pairs(pairs.filter((p) => !isLevel(p)), ["trace_id", "span_id", "request_id", "service", "environment", "deployment_id", "commit_sha"], clauses, args, "logs.attributes");
702 const levels = [...(q.severities ?? []), ...pairs.filter(isLevel).map(([, v]) => v.toLowerCase() as LogSeverity)];
703 const ranges = levels.filter((l) => SEVERITY_FLOOR[l]).map((l) => SEVERITY_FLOOR[l]);
704 if (ranges.length) {
705 clauses.push(`(${ranges.map(() => "logs.severity_number BETWEEN ? AND ?").join(" OR ")})`);
706 for (const [lo, hi] of ranges) args.push(lo, hi);
707 }
708 if (q.trace_id) clauses.push("logs.trace_id = ?"), args.push(q.trace_id);
709 if (q.request_id) clauses.push("logs.request_id = ?"), args.push(q.request_id);
710 let from = "logs";
711 if (words.length && this.fts) {
712 from = "logs JOIN logs_fts ON logs_fts.rowid = logs.rowid";
713 clauses.push("logs_fts MATCH ?");
714 args.push(ftsQuery(words));
715 } else {
716 for (const w of words) clauses.push("logs.body LIKE ?"), args.push(`%${w}%`);
717 }
718 const after = readCursor(q.cursor);
719 if (after) clauses.push("(logs.time_us < ? OR (logs.time_us = ? AND logs.id < ?))"), args.push(after.time, after.time, after.id);
720 const limit = limitOf(q.limit, 200, 1000);
721 const rows = this.rows(
722 `SELECT logs.* FROM ${from} ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY logs.time_us DESC, logs.id DESC LIMIT ?`,
723 ...args,
724 limit + 1,
725 );
726 const items = rows.slice(0, limit).map((r) => this.logOf(r));
727 const last = items[items.length - 1];
728 return { items, next: rows.length > limit && last ? cursorOf(last.time_us, last.id) : null };
729 }
730
731 traces(q: TraceQuery): Page<TraceSummary> {
732 const { clauses, args } = this.filter(q, "start_us");
733 const { words, pairs } = parseSearch(q.text);
734 this.pairs(pairs, ["name", "service", "environment", "deployment_id", "kind", "status"], clauses, args, "attributes");
735 for (const w of words) clauses.push("name LIKE ?"), args.push(`%${w}%`);
736 const after = readCursor(q.cursor);
737 const having: string[] = [];
738 const havingArgs: SqlStorageValue[] = [];
739 if (q.errors_only) having.push("SUM(status = 'error') > 0");
740 if (q.min_duration_ms) having.push("MAX(start_us + duration_us) - MIN(start_us) >= ?"), havingArgs.push(q.min_duration_ms * 1000);
741 if (after) having.push("(MIN(start_us) < ? OR (MIN(start_us) = ? AND trace_id < ?))"), havingArgs.push(after.time, after.time, after.id);
742 const limit = limitOf(q.limit, 50, 200);
743 const rows = this.rows(
744 `SELECT trace_id, MIN(start_us) AS start_us, MAX(start_us + duration_us) - MIN(start_us) AS duration_us, COUNT(*) AS span_count,
745 SUM(status = 'error') AS error_count, GROUP_CONCAT(DISTINCT service) AS services
746 FROM spans ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
747 GROUP BY trace_id ${having.length ? `HAVING ${having.join(" AND ")}` : ""}
748 ORDER BY start_us DESC, trace_id DESC LIMIT ?`,
749 ...args,
750 ...havingArgs,
751 limit + 1,
752 );
753 const items: TraceSummary[] = rows.slice(0, limit).map((r) => {
754 const traceId = String(r.trace_id);
755 // The trace's request on g1t.page (its true root), else its earliest span.
756 const request = this.rows("SELECT * FROM requests WHERE trace_id = ? ORDER BY time_us LIMIT 1", traceId)[0];
757 const first = this.rows("SELECT * FROM spans WHERE trace_id = ? ORDER BY parent_span_id IS NOT NULL, start_us LIMIT 1", traceId)[0];
758 const req = request ? this.requestOf(request) : null;
759 const start = Math.min(Number(r.start_us), req?.time_us ?? Infinity);
760 const end = Math.max(Number(r.start_us) + Number(r.duration_us), req ? req.time_us + req.duration_us : 0);
761 return {
762 trace_id: traceId,
763 name: req ? [req.method, req.route].filter(Boolean).join(" ") : String(first?.name ?? ""),
764 start_us: start,
765 duration_us: end - start,
766 span_count: Number(r.span_count) + (req ? 1 : 0),
767 error_count: Number(r.error_count) + (req?.error ? 1 : 0),
768 services: [...new Set([...(req ? [req.service] : []), ...String(r.services ?? "").split(",").filter(Boolean)])],
769 status: req?.status ?? numOrNull(json<Attributes>(first?.attributes ?? null, {})["http.response.status_code"] as SqlStorageValue),
770 service: req?.service ?? String(first?.service ?? ""),
771 environment: req?.environment ?? strOrNull(first?.environment ?? null),
772 deployment_id: req?.deployment_id ?? strOrNull(first?.deployment_id ?? null),
773 commit_sha: req?.commit_sha ?? strOrNull(first?.commit_sha ?? null),
774 source: req?.source ?? (first?.source === "cloudflare" ? "cloudflare" : "otlp"),
775 };
776 });
777 const last = rows[Math.min(rows.length, limit) - 1];
778 return { items, next: rows.length > limit && last ? cursorOf(Number(last.start_us), String(last.trace_id)) : null };
779 }
780
781 /**
782 * A trace: its spans, with each request on g1t.page in it as the server
783 * span it was (the parent the app's own spans name), and every log
784 * written under it.
785 */
786 trace(traceId: string): TraceDetail | null {
787 const spans = this.rows("SELECT * FROM spans WHERE trace_id = ? ORDER BY start_us LIMIT 5000", traceId).map((r) => this.spanOf(r));
788 const requests = this.rows("SELECT * FROM requests WHERE trace_id = ? ORDER BY time_us LIMIT 100", traceId).map((r) => this.requestOf(r));
789 if (!spans.length && !requests.length) return null;
790 const have = new Set(spans.map((s) => s.span_id));
791 for (const r of requests) {
792 if (have.has(r.id)) continue;
793 spans.unshift({
794 trace_id: traceId,
795 span_id: r.id,
796 parent_span_id: null,
797 name: [r.method, r.route].filter(Boolean).join(" ") || r.trigger,
798 kind: "server",
799 start_us: r.time_us,
800 duration_us: r.duration_us,
801 status: r.error || (r.status ?? 0) >= 500 ? "error" : "unset",
802 status_message: r.error?.message ?? null,
803 attributes: {
804 ...(r.method ? { "http.request.method": r.method } : {}),
805 ...(r.url ? { "url.full": `https://${r.url}` } : {}),
806 ...(r.status !== null ? { "http.response.status_code": r.status } : {}),
807 ...(r.cpu_ms !== null ? { "cloudflare.cpu_time_ms": r.cpu_ms } : {}),
808 ...(r.region ? { "cloudflare.colo": r.region } : {}),
809 "cloudflare.outcome": r.outcome,
810 "g1t.request.id": r.id,
811 },
812 resource: { "service.name": r.service },
813 events: [],
814 links: [],
815 service: r.service,
816 environment: r.environment,
817 deployment_id: r.deployment_id,
818 commit_sha: r.commit_sha,
819 source: r.source,
820 });
821 }
822 const ids = requests.map((r) => r.id);
823 const logs = this.rows(
824 `SELECT * FROM logs WHERE trace_id = ? ${ids.length ? `OR request_id IN (${ids.map(() => "?").join(",")})` : ""} ORDER BY time_us LIMIT 2000`,
825 traceId,
826 ...ids,
827 ).map((r) => this.logOf(r));
828 return { trace_id: traceId, spans, logs };
829 }
830
831 errors(f: TelemetryFilter & { state?: ErrorGroup["state"] | null }): ErrorGroup[] {
832 const { clauses, args } = this.filter({ ...f, deployment_id: null, source: null }, "last_us");
833 if (f.state) clauses.push("state = ?"), args.push(f.state);
834 return this.rows(`SELECT * FROM error_groups ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY last_us DESC LIMIT 200`, ...args).map(errorOf);
835 }
836
837 setError(fp: string, change: { state?: ErrorGroup["state"]; issue_number?: number | null }): ErrorGroup | null {
838 if (change.state && !["open", "resolved", "ignored"].includes(change.state)) throw new Error("State is open, resolved or ignored.");
839 if (change.state) this.sql.exec("UPDATE error_groups SET state = ? WHERE fingerprint = ?", change.state, fp);
840 if (change.issue_number !== undefined) this.sql.exec("UPDATE error_groups SET issue_number = ? WHERE fingerprint = ?", change.issue_number, fp);
841 const row = this.rows("SELECT * FROM error_groups WHERE fingerprint = ?", fp)[0];
842 return row ? errorOf(row) : null;
843 }
844
845 metrics(): MetricInfo[] {
846 return this.rows("SELECT * FROM metric_names ORDER BY name").map((r) => ({
847 name: String(r.name),
848 kind: String(r.kind) as MetricInfo["kind"],
849 unit: String(r.unit),
850 description: String(r.description),
851 services: json(r.services, []),
852 last_us: Number(r.last_us),
853 }));
854 }
855
856 /**
857 * A metric over time. Gauges average (or take the min or max) in each
858 * step; sums add (a cumulative sum becomes the increase in the step);
859 * histograms give their mean, or a percentile from their buckets.
860 */
861 metric(q: TelemetryFilter & { name: string; aggregate?: string | null; group_by?: string | null; step_s?: number | null }): MetricSeries {
862 const now = Date.now() * 1000;
863 const from = q.from_us ?? now - DAY_US;
864 const to = q.to_us ?? now;
865 const step = q.step_s ?? stepFor(to - from);
866 const { clauses, args } = this.filter({ ...q, from_us: from, to_us: to }, "time_us");
867 clauses.push("name = ?");
868 args.push(q.name);
869 const info = this.rows("SELECT unit, kind FROM metric_names WHERE name = ?", q.name)[0];
870 const rows = this.rows(`SELECT * FROM metric_points WHERE ${clauses.join(" AND ")} ORDER BY time_us LIMIT 200000`, ...args);
871 const stepUs = step * 1_000_000;
872 const groups = new Map<string | null, Map<number, number[]>>();
873 const lastCumulative = new Map<string, number>();
874 const aggregate = q.aggregate ?? (info?.kind === "sum" ? "sum" : "avg");
875 for (const r of rows) {
876 const attrs = json<Attributes>(r.attributes, {});
877 const group = q.group_by ? String(q.group_by === "service" ? r.service : (attrs[q.group_by] ?? "")) : null;
878 const bucket = Math.floor(Number(r.time_us) / stepUs) * stepUs;
879 let value: number | null = numOrNull(r.value);
880 if (r.kind === "sum" && r.monotonic === 1) {
881 // Each series of a cumulative sum is told apart by its attributes.
882 const key = `${r.service}\u0000${String(r.attributes)}`;
883 const before = lastCumulative.get(key);
884 lastCumulative.set(key, value ?? 0);
885 value = before === undefined || (value ?? 0) < before ? null : (value ?? 0) - before;
886 }
887 if (r.kind === "histogram" && /^p\d+$/.test(aggregate) && r.counts && r.bounds) {
888 value = histogramQuantile(json(r.bounds, []), json(r.counts, []), Number(aggregate.slice(1)) / 100);
889 }
890 if (value === null) continue;
891 let g = groups.get(group);
892 if (!g) groups.set(group, (g = new Map()));
893 g.set(bucket, [...(g.get(bucket) ?? []), value]);
894 }
895 const reduce = (values: number[]): number => {
896 switch (aggregate) {
897 case "sum":
898 return values.reduce((a, b) => a + b, 0);
899 case "min":
900 return Math.min(...values);
901 case "max":
902 return Math.max(...values);
903 default:
904 return values.reduce((a, b) => a + b, 0) / values.length;
905 }
906 };
907 const start = Math.floor(from / stepUs) * stepUs;
908 return {
909 name: q.name,
910 unit: String(info?.unit ?? ""),
911 step_s: step,
912 series: [...groups.entries()].slice(0, 20).map(([group, points]) => {
913 const out: { time_us: number; value: number | null }[] = [];
914 for (let t = start; t <= to; t += stepUs) {
915 const values = points.get(t);
916 out.push({ time_us: t, value: values ? reduce(values) : null });
917 }
918 return { group, points: out };
919 }),
920 };
921 }
922
923 facets(): { services: string[]; environments: string[]; attribute_keys: string[] } {
924 const since = Date.now() * 1000 - 7 * DAY_US;
925 const services = this.rows(
926 `SELECT service FROM rollups WHERE minute >= ? AND service != '' GROUP BY service
927 UNION SELECT service FROM spans WHERE start_us >= ? AND service != '' GROUP BY service
928 UNION SELECT service FROM logs WHERE time_us >= ? AND service != '' GROUP BY service`,
929 Math.floor(since / MINUTE_US), since, since,
930 ).map((r) => String(r.service));
931 const environments = this.rows(
932 `SELECT environment FROM rollups WHERE minute >= ? AND environment != '' GROUP BY environment
933 UNION SELECT environment FROM logs WHERE time_us >= ? AND environment IS NOT NULL GROUP BY environment`,
934 Math.floor(since / MINUTE_US), since,
935 ).map((r) => String(r.environment));
936 const keys = new Set<string>();
937 for (const r of this.rows("SELECT attributes FROM logs ORDER BY time_us DESC LIMIT 500")) for (const k of Object.keys(json(r.attributes, {}))) keys.add(k);
938 for (const r of this.rows("SELECT attributes FROM spans ORDER BY start_us DESC LIMIT 500")) for (const k of Object.keys(json(r.attributes, {}))) keys.add(k);
939 return { services: [...new Set(services)].sort(), environments: [...new Set(environments)].sort(), attribute_keys: [...keys].sort().slice(0, 200) };
940 }
941
942 usage(): TelemetryUsage {
943 const m = month();
944 const row = this.rows("SELECT * FROM usage WHERE month = ?", m)[0];
945 return {
946 month: m,
947 ingested_bytes: Number(row?.ingested_bytes ?? 0),
948 requests: Number(row?.requests ?? 0),
949 logs: Number(row?.logs ?? 0),
950 spans: Number(row?.spans ?? 0),
951 metric_points: Number(row?.metric_points ?? 0),
952 stored_bytes: this.state.storage.sql.databaseSize,
953 };
954 }
955
956 overview(f: TelemetryFilter & { step_s?: number | null }): TelemetryOverview {
957 const now = Date.now() * 1000;
958 const to = f.to_us ?? now;
959 const from = f.from_us ?? to - DAY_US;
960 const step = f.step_s && STEPS.includes(f.step_s) ? f.step_s : stepFor(to - from);
961 const read = (lo: number, hi: number, detail: boolean) => {
962 const { clauses, args } = this.filter({ ...f, from_us: null, to_us: null }, "minute");
963 clauses.push("minute >= ?", "minute <= ?", detail ? "route != '*'" : "route = '*'");
964 args.push(Math.floor(lo / MINUTE_US), Math.floor(hi / MINUTE_US));
965 const counted = this.rows(`SELECT * FROM rollups WHERE ${clauses.join(" AND ")}`, ...args);
966 // The newest requests, not counted yet, are counted here as they stand.
967 const { clauses: pc, args: pa } = this.filter({ ...f, from_us: lo, to_us: hi }, "time_us");
968 pc.push("rolled = 0");
969 const pending = aggregate(this.rows(`SELECT * FROM requests WHERE ${pc.join(" AND ")} LIMIT 20000`, ...pa))
970 .filter((a) => (detail ? a.route !== "*" : a.route === "*")) as unknown as Row[];
971 return [...counted, ...pending];
972 };
973 const totalsOf = (rows: Row[]): TelemetryTotals => {
974 let requests = 0;
975 let errors = 0;
976 const latency = emptyHistogram();
977 const cpu = emptyHistogram();
978 for (const r of rows) {
979 requests += Number(r.requests);
980 errors += Number(r.errors);
981 addInto(latency, decode(String(r.latency)));
982 addInto(cpu, decode(String(r.cpu)));
983 }
984 return { requests, errors, error_rate: requests ? errors / requests : 0, latency: percentiles(latency), cpu: percentiles(cpu) };
985 };
986
987 const rows = read(from, to, false);
988 const previous = read(from - (to - from), from, false);
989 const stepMinutes = step / 60;
990 const points = new Map<number, { rows: Row[] }>();
991 for (const r of rows) {
992 const bucket = Math.floor(Number(r.minute) / stepMinutes) * stepMinutes;
993 const p = points.get(bucket) ?? { rows: [] };
994 p.rows.push(r);
995 points.set(bucket, p);
996 }
997 const series = [];
998 for (let m = Math.floor(from / MINUTE_US / stepMinutes) * stepMinutes; m * MINUTE_US <= to; m += stepMinutes) {
999 const p = points.get(m)?.rows ?? [];
1000 const t = totalsOf(p);
1001 const s = (k: string) => p.reduce((n, r) => n + Number(r[k]), 0);
1002 series.push({ time_us: m * MINUTE_US, requests: t.requests, errors: t.errors, status_2xx: s("s2"), status_3xx: s("s3"), status_4xx: s("s4"), status_5xx: s("s5"), latency: t.latency, cpu: t.cpu });
1003 }
1004
1005 const routes = new Map<string, Row[]>();
1006 for (const r of read(from, to, true)) {
1007 const key = `${r.method}\u0000${r.route}`;
1008 routes.set(key, [...(routes.get(key) ?? []), r]);
1009 }
1010 const deployments = new Map<string, Row[]>();
1011 const services = new Map<string, Row[]>();
1012 for (const r of rows) {
1013 if (r.deployment_id) deployments.set(String(r.deployment_id), [...(deployments.get(String(r.deployment_id)) ?? []), r]);
1014 const sk = `${r.service}\u0000${r.source}`;
1015 services.set(sk, [...(services.get(sk) ?? []), r]);
1016 }
1017 const { clauses: rc, args: ra } = this.filter({ ...f, from_us: from, to_us: to }, "time_us");
1018 const regions = this.rows(
1019 `SELECT region, COUNT(*) AS n FROM requests WHERE region IS NOT NULL ${rc.length ? `AND ${rc.join(" AND ")}` : ""} GROUP BY region ORDER BY n DESC LIMIT 20`,
1020 ...ra,
1021 );
1022
1023 return {
1024 from_us: from,
1025 to_us: to,
1026 step_s: step,
1027 totals: totalsOf(rows),
1028 previous: totalsOf(previous),
1029 points: series,
1030 routes: [...routes.values()]
1031 .map((list) => {
1032 const t = totalsOf(list);
1033 return { route: String(list[0].route), method: strOrNull(list[0].method), requests: t.requests, errors: t.errors, latency: t.latency };
1034 })
1035 .sort((a, b) => b.requests - a.requests)
1036 .slice(0, 50),
1037 deployments: [...deployments.entries()]
1038 .map(([id, list]) => {
1039 const t = totalsOf(list);
1040 return {
1041 deployment_id: id,
1042 environment: strOrNull(list[0].environment),
1043 commit_sha: strOrNull(list.find((r) => r.commit_sha)?.commit_sha ?? null),
1044 first_us: Math.min(...list.map((r) => Number(r.minute))) * MINUTE_US,
1045 requests: t.requests,
1046 errors: t.errors,
1047 latency: t.latency,
1048 };
1049 })
1050 .sort((a, b) => b.first_us - a.first_us)
1051 .slice(0, 20),
1052 services: [...services.values()]
1053 .map((list) => ({
1054 service: String(list[0].service),
1055 source: list[0].source === "otlp" ? ("otlp" as const) : ("cloudflare" as const),
1056 requests: list.reduce((n, r) => n + Number(r.requests), 0),
1057 errors: list.reduce((n, r) => n + Number(r.errors), 0),
1058 last_us: Math.max(...list.map((r) => Number(r.minute))) * MINUTE_US,
1059 }))
1060 .sort((a, b) => b.requests - a.requests),
1061 regions: regions.map((r) => ({ region: String(r.region), requests: Number(r.n) })),
1062 };
1063 }
1064}
1065
1066/** A request that failed: a 5xx, an outcome other than ok, or an error the app recorded. */
1067const ERROR_SQL = "(status >= 500 OR outcome NOT IN ('ok', 'canceled', 'responseStreamDisconnected') OR error_name IS NOT NULL)";
1068
1069function isError(status: number | null, outcome: string, errorName: SqlStorageValue): boolean {
1070 return (status !== null && status >= 500) || !["ok", "canceled", "responseStreamDisconnected"].includes(outcome) || (errorName !== null && errorName !== undefined && errorName !== "");
1071}
1072
1073function errorOf(r: Row): ErrorGroup {
1074 return {
1075 fingerprint: String(r.fingerprint),
1076 name: String(r.name),
1077 message: String(r.message),
1078 stack: strOrNull(r.stack),
1079 service: String(r.service),
1080 environment: strOrNull(r.environment),
1081 first_us: Number(r.first_us),
1082 last_us: Number(r.last_us),
1083 count: Number(r.count),
1084 first_deployment_id: strOrNull(r.first_deployment_id),
1085 last_request_id: strOrNull(r.last_request_id),
1086 last_trace_id: strOrNull(r.last_trace_id),
1087 state: String(r.state) as ErrorGroup["state"],
1088 issue_number: numOrNull(r.issue_number),
1089 };
1090}
1091
1092/** The step that gives a chart about a hundred points over `rangeUs`. */
1093export function stepFor(rangeUs: number): number {
1094 const seconds = rangeUs / 1_000_000;
1095 return STEPS.find((s) => seconds / s <= 150) ?? STEPS[STEPS.length - 1];
1096}
1097
1098/** A percentile from an OTLP histogram's explicit buckets. */
1099export function histogramQuantile(bounds: number[], counts: number[], q: number): number | null {
1100 const total = counts.reduce((a, b) => a + b, 0);
1101 if (!total) return null;
1102 const rank = q * total;
1103 let seen = 0;
1104 for (let i = 0; i < counts.length; i++) {
1105 if (seen + counts[i] >= rank) {
1106 const low = i === 0 ? 0 : bounds[i - 1];
1107 const high = i < bounds.length ? bounds[i] : bounds[bounds.length - 1] ?? low;
1108 return low + (high - low) * (counts[i] ? (rank - seen) / counts[i] : 0);
1109 }
1110 seen += counts[i];
1111 }
1112 return bounds[bounds.length - 1] ?? null;
1113}
1114
1115/** Requests' counts by minute, service, environment, deployment and route, with all routes together as `*`. */
1116export type Rollup = {
1117 minute: number;
1118 service: string;
1119 environment: string;
1120 deployment_id: string;
1121 commit_sha: string | null;
1122 source: string;
1123 method: string;
1124 route: string;
1125 requests: number;
1126 errors: number;
1127 s2: number;
1128 s3: number;
1129 s4: number;
1130 s5: number;
1131 latency: string;
1132 cpu: string;
1133};
1134
1135export function aggregate(rows: Row[]): Rollup[] {
1136 const acc = new Map<string, Omit<Rollup, "latency" | "cpu"> & { latency: number[]; cpu: number[] }>();
1137 const add = (key: Pick<Rollup, "minute" | "service" | "environment" | "deployment_id" | "commit_sha" | "source" | "method" | "route">, r: Row) => {
1138 const k = [key.minute, key.service, key.environment, key.deployment_id, key.method, key.route].join("\u0000");
1139 let a = acc.get(k);
1140 if (!a) acc.set(k, (a = { ...key, requests: 0, errors: 0, s2: 0, s3: 0, s4: 0, s5: 0, latency: emptyHistogram(), cpu: emptyHistogram() }));
1141 a.requests++;
1142 const status = numOrNull(r.status);
1143 if (isError(status, String(r.outcome), r.error_name)) a.errors++;
1144 if (status !== null && status >= 200 && status < 600) a[`s${Math.floor(status / 100)}` as "s2" | "s3" | "s4" | "s5"]++;
1145 a.latency[bucketOf(Number(r.duration_us) / 1000)]++;
1146 if (r.cpu_ms !== null && r.cpu_ms !== undefined) a.cpu[bucketOf(Number(r.cpu_ms))]++;
1147 };
1148 for (const r of rows) {
1149 const base = {
1150 minute: Math.floor(Number(r.time_us) / MINUTE_US),
1151 service: String(r.service ?? ""),
1152 environment: String(r.environment ?? ""),
1153 deployment_id: String(r.deployment_id ?? ""),
1154 commit_sha: strOrNull(r.commit_sha),
1155 source: String(r.source),
1156 };
1157 add({ ...base, method: "*", route: "*" }, r);
1158 add({ ...base, method: String(r.method ?? ""), route: String(r.route ?? "") }, r);
1159 }
1160 return [...acc.values()].map((a) => ({ ...a, latency: encode(a.latency), cpu: encode(a.cpu) }));
1161}