backlens 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ai/fixes.js CHANGED
@@ -139,7 +139,7 @@ export function createAI(deps) {
139
139
  }
140
140
 
141
141
  const view = (job) => {
142
- const { ctx, issue, backups, convo, abort, ...rest } = job;
142
+ const { ctx, issue, backups, convo, abort, afterTrace, ...rest } = job;
143
143
  return { ...rest, now: Date.now(), issueTitle: issue.title, issueRoute: issue.route, spentTodayUsd: spentToday(), budgetUsd: settings.dailyBudgetUsd };
144
144
  };
145
145
 
@@ -387,6 +387,7 @@ export function createAI(deps) {
387
387
  job.stage = ok.length ? 'proposed' : 'no-edits';
388
388
  } catch (err) {
389
389
  job.error = err.message;
390
+ job.errorCode = err.code || null;
390
391
  job.stage = 'error';
391
392
  }
392
393
  })();
@@ -468,6 +469,12 @@ export function createAI(deps) {
468
469
  return { ok: r.code === 0, errors: r.out };
469
470
  }
470
471
 
472
+ /** What a request cost: the numbers shown before and after a fix. */
473
+ function stats(trace) {
474
+ const count = (kind) => trace.spans.filter((x) => x.kind === kind).length;
475
+ return { status: trace.status, ms: trace.duration, queries: count('db'), redis: count('redis'), http: count('http') };
476
+ }
477
+
471
478
  function replay(job, payload, port) {
472
479
  return new Promise((resolve) => {
473
480
  const timer = setTimeout(() => {
@@ -532,6 +539,9 @@ export function createAI(deps) {
532
539
  r.status = 'skip';
533
540
  r.detail = settings.replay ? 'the request that showed this issue is no longer in memory; use the page again to record a fresh one' : 'replay is off in AI settings';
534
541
  } else {
542
+ // The app has just restarted, so its first request pays for cold caches and new connections.
543
+ // A read is sent once to warm up, and the second one is measured.
544
+ if (!isWrite) await replay(job, payload, deps.appPort());
535
545
  const trace = await replay(job, payload, deps.appPort());
536
546
  if (!trace) {
537
547
  r.status = 'fail';
@@ -544,6 +554,8 @@ export function createAI(deps) {
544
554
  const before = original ? `${original.status} in ${original.duration} ms, ${original.spans.length} calls` : '';
545
555
  r.status = !still && sameClass ? 'done' : 'fail';
546
556
  r.detail = `before: ${before}\nafter: ${trace.status} in ${trace.duration} ms, ${trace.spans.length} calls${still ? ', issue still there' : ', issue gone'}`;
557
+ job.compare = { before: original ? stats(original) : null, after: stats(trace), issueGone: !still, warmed: !isWrite };
558
+ job.afterTrace = trace;
547
559
  if (trace.status === 401 && original && original.status !== 401) r.detail += '\nThe recorded login may have expired: sign in again and use the page once more.';
548
560
  if (r.status === 'fail') return finish(job, false, still ? 'The issue is still there after the change.' : `The request now answers ${trace.status}.`);
549
561
  }
@@ -585,6 +597,8 @@ export function createAI(deps) {
585
597
  append(historyFile, { at: new Date().toISOString(), type: job.issue.type, tier: job.route.tier || job.route.kind, model: job.route.model, verified: Boolean(job.verified), kept: true });
586
598
  cleanup(job);
587
599
  job.stage = 'kept';
600
+ // The measured request joins the normal list, and the issue leaves it until a request shows it again.
601
+ if (deps.markFixed) deps.markFixed(job.issue.key, job.afterTrace && job.compare && job.compare.issueGone ? job.afterTrace : null);
588
602
  ev(job, 'done', 'Kept. Review it in your editor and commit when ready.');
589
603
  return view(job);
590
604
  }
package/ai/providers.js CHANGED
@@ -362,7 +362,12 @@ async function anthropic(task, messages, routeInfo) {
362
362
  } catch (err) {
363
363
  if (err instanceof Anthropic.AuthenticationError) throw new Error('Claude rejected the key. Paste a new one in AI settings.');
364
364
  if (err instanceof Anthropic.RateLimitError) throw new Error('Claude rate limit reached. Wait a minute and try again.');
365
- if (err instanceof Anthropic.APIError) throw new Error(`Claude error ${err.status}: ${err.message}`);
365
+ // The SDK's message carries the raw JSON body; show the API's own sentence instead.
366
+ const said = err?.error?.error?.message || err.message;
367
+ if (err instanceof Anthropic.APIError && /credit balance/i.test(said)) {
368
+ throw Object.assign(new Error('Your Anthropic account has no credit left, so nothing was sent or charged. Add credits in the Anthropic Console, then try again.'), { code: 'billing' });
369
+ }
370
+ if (err instanceof Anthropic.APIError) throw new Error(`Claude error ${err.status}: ${said}`);
366
371
  throw err;
367
372
  }
368
373
  const cost = costUsd(routeInfo.tier, response.usage);
package/analyze.js CHANGED
@@ -236,7 +236,11 @@ function percentile(values, p) {
236
236
  }
237
237
 
238
238
  /** Routes and issues across every trace kept. */
239
- export function summarize(traces, known = [], advice = []) {
239
+ /**
240
+ * fixed: issue key -> when a fix for it was kept. Such an issue is hidden until a request after
241
+ * that moment shows it again.
242
+ */
243
+ export function summarize(traces, known = [], advice = [], fixed = new Map()) {
240
244
  const routes = new Map();
241
245
  const issues = new Map();
242
246
  for (const trace of traces) {
@@ -259,6 +263,7 @@ export function summarize(traces, known = [], advice = []) {
259
263
  agg.seen++;
260
264
  agg.costTotal += issue.cost;
261
265
  agg.example = trace.id;
266
+ agg.lastAt = Math.max(agg.lastAt || 0, trace.at || 0);
262
267
  }
263
268
  }
264
269
  // Index advice: one issue per route that ran the query. Its location skips wrapper lines that
@@ -281,8 +286,9 @@ export function summarize(traces, known = [], advice = []) {
281
286
  const span = trace.spans.find((s) => s.kind === 'db' && normalizeSql(s.name) === sql);
282
287
  if (!span) continue;
283
288
  const route = routeOf(trace.method, trace.url, trace.template);
284
- if (!perRoute.has(route)) perRoute.set(route, { seen: 0, example: trace.id, span });
289
+ if (!perRoute.has(route)) perRoute.set(route, { seen: 0, example: trace.id, span, lastAt: 0 });
285
290
  perRoute.get(route).seen++;
291
+ perRoute.get(route).lastAt = Math.max(perRoute.get(route).lastAt, trace.at || 0);
286
292
  }
287
293
  const big = a.rows >= 1000;
288
294
  const name = `${a.table}_${a.columns.join('_')}_idx`.slice(0, 63);
@@ -302,6 +308,7 @@ export function summarize(traces, known = [], advice = []) {
302
308
  route,
303
309
  seen: info.seen,
304
310
  example: info.example,
311
+ lastAt: info.lastAt,
305
312
  costTotal: 0,
306
313
  });
307
314
  }
@@ -311,6 +318,12 @@ export function summarize(traces, known = [], advice = []) {
311
318
  for (const route of known) {
312
319
  if (!routes.has(route)) routes.set(route, { route, calls: 0, durations: [], queries: 0, redis: 0, http: 0, errors: 0, issueKeys: new Set(), last: 0 });
313
320
  }
321
+ for (const [key, issue] of issues) {
322
+ if (fixed.has(key) && fixed.get(key) >= (issue.lastAt || 0)) {
323
+ issues.delete(key);
324
+ routes.get(issue.route)?.issueKeys.delete(key);
325
+ }
326
+ }
314
327
  const routeList = [...routes.values()]
315
328
  .map((r) => ({
316
329
  route: r.route,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlens",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Run your Node backend through backlens and see every request, every query, and what is wrong with them.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/server.js CHANGED
@@ -30,6 +30,8 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
30
30
  let config = manualArgv ? { app: null, test: null, autoRestart: false } : loadConfig(root);
31
31
 
32
32
  const traces = [];
33
+ // Issue key -> when its fix was kept (see summarize).
34
+ const fixedAt = new Map();
33
35
  let received = 0;
34
36
  const known = new Map();
35
37
  const advice = new Map();
@@ -207,15 +209,15 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
207
209
 
208
210
  // From the dashboard.
209
211
  if (req.method === 'GET' && url.pathname === '/api/state') {
210
- const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()]);
212
+ const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
211
213
  const recent = traces
212
214
  .slice(-300)
213
215
  .reverse()
214
- .map((t) => ({ id: t.id, at: t.at, method: t.method, url: t.url, route: routeOf(t.method, t.url, t.template), status: t.status, duration: t.duration, spans: t.spans.length, aborted: Boolean(t.aborted) }));
216
+ .map((t) => ({ id: t.id, at: t.at, method: t.method, url: t.url, route: routeOf(t.method, t.url, t.template), status: t.status, duration: t.duration, spans: t.spans.length, aborted: Boolean(t.aborted), afterFix: Boolean(t.afterFix) }));
215
217
  return json(res, 200, { routes, issues, recent, total: traces.length, received });
216
218
  }
217
219
  if (req.method === 'GET' && url.pathname === '/api/map') {
218
- const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
220
+ const { issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
219
221
  return json(res, 200, { ...mapOf(traces, issues), advice: [...advice.values()].map((a) => ({ table: a.table, columns: a.columns })) });
220
222
  }
221
223
  if (req.method === 'GET' && url.pathname.startsWith('/api/trace/')) {
@@ -228,12 +230,13 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
228
230
  traces.length = 0;
229
231
  received = 0;
230
232
  advice.clear();
233
+ fixedAt.clear();
231
234
  return json(res, 200, { ok: true });
232
235
  }
233
236
  if (req.method === 'POST' && url.pathname === '/api/snapshot') {
234
237
  const name = (url.searchParams.get('name') || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
235
238
  if (!name) return json(res, 400, { error: 'Give the snapshot a name.' });
236
- const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()]);
239
+ const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
237
240
  mkdirSync(snapshotDir, { recursive: true });
238
241
  writeFileSync(join(snapshotDir, `${name}.json`), `${JSON.stringify({ name, at: Date.now(), requests: traces.length, routes: routes.filter((r) => r.calls), issues: issues.length }, null, 2)}\n`);
239
242
  return json(res, 200, { ok: true, name });
@@ -360,10 +363,19 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
360
363
  pauseWatch: (on) => (watchPaused = on),
361
364
  testsTail: () => tests.lines.slice(-14).map((l) => l.text).join('\n'),
362
365
  getIssue: (key) => {
363
- const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
366
+ const { issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
364
367
  return issues.find((i) => i.key === key) || (testRun.report ? testRun.report.issues.find((i) => i.key === key) : null);
365
368
  },
366
369
  getTrace: (id) => traces.find((t) => t.id === id) || testRun.traces.find((t) => t.id === id),
370
+ markFixed: (key, after) => {
371
+ fixedAt.set(key, Date.now());
372
+ if (after) {
373
+ const { replayOf, ...trace } = after;
374
+ traces.push({ ...trace, afterFix: true });
375
+ received++;
376
+ if (traces.length > KEEP) replays.delete(traces.shift().id);
377
+ }
378
+ },
367
379
  getReplay: (id) => replays.get(id),
368
380
  restartApp: async () => {
369
381
  app.port = null;
package/ui.html CHANGED
@@ -154,7 +154,15 @@
154
154
  .opt-card:has(input:checked) { border-color: var(--accent); background: #ffffff05; }
155
155
  .opt-card { cursor: default; } .opt-card > label { cursor: pointer; }
156
156
  .opt-card a { color: var(--accent); }
157
- .opt-card .btn { text-decoration: none; display: inline-block; }
157
+ a.btn { text-decoration: none; display: inline-block; }
158
+ .compare-card { border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin: 4px 0 14px; }
159
+ .compare-card h4 { margin: 0 0 8px; font-size: 13px; }
160
+ .compare-card table { width: auto; border-collapse: collapse; font: 13px var(--mono); }
161
+ .compare-card th { text-align: left; font-weight: 400; color: var(--sub); padding: 3px 18px 3px 0; }
162
+ .compare-card td { padding: 3px 10px 3px 0; }
163
+ .compare-card .arrow { color: var(--sub); }
164
+ .compare-card .better { color: #4ade80; } .compare-card .worse { color: #f87171; } .compare-card .same { color: var(--text); }
165
+ .compare-card .delta { font-size: 12px; }
158
166
  .field input[type=password] { flex: 1; font: 13px var(--mono); color: var(--text); background: var(--bg); border: 1px solid var(--line-2); border-radius: 6px; padding: 6px 8px; }
159
167
  .progress { height: 6px; border-radius: 3px; background: var(--raise); overflow: hidden; margin: 10px 0 0; }
160
168
  .progress > div { height: 100%; background: var(--accent); transition: width .4s; }
@@ -363,7 +371,7 @@ function renderRequests() {
363
371
  return `<h2>${routeFilter ? `<button class="ghost" type="button" id="unfilter">${esc(routeFilter)} ✕</button>` : `latest ${list.length} requests`}</h2><table>
364
372
  <thead><tr><th>when</th><th>request</th><th class="n">status</th><th class="n">time</th><th class="n hide-sm">calls</th></tr></thead>
365
373
  <tbody>${list.map((t) => `<tr data-trace="${esc(t.id)}" class="${openTrace && openTrace.trace.id === t.id ? 'sel' : ''}">
366
- <td class="muted">${ago(t.at)}</td><td><span class="method">${esc(t.method)}</span><span class="route">${esc(t.url)}</span></td>
374
+ <td class="muted">${ago(t.at)}</td><td><span class="method">${esc(t.method)}</span><span class="route">${esc(t.url)}</span>${t.afterFix ? ' <span class="chip live">after fix</span>' : ''}</td>
367
375
  <td class="n ${statusClass(t.status)}">${t.aborted ? 'cancelled' : t.status}</td><td class="n">${ms(t.duration)}</td><td class="n hide-sm muted">${t.spans}</td></tr>`).join('')}</tbody></table>`;
368
376
  }
369
377
 
@@ -740,6 +748,7 @@ function renderFix() {
740
748
  el.innerHTML = html; return;
741
749
  }
742
750
  html += `<ol class="feed">${j.events.map(feedItem).join('')}${j.stage === 'error' ? `<li class="fi"><span class="dot bad">●</span><div class="verdict bad">${esc(j.error)}</div></li>` : ''}</ol>`;
751
+ html += compareCard(j);
743
752
  const act = [];
744
753
  if (j.stage === 'proposed') act.push('<button class="primary" type="button" id="fix-apply">Apply and check</button><button class="ghost" type="button" id="fix-close-2">Discard</button>');
745
754
  if (j.stage === 'no-edits') act.push(`<span class="note">No change was applied.</span>${escalateBtn(j)}`);
@@ -754,7 +763,29 @@ function renderFix() {
754
763
  el.scrollTop = atBottom || busy ? el.scrollHeight : keepScroll;
755
764
  }
756
765
 
766
+ /** The same request before and after the change, side by side. */
767
+ function compareCard(j) {
768
+ const c = j.compare;
769
+ if (!c || !c.before || !['verified', 'failed', 'unverified', 'kept', 'reverted'].includes(j.stage)) return '';
770
+ const pct = (a, b) => (a ? Math.round(((b - a) / a) * 100) : 0);
771
+ const row = (label, a, b, fmt = (x) => x, lowerIsBetter = true) => {
772
+ const d = pct(a, b);
773
+ const tone = a === b ? 'same' : (b < a) === lowerIsBetter ? 'better' : 'worse';
774
+ return `<tr><th>${label}</th><td>${fmt(a)}</td><td class="arrow">→</td><td class="${tone}">${fmt(b)}</td><td class="${tone} delta">${a === b ? '' : `${d > 0 ? '+' : ''}${d}%`}</td></tr>`;
775
+ };
776
+ const kind = esc((j.issueTitle || '').split(':')[0]);
777
+ return `<section class="compare-card"><h4>Same request, before and after the change</h4><table>
778
+ ${row('time', c.before.ms, c.after.ms, (x) => ms(x))}
779
+ ${row('database queries', c.before.queries, c.after.queries)}
780
+ ${c.before.redis || c.after.redis ? row('redis calls', c.before.redis, c.after.redis) : ''}
781
+ ${c.before.http || c.after.http ? row('outgoing http', c.before.http, c.after.http) : ''}
782
+ <tr><th>status</th><td>${c.before.status}</td><td class="arrow">→</td><td class="${c.before.status === c.after.status ? 'same' : 'worse'}">${c.after.status}</td><td></td></tr>
783
+ <tr><th>issue</th><td>${kind || 'found'}</td><td class="arrow">→</td><td class="${c.issueGone ? 'better' : 'worse'}">${c.issueGone ? 'gone' : 'still there'}</td><td></td></tr>
784
+ </table><p class="note">One request each${c.warmed ? ', the after one measured once the restarted app was warm' : ''}. Query counts are exact; times vary a few ms from run to run.</p></section>`;
785
+ }
786
+
757
787
  function escalateBtn(j) {
788
+ if (j.errorCode === 'billing') return `<a class="btn primary" href="https://console.anthropic.com/settings/billing" target="_blank" rel="noopener">Add credits</a>`;
758
789
  return j.attempt === 0 && j.route.kind === 'anthropic' ? `<button class="ghost" type="button" id="fix-escalate">Try a stronger model</button>` : '';
759
790
  }
760
791