{"version":3,"file":"instrumentation.mjs","names":[],"sources":["../../src/database/instrumentation.ts"],"sourcesContent":["/**\n * Query instrumentation\n *\n * Dev/test-only: captures every Kysely query executed inside a request,\n * tagged with the route, method, and a caller-supplied phase (e.g. \"cold\"\n * or \"warm\"). Events are emitted as prefixed NDJSON on stdout so the\n * harness can capture them from both Node and workerd — workerd has no\n * filesystem access, but `console.log` is portable.\n *\n * The recorder lives on the request context (AsyncLocalStorage). The\n * Kysely `log` hook reads the recorder at query time and appends an\n * event. When no recorder is attached, the hook is a null check.\n */\n\nimport type { LogEvent, Logger } from \"kysely\";\n\nimport { getRequestContext } from \"../request-context.js\";\n\nexport const QUERY_LOG_ENV = \"EMDASH_QUERY_LOG\";\nexport const QUERY_LOG_PREFIX = \"[emdash-query-log]\";\n\nexport interface QueryEvent {\n\tsql: string;\n\tparams: readonly unknown[];\n\tdurationMs: number;\n\troute: string;\n\tmethod: string;\n\tphase: string;\n}\n\nexport interface QueryRecorder {\n\tevents: QueryEvent[];\n\troute: string;\n\tmethod: string;\n\tphase: string;\n\t/**\n\t * Set once the recorder has been emitted, so a flush is idempotent.\n\t * Without this, a fallback flush and the stream-end flush could both\n\t * fire and double-emit the events.\n\t */\n\tflushed?: boolean;\n\t/**\n\t * Set when the response body is wrapped for stream-end metrics. The\n\t * recorder is then flushed when the body finishes streaming (so it\n\t * captures queries issued by components during streaming) rather than\n\t * when middleware returns (headers ready, body not yet streamed).\n\t */\n\tdeferredFlush?: boolean;\n}\n\nexport function createRecorder(route: string, method: string, phase: string): QueryRecorder {\n\treturn { events: [], route, method, phase };\n}\n\nexport function recordEvent(\n\trec: QueryRecorder,\n\tsql: string,\n\tparams: readonly unknown[],\n\tdurationMs: number,\n): void {\n\trec.events.push({\n\t\tsql,\n\t\tparams,\n\t\tdurationMs,\n\t\troute: rec.route,\n\t\tmethod: rec.method,\n\t\tphase: rec.phase,\n\t});\n}\n\n/**\n * Emit all events from a recorder as prefixed NDJSON on stdout. The\n * harness pipes the child's stdout, filters lines beginning with\n * QUERY_LOG_PREFIX, and writes them to its own file. Using stdout means\n * the sink works uniformly in Node and in workerd (which has no fs).\n *\n * Idempotent: the first call emits and marks the recorder flushed, later\n * calls no-op. For streamed responses the flush is deferred to stream end\n * (see wrapBodyForStreamMetrics) so it captures queries issued while the\n * body is still rendering; bodyless responses fall back to a flush when\n * middleware returns.\n */\nexport function flushRecorder(rec: QueryRecorder): void {\n\tif (rec.flushed) return;\n\trec.flushed = true;\n\tfor (const e of rec.events) {\n\t\tconsole.log(`${QUERY_LOG_PREFIX} ${JSON.stringify(e)}`);\n\t}\n}\n\n/**\n * Whether query instrumentation is enabled. Read at Kysely construction\n * time and middleware entry — the env var is a process-lifetime flag, not\n * per-request. Gated via `process.env` so adapters that ship env through\n * to the worker (e.g. Miniflare via wrangler.jsonc `vars` or host env\n * pass-through) can enable it at runtime.\n */\nexport function isInstrumentationEnabled(): boolean {\n\treturn Boolean(\n\t\ttypeof process !== \"undefined\" && process.env && process.env[QUERY_LOG_ENV] === \"1\",\n\t);\n}\n\nfunction kyselyLog(event: LogEvent): void {\n\tif (event.level !== \"query\") return;\n\tconst ctx = getRequestContext();\n\tif (!ctx) return;\n\tconst dur = event.queryDurationMillis;\n\tif (ctx.metrics) {\n\t\tconst m = ctx.metrics;\n\t\tm.dbCount += 1;\n\t\tm.dbTotalMs += dur;\n\t\tconst finishedAt = performance.now() - m.start;\n\t\tconst startedAt = finishedAt - dur;\n\t\tif (m.dbFirstOffset === null) m.dbFirstOffset = startedAt;\n\t\tm.dbLastOffset = finishedAt;\n\t}\n\tif (ctx.queryRecorder) {\n\t\trecordEvent(ctx.queryRecorder, event.query.sql, event.query.parameters, dur);\n\t}\n}\n\n/**\n * Returns a Kysely `log` callback. Always returns a function so per-request\n * counters (db.count, db.total, db.first, db.last) and the optional NDJSON\n * recorder both get fed. The cost over the previous \"undefined when off\"\n * behaviour is one `performance.now()` pair per query inside Kysely, which\n * is in the noise compared to any real query.\n */\nexport function kyselyLogOption(): Logger {\n\treturn kyselyLog;\n}\n\n/**\n * Record physical database round trips for the current request.\n *\n * Called by backends that batch (the DO SQL driver coalesces same-turn SELECTs\n * into one RPC), so we can see round-trip count separately from logical query\n * count (`dbCount`, bumped by the Kysely log hook). No-op outside a request or\n * when metrics aren't attached (e.g. migrations on the singleton).\n */\nexport function recordRpc(count = 1): void {\n\tconst ctx = getRequestContext();\n\tif (ctx?.metrics) ctx.metrics.rpcCount += count;\n}\n"],"mappings":";;;AAkBA,MAAa,gBAAgB;AAC7B,MAAa,mBAAmB;AA+BhC,SAAgB,eAAe,OAAe,QAAgB,OAA8B;AAC3F,QAAO;EAAE,QAAQ,EAAE;EAAE;EAAO;EAAQ;EAAO;;AAG5C,SAAgB,YACf,KACA,KACA,QACA,YACO;AACP,KAAI,OAAO,KAAK;EACf;EACA;EACA;EACA,OAAO,IAAI;EACX,QAAQ,IAAI;EACZ,OAAO,IAAI;EACX,CAAC;;;;;;;;;;;;;;AAeH,SAAgB,cAAc,KAA0B;AACvD,KAAI,IAAI,QAAS;AACjB,KAAI,UAAU;AACd,MAAK,MAAM,KAAK,IAAI,OACnB,SAAQ,IAAI,GAAG,iBAAiB,GAAG,KAAK,UAAU,EAAE,GAAG;;;;;;;;;AAWzD,SAAgB,2BAAoC;AACnD,QAAO,QACN,OAAO,YAAY,eAAe,QAAQ,OAAO,QAAQ,IAAI,mBAAmB,IAChF;;AAGF,SAAS,UAAU,OAAuB;AACzC,KAAI,MAAM,UAAU,QAAS;CAC7B,MAAM,MAAM,mBAAmB;AAC/B,KAAI,CAAC,IAAK;CACV,MAAM,MAAM,MAAM;AAClB,KAAI,IAAI,SAAS;EAChB,MAAM,IAAI,IAAI;AACd,IAAE,WAAW;AACb,IAAE,aAAa;EACf,MAAM,aAAa,YAAY,KAAK,GAAG,EAAE;EACzC,MAAM,YAAY,aAAa;AAC/B,MAAI,EAAE,kBAAkB,KAAM,GAAE,gBAAgB;AAChD,IAAE,eAAe;;AAElB,KAAI,IAAI,cACP,aAAY,IAAI,eAAe,MAAM,MAAM,KAAK,MAAM,MAAM,YAAY,IAAI;;;;;;;;;AAW9E,SAAgB,kBAA0B;AACzC,QAAO;;;;;;;;;;AAWR,SAAgB,UAAU,QAAQ,GAAS;CAC1C,MAAM,MAAM,mBAAmB;AAC/B,KAAI,KAAK,QAAS,KAAI,QAAQ,YAAY"}