'
+ '
Request ID
' + t.requestId + '
'
diff --git a/lib/diff.js b/lib/diff.js
new file mode 100644
index 0000000..2faad0a
--- /dev/null
+++ b/lib/diff.js
@@ -0,0 +1,129 @@
+'use strict';
+
+const { buildTimeline } = require('./timeline');
+const { groupSteps } = require('./analysis');
+
+// Compares two traces (a baseline "A" and a candidate "B") and reports how the
+// request shape changed: total duration delta, per-step regressions, added and
+// removed steps. Built for PR comments and CI gates.
+function diffTraces(traceA, traceB, options = {}) {
+ const a = buildTimeline(traceA);
+ const b = buildTimeline(traceB);
+
+ const groupsA = aggregateGroups(a.steps);
+ const groupsB = aggregateGroups(b.steps);
+ const keys = new Set([...groupsA.keys(), ...groupsB.keys()]);
+
+ const steps = [];
+ for (const key of keys) {
+ const ga = groupsA.get(key);
+ const gb = groupsB.get(key);
+ const durA = ga ? ga.totalDuration : 0;
+ const durB = gb ? gb.totalDuration : 0;
+ let status;
+ if (!ga) status = 'added';
+ else if (!gb) status = 'removed';
+ else if (durB > durA) status = 'slower';
+ else if (durB < durA) status = 'faster';
+ else status = 'unchanged';
+
+ steps.push({
+ name: (gb || ga).sample,
+ status,
+ durationA: durA,
+ durationB: durB,
+ deltaMs: durB - durA,
+ deltaPercent: percentChange(durA, durB),
+ countA: ga ? ga.count : 0,
+ countB: gb ? gb.count : 0,
+ });
+ }
+
+ // Most impactful changes first (largest absolute time delta).
+ steps.sort((x, y) => Math.abs(y.deltaMs) - Math.abs(x.deltaMs));
+
+ const totalDelta = b.totalDuration - a.totalDuration;
+ const regressionThreshold = options.regressionPercent || 0;
+ const totalPercent = percentChange(a.totalDuration, b.totalDuration);
+ const regressed =
+ totalPercent !== null && totalPercent > regressionThreshold && totalDelta > 0;
+
+ return {
+ a: summarize(a),
+ b: summarize(b),
+ totalDeltaMs: totalDelta,
+ totalDeltaPercent: totalPercent,
+ regressed,
+ added: steps.filter(s => s.status === 'added'),
+ removed: steps.filter(s => s.status === 'removed'),
+ slower: steps.filter(s => s.status === 'slower'),
+ faster: steps.filter(s => s.status === 'faster'),
+ steps,
+ };
+}
+
+function aggregateGroups(steps) {
+ // Reuse the normalization from analysis so dynamic ids collapse together.
+ return groupSteps(steps);
+}
+
+function summarize(report) {
+ return {
+ requestId: report.requestId,
+ method: report.method,
+ path: report.path,
+ totalDuration: report.totalDuration,
+ status: report.status,
+ };
+}
+
+function percentChange(from, to) {
+ if (!from) return to ? null : 0;
+ return Number((((to - from) / from) * 100).toFixed(1));
+}
+
+// Render a markdown table suitable for a PR comment.
+function diffToMarkdown(diff) {
+ const arrow = diff.totalDeltaMs > 0 ? '๐บ' : diff.totalDeltaMs < 0 ? '๐ป' : 'โ';
+ const pct = diff.totalDeltaPercent === null ? 'n/a' : `${diff.totalDeltaPercent}%`;
+ const lines = [];
+ lines.push(
+ `### ${arrow} Trace diff: \`${diff.b.method} ${diff.b.path}\``
+ );
+ lines.push('');
+ lines.push(
+ `**Total:** ${diff.a.totalDuration}ms โ ${diff.b.totalDuration}ms ` +
+ `(${signed(diff.totalDeltaMs)}ms, ${pct})` +
+ (diff.regressed ? ' โ โ ๏ธ **regression**' : '')
+ );
+ lines.push('');
+ lines.push('| Step | Before | After | ฮ | Change |');
+ lines.push('|---|---:|---:|---:|---|');
+ for (const s of diff.steps) {
+ if (s.status === 'unchanged') continue;
+ const change = changeLabel(s);
+ lines.push(
+ `| \`${escapeCell(s.name)}\` | ${s.durationA}ms | ${s.durationB}ms | ` +
+ `${signed(s.deltaMs)}ms | ${change} |`
+ );
+ }
+ return lines.join('\n');
+}
+
+function changeLabel(s) {
+ if (s.status === 'added') return '๐ added';
+ if (s.status === 'removed') return '๐๏ธ removed';
+ const pct = s.deltaPercent === null ? '' : ` (${signed(s.deltaPercent)}%)`;
+ if (s.status === 'slower') return `๐บ slower${pct}`;
+ return `๐ป faster${pct}`;
+}
+
+function signed(n) {
+ return n > 0 ? `+${n}` : `${n}`;
+}
+
+function escapeCell(value) {
+ return String(value).replace(/\|/g, '\\|');
+}
+
+module.exports = { diffTraces, diffToMarkdown };
diff --git a/lib/explain.js b/lib/explain.js
new file mode 100644
index 0000000..9d92556
--- /dev/null
+++ b/lib/explain.js
@@ -0,0 +1,105 @@
+'use strict';
+
+const { buildTimeline } = require('./timeline');
+
+// Builds a compact, structured summary of a trace that's ideal as an LLM
+// prompt. Kept separate from any network call so it's testable and usable
+// offline (e.g. paste into your own chat).
+function buildExplainPrompt(trace) {
+ const report = buildTimeline(trace);
+ const s = report.summary;
+
+ const facts = [];
+ facts.push(`Request: ${report.method} ${report.path} (status ${report.status})`);
+ facts.push(`Total duration: ${report.totalDuration}ms across ${report.stepCount} steps.`);
+ if (s.bottleneck) {
+ facts.push(
+ `Bottleneck: "${s.bottleneck.name}" took ${s.bottleneck.duration}ms ` +
+ `(${s.bottleneck.percentOfRequest}% of the request).`
+ );
+ }
+ facts.push(
+ `Coverage: ${s.coveragePercent}% of the request is explained by recorded steps; ` +
+ `${s.uninstrumentedDuration}ms is uninstrumented (untraced time).`
+ );
+ if (s.errorCount) facts.push(`${s.errorCount} step(s) errored.`);
+
+ if (s.duplicates && s.duplicates.length) {
+ for (const d of s.duplicates) {
+ facts.push(
+ `${d.isNPlusOne ? 'N+1 pattern' : 'Duplicate work'}: "${d.sample}" ran ` +
+ `${d.count}ร for ${d.totalDuration}ms total.`
+ );
+ }
+ }
+
+ if (s.gaps && s.gaps.length) {
+ const biggest = s.gaps.slice().sort((a, b) => b.duration - a.duration)[0];
+ facts.push(`Largest untraced gap: ${biggest.duration}ms (${biggest.percentOfRequest}%).`);
+ }
+
+ const steps = report.steps
+ .map(st => `- ${st.name}: ${st.duration}ms${st.type ? ` [${st.type}]` : ''}${st.error ? ` ERROR: ${st.error}` : ''}`)
+ .join('\n');
+
+ const system =
+ 'You are a senior performance engineer. Given a single web request trace, ' +
+ 'explain in 3-5 concise bullet points why it was slow and what to optimize ' +
+ 'first. Be specific and actionable. Prefer indexing, batching (fixing N+1), ' +
+ 'caching, and parallelization advice when the data supports it.';
+
+ const user =
+ `Trace summary:\n${facts.join('\n')}\n\nSteps (in order):\n${steps || '(none)'}`;
+
+ return { system, user, report };
+}
+
+// Calls an OpenAI-compatible chat completions endpoint. The caller supplies the
+// API key/URL/model so we never bundle a vendor or store secrets. Returns the
+// assistant text. Uses global fetch (Node 18+); falls back gracefully.
+async function explainTrace(trace, options = {}) {
+ const { system, user } = buildExplainPrompt(trace);
+
+ const apiKey = options.apiKey || process.env.OPENAI_API_KEY || process.env.LLM_API_KEY;
+ if (!apiKey) {
+ const err = new Error(
+ 'No API key provided. Set OPENAI_API_KEY or pass { apiKey }. ' +
+ 'You can also use buildExplainPrompt(trace) to copy the prompt manually.'
+ );
+ err.code = 'NO_API_KEY';
+ throw err;
+ }
+
+ const baseUrl = options.baseUrl || process.env.LLM_BASE_URL || 'https://api.openai.com/v1';
+ const model = options.model || process.env.LLM_MODEL || 'gpt-4o-mini';
+
+ if (typeof fetch !== 'function') {
+ throw new Error('global fetch is unavailable; Node 18+ is required for explainTrace');
+ }
+
+ const res = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model,
+ temperature: options.temperature ?? 0.2,
+ messages: [
+ { role: 'system', content: system },
+ { role: 'user', content: user },
+ ],
+ }),
+ });
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(`LLM request failed: HTTP ${res.status} ${text}`);
+ }
+
+ const json = await res.json();
+ return json.choices?.[0]?.message?.content?.trim() || '';
+}
+
+module.exports = { buildExplainPrompt, explainTrace };
diff --git a/lib/markdown.js b/lib/markdown.js
new file mode 100644
index 0000000..8d5b9a8
--- /dev/null
+++ b/lib/markdown.js
@@ -0,0 +1,76 @@
+'use strict';
+
+const { buildTimeline, renderTimeline } = require('./timeline');
+
+// GitHub-flavored markdown export for a single trace. Designed to be pasted
+// directly into a PR description, issue, or incident note. Uses a collapsible
+//
block so it stays compact in long threads.
+function toMarkdown(trace, options = {}) {
+ const report = buildTimeline(trace);
+ const s = report.summary;
+ const slow = options.slowThreshold && report.totalDuration >= options.slowThreshold;
+ const emoji = slow ? '๐ข' : 'โก';
+
+ const lines = [];
+ const title =
+ `${emoji} \`${report.method} ${report.path}\` โ **${report.totalDuration}ms** ` +
+ `(${report.status})`;
+ lines.push(``);
+ lines.push(`${title}
`);
+ lines.push('');
+
+ // Summary bullets.
+ if (report.requestId) lines.push(`- **Request:** \`${report.requestId}\``);
+ if (s.bottleneck) {
+ lines.push(
+ `- **Bottleneck:** \`${s.bottleneck.name}\` ${s.bottleneck.duration}ms ` +
+ `(${s.bottleneck.percentOfRequest}%)`
+ );
+ }
+ lines.push(
+ `- **Coverage:** ${s.coveredDuration}ms traced (${s.coveragePercent}%), ` +
+ `${s.uninstrumentedDuration}ms uninstrumented`
+ );
+ if (s.errorCount) lines.push(`- **Errors:** ${s.errorCount}`);
+
+ // N+1 / duplicate warnings get pulled to the top โ that's the shareable bit.
+ if (s.duplicates && s.duplicates.length) {
+ lines.push('');
+ for (const d of s.duplicates) {
+ const label = d.isNPlusOne ? 'โ ๏ธ **N+1 detected**' : 'โ ๏ธ **Duplicate work**';
+ lines.push(`- ${label}: \`${d.sample}\` ran **${d.count}ร** (${d.totalDuration}ms total)`);
+ }
+ }
+
+ // Step table.
+ lines.push('');
+ lines.push('| Step | Start | Duration | % | Type |');
+ lines.push('|---|---:|---:|---:|---|');
+ if (report.steps.length) {
+ for (const step of report.steps) {
+ const name = step.error ? `${step.name} โ` : step.name;
+ lines.push(
+ `| ${escapeCell(name)} | ${step.offset}ms | ${step.duration}ms | ` +
+ `${step.percentOfRequest}% | ${step.type || ''} |`
+ );
+ }
+ } else {
+ lines.push('| _no steps recorded_ | | | | |');
+ }
+
+ // ASCII timeline in a code block for the visual hook.
+ lines.push('');
+ lines.push('```txt');
+ lines.push(renderTimeline(trace, { ascii: true }));
+ lines.push('```');
+ lines.push('');
+ lines.push(' ');
+
+ return lines.join('\n');
+}
+
+function escapeCell(value) {
+ return String(value).replace(/\|/g, '\\|');
+}
+
+module.exports = { toMarkdown };
diff --git a/lib/routes.js b/lib/routes.js
index 177d2ad..037f82d 100644
--- a/lib/routes.js
+++ b/lib/routes.js
@@ -2,6 +2,10 @@
const { getDashboardHtml } = require('./dashboard');
const { toChromeTraceFormat } = require('./chrome-trace');
+const { toSpeedscope } = require('./speedscope');
+const { toShareableHtml } = require('./snapshot');
+const { toMarkdown } = require('./markdown');
+const { diffTraces } = require('./diff');
const { buildTimeline, renderTimeline } = require('./timeline');
function createRouter(tracer) {
@@ -47,6 +51,52 @@ function createRouter(tracer) {
return res.end(JSON.stringify({ error: 'Trace not found' }));
}
+ const speedscopeMatch = url.match(/^\/trace\/([a-zA-Z0-9_]+)\/speedscope$/);
+ if (speedscopeMatch) {
+ const trace = tracer.storage.get(speedscopeMatch[1]);
+ if (trace) {
+ return serveJson(res, toSpeedscope(trace));
+ }
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: 'Trace not found' }));
+ }
+
+ const markdownMatch = url.match(/^\/trace\/([a-zA-Z0-9_]+)\/markdown$/);
+ if (markdownMatch) {
+ const trace = tracer.storage.get(markdownMatch[1]);
+ if (trace) {
+ res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
+ return res.end(toMarkdown(trace, { slowThreshold: tracer.config.slowThreshold }));
+ }
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: 'Trace not found' }));
+ }
+
+ const snapshotMatch = url.match(/^\/trace\/([a-zA-Z0-9_]+)\/snapshot$/);
+ if (snapshotMatch) {
+ const trace = tracer.storage.get(snapshotMatch[1]);
+ if (trace) {
+ res.writeHead(200, {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="${snapshotMatch[1]}.html"`,
+ });
+ return res.end(toShareableHtml(trace));
+ }
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: 'Trace not found' }));
+ }
+
+ const diffMatch = url.match(/^\/trace\/diff\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)$/);
+ if (diffMatch) {
+ const a = tracer.storage.get(diffMatch[1]);
+ const b = tracer.storage.get(diffMatch[2]);
+ if (a && b) {
+ return serveJson(res, diffTraces(a, b));
+ }
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: 'Trace not found' }));
+ }
+
const singleMatch = url.match(/^\/trace\/([a-zA-Z0-9_]+)$/);
if (singleMatch) {
const trace = tracer.storage.get(singleMatch[1]);
diff --git a/lib/snapshot.js b/lib/snapshot.js
new file mode 100644
index 0000000..7e3b0ef
--- /dev/null
+++ b/lib/snapshot.js
@@ -0,0 +1,95 @@
+'use strict';
+
+const { buildTimeline } = require('./timeline');
+
+// Produces a single self-contained HTML file (no external assets, no server)
+// that renders a shareable view of one trace. "Send your slow trace to a
+// teammate as one .html file."
+function toShareableHtml(trace) {
+ const report = buildTimeline(trace);
+ const data = JSON.stringify(report).replace(/
+
+
+
+
+Trace ${escapeHtml(report.requestId || '')} ยท ${escapeHtml(report.method)} ${escapeHtml(report.path)}
+
+
+
+
+
+
+`;
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
+
+module.exports = { toShareableHtml };
diff --git a/lib/speedscope.js b/lib/speedscope.js
new file mode 100644
index 0000000..6d73102
--- /dev/null
+++ b/lib/speedscope.js
@@ -0,0 +1,84 @@
+'use strict';
+
+// Speedscope is the de-facto interactive flamegraph viewer
+// (https://www.speedscope.app). We emit the "evented" profile format, which is
+// the most forgiving for sequential/overlapping spans like ours.
+//
+// Each step becomes an OpenFrame (O) / CloseFrame (C) event pair on a single
+// timeline ordered by start time. Drop the JSON at speedscope.app to explore.
+function toSpeedscope(trace) {
+ const steps = Array.isArray(trace.steps) ? trace.steps : [];
+ const requestStart = Number(trace.startTime) || firstStart(steps);
+
+ const frames = [];
+ const frameIndex = new Map();
+ const getFrame = (name) => {
+ if (frameIndex.has(name)) return frameIndex.get(name);
+ const idx = frames.length;
+ frames.push({ name });
+ frameIndex.set(name, idx);
+ return idx;
+ };
+
+ // Build (start, end, frame) tuples relative to request start, in ms.
+ const spans = steps.map((step) => {
+ const at = (Number(step.start) || requestStart) - requestStart;
+ const dur = Math.max(Number(step.duration) || 0, 0);
+ return {
+ frame: getFrame(step.error ? `${step.name} (error)` : step.name),
+ at: Math.max(at, 0),
+ end: Math.max(at, 0) + dur,
+ };
+ });
+
+ // Speedscope evented format requires events in non-decreasing time order,
+ // with closes before opens at the same timestamp handled via a stack-safe
+ // ordering. We use a simple sweep producing O then C per span โ correct for
+ // a single-thread sequential view.
+ const events = [];
+ for (const span of spans.sort((a, b) => a.at - b.at)) {
+ events.push({ type: 'O', frame: span.frame, at: span.at });
+ events.push({ type: 'C', frame: span.frame, at: span.end });
+ }
+ // Re-sort so all events are time-ordered; on ties, closes precede opens.
+ events.sort((a, b) => {
+ if (a.at !== b.at) return a.at - b.at;
+ if (a.type === b.type) return 0;
+ return a.type === 'C' ? -1 : 1;
+ });
+
+ const totalDuration = Math.max(
+ Number(trace.duration) || 0,
+ events.length ? events[events.length - 1].at : 0
+ );
+
+ return {
+ $schema: 'https://www.speedscope.app/file-format-schema.json',
+ name: `${trace.method || 'REQ'} ${trace.path || '/'} (${trace.requestId || 'trace'})`,
+ activeProfileIndex: 0,
+ exporter: 'node-request-trace',
+ shared: { frames },
+ profiles: [
+ {
+ type: 'evented',
+ name: `${trace.method || 'REQ'} ${trace.path || '/'}`,
+ unit: 'milliseconds',
+ startValue: 0,
+ endValue: totalDuration,
+ events,
+ },
+ ],
+ };
+}
+
+function toSpeedscopeJson(trace) {
+ return JSON.stringify(toSpeedscope(trace));
+}
+
+function firstStart(steps) {
+ if (!steps.length) return Date.now();
+ const starts = steps.map(s => Number(s.start)).filter(Number.isFinite);
+ return starts.length ? Math.min(...starts) : Date.now();
+}
+
+module.exports = { toSpeedscope, toSpeedscopeJson };
diff --git a/lib/timeline.js b/lib/timeline.js
index 6437e02..95aff97 100644
--- a/lib/timeline.js
+++ b/lib/timeline.js
@@ -1,5 +1,7 @@
'use strict';
+const { analyzeRepetition, formatRepetitionWarnings } = require('./analysis');
+
const DEFAULT_WIDTH = 48;
function buildTimeline(trace) {
@@ -21,6 +23,8 @@ function buildTimeline(trace) {
return current;
}, null);
+ const repetition = analyzeRepetition(steps);
+
return {
requestId: trace.requestId || null,
method: trace.method || 'UNKNOWN',
@@ -39,6 +43,10 @@ function buildTimeline(trace) {
coveragePercent: percent(coveredDuration, totalDuration),
errorCount: steps.filter(step => step.error).length,
gaps,
+ nPlusOne: repetition.nPlusOne,
+ duplicates: repetition.duplicates,
+ hasNPlusOne: repetition.hasNPlusOne,
+ wastedDuration: repetition.wastedDuration,
},
};
}
@@ -67,6 +75,15 @@ function renderTimeline(trace, options = {}) {
`coverage: ${report.summary.coveredDuration}ms traced (${report.summary.coveragePercent}%), ` +
`${report.summary.uninstrumentedDuration}ms uninstrumented`
);
+
+ const warnings = formatRepetitionWarnings(
+ { duplicates: report.summary.duplicates },
+ { ascii: options.ascii }
+ );
+ for (const warning of warnings) {
+ lines.push(warning);
+ }
+
lines.push(`0ms |${'-'.repeat(width)}| ${report.totalDuration}ms`);
if (!report.steps.length) {
diff --git a/package.json b/package.json
index 0674b7e..2ee2df0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "node-request-trace",
- "version": "2.2.0",
+ "version": "2.3.0",
"description": "Debug Node.js requests as timelines without OpenTelemetry",
"main": "index.js",
"types": "index.d.ts",
diff --git a/test/run.js b/test/run.js
index 9656442..99ac7eb 100644
--- a/test/run.js
+++ b/test/run.js
@@ -2481,5 +2481,706 @@ test('api: destroy disables http tracing', () => {
assert(!httpTracer.isEnabled());
});
+// =========================================================================
+// analysis.js โ N+1 / duplicate detection
+// =========================================================================
+const analysis = require('../lib/analysis');
+
+test('analysis: normalizeStepName collapses ids, uuids, literals', () => {
+ assertEqual(
+ analysis.normalizeStepName('SELECT * FROM users WHERE id = 42'),
+ 'SELECT * FROM users WHERE id = ?'
+ );
+ assertEqual(
+ analysis.normalizeStepName("SELECT * FROM t WHERE name = 'bob'"),
+ "SELECT * FROM t WHERE name = '?'"
+ );
+ assertEqual(
+ analysis.normalizeStepName('GET /api/users/123?foo=bar'),
+ 'GET /api/users/?'
+ );
+ assertEqual(
+ analysis.normalizeStepName('find 507f1f77bcf86cd799439011'),
+ 'find '
+ );
+});
+
+test('analysis: analyzeRepetition flags N+1 above threshold', () => {
+ const steps = [];
+ for (let i = 1; i <= 6; i++) {
+ steps.push({ name: `SELECT * FROM users WHERE id = ${i}`, duration: 10, type: 'db' });
+ }
+ const result = analysis.analyzeRepetition(steps);
+ assert(result.hasNPlusOne, 'should detect N+1');
+ assertEqual(result.duplicates[0].count, 6);
+ assertEqual(result.duplicates[0].isNPlusOne, true);
+ assertEqual(result.duplicates[0].totalDuration, 60);
+});
+
+test('analysis: duplicates under N+1 threshold are not N+1', () => {
+ const steps = [
+ { name: 'cache.get key 1', duration: 2 },
+ { name: 'cache.get key 2', duration: 2 },
+ { name: 'cache.get key 3', duration: 2 },
+ ];
+ const result = analysis.analyzeRepetition(steps);
+ assertEqual(result.duplicateCount, 1);
+ assertEqual(result.hasNPlusOne, false);
+});
+
+test('analysis: unique steps produce no duplicates', () => {
+ const steps = [
+ { name: 'auth', duration: 5 },
+ { name: 'render', duration: 8 },
+ ];
+ const result = analysis.analyzeRepetition(steps);
+ assertEqual(result.duplicateCount, 0);
+ assertEqual(result.wastedDuration, 0);
+});
+
+test('analysis: wastedDuration excludes one max-cost run', () => {
+ const steps = [
+ { name: 'q 1', duration: 10 },
+ { name: 'q 2', duration: 10 },
+ { name: 'q 3', duration: 30 },
+ ];
+ const result = analysis.analyzeRepetition(steps);
+ // total 50, minus the single max (30) = 20 wasted
+ assertEqual(result.wastedDuration, 20);
+});
+
+test('analysis: formatRepetitionWarnings produces lines', () => {
+ const steps = [];
+ for (let i = 0; i < 5; i++) steps.push({ name: `q ${i}`, duration: 4 });
+ const result = analysis.analyzeRepetition(steps);
+ const lines = analysis.formatRepetitionWarnings(result, { ascii: true });
+ assertEqual(lines.length, 1);
+ assert(lines[0].includes('N+1 detected'));
+ assert(lines[0].includes('ran 5ร'));
+});
+
+test('analysis: empty/invalid steps are safe', () => {
+ assertEqual(analysis.analyzeRepetition().duplicateCount, 0);
+ assertEqual(analysis.analyzeRepetition(null).duplicateCount, 0);
+ assertEqual(analysis.analyzeRepetition([]).duplicateCount, 0);
+});
+
+// =========================================================================
+// speedscope.js โ flamegraph export
+// =========================================================================
+const speedscope = require('../lib/speedscope');
+
+test('speedscope: toSpeedscope produces evented profile', () => {
+ const trace = {
+ requestId: 'ss1', method: 'GET', path: '/x', startTime: 100, duration: 50, status: 200,
+ steps: [
+ { name: 'a', start: 100, duration: 10 },
+ { name: 'b', start: 110, duration: 20 },
+ ],
+ };
+ const out = speedscope.toSpeedscope(trace);
+ assertEqual(out.profiles[0].type, 'evented');
+ assertEqual(out.shared.frames.length, 2);
+ assertEqual(out.profiles[0].events.length, 4); // 2 open + 2 close
+ assertEqual(out.profiles[0].unit, 'milliseconds');
+});
+
+test('speedscope: events are time-ordered with closes before opens on ties', () => {
+ const trace = {
+ startTime: 0, duration: 10,
+ steps: [
+ { name: 'a', start: 0, duration: 5 },
+ { name: 'b', start: 5, duration: 5 },
+ ],
+ };
+ const events = speedscope.toSpeedscope(trace).profiles[0].events;
+ for (let i = 1; i < events.length; i++) {
+ assert(events[i].at >= events[i - 1].at, 'events must be non-decreasing in time');
+ }
+ // At t=5: close of 'a' must come before open of 'b'
+ const atFive = events.filter(e => e.at === 5);
+ assertEqual(atFive[0].type, 'C');
+ assertEqual(atFive[1].type, 'O');
+});
+
+test('speedscope: toSpeedscopeJson returns valid JSON string', () => {
+ const json = speedscope.toSpeedscopeJson({ startTime: 0, duration: 1, steps: [] });
+ const parsed = JSON.parse(json);
+ assertEqual(parsed.exporter, 'node-request-trace');
+});
+
+test('speedscope: error steps get labeled frames', () => {
+ const out = speedscope.toSpeedscope({
+ startTime: 0, duration: 5,
+ steps: [{ name: 'boom', start: 0, duration: 5, error: 'x' }],
+ });
+ assert(out.shared.frames[0].name.includes('(error)'));
+});
+
+// =========================================================================
+// markdown.js โ GitHub-flavored export
+// =========================================================================
+const markdown = require('../lib/markdown');
+
+test('markdown: toMarkdown renders collapsible summary and table', () => {
+ const trace = {
+ requestId: 'md1', method: 'POST', path: '/pay', startTime: 0, duration: 120, status: 201,
+ steps: [{ name: 'charge', start: 10, duration: 100, type: 'http' }],
+ };
+ const md = markdown.toMarkdown(trace);
+ assert(md.includes(''));
+ assert(md.includes(' '));
+ assert(md.includes('`POST /pay`'));
+ assert(md.includes('| Step | Start | Duration | % | Type |'));
+ assert(md.includes('charge'));
+ assert(md.includes('```txt'));
+});
+
+test('markdown: slow trace gets turtle emoji', () => {
+ const trace = { method: 'GET', path: '/', startTime: 0, duration: 900, status: 200, steps: [] };
+ const md = markdown.toMarkdown(trace, { slowThreshold: 500 });
+ assert(md.includes('๐ข'));
+});
+
+test('markdown: fast trace gets lightning emoji', () => {
+ const trace = { method: 'GET', path: '/', startTime: 0, duration: 5, status: 200, steps: [] };
+ const md = markdown.toMarkdown(trace, { slowThreshold: 500 });
+ assert(md.includes('โก'));
+});
+
+test('markdown: N+1 surfaces in markdown bullets', () => {
+ const steps = [];
+ for (let i = 1; i <= 5; i++) steps.push({ name: `SELECT x WHERE id = ${i}`, start: i, duration: 5, type: 'db' });
+ const md = markdown.toMarkdown({ method: 'GET', path: '/n', startTime: 0, duration: 30, status: 200, steps });
+ assert(md.includes('N+1 detected'));
+});
+
+test('markdown: pipe characters in step names are escaped', () => {
+ const trace = {
+ method: 'GET', path: '/', startTime: 0, duration: 10, status: 200,
+ steps: [{ name: 'a | b', start: 0, duration: 5 }],
+ };
+ const md = markdown.toMarkdown(trace);
+ assert(md.includes('a \\| b'));
+});
+
+// =========================================================================
+// snapshot.js โ self-contained HTML
+// =========================================================================
+const snapshot = require('../lib/snapshot');
+
+test('snapshot: toShareableHtml is self-contained (no external src/href except repo link)', () => {
+ const trace = {
+ requestId: 'snap1', method: 'GET', path: '/u', startTime: 0, duration: 30, status: 200,
+ steps: [{ name: 'work', start: 0, duration: 30 }],
+ };
+ const html = snapshot.toShareableHtml(trace);
+ assert(html.startsWith(''));
+ assert(html.includes('const R ='));
+ // No external scripts/styles
+ assert(!/', startTime: 0, duration: 5, status: 200,
+ steps: [{ name: 'x', start: 0, duration: 5 }],
+ };
+ const html = snapshot.toShareableHtml(trace);
+ // The embedded JSON must not contain a raw closing script tag
+ assert(!html.includes('x'), 'no premature script close in data');
+ assert(html.includes('\\u003c/script>') || html.includes('\\u003c'), 'angle brackets escaped in data');
+});
+
+// =========================================================================
+// diff.js โ regression diff
+// =========================================================================
+const diffMod = require('../lib/diff');
+
+function mkTrace(dur, steps) {
+ return { method: 'GET', path: '/d', startTime: 0, duration: dur, status: 200, steps };
+}
+
+test('diff: detects slower step and regression', () => {
+ const a = mkTrace(100, [{ name: 'db query', start: 0, duration: 40 }]);
+ const b = mkTrace(160, [{ name: 'db query', start: 0, duration: 100 }]);
+ const d = diffMod.diffTraces(a, b, { regressionPercent: 10 });
+ assertEqual(d.totalDeltaMs, 60);
+ assertEqual(d.regressed, true);
+ assertEqual(d.slower.length, 1);
+ assertEqual(d.slower[0].deltaMs, 60);
+});
+
+test('diff: detects added and removed steps', () => {
+ const a = mkTrace(50, [{ name: 'old step', start: 0, duration: 50 }]);
+ const b = mkTrace(60, [{ name: 'new step', start: 0, duration: 60 }]);
+ const d = diffMod.diffTraces(a, b);
+ assertEqual(d.added.length, 1);
+ assertEqual(d.removed.length, 1);
+ assertEqual(d.added[0].name, 'new step');
+ assertEqual(d.removed[0].name, 'old step');
+});
+
+test('diff: faster step is not a regression', () => {
+ const a = mkTrace(100, [{ name: 'x', start: 0, duration: 80 }]);
+ const b = mkTrace(40, [{ name: 'x', start: 0, duration: 20 }]);
+ const d = diffMod.diffTraces(a, b, { regressionPercent: 5 });
+ assertEqual(d.regressed, false);
+ assertEqual(d.faster.length, 1);
+});
+
+test('diff: normalized step names group across dynamic ids', () => {
+ const a = mkTrace(20, [{ name: 'SELECT WHERE id = 1', start: 0, duration: 20 }]);
+ const b = mkTrace(40, [{ name: 'SELECT WHERE id = 2', start: 0, duration: 40 }]);
+ const d = diffMod.diffTraces(a, b);
+ assertEqual(d.steps.length, 1);
+ assertEqual(d.steps[0].status, 'slower');
+});
+
+test('diff: diffToMarkdown renders table and regression flag', () => {
+ const a = mkTrace(100, [{ name: 'q', start: 0, duration: 40 }]);
+ const b = mkTrace(200, [{ name: 'q', start: 0, duration: 140 }]);
+ const md = diffMod.diffToMarkdown(diffMod.diffTraces(a, b, { regressionPercent: 10 }));
+ assert(md.includes('Trace diff'));
+ assert(md.includes('regression'));
+ assert(md.includes('| Step | Before | After |'));
+});
+
+// =========================================================================
+// explain.js โ AI prompt builder
+// =========================================================================
+const explain = require('../lib/explain');
+
+test('explain: buildExplainPrompt includes facts and steps', () => {
+ const trace = {
+ method: 'GET', path: '/slow', startTime: 0, duration: 500, status: 200,
+ steps: [{ name: 'db', start: 0, duration: 450, type: 'db' }],
+ };
+ const { system, user } = explain.buildExplainPrompt(trace);
+ assert(system.includes('performance engineer'));
+ assert(user.includes('GET /slow'));
+ assert(user.includes('Bottleneck'));
+ assert(user.includes('db: 450ms'));
+});
+
+test('explain: prompt surfaces N+1 patterns', () => {
+ const steps = [];
+ for (let i = 1; i <= 6; i++) steps.push({ name: `SELECT id = ${i}`, start: i, duration: 5, type: 'db' });
+ const { user } = explain.buildExplainPrompt(mkTrace(60, steps));
+ assert(user.includes('N+1 pattern'));
+});
+
+test('explain: explainTrace rejects without API key', async () => {
+ const saved = { o: process.env.OPENAI_API_KEY, l: process.env.LLM_API_KEY };
+ delete process.env.OPENAI_API_KEY;
+ delete process.env.LLM_API_KEY;
+ let threw = false;
+ try {
+ await explain.explainTrace(mkTrace(10, []));
+ } catch (err) {
+ threw = true;
+ assertEqual(err.code, 'NO_API_KEY');
+ }
+ if (saved.o) process.env.OPENAI_API_KEY = saved.o;
+ if (saved.l) process.env.LLM_API_KEY = saved.l;
+ assert(threw, 'should throw without API key');
+});
+
+// =========================================================================
+// auto-instrument.js
+// =========================================================================
+const autoInstrument = require('../lib/auto-instrument');
+const { runWithTrace: rwtAI } = require('../lib/trace-engine');
+
+test('auto-instrument: enable returns applied list (best-effort)', () => {
+ const applied = autoInstrument.enableAutoInstrumentation();
+ assert(Array.isArray(applied));
+ autoInstrument.disableAutoInstrumentation();
+});
+
+test('auto-instrument: instrumentKnexInstance wires event hooks', () => {
+ const { EventEmitter } = require('node:events');
+ const fakeKnex = new EventEmitter();
+ const returned = autoInstrument.instrumentKnexInstance(fakeKnex);
+ assertEqual(returned, fakeKnex);
+ const trace = { steps: [] };
+ rwtAI(trace, () => {
+ const q = { __knexQueryUid: 'u1', sql: 'SELECT 1' };
+ fakeKnex.emit('query', q);
+ fakeKnex.emit('query-response', {}, q);
+ });
+ assertEqual(trace.steps.length, 1);
+ assert(trace.steps[0].name.includes('knex'));
+});
+
+test('auto-instrument: instrumentKnexInstance records query errors', () => {
+ const { EventEmitter } = require('node:events');
+ const fakeKnex = new EventEmitter();
+ autoInstrument.instrumentKnexInstance(fakeKnex);
+ const trace = { steps: [] };
+ rwtAI(trace, () => {
+ const q = { __knexQueryUid: 'e1', sql: 'BAD SQL' };
+ fakeKnex.emit('query', q);
+ fakeKnex.emit('query-error', new Error('boom'), q);
+ });
+ assertEqual(trace.steps[0].error, 'boom');
+});
+
+test('auto-instrument: instrumentPrismaClient registers $use middleware', async () => {
+ let registered = null;
+ const fakeClient = { $use(fn) { registered = fn; } };
+ const returned = autoInstrument.instrumentPrismaClient(fakeClient);
+ assertEqual(returned, fakeClient);
+ assert(typeof registered === 'function');
+ const trace = { steps: [] };
+ await rwtAI(trace, async () => {
+ await registered({ model: 'User', action: 'findMany' }, async () => ['ok']);
+ });
+ assertEqual(trace.steps.length, 1);
+ assert(trace.steps[0].name.includes('prisma User.findMany'));
+});
+
+test('auto-instrument: null instances are returned untouched', () => {
+ assertEqual(autoInstrument.instrumentKnexInstance(null), null);
+ assertEqual(autoInstrument.instrumentPrismaClient(null), null);
+});
+
+// =========================================================================
+// public API โ new viral features
+// =========================================================================
+const { RequestTracer: RTViral } = require('../index');
+
+test('api: analyze detects N+1 on a trace', () => {
+ const t = new RTViral();
+ const steps = [];
+ for (let i = 1; i <= 5; i++) steps.push({ name: `SELECT id=${i}`, start: i, duration: 4 });
+ const result = t.analyze({ steps });
+ assert(result.hasNPlusOne);
+});
+
+test('api: exportSpeedscope / exportSpeedscopeJson', () => {
+ const t = new RTViral();
+ const trace = { startTime: 0, duration: 10, steps: [{ name: 'a', start: 0, duration: 10 }] };
+ assertEqual(t.exportSpeedscope(trace).profiles[0].type, 'evented');
+ assert(typeof t.exportSpeedscopeJson(trace) === 'string');
+});
+
+test('api: toShareableHtml and toMarkdown', () => {
+ const t = new RTViral();
+ const trace = { method: 'GET', path: '/x', startTime: 0, duration: 10, status: 200, steps: [] };
+ assert(t.toShareableHtml(trace).startsWith(''));
+ assert(t.toMarkdown(trace).includes(''));
+});
+
+test('api: diff and diffToMarkdown', () => {
+ const t = new RTViral();
+ const a = mkTrace(100, [{ name: 'q', start: 0, duration: 40 }]);
+ const b = mkTrace(180, [{ name: 'q', start: 0, duration: 120 }]);
+ assertEqual(t.diff(a, b, { regressionPercent: 10 }).regressed, true);
+ assert(t.diffToMarkdown(a, b).includes('Trace diff'));
+});
+
+test('api: buildExplainPrompt returns system+user', () => {
+ const t = new RTViral();
+ const p = t.buildExplainPrompt(mkTrace(10, [{ name: 'x', start: 0, duration: 10 }]));
+ assert(p.system && p.user);
+});
+
+test('api: timeline summary now includes N+1 fields', () => {
+ const t = new RTViral();
+ const steps = [];
+ for (let i = 1; i <= 5; i++) steps.push({ name: `SELECT id=${i}`, start: i, duration: 4 });
+ const report = t.timeline({ requestId: 'r', method: 'GET', path: '/n', startTime: 0, duration: 30, status: 200, steps });
+ assertEqual(report.summary.hasNPlusOne, true);
+ assert(Array.isArray(report.summary.nPlusOne));
+ assert(report.summary.duplicates.length >= 1);
+});
+
+test('api: renderTimeline includes N+1 warning line', () => {
+ const t = new RTViral();
+ const steps = [];
+ for (let i = 1; i <= 5; i++) steps.push({ name: `SELECT id=${i}`, start: i, duration: 4 });
+ const text = t.renderTimeline({ method: 'GET', path: '/n', startTime: 0, duration: 30, status: 200, steps }, { ascii: true });
+ assert(text.includes('N+1 detected'));
+});
+
+test('api: init with autoInstrument enables and destroy disables', () => {
+ const t = new RTViral();
+ t.init({ autoInstrument: true });
+ assert(Array.isArray(t._autoInstrumented));
+ t.destroy();
+ assertEqual(t._autoInstrumented.length, 0);
+});
+
+// =========================================================================
+// routes.js โ new export endpoints
+// =========================================================================
+const { createRouter: createRouterV } = require('../lib/routes');
+
+function mockResV() {
+ const r = { _status: 0, _headers: {}, _body: '' };
+ r.writeHead = (code, headers) => { r._status = code; Object.assign(r._headers, headers || {}); };
+ r.end = (data) => { r._body = data || ''; };
+ r.statusCode = 200;
+ return r;
+}
+
+function tracerWithTraces() {
+ const { RequestTracer } = require('../index');
+ const t = new RequestTracer();
+ t.init();
+ const mk = (id, dur, qDur) => t.storage.store({
+ requestId: id, method: 'GET', path: '/u', startTime: 1000, duration: dur, status: 200,
+ steps: [{ name: 'SELECT WHERE id = 1', start: 1000, duration: qDur, type: 'db' }],
+ });
+ mk('rv_a', 100, 40);
+ mk('rv_b', 200, 120);
+ return t;
+}
+
+test('routes: /trace/:id/speedscope returns evented profile', () => {
+ const t = tracerWithTraces();
+ const res = mockResV();
+ createRouterV(t)({ url: '/trace/rv_a/speedscope' }, res);
+ assertEqual(res._status, 200);
+ assertEqual(JSON.parse(res._body).profiles[0].type, 'evented');
+ t.destroy();
+});
+
+test('routes: /trace/:id/speedscope 404 for missing', () => {
+ const t = tracerWithTraces();
+ const res = mockResV();
+ createRouterV(t)({ url: '/trace/nope/speedscope' }, res);
+ assertEqual(res._status, 404);
+ t.destroy();
+});
+
+test('routes: /trace/:id/markdown returns markdown', () => {
+ const t = tracerWithTraces();
+ const res = mockResV();
+ createRouterV(t)({ url: '/trace/rv_a/markdown' }, res);
+ assertEqual(res._status, 200);
+ assert(res._headers['Content-Type'].includes('text/markdown'));
+ assert(res._body.includes(''));
+ t.destroy();
+});
+
+test('routes: /trace/:id/markdown 404 for missing', () => {
+ const t = tracerWithTraces();
+ const res = mockResV();
+ createRouterV(t)({ url: '/trace/nope/markdown' }, res);
+ assertEqual(res._status, 404);
+ t.destroy();
+});
+
+test('routes: /trace/:id/snapshot returns downloadable html', () => {
+ const t = tracerWithTraces();
+ const res = mockResV();
+ createRouterV(t)({ url: '/trace/rv_a/snapshot' }, res);
+ assertEqual(res._status, 200);
+ assert(res._headers['Content-Disposition'].includes('rv_a.html'));
+ assert(res._body.startsWith(''));
+ t.destroy();
+});
+
+test('routes: /trace/:id/snapshot 404 for missing', () => {
+ const t = tracerWithTraces();
+ const res = mockResV();
+ createRouterV(t)({ url: '/trace/nope/snapshot' }, res);
+ assertEqual(res._status, 404);
+ t.destroy();
+});
+
+test('routes: /trace/diff/:a/:b returns diff json', () => {
+ const t = tracerWithTraces();
+ const res = mockResV();
+ createRouterV(t)({ url: '/trace/diff/rv_a/rv_b' }, res);
+ assertEqual(res._status, 200);
+ const d = JSON.parse(res._body);
+ assertEqual(d.totalDeltaMs, 100);
+ t.destroy();
+});
+
+test('routes: /trace/diff 404 when a trace missing', () => {
+ const t = tracerWithTraces();
+ const res = mockResV();
+ createRouterV(t)({ url: '/trace/diff/rv_a/nope' }, res);
+ assertEqual(res._status, 404);
+ t.destroy();
+});
+
+// =========================================================================
+// explain.js โ LLM call paths (mocked fetch)
+// =========================================================================
+test('explain: explainTrace calls fetch and returns content', async () => {
+ const savedFetch = global.fetch;
+ const savedKey = process.env.OPENAI_API_KEY;
+ let captured = null;
+ global.fetch = async (url, opts) => {
+ captured = { url, opts };
+ return {
+ ok: true,
+ json: async () => ({ choices: [{ message: { content: ' do batching ' } }] }),
+ };
+ };
+ const out = await explain.explainTrace(mkTrace(50, [{ name: 'x', start: 0, duration: 50 }]), {
+ apiKey: 'test-key', model: 'm', baseUrl: 'https://example.com/v1/',
+ });
+ assertEqual(out, 'do batching');
+ assert(captured.url === 'https://example.com/v1/chat/completions');
+ assert(captured.opts.headers.Authorization === 'Bearer test-key');
+ global.fetch = savedFetch;
+ if (savedKey) process.env.OPENAI_API_KEY = savedKey;
+});
+
+test('explain: explainTrace throws on non-ok response', async () => {
+ const savedFetch = global.fetch;
+ global.fetch = async () => ({ ok: false, status: 500, text: async () => 'boom' });
+ let threw = false;
+ try {
+ await explain.explainTrace(mkTrace(10, []), { apiKey: 'k' });
+ } catch (err) {
+ threw = true;
+ assert(err.message.includes('HTTP 500'));
+ }
+ global.fetch = savedFetch;
+ assert(threw);
+});
+
+test('explain: prompt includes errors and gaps when present', () => {
+ const trace = {
+ method: 'GET', path: '/g', startTime: 0, duration: 200, status: 500,
+ steps: [{ name: 'fail', start: 0, duration: 10, error: 'nope' }],
+ };
+ const { user } = explain.buildExplainPrompt(trace);
+ assert(user.includes('errored'));
+ assert(user.includes('untraced gap'));
+});
+
+// =========================================================================
+// auto-instrument.js โ mocked driver patching
+// =========================================================================
+test('auto-instrument: enableAutoInstrumentation honors only filter', () => {
+ const applied = autoInstrument.enableAutoInstrumentation({ only: ['knex'] });
+ assert(Array.isArray(applied));
+ assert(applied.every(n => n === 'knex'));
+ autoInstrument.disableAutoInstrumentation();
+});
+
+test('auto-instrument: knex instance without .on is returned untouched', () => {
+ const fake = {};
+ assertEqual(autoInstrument.instrumentKnexInstance(fake), fake);
+});
+
+test('auto-instrument: prisma middleware records errors', async () => {
+ let registered = null;
+ autoInstrument.instrumentPrismaClient({ $use(fn) { registered = fn; } });
+ const trace = { steps: [] };
+ let threw = false;
+ await rwtAI(trace, async () => {
+ try {
+ await registered({ model: 'User', action: 'create' }, async () => { throw new Error('dup'); });
+ } catch (_) { threw = true; }
+ });
+ assert(threw);
+ assertEqual(trace.steps[0].error, 'dup');
+});
+
+test('auto-instrument: disable is idempotent', () => {
+ autoInstrument.disableAutoInstrumentation();
+ autoInstrument.disableAutoInstrumentation();
+ assert(true);
+});
+
+test('auto-instrument: pg query is timed (promise + callback)', async () => {
+ class Client { query() { return Promise.resolve({ rows: [] }); } }
+ autoInstrument.__setRequireForTests((name) => (name === 'pg' ? { Client } : null));
+ const applied = autoInstrument.enableAutoInstrumentation({ only: ['pg'] });
+ assertEqual(applied[0], 'pg');
+ const trace = { steps: [] };
+ await rwtAI(trace, async () => {
+ const c = new Client();
+ await c.query('SELECT * FROM users WHERE id = 1');
+ await c.query({ text: 'SELECT 2' });
+ });
+ assertEqual(trace.steps.length, 2);
+ assert(trace.steps[0].name.startsWith('pg '));
+ autoInstrument.disableAutoInstrumentation();
+ autoInstrument.__setRequireForTests(null);
+});
+
+test('auto-instrument: pg callback style records step', () => {
+ class Client { query(sql, cb) { cb(null, { rows: [] }); } }
+ autoInstrument.__setRequireForTests((name) => (name === 'pg' ? { Client } : null));
+ autoInstrument.enableAutoInstrumentation({ only: ['pg'] });
+ const trace = { steps: [] };
+ rwtAI(trace, () => {
+ const c = new Client();
+ c.query('SELECT 1', () => {});
+ });
+ assertEqual(trace.steps.length, 1);
+ autoInstrument.disableAutoInstrumentation();
+ autoInstrument.__setRequireForTests(null);
+});
+
+test('auto-instrument: mongodb collection methods are timed', async () => {
+ class Collection {
+ constructor() { this.collectionName = 'users'; }
+ find() { return { toArray: () => Promise.resolve([]) }; }
+ findOne() { return Promise.resolve(null); }
+ insertOne() { return Promise.resolve({}); }
+ insertMany() { return Promise.resolve({}); }
+ updateOne() { return Promise.resolve({}); }
+ updateMany() { return Promise.resolve({}); }
+ deleteOne() { return Promise.resolve({}); }
+ deleteMany() { return Promise.resolve({}); }
+ aggregate() { return { toArray: () => Promise.resolve([]) }; }
+ countDocuments() { return Promise.resolve(0); }
+ }
+ autoInstrument.__setRequireForTests((name) => (name === 'mongodb' ? { Collection } : null));
+ const applied = autoInstrument.enableAutoInstrumentation({ only: ['mongodb'] });
+ assertEqual(applied[0], 'mongodb');
+ const trace = { steps: [] };
+ await rwtAI(trace, async () => {
+ const col = new Collection();
+ col.find({}); // cursor (sync timed)
+ await col.findOne({}); // promise timed
+ await col.insertOne({});
+ });
+ assert(trace.steps.length >= 3);
+ assert(trace.steps.some(s => s.name.includes('mongo users.find')));
+ autoInstrument.disableAutoInstrumentation();
+ autoInstrument.__setRequireForTests(null);
+});
+
+test('auto-instrument: ioredis sendCommand is timed', async () => {
+ class Redis { sendCommand() { return Promise.resolve('OK'); } }
+ autoInstrument.__setRequireForTests((name) => (name === 'ioredis' ? Redis : null));
+ const applied = autoInstrument.enableAutoInstrumentation({ only: ['redis'] });
+ assertEqual(applied[0], 'redis');
+ const trace = { steps: [] };
+ await rwtAI(trace, async () => {
+ const r = new Redis();
+ await r.sendCommand({ name: 'get' });
+ });
+ assertEqual(trace.steps.length, 1);
+ assert(trace.steps[0].name === 'redis GET');
+ autoInstrument.disableAutoInstrumentation();
+ autoInstrument.__setRequireForTests(null);
+});
+
+test('auto-instrument: recordStep is a no-op without active trace', () => {
+ class Client { query() { return Promise.resolve({}); } }
+ autoInstrument.__setRequireForTests((name) => (name === 'pg' ? { Client } : null));
+ autoInstrument.enableAutoInstrumentation({ only: ['pg'] });
+ // No runWithTrace wrapper -> no active trace -> should not throw
+ const c = new Client();
+ c.query('SELECT 1');
+ assert(true);
+ autoInstrument.disableAutoInstrumentation();
+ autoInstrument.__setRequireForTests(null);
+});
+
// Run all tests
run();