From 971322845a1ee951bb2d0afe2bd6e56dcbe3aa88 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:38:25 +0000 Subject: [PATCH 1/4] Add Cloudflare D1 (local) adapter for tracing queries during wrangler dev D1 runs inside workerd, so the mysqlnd hook cannot see it. This adds d1-adapter/, a two-part adapter that feeds D1 queries into the existing profiler pipeline: - index.js: Worker-side traceD1() wrapper for the D1Database binding. Intercepts prepare/bind/first/run/all/raw/batch/exec/withSession and reports query, bound params, ok/err status, optional JS backtrace and context tags (d1ProfilerTag/Untag/GetTag) to a local collector via fire-and-forget fetch. enabled:false returns the original binding. - collector.js: dependency-free Node HTTP collector that honors jobs.json active jobs and appends {key}.jsonl / {key}.raw.log in the exact format of profiler_log.c, so the CLI tool and the VSCode / JetBrains plugins display D1 queries unchanged. Includes node:test coverage for both parts, format-compatibility assertions, docs, and a CI job (Node 18/20/22). https://claude.ai/code/session_01X8GQfc7ymjHGGUaHdaeReN --- .github/workflows/tests.yml | 21 +++ README.md | 30 +++ d1-adapter/README.md | 122 ++++++++++++ d1-adapter/collector.js | 288 +++++++++++++++++++++++++++++ d1-adapter/index.d.ts | 55 ++++++ d1-adapter/index.js | 262 ++++++++++++++++++++++++++ d1-adapter/package.json | 32 ++++ d1-adapter/test/collector.test.mjs | 152 +++++++++++++++ d1-adapter/test/trace-d1.test.mjs | 162 ++++++++++++++++ 9 files changed, 1124 insertions(+) create mode 100644 d1-adapter/README.md create mode 100644 d1-adapter/collector.js create mode 100644 d1-adapter/index.d.ts create mode 100644 d1-adapter/index.js create mode 100644 d1-adapter/package.json create mode 100644 d1-adapter/test/collector.test.mjs create mode 100644 d1-adapter/test/trace-d1.test.mjs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 96bf54a..9af72ef 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,6 +46,27 @@ jobs: - name: Run Integration tests run: php tests/test_integration.php + test-d1-adapter: + if: github.event_name != 'pull_request' || !contains(github.event.pull_request.labels.*.name, 'no_run') + name: D1 Adapter Tests (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['18', '20', '22'] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Run D1 adapter tests + working-directory: d1-adapter + run: npm test + build-extension: if: github.event_name != 'pull_request' || !contains(github.event.pull_request.labels.*.name, 'no_run') name: Build Extension (PHP ${{ matrix.php-version }}) diff --git a/README.md b/README.md index ac20968..b478826 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Works with any database access method that uses mysqlnd, including PDO, mysqli, | `cli/` | CLI profiler management tool (PHP) | | `demo/` | Docker-based web demo (Laravel + WebSocket) | | `jetbrains-plugin/` | JetBrains IDE plugin (Kotlin) | +| `d1-adapter/` | Cloudflare D1 (local) adapter for `wrangler dev` (JS) | ## Features @@ -28,6 +29,7 @@ Works with any database access method that uses mysqlnd, including PDO, mysqli, - **Prepared statement support** — Logs bound parameters (PHP 7.0+) - **SQL analysis** — Automatic extraction of table and column names - **Job management** — Concurrent profiling sessions with parent-child relationships +- **Cloudflare D1 (local)** — Traces D1 queries during `wrangler dev` into the same job logs (see [`d1-adapter/`](d1-adapter/)) - **Cross-platform** — Linux / macOS / Windows ## Requirements @@ -123,6 +125,34 @@ $tag = mariadb_profiler_get_tag(); // 'checkout_flow' mariadb_profiler_untag(); ``` +### Tracing Cloudflare D1 (local) + +D1 queries executed during `wrangler dev` can be traced into the same job +logs via the [`d1-adapter/`](d1-adapter/): + +```bash +# Terminal 1: start the local collector +node d1-adapter/collector.js + +# Terminal 2: start a job, then exercise your Worker +php cli/mariadb_profiler.php job start d1job +``` + +```ts +// In your Worker (local dev only) +import { traceD1 } from './mariadb-profiler-d1'; + +export default { + async fetch(request, env, ctx) { + const db = traceD1(env.DB, { ctx, enabled: env.D1_TRACE === '1' }); + const user = await db.prepare('SELECT * FROM users WHERE id = ?').bind(42).first(); + return Response.json(user); + }, +}; +``` + +See [`d1-adapter/README.md`](d1-adapter/README.md) for details. + ### Demo ```bash diff --git a/d1-adapter/README.md b/d1-adapter/README.md new file mode 100644 index 0000000..4653451 --- /dev/null +++ b/d1-adapter/README.md @@ -0,0 +1,122 @@ +# Cloudflare D1 (local) Adapter + +Trace Cloudflare D1 queries during local development (`wrangler dev`) with the +same job logs, CLI tool, and IDE plugins as the PHP extension. + +D1 runs inside workerd, so the PHP extension cannot hook it directly. Instead +this adapter has two small parts: + +``` +┌─────────────────────────────┐ ┌─────────────────────────┐ +│ wrangler dev (workerd) │ │ collector.js (Node) │ +│ │ HTTP │ │ +│ traceD1(env.DB) ──────────────────▶ writes jobs logs: │ +│ intercepts prepare/bind/ │ POST │ {key}.jsonl │ +│ run/all/first/raw/batch/ │ │ {key}.raw.log │ +│ exec/withSession │ │ (same format as the │ +└─────────────────────────────┘ │ PHP extension) │ + └─────────────────────────┘ +``` + +Queries are recorded only while a profiling job is active (started via the +CLI tool or the VSCode / JetBrains plugins), exactly like the extension. +When no collector is running or no job is active, queries are dropped +silently with no impact on your Worker. + +## Setup + +### 1. Start the collector + +```bash +node d1-adapter/collector.js +# [mariadb-profiler-d1] collector listening on http://127.0.0.1:8786/ +# [mariadb-profiler-d1] log dir: /tmp/mariadb_profiler +``` + +Options: + +| Option | Default | Description | +|---|---|---| +| `--port ` | `8786` | Listen port (also `MARIADB_PROFILER_COLLECTOR_PORT`) | +| `--host ` | `127.0.0.1` | Listen host | +| `--log-dir ` | `/tmp/mariadb_profiler` | Profiler log directory (also `MARIADB_PROFILER_LOG_DIR`); must match the CLI / IDE plugin setting | +| `--no-raw` | raw enabled | Disable `.raw.log` output (like `mariadb_profiler.raw_log=0`) | +| `--job-check-interval ` | `1000` | How often `jobs.json` is re-read | + +### 2. Wrap your D1 binding + +Copy `index.js` into your Worker project (or vendor this directory and import +it). Then wrap the binding in your fetch handler: + +```ts +import { traceD1, d1ProfilerTag, d1ProfilerUntag } from './mariadb-profiler-d1'; + +export default { + async fetch(request, env, ctx) { + // Only trace in local dev: gate with a wrangler dev var. + const db = traceD1(env.DB, { ctx, enabled: env.D1_TRACE === '1' }); + + d1ProfilerTag('user_lookup'); + const user = await db.prepare('SELECT * FROM users WHERE id = ?') + .bind(42) + .first(); + d1ProfilerUntag(); + + return Response.json(user); + }, +}; +``` + +`wrangler.toml`: + +```toml +[vars] +D1_TRACE = "1" # set to "0" / omit in production environments +``` + +When `enabled` is `false`, `traceD1` returns the original binding untouched, +so the call is safe to keep in production code paths. Passing `ctx` is +strongly recommended — log delivery is registered with `ctx.waitUntil()` so +it is not cancelled when the response returns. + +### 3. Start a job and run queries + +```bash +php cli/mariadb_profiler.php job start d1job +# ... exercise your Worker with wrangler dev ... +php cli/mariadb_profiler.php job end d1job +php cli/mariadb_profiler.php job show d1job +``` + +Or start/stop jobs from the VSCode / JetBrains plugin — D1 queries appear in +the same query list and live tail as mysqlnd queries. + +## API + +| Function | Description | +|---|---| +| `traceD1(db, options?)` | Wrap a `D1Database` (or `D1DatabaseSession`); intercepts `prepare/bind/first/run/all/raw/batch/exec/withSession` | +| `d1ProfilerTag(tag)` | Push a context tag onto the stack | +| `d1ProfilerUntag(tag?)` | Pop a tag (optionally unwind to a specific tag); returns the popped tag or `null` | +| `d1ProfilerGetTag()` | Get the current tag, or `null` | + +### `traceD1` options + +| Option | Default | Description | +|---|---|---| +| `enabled` | `true` | `false` returns the original binding untouched | +| `ctx` | — | Request `ExecutionContext`; used for `waitUntil()` (recommended) | +| `collectorUrl` | `http://127.0.0.1:8786/` | Collector endpoint | +| `traceDepth` | `0` | JS backtrace frames to record per query (like `mariadb_profiler.trace_depth`; `0` = disabled) | +| `fetch` | global `fetch` | Custom fetch implementation (for tests) | + +Failed queries are logged with status `err` (the error is re-thrown), bound +parameters are recorded like prepared statements in the extension, and +`batch()` logs each statement individually. + +## Tests + +```bash +cd d1-adapter +npm test +``` diff --git a/d1-adapter/collector.js b/d1-adapter/collector.js new file mode 100644 index 0000000..04f20ec --- /dev/null +++ b/d1-adapter/collector.js @@ -0,0 +1,288 @@ +#!/usr/bin/env node +/* + MariaDB Profiler - Cloudflare D1 (local) collector. + + Receives query events from the Worker-side traceD1() wrapper over HTTP + and appends them to the profiler's per-job log files in the exact same + format as the PHP extension (profiler_log.c): + + {log_dir}/jobs.json - job state, managed by the CLI tool + {log_dir}/{key}.jsonl - one JSON object per query + {log_dir}/{key}.raw.log - human-readable text log + + Queries are only recorded while at least one job is active (started via + `php cli/mariadb_profiler.php job start ` or from the IDE plugins). + + Usage: + node collector.js [--port 8786] [--host 127.0.0.1] \ + [--log-dir /tmp/mariadb_profiler] [--no-raw] \ + [--job-check-interval 1000] +*/ + +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const RAW_LOG_EXT = '.raw.log'; +const PARSED_LOG_EXT = '.jsonl'; +const DEFAULT_PORT = 8786; +const DEFAULT_LOG_DIR = '/tmp/mariadb_profiler'; +const MAX_BODY_BYTES = 10 * 1024 * 1024; + +export class Collector { + constructor(options = {}) { + this.logDir = options.logDir || process.env.MARIADB_PROFILER_LOG_DIR || DEFAULT_LOG_DIR; + this.rawLog = options.rawLog !== false; + /* Milliseconds between jobs.json reads (the extension uses seconds). */ + this.jobCheckInterval = options.jobCheckInterval ?? 1000; + this._jobsCache = null; + this._jobsCacheAt = 0; + } + + /** Returns the list of active job keys from jobs.json (cached). */ + getActiveJobs() { + const now = Date.now(); + if (this._jobsCache !== null && now - this._jobsCacheAt < this.jobCheckInterval) { + return this._jobsCache; + } + + let jobs = []; + try { + const content = fs.readFileSync(path.join(this.logDir, 'jobs.json'), 'utf-8'); + const data = JSON.parse(content); + if (data && typeof data.active_jobs === 'object' && data.active_jobs !== null) { + jobs = Object.keys(data.active_jobs); + } + } catch { + /* No jobs.json or unreadable: no active jobs. */ + } + + this._jobsCache = jobs; + this._jobsCacheAt = now; + return jobs; + } + + /** + * Record one query event to every active job. + * Event shape: { q, s?, tag?, params?, trace?, ts? } + * Returns the number of jobs the event was written to. + */ + handleEvent(event) { + if (!event || typeof event.q !== 'string' || event.q === '') { + return 0; + } + + const jobs = this.getActiveJobs(); + if (jobs.length === 0) { + return 0; + } + + const ts = typeof event.ts === 'number' && event.ts > 0 ? event.ts : Date.now() / 1000; + + for (const key of jobs) { + this._appendJsonl(key, event, ts); + if (this.rawLog) { + this._appendRaw(key, event, ts); + } + } + return jobs.length; + } + + _appendJsonl(jobKey, event, ts) { + /* Field order matches profiler_log_jsonl(): k, q, tag, params, trace, s, ts */ + let line = `{"k":${JSON.stringify(jobKey)},"q":${JSON.stringify(event.q)}`; + if (typeof event.tag === 'string' && event.tag !== '') { + line += `,"tag":${JSON.stringify(event.tag)}`; + } + if (Array.isArray(event.params) && event.params.length > 0) { + line += `,"params":${JSON.stringify(event.params.map(normalizeParam))}`; + } + const trace = normalizeTrace(event.trace); + if (trace) { + line += `,"trace":${JSON.stringify(trace)}`; + } + line += `,"s":${JSON.stringify(event.s === 'err' ? 'err' : 'ok')}`; + line += `,"ts":${ts.toFixed(6)}}\n`; + + this._append(jobKey + PARSED_LOG_EXT, line); + } + + _appendRaw(jobKey, event, ts) { + const status = event.s === 'err' ? 'err' : 'ok'; + let text; + if (typeof event.tag === 'string' && event.tag !== '') { + text = `[${formatTimestamp(ts)}] [${status}] [${event.tag}] ${event.q}\n`; + } else { + text = `[${formatTimestamp(ts)}] [${status}] ${event.q}\n`; + } + + if (Array.isArray(event.params) && event.params.length > 0) { + text += ` params: ${JSON.stringify(event.params.map(normalizeParam))}\n`; + } + + const trace = normalizeTrace(event.trace); + if (trace) { + for (const frame of trace) { + text += ` <- ${frame.call}() ${frame.file}:${frame.line}\n`; + } + } + + this._append(jobKey + RAW_LOG_EXT, text); + } + + _append(filename, text) { + try { + fs.appendFileSync(path.join(this.logDir, filename), text); + } catch { + /* Log dir vanished or is unwritable - drop, like the extension. */ + } + } +} + +function normalizeParam(value) { + if (value === null || value === undefined) { + return null; + } + return typeof value === 'string' ? value : String(value); +} + +function normalizeTrace(trace) { + if (!Array.isArray(trace) || trace.length === 0) { + return null; + } + const frames = []; + for (const f of trace) { + if (!f || typeof f !== 'object') { + continue; + } + frames.push({ + call: typeof f.call === 'string' ? f.call : '(unknown)', + file: typeof f.file === 'string' ? f.file : '', + line: Number.isFinite(f.line) ? Math.trunc(f.line) : 0, + }); + } + return frames.length > 0 ? frames : null; +} + +/* Local time "YYYY-MM-DD HH:MM:SS.mmm", same as profiler_log_get_timestamp(). */ +function formatTimestamp(tsSec) { + const d = new Date(tsSec * 1000); + const pad = (n, w = 2) => String(n).padStart(w, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.` + + `${pad(d.getMilliseconds(), 3)}`; +} + +export function createServer(collector) { + return http.createServer((req, res) => { + if (req.method === 'GET') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + ok: true, + log_dir: collector.logDir, + active_jobs: collector.getActiveJobs(), + }) + '\n'); + return; + } + + if (req.method !== 'POST') { + res.writeHead(405, { 'content-type': 'application/json' }); + res.end('{"error":"method not allowed"}\n'); + return; + } + + const chunks = []; + let size = 0; + let aborted = false; + + req.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + aborted = true; + res.writeHead(413, { 'content-type': 'application/json' }); + res.end('{"error":"payload too large"}\n'); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + if (aborted) { + return; + } + + let body; + try { + body = JSON.parse(Buffer.concat(chunks).toString('utf-8')); + } catch { + res.writeHead(400, { 'content-type': 'application/json' }); + res.end('{"error":"invalid json"}\n'); + return; + } + + const events = Array.isArray(body) ? body : [body]; + let written = 0; + for (const event of events) { + written += collector.handleEvent(event); + } + + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(`{"written":${written}}\n`); + }); + + req.on('error', () => { /* client went away mid-request */ }); + }); +} + +function parseArgs(argv) { + const opts = { + port: Number(process.env.MARIADB_PROFILER_COLLECTOR_PORT) || DEFAULT_PORT, + host: '127.0.0.1', + logDir: undefined, + rawLog: true, + jobCheckInterval: 1000, + }; + + for (let i = 0; i < argv.length; i++) { + switch (argv[i]) { + case '--port': opts.port = Number(argv[++i]); break; + case '--host': opts.host = argv[++i]; break; + case '--log-dir': opts.logDir = argv[++i]; break; + case '--no-raw': opts.rawLog = false; break; + case '--job-check-interval': opts.jobCheckInterval = Number(argv[++i]); break; + case '--help': + case '-h': + console.log('Usage: mariadb-profiler-d1-collector [--port 8786] [--host 127.0.0.1]'); + console.log(' [--log-dir /tmp/mariadb_profiler] [--no-raw] [--job-check-interval ms]'); + process.exit(0); + break; + default: + console.error(`Unknown option: ${argv[i]}`); + process.exit(1); + } + } + + return opts; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + const collector = new Collector(opts); + + fs.mkdirSync(collector.logDir, { recursive: true }); + + const server = createServer(collector); + server.listen(opts.port, opts.host, () => { + console.log(`[mariadb-profiler-d1] collector listening on http://${opts.host}:${opts.port}/`); + console.log(`[mariadb-profiler-d1] log dir: ${collector.logDir}`); + console.log(`[mariadb-profiler-d1] raw log: ${collector.rawLog ? 'enabled' : 'disabled'}`); + const jobs = collector.getActiveJobs(); + console.log(`[mariadb-profiler-d1] active jobs: ${jobs.length > 0 ? jobs.join(', ') : '(none)'}`); + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/d1-adapter/index.d.ts b/d1-adapter/index.d.ts new file mode 100644 index 0000000..cd64870 --- /dev/null +++ b/d1-adapter/index.d.ts @@ -0,0 +1,55 @@ +/** + * MariaDB Profiler - Cloudflare D1 (local) adapter, Worker side. + */ + +export interface TraceD1Options { + /** + * Set to false to return the original binding untouched + * (e.g. in production). Default: true. + */ + enabled?: boolean; + + /** + * The ExecutionContext of the current request. When provided, log + * delivery is registered via ctx.waitUntil() so it is not cancelled + * when the response is returned. Strongly recommended. + */ + ctx?: { waitUntil(promise: Promise): void }; + + /** + * URL of the local collector process started with + * `node collector.js`. Default: "http://127.0.0.1:8786/". + */ + collectorUrl?: string; + + /** + * Number of JS backtrace frames to record per query + * (like mariadb_profiler.trace_depth). Default: 0 (disabled). + */ + traceDepth?: number; + + /** Custom fetch implementation (mainly for tests). */ + fetch?: typeof fetch; +} + +/** + * Wrap a D1Database (or D1DatabaseSession) binding so every executed + * query is reported to the local profiler collector. + * + * @example + * const db = traceD1(env.DB, { ctx, enabled: env.D1_TRACE === '1' }); + * await db.prepare('SELECT * FROM users WHERE id = ?').bind(1).first(); + */ +export function traceD1(db: T, options?: TraceD1Options): T; + +/** Push a context tag onto the stack (like mariadb_profiler_tag). */ +export function d1ProfilerTag(tag: string): void; + +/** + * Pop a tag (optionally unwind to a specific tag), like + * mariadb_profiler_untag. Returns the popped tag or null. + */ +export function d1ProfilerUntag(tag?: string | null): string | null; + +/** Get the current tag, or null (like mariadb_profiler_get_tag). */ +export function d1ProfilerGetTag(): string | null; diff --git a/d1-adapter/index.js b/d1-adapter/index.js new file mode 100644 index 0000000..7c25dda --- /dev/null +++ b/d1-adapter/index.js @@ -0,0 +1,262 @@ +/* + MariaDB Profiler - Cloudflare D1 (local) adapter, Worker side. + + Wraps a D1Database binding so every query executed during `wrangler dev` + is reported to a local collector process (see collector.js), which writes + the queries into the profiler's job logs in the same format as the PHP + extension. Existing tooling (CLI, VSCode extension, JetBrains plugin) + then works unchanged. + + Runs inside workerd: no Node APIs, no dependencies, fire-and-forget fetch. +*/ + +const DEFAULT_COLLECTOR_URL = 'http://127.0.0.1:8786/'; + +const ORIGINAL = Symbol('mariadb_profiler_original'); +const SQL = Symbol('mariadb_profiler_sql'); +const PARAMS = Symbol('mariadb_profiler_params'); + +/* Function names of this module, filtered out of captured backtraces. */ +const INTERNAL_CALLS = new Set([ + 'captureTrace', 'logQuery', 'execAndLog', 'runBatch', 'runExec', + 'first', 'run', 'all', 'raw', 'batch', 'exec', +]); + +/* ----------------------------------------------------------------- */ +/* Context tags (mirrors mariadb_profiler_tag/untag/get_tag semantics) */ +/* ----------------------------------------------------------------- */ + +const tagStack = []; + +export function d1ProfilerTag(tag) { + tagStack.push(String(tag)); +} + +export function d1ProfilerUntag(tag = null) { + if (tag === null || tag === undefined) { + return tagStack.length > 0 ? tagStack.pop() : null; + } + + /* Named argument: pop all tags down to and including the target tag */ + const target = String(tag); + for (let i = tagStack.length - 1; i >= 0; i--) { + if (tagStack[i] === target) { + tagStack.length = i; + return target; + } + } + return null; +} + +export function d1ProfilerGetTag() { + return tagStack.length > 0 ? tagStack[tagStack.length - 1] : null; +} + +/* ----------------------------------------------------------------- */ +/* Main entry point */ +/* ----------------------------------------------------------------- */ + +/** + * Wrap a D1Database (or D1DatabaseSession) binding so every executed + * query is reported to the local profiler collector. + * + * Typical usage in a Worker fetch handler: + * + * const db = traceD1(env.DB, { ctx, enabled: env.D1_TRACE === '1' }); + * + * When `enabled` is false the original binding is returned untouched, + * so the wrapper is safe to leave in production code paths. + */ +export function traceD1(db, options = {}) { + if (options.enabled === false || !db) { + return db; + } + + const cfg = { + collectorUrl: options.collectorUrl || DEFAULT_COLLECTOR_URL, + ctx: options.ctx || null, + traceDepth: options.traceDepth | 0, + fetch: options.fetch || ((...args) => fetch(...args)), + }; + + return wrapQueryable(db, cfg); +} + +/* ----------------------------------------------------------------- */ +/* Internals */ +/* ----------------------------------------------------------------- */ + +function wrapQueryable(target, cfg) { + return new Proxy(target, { + get(t, prop) { + if (prop === ORIGINAL) { + return t; + } + if (prop === 'prepare' && typeof t.prepare === 'function') { + return (sql) => wrapStatement(t.prepare(sql), String(sql), undefined, cfg); + } + if (prop === 'batch' && typeof t.batch === 'function') { + return (stmts) => runBatch(t, stmts, cfg); + } + if (prop === 'exec' && typeof t.exec === 'function') { + return (sql) => runExec(t, sql, cfg); + } + if (prop === 'withSession' && typeof t.withSession === 'function') { + return (...args) => wrapQueryable(t.withSession(...args), cfg); + } + const value = Reflect.get(t, prop, t); + return typeof value === 'function' ? value.bind(t) : value; + }, + }); +} + +function wrapStatement(stmt, sql, params, cfg) { + return { + [ORIGINAL]: stmt, + [SQL]: sql, + [PARAMS]: params, + bind(...values) { + return wrapStatement(stmt.bind(...values), sql, values, cfg); + }, + first(...args) { + return execAndLog(stmt, 'first', args, sql, params, cfg); + }, + run(...args) { + return execAndLog(stmt, 'run', args, sql, params, cfg); + }, + all(...args) { + return execAndLog(stmt, 'all', args, sql, params, cfg); + }, + raw(...args) { + return execAndLog(stmt, 'raw', args, sql, params, cfg); + }, + }; +} + +async function execAndLog(stmt, method, args, sql, params, cfg) { + const trace = captureTrace(cfg.traceDepth); + try { + const result = await stmt[method](...args); + logQuery(cfg, sql, params, 'ok', trace); + return result; + } catch (e) { + logQuery(cfg, sql, params, 'err', trace); + throw e; + } +} + +async function runBatch(db, stmts, cfg) { + const trace = captureTrace(cfg.traceDepth); + const originals = stmts.map((s) => (s && s[ORIGINAL]) || s); + let status = 'ok'; + try { + return await db.batch(originals); + } catch (e) { + status = 'err'; + throw e; + } finally { + for (const s of stmts) { + if (s && s[SQL]) { + logQuery(cfg, s[SQL], s[PARAMS], status, trace); + } + } + } +} + +async function runExec(db, sql, cfg) { + const trace = captureTrace(cfg.traceDepth); + try { + const result = await db.exec(sql); + logQuery(cfg, String(sql), undefined, 'ok', trace); + return result; + } catch (e) { + logQuery(cfg, String(sql), undefined, 'err', trace); + throw e; + } +} + +function logQuery(cfg, sql, params, status, trace) { + const event = { + q: sql, + s: status, + ts: Date.now() / 1000, + db: 'd1', + }; + + const tag = d1ProfilerGetTag(); + if (tag !== null) { + event.tag = tag; + } + if (params && params.length > 0) { + event.params = params.map(formatParam); + } + if (trace && trace.length > 0) { + event.trace = trace; + } + + const promise = cfg.fetch(cfg.collectorUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(event), + }).then( + (res) => { + /* Drain the body so the connection can be reused. */ + if (res && res.body && typeof res.body.cancel === 'function') { + return res.body.cancel(); + } + }, + () => { /* Collector not running - silently drop, like the extension. */ } + ); + + if (cfg.ctx && typeof cfg.ctx.waitUntil === 'function') { + cfg.ctx.waitUntil(promise); + } +} + +/* Match the PHP extension's params format: strings or null. */ +function formatParam(value) { + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'string') { + return value; + } + if (value instanceof ArrayBuffer) { + return ``; + } + if (ArrayBuffer.isView(value)) { + return ``; + } + return String(value); +} + +/* Capture a JS backtrace as [{call, file, line}, ...] frames, + * matching the shape produced by profiler_trace.c. depth 0 disables. */ +function captureTrace(depth) { + if (!depth || depth <= 0) { + return undefined; + } + + const stack = new Error().stack; + if (!stack) { + return undefined; + } + + const frames = []; + for (const line of stack.split('\n')) { + const m = line.match(/^\s*at\s+(?:async\s+)?(?:(.+?)\s+\()?(.+?):(\d+):\d+\)?\s*$/); + if (!m) { + continue; + } + const call = m[1] || '{closure}'; + if (INTERNAL_CALLS.has(call) || INTERNAL_CALLS.has(call.replace(/^Object\./, ''))) { + continue; + } + frames.push({ call, file: m[2], line: parseInt(m[3], 10) }); + if (frames.length >= depth) { + break; + } + } + + return frames.length > 0 ? frames : undefined; +} diff --git a/d1-adapter/package.json b/d1-adapter/package.json new file mode 100644 index 0000000..f3de579 --- /dev/null +++ b/d1-adapter/package.json @@ -0,0 +1,32 @@ +{ + "name": "mariadb-profiler-d1", + "version": "0.1.0", + "description": "Cloudflare D1 (local) adapter for MariaDB Profiler - traces D1 queries during wrangler dev into the profiler's job logs", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./collector": "./collector.js" + }, + "bin": { + "mariadb-profiler-d1-collector": "collector.js" + }, + "scripts": { + "test": "node --test", + "collector": "node collector.js" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "index.js", + "index.d.ts", + "collector.js", + "README.md" + ], + "license": "MIT" +} diff --git a/d1-adapter/test/collector.test.mjs b/d1-adapter/test/collector.test.mjs new file mode 100644 index 0000000..634110c --- /dev/null +++ b/d1-adapter/test/collector.test.mjs @@ -0,0 +1,152 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Collector, createServer } from '../collector.js'; + +function makeLogDir(activeJobs = ['testjob']) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mariadb-profiler-d1-')); + const active = {}; + for (const key of activeJobs) { + active[key] = { started_at: Date.now() / 1000, parent: null }; + } + fs.writeFileSync( + path.join(dir, 'jobs.json'), + JSON.stringify({ active_jobs: active, completed_jobs: {} }) + ); + return dir; +} + +test('writes jsonl entry in extension-compatible format', () => { + const logDir = makeLogDir(); + const collector = new Collector({ logDir, jobCheckInterval: 0 }); + + const written = collector.handleEvent({ + q: "SELECT * FROM users WHERE id = ?", + params: ['42'], + tag: 'checkout_flow', + trace: [{ call: 'getUser', file: '/app/src/index.ts', line: 12 }], + s: 'ok', + ts: 1750000000.123456, + }); + + assert.equal(written, 1); + + const lines = fs.readFileSync(path.join(logDir, 'testjob.jsonl'), 'utf-8') + .trim().split('\n'); + assert.equal(lines.length, 1); + + // Exact field order must match profiler_log_jsonl(): k, q, tag, params, trace, s, ts + assert.equal( + lines[0], + '{"k":"testjob","q":"SELECT * FROM users WHERE id = ?","tag":"checkout_flow",' + + '"params":["42"],"trace":[{"call":"getUser","file":"/app/src/index.ts","line":12}],' + + '"s":"ok","ts":1750000000.123456}' + ); + + // Must also round-trip through JSON.parse like the IDE parsers do + const entry = JSON.parse(lines[0]); + assert.equal(entry.k, 'testjob'); + assert.equal(entry.q, 'SELECT * FROM users WHERE id = ?'); + assert.deepEqual(entry.params, ['42']); + assert.equal(entry.trace[0].line, 12); +}); + +test('writes raw log entry in extension-compatible format', () => { + const logDir = makeLogDir(); + const collector = new Collector({ logDir, jobCheckInterval: 0 }); + + collector.handleEvent({ + q: 'INSERT INTO orders (id) VALUES (?)', + params: ['7', null], + tag: 'orders', + trace: [{ call: 'createOrder', file: '/app/src/orders.ts', line: 33 }], + s: 'err', + ts: 1750000000.5, + }); + + const raw = fs.readFileSync(path.join(logDir, 'testjob.raw.log'), 'utf-8'); + const lines = raw.split('\n'); + + assert.match(lines[0], /^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] \[err\] \[orders\] INSERT INTO orders \(id\) VALUES \(\?\)$/); + assert.equal(lines[1], ' params: ["7",null]'); + assert.equal(lines[2], ' <- createOrder() /app/src/orders.ts:33'); +}); + +test('omits optional fields when absent', () => { + const logDir = makeLogDir(); + const collector = new Collector({ logDir, jobCheckInterval: 0 }); + + collector.handleEvent({ q: 'SELECT 1', ts: 1750000001 }); + + const line = fs.readFileSync(path.join(logDir, 'testjob.jsonl'), 'utf-8').trim(); + assert.equal(line, '{"k":"testjob","q":"SELECT 1","s":"ok","ts":1750000001.000000}'); + + const raw = fs.readFileSync(path.join(logDir, 'testjob.raw.log'), 'utf-8'); + assert.match(raw, /^\[[^\]]+\] \[ok\] SELECT 1\n$/); +}); + +test('drops events when no job is active', () => { + const logDir = makeLogDir([]); + const collector = new Collector({ logDir, jobCheckInterval: 0 }); + + const written = collector.handleEvent({ q: 'SELECT 1' }); + assert.equal(written, 0); + assert.equal(fs.existsSync(path.join(logDir, 'testjob.jsonl')), false); +}); + +test('fans out to all active jobs', () => { + const logDir = makeLogDir(['job_a', 'job_b']); + const collector = new Collector({ logDir, jobCheckInterval: 0 }); + + const written = collector.handleEvent({ q: 'SELECT 2' }); + assert.equal(written, 2); + assert.ok(fs.existsSync(path.join(logDir, 'job_a.jsonl'))); + assert.ok(fs.existsSync(path.join(logDir, 'job_b.jsonl'))); +}); + +test('respects rawLog=false', () => { + const logDir = makeLogDir(); + const collector = new Collector({ logDir, rawLog: false, jobCheckInterval: 0 }); + + collector.handleEvent({ q: 'SELECT 3' }); + assert.ok(fs.existsSync(path.join(logDir, 'testjob.jsonl'))); + assert.equal(fs.existsSync(path.join(logDir, 'testjob.raw.log')), false); +}); + +test('HTTP server accepts events and reports active jobs', async () => { + const logDir = makeLogDir(); + const collector = new Collector({ logDir, jobCheckInterval: 0 }); + const server = createServer(collector); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const base = `http://127.0.0.1:${server.address().port}/`; + + try { + const health = await (await fetch(base)).json(); + assert.equal(health.ok, true); + assert.deepEqual(health.active_jobs, ['testjob']); + + const res = await fetch(base, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify([ + { q: 'SELECT 10', ts: 1750000010 }, + { q: 'SELECT 11', ts: 1750000011 }, + ]), + }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { written: 2 }); + + const lines = fs.readFileSync(path.join(logDir, 'testjob.jsonl'), 'utf-8') + .trim().split('\n'); + assert.equal(lines.length, 2); + assert.equal(JSON.parse(lines[0]).q, 'SELECT 10'); + assert.equal(JSON.parse(lines[1]).q, 'SELECT 11'); + + const bad = await fetch(base, { method: 'POST', body: 'not json' }); + assert.equal(bad.status, 400); + } finally { + server.close(); + } +}); diff --git a/d1-adapter/test/trace-d1.test.mjs b/d1-adapter/test/trace-d1.test.mjs new file mode 100644 index 0000000..8430fa3 --- /dev/null +++ b/d1-adapter/test/trace-d1.test.mjs @@ -0,0 +1,162 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + traceD1, + d1ProfilerTag, + d1ProfilerUntag, + d1ProfilerGetTag, +} from '../index.js'; + +/* Minimal stand-in for a D1Database binding. */ +function makeFakeD1(log = []) { + function makeStatement(sql, params = []) { + return { + bind: (...values) => makeStatement(sql, values), + first: async () => { log.push({ method: 'first', sql, params }); return { id: 1 }; }, + run: async () => { log.push({ method: 'run', sql, params }); return { success: true }; }, + all: async () => { log.push({ method: 'all', sql, params }); return { results: [] }; }, + raw: async () => { log.push({ method: 'raw', sql, params }); return []; }, + }; + } + + return { + prepare: (sql) => makeStatement(sql), + batch: async (stmts) => stmts.map(() => ({ success: true })), + exec: async (sql) => { log.push({ method: 'exec', sql }); return { count: 1 }; }, + withSession: () => ({ + prepare: (sql) => makeStatement(sql), + batch: async (stmts) => stmts.map(() => ({ success: true })), + }), + }; +} + +/* Captures events that traceD1 would POST to the collector. */ +function makeCapture() { + const events = []; + const pending = []; + return { + events, + fetch: (url, init) => { + events.push({ url, body: JSON.parse(init.body) }); + return Promise.resolve({ body: null }); + }, + ctx: { waitUntil: (p) => pending.push(p) }, + flush: () => Promise.all(pending), + }; +} + +test('logs prepared statement with bound params', async () => { + const cap = makeCapture(); + const db = traceD1(makeFakeD1(), { fetch: cap.fetch, ctx: cap.ctx }); + + const row = await db.prepare('SELECT * FROM users WHERE id = ?').bind(42).first(); + await cap.flush(); + + assert.deepEqual(row, { id: 1 }); + assert.equal(cap.events.length, 1); + const event = cap.events[0].body; + assert.equal(event.q, 'SELECT * FROM users WHERE id = ?'); + assert.deepEqual(event.params, ['42']); + assert.equal(event.s, 'ok'); + assert.equal(typeof event.ts, 'number'); +}); + +test('logs err status when query throws', async () => { + const cap = makeCapture(); + const db = traceD1({ + prepare: () => ({ + run: async () => { throw new Error('SQLITE_ERROR'); }, + bind: function () { return this; }, + }), + }, { fetch: cap.fetch, ctx: cap.ctx }); + + await assert.rejects(() => db.prepare('SELECT broken').run(), /SQLITE_ERROR/); + await cap.flush(); + + assert.equal(cap.events[0].body.s, 'err'); +}); + +test('logs every statement in a batch', async () => { + const cap = makeCapture(); + const db = traceD1(makeFakeD1(), { fetch: cap.fetch, ctx: cap.ctx }); + + await db.batch([ + db.prepare('INSERT INTO t VALUES (?)').bind(1), + db.prepare('INSERT INTO t VALUES (?)').bind(2), + ]); + await cap.flush(); + + assert.equal(cap.events.length, 2); + assert.deepEqual(cap.events[0].body.params, ['1']); + assert.deepEqual(cap.events[1].body.params, ['2']); +}); + +test('logs exec and session queries', async () => { + const cap = makeCapture(); + const db = traceD1(makeFakeD1(), { fetch: cap.fetch, ctx: cap.ctx }); + + await db.exec('CREATE TABLE t (id INTEGER)'); + await db.withSession('first-primary').prepare('SELECT 1').all(); + await cap.flush(); + + assert.equal(cap.events.length, 2); + assert.equal(cap.events[0].body.q, 'CREATE TABLE t (id INTEGER)'); + assert.equal(cap.events[1].body.q, 'SELECT 1'); +}); + +test('attaches current tag and supports unwind', async () => { + const cap = makeCapture(); + const db = traceD1(makeFakeD1(), { fetch: cap.fetch, ctx: cap.ctx }); + + d1ProfilerTag('outer'); + d1ProfilerTag('inner'); + assert.equal(d1ProfilerGetTag(), 'inner'); + + await db.prepare('SELECT 1').run(); + + assert.equal(d1ProfilerUntag('outer'), 'outer'); + assert.equal(d1ProfilerGetTag(), null); + assert.equal(d1ProfilerUntag(), null); + + await db.prepare('SELECT 2').run(); + await cap.flush(); + + assert.equal(cap.events[0].body.tag, 'inner'); + assert.equal(cap.events[1].body.tag, undefined); +}); + +test('captures backtrace frames when traceDepth > 0', async () => { + const cap = makeCapture(); + const db = traceD1(makeFakeD1(), { fetch: cap.fetch, ctx: cap.ctx, traceDepth: 5 }); + + await db.prepare('SELECT 1').run(); + await cap.flush(); + + const trace = cap.events[0].body.trace; + assert.ok(Array.isArray(trace) && trace.length > 0, 'trace should be captured'); + for (const frame of trace) { + assert.equal(typeof frame.call, 'string'); + assert.equal(typeof frame.file, 'string'); + assert.equal(typeof frame.line, 'number'); + } + // This test file must appear in the trace (the caller of run()). + assert.ok(trace.some((f) => f.file.includes('trace-d1.test')), + `expected a frame from the test file, got: ${JSON.stringify(trace)}`); +}); + +test('enabled:false returns the original binding', () => { + const original = makeFakeD1(); + assert.equal(traceD1(original, { enabled: false }), original); +}); + +test('queries are dropped silently when collector is down', async () => { + const pending = []; + const db = traceD1(makeFakeD1(), { + fetch: () => Promise.reject(new Error('ECONNREFUSED')), + ctx: { waitUntil: (p) => pending.push(p) }, + }); + + const result = await db.prepare('SELECT 1').run(); + assert.deepEqual(result, { success: true }); + await Promise.all(pending); // must not reject +}); From 39e946dc0e73fe8a9ff431fdd8a5e7d9a4c5259f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:38:16 +0000 Subject: [PATCH 2/4] Show source database (D1 / MariaDB) for each query in IDE plugins The D1 collector now persists the worker's db marker into the jsonl log ("db":"d1"); entries written by the PHP extension have no db field and are treated as MariaDB, so existing logs stay compatible. - VSCode: source label in each query row's description and a Source: metadata item in the expanded details - JetBrains: new DB column in the query table (colored, searchable via the text filter) and a Source: row in the detail panel - CLI: job show passes the db field through - Tests: collector format/sanitization, fromRaw/getDbLabel (vitest), kotlinx deserialization of the db marker https://claude.ai/code/session_01X8GQfc7ymjHGGUaHdaeReN --- cli/mariadb_profiler.php | 5 ++++ d1-adapter/README.md | 7 ++++++ d1-adapter/collector.js | 15 ++++++++++++ d1-adapter/test/collector.test.mjs | 24 +++++++++++++++++++ .../plugin/model/QueryEntry.kt | 13 +++++++++- .../plugin/ui/panel/QueryDetailPanel.kt | 7 ++++++ .../plugin/ui/panel/QueryLogPanel.kt | 10 ++++---- .../plugin/ui/table/QueryCellRenderer.kt | 12 ++++++++++ .../plugin/ui/table/QueryTableModel.kt | 12 ++++++---- .../plugin/service/JsonParsingTest.kt | 15 ++++++++++++ vscode-extension/src/model/QueryEntry.ts | 15 ++++++++++++ .../src/provider/QueryTreeProvider.ts | 10 +++++++- vscode-extension/test/unit/QueryEntry.test.ts | 22 +++++++++++++++++ 13 files changed, 156 insertions(+), 11 deletions(-) diff --git a/cli/mariadb_profiler.php b/cli/mariadb_profiler.php index 4c0bd5f..254c903 100644 --- a/cli/mariadb_profiler.php +++ b/cli/mariadb_profiler.php @@ -229,6 +229,11 @@ function cmdJobShow(JobManager $manager, $key, $tagFilter = null) $output['trace'] = $entry['trace']; } + // Include source database marker if present (e.g. "d1") + if (isset($entry['db']) && $entry['db'] !== '') { + $output['db'] = $entry['db']; + } + fwrite(STDOUT, json_encode($output, JSON_UNESCAPED_UNICODE) . "\n"); } } diff --git a/d1-adapter/README.md b/d1-adapter/README.md index 4653451..ee2c88c 100644 --- a/d1-adapter/README.md +++ b/d1-adapter/README.md @@ -91,6 +91,13 @@ php cli/mariadb_profiler.php job show d1job Or start/stop jobs from the VSCode / JetBrains plugin — D1 queries appear in the same query list and live tail as mysqlnd queries. +D1 entries carry a `"db":"d1"` marker in the `.jsonl` log (entries written by +the PHP extension have no `db` field and are treated as MariaDB). The VSCode +extension shows the source (`D1` / `MariaDB`) next to each query and as a +`Source:` row in the expanded details; the JetBrains plugin shows it in the +`DB` column of the query table and in the detail panel. `php cli/... job show` +passes the `db` field through as well. + ## API | Function | Description | diff --git a/d1-adapter/collector.js b/d1-adapter/collector.js index 04f20ec..20494d4 100644 --- a/d1-adapter/collector.js +++ b/d1-adapter/collector.js @@ -103,6 +103,12 @@ export class Collector { line += `,"trace":${JSON.stringify(trace)}`; } line += `,"s":${JSON.stringify(event.s === 'err' ? 'err' : 'ok')}`; + /* Source database marker (e.g. "d1"). The PHP extension never writes + * this field, so its absence means mysqlnd/MariaDB. */ + const db = normalizeDb(event.db); + if (db) { + line += `,"db":${JSON.stringify(db)}`; + } line += `,"ts":${ts.toFixed(6)}}\n`; this._append(jobKey + PARSED_LOG_EXT, line); @@ -140,6 +146,15 @@ export class Collector { } } +/* Keep the db marker a short, safe token (lowercase alnum/_/-). */ +function normalizeDb(value) { + if (typeof value !== 'string') { + return null; + } + const db = value.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 32); + return db !== '' ? db : null; +} + function normalizeParam(value) { if (value === null || value === undefined) { return null; diff --git a/d1-adapter/test/collector.test.mjs b/d1-adapter/test/collector.test.mjs index 634110c..09c4513 100644 --- a/d1-adapter/test/collector.test.mjs +++ b/d1-adapter/test/collector.test.mjs @@ -87,6 +87,30 @@ test('omits optional fields when absent', () => { assert.match(raw, /^\[[^\]]+\] \[ok\] SELECT 1\n$/); }); +test('writes db source marker for D1 events', () => { + const logDir = makeLogDir(); + const collector = new Collector({ logDir, jobCheckInterval: 0 }); + + collector.handleEvent({ q: 'SELECT 1', db: 'd1', ts: 1750000002 }); + + const line = fs.readFileSync(path.join(logDir, 'testjob.jsonl'), 'utf-8').trim(); + assert.equal(line, '{"k":"testjob","q":"SELECT 1","s":"ok","db":"d1","ts":1750000002.000000}'); + assert.equal(JSON.parse(line).db, 'd1'); +}); + +test('sanitizes unsafe db markers', () => { + const logDir = makeLogDir(); + const collector = new Collector({ logDir, jobCheckInterval: 0 }); + + collector.handleEvent({ q: 'SELECT 1', db: 'D1"},\n{"evil', ts: 1750000003 }); + collector.handleEvent({ q: 'SELECT 2', db: 123, ts: 1750000004 }); + + const lines = fs.readFileSync(path.join(logDir, 'testjob.jsonl'), 'utf-8') + .trim().split('\n'); + assert.equal(JSON.parse(lines[0]).db, 'd1evil'); + assert.equal(JSON.parse(lines[1]).db, undefined); +}); + test('drops events when no job is active', () => { const logDir = makeLogDir([]); const collector = new Collector({ logDir, jobCheckInterval: 0 }); diff --git a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/model/QueryEntry.kt b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/model/QueryEntry.kt index 40aedfc..c2108f9 100644 --- a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/model/QueryEntry.kt +++ b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/model/QueryEntry.kt @@ -18,8 +18,19 @@ data class QueryEntry( @SerialName("params") val params: List = emptyList(), @SerialName("trace") - val trace: List = emptyList() + val trace: List = emptyList(), + /** Source database marker (e.g. "d1"). Absent for mysqlnd/MariaDB. */ + @SerialName("db") + val db: String? = null ) { + /** Human-readable source database label for UI display */ + val dbLabel: String + get() = when { + db == null -> "MariaDB" + db == "d1" -> "D1" + else -> db.uppercase() + } + /** Whether this query has bound parameters (prepared statement) */ val hasParams: Boolean get() = params.isNotEmpty() diff --git a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryDetailPanel.kt b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryDetailPanel.kt index bf53cae..27b29eb 100644 --- a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryDetailPanel.kt +++ b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryDetailPanel.kt @@ -47,6 +47,7 @@ class QueryDetailPanel(private val project: Project) : JPanel(BorderLayout()) { private val tablesLabel = JBLabel() private val tagsLabel = JBLabel() private val timestampLabel = JBLabel() + private val sourceLabel = JBLabel() private val backtracePanel = JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) } @@ -94,6 +95,11 @@ class QueryDetailPanel(private val project: Project) : JPanel(BorderLayout()) { add(JBLabel("Tags:"), gbc) gbc.gridx = 1; gbc.weightx = 1.0 add(tagsLabel, gbc) + + gbc.gridx = 0; gbc.gridy = 3; gbc.weightx = 0.0 + add(JBLabel("Source:"), gbc) + gbc.gridx = 1; gbc.weightx = 1.0 + add(sourceLabel, gbc) } // Backtrace panel @@ -146,6 +152,7 @@ class QueryDetailPanel(private val project: Project) : JPanel(BorderLayout()) { timestampLabel.text = entry.formattedTimestamp tablesLabel.text = entry.tables.joinToString(", ").ifEmpty { "-" } tagsLabel.text = entry.tags.joinToString(", ").ifEmpty { "-" } + sourceLabel.text = entry.dbLabel // Determine highlighted depth from Groovy frame resolver val resolver = project.getService(FrameResolverService::class.java) diff --git a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryLogPanel.kt b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryLogPanel.kt index 6eaaafb..ead2e7d 100644 --- a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryLogPanel.kt +++ b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryLogPanel.kt @@ -45,10 +45,12 @@ class QueryLogPanel( columnModel.getColumn(1).maxWidth = 120 columnModel.getColumn(2).preferredWidth = 70 // Type columnModel.getColumn(2).maxWidth = 80 - columnModel.getColumn(3).preferredWidth = 300 // SQL - columnModel.getColumn(4).preferredWidth = 80 // Tags - columnModel.getColumn(5).preferredWidth = 250 // Function - columnModel.getColumn(6).preferredWidth = 180 // File + columnModel.getColumn(3).preferredWidth = 70 // DB + columnModel.getColumn(3).maxWidth = 90 + columnModel.getColumn(4).preferredWidth = 300 // SQL + columnModel.getColumn(5).preferredWidth = 80 // Tags + columnModel.getColumn(6).preferredWidth = 250 // Function + columnModel.getColumn(7).preferredWidth = 180 // File selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION selectionModel.addListSelectionListener { e -> diff --git a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/table/QueryCellRenderer.kt b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/table/QueryCellRenderer.kt index e90cc0f..02fea16 100644 --- a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/table/QueryCellRenderer.kt +++ b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/table/QueryCellRenderer.kt @@ -33,6 +33,18 @@ class QueryCellRenderer : DefaultTableCellRenderer() { component.horizontalAlignment = CENTER } + if (component is JLabel && column == 3) { + val dbText = value?.toString() ?: "" + if (!isSelected) { + component.foreground = when (dbText) { + "D1" -> JBColor(0x00838F, 0x4DD0E1) // cyan (Cloudflare D1) + "MariaDB" -> JBColor.foreground() + else -> JBColor(0x6A1B9A, 0xCE93D8) // purple (other sources) + } + } + component.horizontalAlignment = CENTER + } + if (component is JLabel && column == 0) { component.horizontalAlignment = RIGHT } diff --git a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/table/QueryTableModel.kt b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/table/QueryTableModel.kt index 94f264a..dc17097 100644 --- a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/table/QueryTableModel.kt +++ b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/table/QueryTableModel.kt @@ -15,7 +15,7 @@ class QueryTableModel : AbstractTableModel() { var frameResolver: FrameResolverService? = null - private val columns = arrayOf("#", "Time", "Type", "SQL", "Tags", "Function", "File") + private val columns = arrayOf("#", "Time", "Type", "DB", "SQL", "Tags", "Function", "File") override fun getRowCount(): Int = filteredEntries.size @@ -29,10 +29,11 @@ class QueryTableModel : AbstractTableModel() { 0 -> rowIndex + 1 1 -> entry.formattedTimestamp 2 -> entry.queryType.label - 3 -> entry.shortSql - 4 -> entry.tags.joinToString(", ") - 5 -> getFrameFunction(entry) - 6 -> getFrameFile(entry) + 3 -> entry.dbLabel + 4 -> entry.shortSql + 5 -> entry.tags.joinToString(", ") + 6 -> getFrameFunction(entry) + 7 -> getFrameFile(entry) else -> "" } } @@ -100,6 +101,7 @@ class QueryTableModel : AbstractTableModel() { val matchesText = textFilter.isEmpty() || entry.query.lowercase().contains(textFilter) || (entry.boundQuery?.lowercase()?.contains(textFilter) == true) || + entry.dbLabel.lowercase().contains(textFilter) || entry.tags.any { it.lowercase().contains(textFilter) } || getFrameFile(entry).lowercase().contains(textFilter) || getFrameFunction(entry).lowercase().contains(textFilter) diff --git a/jetbrains-plugin/src/test/kotlin/com/mariadbprofiler/plugin/service/JsonParsingTest.kt b/jetbrains-plugin/src/test/kotlin/com/mariadbprofiler/plugin/service/JsonParsingTest.kt index c29c134..fab2f10 100644 --- a/jetbrains-plugin/src/test/kotlin/com/mariadbprofiler/plugin/service/JsonParsingTest.kt +++ b/jetbrains-plugin/src/test/kotlin/com/mariadbprofiler/plugin/service/JsonParsingTest.kt @@ -31,6 +31,21 @@ class JsonParsingTest { assertEquals(emptyList(), entries[1].tags) } + @Test + fun `parse db source marker`() { + val d1 = json.decodeFromString( + """{"k":"job1","q":"SELECT 1","s":"ok","db":"d1","ts":1.0}""" + ) + val mariadb = json.decodeFromString( + """{"k":"job1","q":"SELECT 2","s":"ok","ts":2.0}""" + ) + + assertEquals("d1", d1.db) + assertEquals("D1", d1.dbLabel) + assertEquals(null, mariadb.db) + assertEquals("MariaDB", mariadb.dbLabel) + } + @Test fun `parse JSONL file from temp file`() { val tempFile = File.createTempFile("profiler_test", ".jsonl") diff --git a/vscode-extension/src/model/QueryEntry.ts b/vscode-extension/src/model/QueryEntry.ts index 087e56c..01be0a2 100644 --- a/vscode-extension/src/model/QueryEntry.ts +++ b/vscode-extension/src/model/QueryEntry.ts @@ -14,6 +14,8 @@ export interface RawQueryEntry { s?: string; params?: (string | null)[]; trace?: BacktraceFrame[]; + /** Source database marker (e.g. "d1"). Absent for mysqlnd/MariaDB. */ + db?: string; } export interface QueryEntry { @@ -24,6 +26,8 @@ export interface QueryEntry { status?: string; params?: (string | null)[]; trace?: BacktraceFrame[]; + /** Source database marker (e.g. "d1"). Absent for mysqlnd/MariaDB. */ + db?: string; } export type QueryType = 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'OTHER'; @@ -37,9 +41,20 @@ export function fromRaw(raw: RawQueryEntry): QueryEntry { status: raw.s, params: raw.params, trace: raw.trace, + db: raw.db, }; } +/** + * Human-readable source database label. Entries without a db marker were + * written by the mysqlnd hook, i.e. MariaDB/MySQL. + */ +export function getDbLabel(entry: QueryEntry): string { + if (!entry.db) { return 'MariaDB'; } + if (entry.db === 'd1') { return 'D1'; } + return entry.db.toUpperCase(); +} + export function getQueryType(entry: QueryEntry): QueryType { const trimmed = entry.query.trimStart().toUpperCase(); if (trimmed.startsWith('SELECT')) { return 'SELECT'; } diff --git a/vscode-extension/src/provider/QueryTreeProvider.ts b/vscode-extension/src/provider/QueryTreeProvider.ts index 9a2b7da..8910969 100644 --- a/vscode-extension/src/provider/QueryTreeProvider.ts +++ b/vscode-extension/src/provider/QueryTreeProvider.ts @@ -6,6 +6,7 @@ import { getShortSql, getTables, getBoundQuery, + getDbLabel, formatTimestamp, QueryType, } from '../model/QueryEntry'; @@ -116,6 +117,12 @@ export class QueryTreeProvider implements vscode.TreeDataProvider const entry = item.entry; const children: QueryTreeItem[] = []; + // Source database + children.push(new QueryMetadataItem( + `Source: ${getDbLabel(entry)}`, + entry.db === 'd1' ? 'cloud' : 'server', + )); + // Tables const tables = getTables(entry); if (tables.length > 0) { @@ -212,8 +219,9 @@ export class QueryEntryItem extends vscode.TreeItem { this.entryIndex = entryIndex; this.contextValue = 'queryEntry'; - // Description: [tag] HH:MM:SS.mmm + // Description: source [tag] HH:MM:SS.mmm const parts: string[] = []; + parts.push(getDbLabel(entry)); if (entry.tag) { parts.push(`[${entry.tag}]`); } parts.push(formatTimestamp(entry.timestamp)); this.description = parts.join(' '); diff --git a/vscode-extension/test/unit/QueryEntry.test.ts b/vscode-extension/test/unit/QueryEntry.test.ts index 3fe623f..ef9ed1a 100644 --- a/vscode-extension/test/unit/QueryEntry.test.ts +++ b/vscode-extension/test/unit/QueryEntry.test.ts @@ -8,6 +8,7 @@ import { getTables, getShortSql, getSourceFile, + getDbLabel, formatTimestamp, } from '../../src/model/QueryEntry'; @@ -35,6 +36,11 @@ describe('fromRaw', () => { expect(entry.trace![0].file).toBe('/app/UserController.php'); }); + it('should map the db source marker', () => { + const raw: RawQueryEntry = { k: 'j', q: 'SELECT 1', ts: 0, db: 'd1' }; + expect(fromRaw(raw).db).toBe('d1'); + }); + it('should handle minimal entry', () => { const raw: RawQueryEntry = { k: 'j', q: 'SELECT 1', ts: 0 }; const entry = fromRaw(raw); @@ -48,6 +54,22 @@ describe('fromRaw', () => { }); }); +describe('getDbLabel', () => { + const base: QueryEntry = { jobKey: 'j', query: 'SELECT 1', timestamp: 0 }; + + it('should label entries without a db marker as MariaDB', () => { + expect(getDbLabel(base)).toBe('MariaDB'); + }); + + it('should label d1 entries as D1', () => { + expect(getDbLabel({ ...base, db: 'd1' })).toBe('D1'); + }); + + it('should uppercase unknown markers', () => { + expect(getDbLabel({ ...base, db: 'sqlite' })).toBe('SQLITE'); + }); +}); + describe('getQueryType', () => { it('should detect SELECT', () => { expect(getQueryType({ jobKey: '', query: 'SELECT * FROM users', timestamp: 0 })).toBe('SELECT'); From 2eeff240b2c1b1f654ec8785465cc741b24e977a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:46:18 +0000 Subject: [PATCH 3/4] Address review: request-scoped tags, safe send, CI hardening - traceD1() wrappers now expose profilerTag/profilerUntag/profilerGetTag with a per-instance tag stack, so tags no longer interleave across concurrent requests; the module-level functions remain as the shared fallback and the instance stack takes precedence - Absorb synchronous throws from an injected fetch implementation so collector failures can never reach the caller - tests.yml: explicit workflow-level 'permissions: contents: read' and persist-credentials: false on the D1 job's checkout - README: language on the diagram code fence, document the new request-scoped tag API https://claude.ai/code/session_01X8GQfc7ymjHGGUaHdaeReN --- .github/workflows/tests.yml | 5 +++ d1-adapter/README.md | 23 +++++++--- d1-adapter/index.d.ts | 24 ++++++++--- d1-adapter/index.js | 71 ++++++++++++++++++++++--------- d1-adapter/test/trace-d1.test.mjs | 38 +++++++++++++++++ 5 files changed, 130 insertions(+), 31 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9af72ef..76f48f6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,6 +10,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-cli: if: github.event_name != 'pull_request' || !contains(github.event.pull_request.labels.*.name, 'no_run') @@ -57,6 +60,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/d1-adapter/README.md b/d1-adapter/README.md index ee2c88c..28605e7 100644 --- a/d1-adapter/README.md +++ b/d1-adapter/README.md @@ -6,7 +6,7 @@ same job logs, CLI tool, and IDE plugins as the PHP extension. D1 runs inside workerd, so the PHP extension cannot hook it directly. Instead this adapter has two small parts: -``` +```text ┌─────────────────────────────┐ ┌─────────────────────────┐ │ wrangler dev (workerd) │ │ collector.js (Node) │ │ │ HTTP │ │ @@ -49,18 +49,18 @@ Copy `index.js` into your Worker project (or vendor this directory and import it). Then wrap the binding in your fetch handler: ```ts -import { traceD1, d1ProfilerTag, d1ProfilerUntag } from './mariadb-profiler-d1'; +import { traceD1 } from './mariadb-profiler-d1'; export default { async fetch(request, env, ctx) { // Only trace in local dev: gate with a wrangler dev var. const db = traceD1(env.DB, { ctx, enabled: env.D1_TRACE === '1' }); - d1ProfilerTag('user_lookup'); + db.profilerTag('user_lookup'); const user = await db.prepare('SELECT * FROM users WHERE id = ?') .bind(42) .first(); - d1ProfilerUntag(); + db.profilerUntag(); return Response.json(user); }, @@ -103,9 +103,18 @@ passes the `db` field through as well. | Function | Description | |---|---| | `traceD1(db, options?)` | Wrap a `D1Database` (or `D1DatabaseSession`); intercepts `prepare/bind/first/run/all/raw/batch/exec/withSession` | -| `d1ProfilerTag(tag)` | Push a context tag onto the stack | -| `d1ProfilerUntag(tag?)` | Pop a tag (optionally unwind to a specific tag); returns the popped tag or `null` | -| `d1ProfilerGetTag()` | Get the current tag, or `null` | +| `db.profilerTag(tag)` | Push a tag onto the wrapper's request-scoped stack (recommended) | +| `db.profilerUntag(tag?)` | Pop a tag from the wrapper's stack; returns the popped tag or `null` | +| `db.profilerGetTag()` | Current tag (wrapper stack first, then the shared stack), or `null` | +| `d1ProfilerTag(tag)` | Push a tag onto the **shared** stack (isolate-wide) | +| `d1ProfilerUntag(tag?)` | Pop a tag from the shared stack; returns the popped tag or `null` | +| `d1ProfilerGetTag()` | Get the current shared tag, or `null` | + +Create the wrapper inside your fetch handler and use the `db.profilerTag()` +methods: each wrapper has its own tag stack, so tags never interleave across +concurrent requests. The module-level `d1ProfilerTag()` functions operate on +a single stack shared by the whole isolate — fine for simple sequential local +testing, but concurrent requests will see each other's tags. ### `traceD1` options diff --git a/d1-adapter/index.d.ts b/d1-adapter/index.d.ts index cd64870..edb19ee 100644 --- a/d1-adapter/index.d.ts +++ b/d1-adapter/index.d.ts @@ -32,6 +32,16 @@ export interface TraceD1Options { fetch?: typeof fetch; } +/** Request-scoped tag methods exposed on the wrapper returned by traceD1. */ +export interface D1ProfilerTagMethods { + /** Push a tag onto this wrapper's own stack (safe under concurrency). */ + profilerTag(tag: string): void; + /** Pop a tag from this wrapper's stack; returns the popped tag or null. */ + profilerUntag(tag?: string | null): string | null; + /** Current tag: this wrapper's stack first, then the shared stack. */ + profilerGetTag(): string | null; +} + /** * Wrap a D1Database (or D1DatabaseSession) binding so every executed * query is reported to the local profiler collector. @@ -40,16 +50,20 @@ export interface TraceD1Options { * const db = traceD1(env.DB, { ctx, enabled: env.D1_TRACE === '1' }); * await db.prepare('SELECT * FROM users WHERE id = ?').bind(1).first(); */ -export function traceD1(db: T, options?: TraceD1Options): T; +export function traceD1(db: T, options?: TraceD1Options): T & D1ProfilerTagMethods; -/** Push a context tag onto the stack (like mariadb_profiler_tag). */ +/** + * Push a context tag onto the shared stack (like mariadb_profiler_tag). + * The shared stack is isolate-wide: concurrent requests interleave on it. + * Prefer the wrapper's profilerTag() for request-scoped tagging. + */ export function d1ProfilerTag(tag: string): void; /** - * Pop a tag (optionally unwind to a specific tag), like - * mariadb_profiler_untag. Returns the popped tag or null. + * Pop a tag from the shared stack (optionally unwind to a specific tag), + * like mariadb_profiler_untag. Returns the popped tag or null. */ export function d1ProfilerUntag(tag?: string | null): string | null; -/** Get the current tag, or null (like mariadb_profiler_get_tag). */ +/** Get the current shared tag, or null (like mariadb_profiler_get_tag). */ export function d1ProfilerGetTag(): string | null; diff --git a/d1-adapter/index.js b/d1-adapter/index.js index 7c25dda..1c207e9 100644 --- a/d1-adapter/index.js +++ b/d1-adapter/index.js @@ -26,32 +26,50 @@ const INTERNAL_CALLS = new Set([ /* Context tags (mirrors mariadb_profiler_tag/untag/get_tag semantics) */ /* ----------------------------------------------------------------- */ +/* + * Module-level stack, shared across the whole isolate. Convenient for + * simple local dev, but concurrent requests interleave on it - for + * request-scoped tags use the profilerTag/profilerUntag/profilerGetTag + * methods on the wrapper returned by traceD1() instead. + */ const tagStack = []; -export function d1ProfilerTag(tag) { - tagStack.push(String(tag)); -} - -export function d1ProfilerUntag(tag = null) { +function popTag(stack, tag) { if (tag === null || tag === undefined) { - return tagStack.length > 0 ? tagStack.pop() : null; + return stack.length > 0 ? stack.pop() : null; } /* Named argument: pop all tags down to and including the target tag */ const target = String(tag); - for (let i = tagStack.length - 1; i >= 0; i--) { - if (tagStack[i] === target) { - tagStack.length = i; + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i] === target) { + stack.length = i; return target; } } return null; } +export function d1ProfilerTag(tag) { + tagStack.push(String(tag)); +} + +export function d1ProfilerUntag(tag = null) { + return popTag(tagStack, tag); +} + export function d1ProfilerGetTag() { return tagStack.length > 0 ? tagStack[tagStack.length - 1] : null; } +/* Instance tags (set via the wrapper methods) win over the shared stack. */ +function currentTag(cfg) { + if (cfg.tags.length > 0) { + return cfg.tags[cfg.tags.length - 1]; + } + return d1ProfilerGetTag(); +} + /* ----------------------------------------------------------------- */ /* Main entry point */ /* ----------------------------------------------------------------- */ @@ -77,6 +95,8 @@ export function traceD1(db, options = {}) { ctx: options.ctx || null, traceDepth: options.traceDepth | 0, fetch: options.fetch || ((...args) => fetch(...args)), + /* Request-scoped tag stack, owned by this wrapper instance. */ + tags: [], }; return wrapQueryable(db, cfg); @@ -104,6 +124,17 @@ function wrapQueryable(target, cfg) { if (prop === 'withSession' && typeof t.withSession === 'function') { return (...args) => wrapQueryable(t.withSession(...args), cfg); } + /* Request-scoped tag API (immune to concurrent-request interleaving, + * unlike the module-level d1ProfilerTag/Untag/GetTag functions). */ + if (prop === 'profilerTag') { + return (tag) => { cfg.tags.push(String(tag)); }; + } + if (prop === 'profilerUntag') { + return (tag = null) => popTag(cfg.tags, tag); + } + if (prop === 'profilerGetTag') { + return () => currentTag(cfg); + } const value = Reflect.get(t, prop, t); return typeof value === 'function' ? value.bind(t) : value; }, @@ -183,7 +214,7 @@ function logQuery(cfg, sql, params, status, trace) { db: 'd1', }; - const tag = d1ProfilerGetTag(); + const tag = currentTag(cfg); if (tag !== null) { event.tag = tag; } @@ -194,19 +225,21 @@ function logQuery(cfg, sql, params, status, trace) { event.trace = trace; } - const promise = cfg.fetch(cfg.collectorUrl, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(event), - }).then( - (res) => { + /* Promise.resolve().then() also absorbs synchronous throws from an + * injected fetch - collector failures must never reach the caller. */ + const promise = Promise.resolve() + .then(() => cfg.fetch(cfg.collectorUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(event), + })) + .then((res) => { /* Drain the body so the connection can be reused. */ if (res && res.body && typeof res.body.cancel === 'function') { return res.body.cancel(); } - }, - () => { /* Collector not running - silently drop, like the extension. */ } - ); + }) + .catch(() => { /* Collector not running - silently drop, like the extension. */ }); if (cfg.ctx && typeof cfg.ctx.waitUntil === 'function') { cfg.ctx.waitUntil(promise); diff --git a/d1-adapter/test/trace-d1.test.mjs b/d1-adapter/test/trace-d1.test.mjs index 8430fa3..7763a8c 100644 --- a/d1-adapter/test/trace-d1.test.mjs +++ b/d1-adapter/test/trace-d1.test.mjs @@ -125,6 +125,44 @@ test('attaches current tag and supports unwind', async () => { assert.equal(cap.events[1].body.tag, undefined); }); +test('instance tags are isolated between wrappers and win over shared tags', async () => { + const capA = makeCapture(); + const capB = makeCapture(); + const dbA = traceD1(makeFakeD1(), { fetch: capA.fetch, ctx: capA.ctx }); + const dbB = traceD1(makeFakeD1(), { fetch: capB.fetch, ctx: capB.ctx }); + + d1ProfilerTag('shared'); + dbA.profilerTag('request_a'); + assert.equal(dbA.profilerGetTag(), 'request_a'); + assert.equal(dbB.profilerGetTag(), 'shared'); // falls back to shared stack + + await dbA.prepare('SELECT 1').run(); + await dbB.prepare('SELECT 2').run(); + + assert.equal(dbA.profilerUntag(), 'request_a'); + assert.equal(d1ProfilerUntag(), 'shared'); + + await dbA.prepare('SELECT 3').run(); + await capA.flush(); + await capB.flush(); + + assert.equal(capA.events[0].body.tag, 'request_a'); + assert.equal(capB.events[0].body.tag, 'shared'); + assert.equal(capA.events[1].body.tag, undefined); +}); + +test('synchronous throw from injected fetch is absorbed', async () => { + const pending = []; + const db = traceD1(makeFakeD1(), { + fetch: () => { throw new Error('sync boom'); }, + ctx: { waitUntil: (p) => pending.push(p) }, + }); + + const result = await db.prepare('SELECT 1').run(); + assert.deepEqual(result, { success: true }); + await Promise.all(pending); // must not reject +}); + test('captures backtrace frames when traceDepth > 0', async () => { const cap = makeCapture(); const db = traceD1(makeFakeD1(), { fetch: cap.fetch, ctx: cap.ctx, traceDepth: 5 }); From baf216037b50aa95d9ffdf7ec82753140e57a1a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:56:30 +0000 Subject: [PATCH 4/4] Fix stale jsonl field-order comment in collector https://claude.ai/code/session_01X8GQfc7ymjHGGUaHdaeReN --- d1-adapter/collector.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/d1-adapter/collector.js b/d1-adapter/collector.js index 20494d4..51c621d 100644 --- a/d1-adapter/collector.js +++ b/d1-adapter/collector.js @@ -90,7 +90,8 @@ export class Collector { } _appendJsonl(jobKey, event, ts) { - /* Field order matches profiler_log_jsonl(): k, q, tag, params, trace, s, ts */ + /* Emitted field order: k, q, tag, params, trace, s, db, ts - same as + * profiler_log_jsonl() plus the optional db marker before ts. */ let line = `{"k":${JSON.stringify(jobKey)},"q":${JSON.stringify(event.q)}`; if (typeof event.tag === 'string' && event.tag !== '') { line += `,"tag":${JSON.stringify(event.tag)}`;