diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 96bf54a..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') @@ -46,6 +49,29 @@ 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 + with: + persist-credentials: false + + - 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/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 new file mode 100644 index 0000000..28605e7 --- /dev/null +++ b/d1-adapter/README.md @@ -0,0 +1,138 @@ +# 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: + +```text +┌─────────────────────────────┐ ┌─────────────────────────┐ +│ 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 } 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' }); + + db.profilerTag('user_lookup'); + const user = await db.prepare('SELECT * FROM users WHERE id = ?') + .bind(42) + .first(); + db.profilerUntag(); + + 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. + +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 | +|---|---| +| `traceD1(db, options?)` | Wrap a `D1Database` (or `D1DatabaseSession`); intercepts `prepare/bind/first/run/all/raw/batch/exec/withSession` | +| `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 + +| 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..51c621d --- /dev/null +++ b/d1-adapter/collector.js @@ -0,0 +1,304 @@ +#!/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) { + /* 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)}`; + } + 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')}`; + /* 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); + } + + _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. */ + } + } +} + +/* 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; + } + 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..edb19ee --- /dev/null +++ b/d1-adapter/index.d.ts @@ -0,0 +1,69 @@ +/** + * 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; +} + +/** 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. + * + * @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 & D1ProfilerTagMethods; + +/** + * 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 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 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 new file mode 100644 index 0000000..1c207e9 --- /dev/null +++ b/d1-adapter/index.js @@ -0,0 +1,295 @@ +/* + 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) */ +/* ----------------------------------------------------------------- */ + +/* + * 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 = []; + +function popTag(stack, tag) { + if (tag === null || tag === undefined) { + 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 = 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 */ +/* ----------------------------------------------------------------- */ + +/** + * 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)), + /* Request-scoped tag stack, owned by this wrapper instance. */ + tags: [], + }; + + 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); + } + /* 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; + }, + }); +} + +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 = currentTag(cfg); + if (tag !== null) { + event.tag = tag; + } + if (params && params.length > 0) { + event.params = params.map(formatParam); + } + if (trace && trace.length > 0) { + event.trace = trace; + } + + /* 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(); + } + }) + .catch(() => { /* 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..09c4513 --- /dev/null +++ b/d1-adapter/test/collector.test.mjs @@ -0,0 +1,176 @@ +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('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 }); + + 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..7763a8c --- /dev/null +++ b/d1-adapter/test/trace-d1.test.mjs @@ -0,0 +1,200 @@ +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('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 }); + + 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 +}); 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');