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 +15 -1
- package/ai/providers.js +6 -1
- package/analyze.js +15 -2
- package/package.json +1 -1
- package/server.js +17 -5
- package/ui.html +33 -2
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
|
-
|
|
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
|
-
|
|
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
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
|
-
.
|
|
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
|
|
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
|
|