From edec2aee650ef9f96d8685a17fbed870313144bb Mon Sep 17 00:00:00 2001 From: DawidWraga Date: Wed, 3 Jun 2026 21:40:01 +0100 Subject: [PATCH 01/72] feat(logs-server): add runtime column (browser|node|edge) to telemetry rows --- .../__tests__/bun-only/clean.test.ts | 1 + .../logs-server/__tests__/bun-only/db.test.ts | 63 +++++++++++++++++++ .../logs-server/__tests__/envelope.test.ts | 46 ++++++++++++++ packages/logs-server/src/db.ts | 24 ++++++- packages/logs-server/src/envelope.ts | 15 +++++ 5 files changed, 148 insertions(+), 1 deletion(-) diff --git a/packages/logs-server/__tests__/bun-only/clean.test.ts b/packages/logs-server/__tests__/bun-only/clean.test.ts index a010078..f6a6fad 100644 --- a/packages/logs-server/__tests__/bun-only/clean.test.ts +++ b/packages/logs-server/__tests__/bun-only/clean.test.ts @@ -43,6 +43,7 @@ function row(over: Partial): LogRow { attrs: null, tag: null, duration_ms: null, + runtime: null, ...over, }; } diff --git a/packages/logs-server/__tests__/bun-only/db.test.ts b/packages/logs-server/__tests__/bun-only/db.test.ts index 53fd3a5..e7bce1c 100644 --- a/packages/logs-server/__tests__/bun-only/db.test.ts +++ b/packages/logs-server/__tests__/bun-only/db.test.ts @@ -24,6 +24,7 @@ function row(over: Partial): LogRow { attrs: null, tag: null, duration_ms: null, + runtime: null, ...over, }; } @@ -188,6 +189,68 @@ test('openDb migrates pre-2.7 schema: adds kind/duration_ms, existing rows → k } }); +test('openDb stores runtime column; it round-trips and absence is NULL', () => { + const db = openDb(':memory:'); + const cols = db.query('PRAGMA table_info(logs)').all() as { name: string }[]; + expect(cols.some((c) => c.name === 'runtime')).toBe(true); + insertLogs(db, [ + row({ msg: 'browser-row', runtime: 'browser' }), + row({ msg: 'node-row', runtime: 'node' }), + row({ msg: 'unstamped' }), // runtime defaults to null in row() + ]); + const got = db + .query('SELECT msg, runtime FROM logs ORDER BY id') + .all() as { msg: string; runtime: unknown }[]; + expect(got).toEqual([ + { msg: 'browser-row', runtime: 'browser' }, + { msg: 'node-row', runtime: 'node' }, + { msg: 'unstamped', runtime: null }, + ]); +}); + +test('openDb migrates pre-2.9 schema: adds runtime column, existing rows → NULL', () => { + // Hand-build a 2.8-era table (kind/duration_ms present, no runtime). The + // migration must add the column; existing rows stay NULL. + const { Database } = require('bun:sqlite'); + const legacy = new Database(':memory:'); + legacy.exec(`CREATE TABLE logs ( + id INTEGER PRIMARY KEY, ts REAL, recv_ts REAL, kind TEXT DEFAULT 'log', + project TEXT, service TEXT, run_id TEXT, trace_id TEXT, span_id TEXT, + level TEXT, severity_number INTEGER, logger TEXT, msg TEXT, data TEXT, + attrs TEXT, tag TEXT, duration_ms REAL + )`); + legacy.exec(`INSERT INTO logs (ts, recv_ts, kind, project, msg, data) VALUES + (1, 1, 'log', 'p', 'old-one', '{"body":"x"}')`); + const tmp = `/tmp/migrate-runtime-${process.pid}-${Date.now()}.db`; + legacy.exec(`VACUUM INTO '${tmp}'`); + legacy.close(); + + const db = openDb(tmp); + const cols = db.query('PRAGMA table_info(logs)').all() as { name: string }[]; + expect(cols.some((c) => c.name === 'runtime')).toBe(true); + const old = db.query("SELECT runtime FROM logs WHERE msg = 'old-one'").get() as { + runtime: unknown; + }; + expect(old.runtime).toBeNull(); + + // Idempotent — a second open is a no-op, and a freshly stamped row coexists. + db.close(); + const db2 = openDb(tmp); + insertLogs(db2, [row({ msg: 'new-row', runtime: 'edge', ts: 2, recv_ts: 2 })]); + const got = db2.query("SELECT runtime FROM logs WHERE msg = 'new-row'").get() as { + runtime: unknown; + }; + expect(got.runtime).toBe('edge'); + db2.close(); + for (const p of [tmp, `${tmp}-wal`, `${tmp}-shm`]) { + try { + require('node:fs').rmSync(p, { force: true }); + } catch { + /* temp file still locked — leave it for the OS */ + } + } +}); + test('insertLogs batch-inserts and rows are retrievable with autoincrement id', () => { const db = openDb(':memory:'); const n = insertLogs(db, [row({ msg: 'a' }), row({ msg: 'b' }), row({ msg: 'c' })]); diff --git a/packages/logs-server/__tests__/envelope.test.ts b/packages/logs-server/__tests__/envelope.test.ts index f4ecfc1..54f7a8c 100644 --- a/packages/logs-server/__tests__/envelope.test.ts +++ b/packages/logs-server/__tests__/envelope.test.ts @@ -497,6 +497,52 @@ test('back-compat: a mixed envelope (log item + transaction item) yields both ki expect(logRow.duration_ms).toBeNull(); }); +//* MARK: Runtime + +// The `runtime` column records which Next.js runtime (browser|node|edge) +// emitted the row, stamped at the Sentry config source. Logs read it from +// `attributes.runtime`; spans from the span's plain `data.runtime`; events from +// `tags.runtime` (falling back to Sentry's native `contexts.runtime.name`). + +test('log runtime comes from attributes.runtime', () => { + const { rows } = parseEnvelope(envelope([log({ attributes: { runtime: a('browser') } })])); + expect(rows[0].runtime).toBe('browser'); +}); + +test('log runtime is null when attributes.runtime is absent', () => { + const { rows } = parseEnvelope(envelope([log()])); + expect(rows[0].runtime).toBeNull(); +}); + +test('span runtime comes from the span/trace plain data.runtime, root + children', () => { + const tx = transaction(); + (tx.contexts.trace.data as Record).runtime = 'node'; + (tx.spans[0].data as Record).runtime = 'node'; + (tx.spans[1].data as Record).runtime = 'node'; + const { rows } = parseEnvelope(txEnvelope(tx)); + expect(rows.every((r) => r.runtime === 'node')).toBe(true); +}); + +test('span runtime is null when data.runtime is absent', () => { + const { rows } = parseEnvelope(txEnvelope(transaction())); + expect(rows.every((r) => r.runtime === null)).toBe(true); +}); + +test('event runtime prefers tags.runtime', () => { + const ev = exceptionEvent({ tags: { runtime: 'edge' } }); + expect(parseEnvelope(eventEnvelope(ev)).rows[0].runtime).toBe('edge'); +}); + +test('event runtime falls back to contexts.runtime.name when no tag', () => { + // exceptionEvent() carries contexts.runtime = { name: 'node', ... } + expect(parseEnvelope(eventEnvelope(exceptionEvent())).rows[0].runtime).toBe('node'); +}); + +test('event runtime is null when neither tags.runtime nor contexts.runtime present', () => { + const bare = { event_id: 'deadbeef', level: 'info', timestamp: 1.0 }; + expect(parseEnvelope(eventEnvelope(bare)).rows[0].runtime).toBeNull(); +}); + //* MARK: Events (exceptions) // Real error/message events. Both fixtures below are the verbatim payloads diff --git a/packages/logs-server/src/db.ts b/packages/logs-server/src/db.ts index 3a8d2c6..f372a8c 100644 --- a/packages/logs-server/src/db.ts +++ b/packages/logs-server/src/db.ts @@ -22,6 +22,7 @@ export type LogRow = { attrs: string | null; // flat key→value JSON (NULL when none). Spans add op/status/parent_span_id/description/duration_ms. tag: string | null; // diag.tag attribution (optional) duration_ms: number | null; // span headline metric ((timestamp - start_timestamp)*1000); NULL for logs + runtime: string | null; // emitting Next.js runtime (browser|node|edge), stamped at the Sentry config; NULL when absent }; const COLS: (keyof LogRow)[] = [ @@ -41,6 +42,7 @@ const COLS: (keyof LogRow)[] = [ 'attrs', 'tag', 'duration_ms', + 'runtime', ]; // Migrate pre-2.2 schemas: add the `attrs` column if missing, backfill from @@ -102,6 +104,24 @@ function migrateKindColumns(db: Database): void { } } +// Migrate pre-2.9 schemas: add the free-text `runtime` column if missing. It +// records which Next.js runtime (browser|node|edge) emitted the row, stamped at +// the Sentry config source. Legacy rows stay NULL (the runtime split didn't +// exist when they were written). Wrapped in BEGIN IMMEDIATE so partial state +// can't leak; idempotent (guarded by the table_info check). +function migrateRuntimeColumn(db: Database): void { + const cols = db.query('PRAGMA table_info(logs)').all() as { name: string }[]; + if (cols.some((c) => c.name === 'runtime')) return; + db.exec('BEGIN IMMEDIATE'); + try { + db.exec('ALTER TABLE logs ADD COLUMN runtime TEXT'); + db.exec('COMMIT'); + } catch (err) { + db.exec('ROLLBACK'); + throw err; + } +} + export function openDb(path: string): Database { const db = new Database(path); db.exec('PRAGMA journal_mode = WAL'); // concurrent hammer-ingest + query @@ -123,7 +143,8 @@ export function openDb(path: string): Database { data TEXT, attrs TEXT, tag TEXT, - duration_ms REAL + duration_ms REAL, + runtime TEXT )`); db.exec( `CREATE INDEX IF NOT EXISTS idx_logs_corr @@ -131,6 +152,7 @@ export function openDb(path: string): Database { ); migrateAttrsColumn(db); migrateKindColumns(db); + migrateRuntimeColumn(db); return db; } diff --git a/packages/logs-server/src/envelope.ts b/packages/logs-server/src/envelope.ts index 50d3aac..9191363 100644 --- a/packages/logs-server/src/envelope.ts +++ b/packages/logs-server/src/envelope.ts @@ -54,6 +54,12 @@ function strOr(v: unknown, d: string): string { return v === undefined || v === null ? d : String(v); } +// Like strOr but yields null (not a default) for absent values — for nullable +// columns (e.g. `runtime`) where absence must round-trip as SQL NULL. +function strOrNull(v: unknown): string | null { + return v === undefined || v === null ? null : String(v); +} + // Strip a leading ANSI styled prefix shaped like `\x1b[