backlens 0.2.2 → 0.2.4
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/README.md +108 -44
- package/ai/fixes.js +28 -6
- package/package.json +1 -1
- package/server.js +6 -0
- package/ui.html +9 -8
package/README.md
CHANGED
|
@@ -1,29 +1,68 @@
|
|
|
1
1
|
# backlens
|
|
2
2
|
|
|
3
|
-
See
|
|
3
|
+
**See what your Node backend really does on every request, find what is slow, and get the fix proven before you keep it.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/backlens)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](package.json)
|
|
4
8
|
|
|
5
9
|
```
|
|
6
10
|
npx backlens
|
|
7
11
|
```
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
One command, in your server's folder. No code changes, no account, nothing leaves your machine.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
backlens runs your app with a small recorder inside it and opens a dashboard in your browser. As you use your app, every request shows up with the database queries, Redis calls and outgoing HTTP calls it made, and the line of your code behind each one.
|
|
20
|
+
|
|
21
|
+
From that real traffic it finds what makes backends slow:
|
|
22
|
+
|
|
23
|
+
| Issue | What it looks like | Example |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| **N+1** | one query run once per item in a loop | 20 reviews → 20 `select name from users where id = $1` |
|
|
26
|
+
| **Repeated calls** | the same query with the same values twice in one request | two services both load the current user |
|
|
27
|
+
| **Calls that wait for each other** | independent queries awaited one after another | `await getCounts(); await getRows();` |
|
|
28
|
+
| **Missing indexes** | Postgres reads every row to filter a table | `where state = $1 order by updated_at desc`, no index |
|
|
29
|
+
|
|
30
|
+
Each issue points at the file and line that caused it, opens in your editor, and can be explained or fixed.
|
|
31
|
+
|
|
32
|
+
## Fixes, with proof
|
|
33
|
+
|
|
34
|
+
**Suggest fix** shows the change as a diff. **Apply and check** then:
|
|
35
|
+
|
|
36
|
+
1. keeps an exact copy of every file it touches
|
|
37
|
+
2. applies the change and runs your typecheck (type errors go back to the model, up to twice)
|
|
38
|
+
3. restarts your app, warms it up, and replays the request that showed the issue
|
|
39
|
+
4. runs your tests
|
|
40
|
+
|
|
41
|
+
Only a change that removes the issue and keeps the tests green is marked **proven**, and you see the same request before and after:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
before after
|
|
45
|
+
time (median) 48 ms → 21 ms -56%
|
|
46
|
+
database queries 22 → 2 -91%
|
|
47
|
+
issue found → gone
|
|
48
|
+
```
|
|
10
49
|
|
|
11
|
-
|
|
50
|
+
Then **Keep** it, or **Revert** to the exact files you had.
|
|
12
51
|
|
|
13
|
-
|
|
52
|
+
Some fixes need no AI: running independent calls together (`Promise.all`) and missing indexes are written by backlens itself. A missing index can be created on your dev database from the dashboard, checked against the query's plan, and undone.
|
|
14
53
|
|
|
15
|
-
|
|
54
|
+
## AI: none, local or Claude
|
|
16
55
|
|
|
17
|
-
|
|
56
|
+
Set up in **AI settings**, entirely from the dashboard.
|
|
18
57
|
|
|
19
|
-
|
|
58
|
+
| | Cost | Privacy | Good for |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| **No model** | free | nothing leaves your machine | plain explanations, the built-in fixes |
|
|
61
|
+
| **Local model** via [Ollama](https://ollama.com) | free | code stays on your machine | explaining; fixes are hit-and-miss on small models |
|
|
62
|
+
| **Claude**, your own key | a few cents per fix | what the preview shows is sent to Anthropic | the strongest fixes |
|
|
20
63
|
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
- **Issues**: problems found in real traffic, worst first, each pointing at the code that caused it:
|
|
24
|
-
- **Repeated calls**: the same query with the same values twice in one request. backlens names the two places the code paths part, which is where the second fetch comes from.
|
|
25
|
-
- **N+1**: one query run three or more times with different values in one request.
|
|
26
|
-
- **Calls that wait for each other**: two or more calls of the same kind, awaited back to back in one function. Shown as a hint: if they don't depend on each other, `Promise.all` runs them together.
|
|
64
|
+
- The local card links to the Ollama download, notices when it runs, suggests the `qwen2.5-coder` size that fits your memory and downloads it with a progress bar.
|
|
65
|
+
- A Claude key is pasted in the dashboard, checked for free, and saved in your user folder (`~/.config/backlens`, readable by you only), never in the project. Haiku explains; Sonnet or Opus fixes, depending on how hard the issue is. The cost is estimated before sending and a daily budget (default $1) is enforced. A typical fix costs about $0.03.
|
|
27
66
|
|
|
28
67
|
## In CI: fail the build on new issues
|
|
29
68
|
|
|
@@ -31,64 +70,89 @@ The dashboard uses port 7777, or the next free one if it is taken. `--port` pick
|
|
|
31
70
|
npx backlens test -- npx vitest run
|
|
32
71
|
```
|
|
33
72
|
|
|
34
|
-
Runs your
|
|
35
|
-
|
|
36
|
-
- `--update-baseline` accepts everything found in this run as known, in `backlens.baseline.json`. Commit that file. An issue keeps its identity when lines above it move.
|
|
37
|
-
- `--strict` fails on new hints too.
|
|
38
|
-
- `--json report.json` writes the report for a CI artifact.
|
|
73
|
+
Runs your tests with recording on, prints every issue, and exits 1 on a new one. Tests that call your app over HTTP (supertest, fetch) count as real traffic.
|
|
39
74
|
|
|
40
75
|
```
|
|
41
76
|
backlens 90 requests (207 queries, 241 redis, 0 http out), 11 of 47 routes called, 3 issues, 2 new
|
|
42
77
|
new 3 calls are made twice in one request GET /v1/lessons/:id, 5/11 requests
|
|
43
78
|
src/modules/access/access.service.ts:75 → src/modules/access/access.service.ts:55
|
|
44
|
-
36 routes never called by these tests
|
|
45
79
|
backlens: 2 new issues. Fix them, or accept with --update-baseline.
|
|
46
80
|
```
|
|
47
81
|
|
|
48
|
-
|
|
82
|
+
- `--update-baseline` accepts what this run found, in `backlens.baseline.json`. Commit it.
|
|
83
|
+
- `--strict` fails on new hints too. `--json report.json` writes the report for a CI artifact.
|
|
49
84
|
|
|
50
|
-
|
|
85
|
+
## The dashboard
|
|
51
86
|
|
|
52
|
-
|
|
87
|
+
- **Map**: a live diagram of your routes, the tables and Redis keys they touch, and where the issues are.
|
|
88
|
+
- **Routes**: every endpoint with calls, p50, p95 and queries per request.
|
|
89
|
+
- **Issues**: worst first, with Explain, Suggest fix and the request that showed it.
|
|
90
|
+
- **Requests**: a waterfall of any request, with the code behind each call.
|
|
91
|
+
- **Tests**: run your test script and see what it hit.
|
|
92
|
+
- **Compare**: snapshot, change code, compare per route.
|
|
53
93
|
|
|
54
|
-
|
|
94
|
+
Start, stop, restart and read your app's logs from the same page.
|
|
95
|
+
|
|
96
|
+
## Getting started
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
cd your-server
|
|
100
|
+
npx backlens
|
|
101
|
+
```
|
|
55
102
|
|
|
56
|
-
|
|
103
|
+
The first time, pick which `package.json` script starts the app (like `dev`) and which runs the tests. backlens remembers this in `.backlens/` and adds it to your `.gitignore`.
|
|
57
104
|
|
|
58
|
-
|
|
105
|
+
- Keep it in the project: `npm i -D backlens`, `pnpm add -D backlens` or `yarn add -D backlens` (in a pnpm or yarn workspace, use that tool).
|
|
106
|
+
- Run one exact command: `npx backlens -- node server.js`.
|
|
107
|
+
- Port 7777, or the next free one. `--port` picks one, `--no-open` skips the browser.
|
|
59
108
|
|
|
60
|
-
|
|
109
|
+
## Works with
|
|
61
110
|
|
|
62
|
-
|
|
111
|
+
| | |
|
|
112
|
+
|---|---|
|
|
113
|
+
| Frameworks | express, fastify, nest, Next.js route handlers, plain `node:http` |
|
|
114
|
+
| Database | `pg` (Client and Pool): drizzle, Kysely, knex, raw SQL on Postgres |
|
|
115
|
+
| Cache | `ioredis` |
|
|
116
|
+
| Outgoing calls | global `fetch` |
|
|
117
|
+
| Modules | CommonJS and ESM, vitest included |
|
|
118
|
+
| Runtime | Node 20+, tested on macOS; Linux should work the same way |
|
|
63
119
|
|
|
64
|
-
|
|
65
|
-
- **Claude with your own key**: paste the key and backlens checks it for free before saving it in your user folder (`~/.config/backlens`, readable by you only), never in the project. `ANTHROPIC_API_KEY` in the environment wins if set. Explaining uses Claude Haiku 5.5; fixes use Sonnet 5.5 or Opus 5.5 depending on how hard the issue looks (files involved, issue type, test coverage, your past results), one step up after a failed check. A daily budget (default $1) is enforced before sending.
|
|
66
|
-
- **No model**: explanations in backlens's own words.
|
|
120
|
+
Not yet: Prisma, mysql2, MongoDB, Windows.
|
|
67
121
|
|
|
68
|
-
|
|
122
|
+
## Privacy and safety
|
|
69
123
|
|
|
70
|
-
Values
|
|
124
|
+
- **Values never leave your app.** Query parameters and results become short hashes; emails, IPs and ids in Redis keys are masked.
|
|
125
|
+
- **The dashboard is yours only.** It listens on 127.0.0.1, needs the private token in the link it prints, and refuses requests from other websites.
|
|
126
|
+
- **It runs only scripts from your `package.json`,** by name.
|
|
127
|
+
- **Files change only when you click Apply,** with a copy kept first. Revert never overwrites edits you made afterwards.
|
|
128
|
+
- **AI sees only what the preview shows.** Secret-looking values are masked; `.env` and `node_modules` are never read.
|
|
129
|
+
- **Replaying writes is off by default,** since it changes your dev database again.
|
|
130
|
+
- **Indexes are built from checked table and column names** through your app's own connection: backlens never sees a database password.
|
|
71
131
|
|
|
72
132
|
## How it works
|
|
73
133
|
|
|
74
|
-
`backlens` starts the dashboard, then runs your
|
|
75
|
-
|
|
76
|
-
- times each incoming request on Node's own HTTP server, so it works with express, fastify, nest, Next.js and plain `http`, and records requests the client cancels. Framework internals such as `/_next/` assets and favicons are skipped;
|
|
77
|
-
- wraps `pg` (Client and Pool), `ioredis` and global `fetch`;
|
|
78
|
-
- records where each call came from. For ORMs like drizzle, which call the driver from a microtask, the call site is read from V8's async stack once the query settles. Code run from bundled chunks (Next.js with Turbopack or webpack) is traced back to your source files through their source maps.
|
|
134
|
+
`backlens` starts the dashboard, then runs your app with `NODE_OPTIONS=--require backlens/register`. Inside your app, the recorder:
|
|
79
135
|
|
|
80
|
-
|
|
136
|
+
- times each request on Node's own HTTP server, so any framework on top of it works;
|
|
137
|
+
- wraps `pg`, `ioredis` and `fetch`, and records the line of your code behind each call, through V8's async stack traces for ORMs like drizzle and through source maps for bundled code;
|
|
138
|
+
- asks Postgres for each distinct SELECT's plan with `EXPLAIN (GENERIC_PLAN)` (Postgres 16+), which never runs the query, and flags full-table reads no index covers.
|
|
81
139
|
|
|
82
|
-
## Limits
|
|
140
|
+
## Limits
|
|
83
141
|
|
|
84
|
-
-
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
- The index advisor needs Postgres 16 or newer, and looks at equality filters only.
|
|
88
|
-
- The "wait for each other" hint skips calls whose input matches an earlier call's output, but it cannot see every dependency (a value changed before use, an order that matters for side effects), so read it as a question, not a verdict.
|
|
142
|
+
- The "wait for each other" hint skips calls that use an earlier call's result, but it cannot see every dependency: read it as a question, not a verdict.
|
|
143
|
+
- The index advisor needs Postgres 16+ and looks at equality filters and the sort above them.
|
|
144
|
+
- Timings on a dev machine are rough; query counts are exact.
|
|
89
145
|
|
|
90
146
|
## Develop
|
|
91
147
|
|
|
92
148
|
```
|
|
149
|
+
git clone https://github.com/shayandutta/backlens
|
|
150
|
+
cd backlens && npm install
|
|
93
151
|
node --test test/
|
|
94
152
|
```
|
|
153
|
+
|
|
154
|
+
Bug reports and pull requests are welcome.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
[MIT](LICENSE) © Shayan Dutta
|
package/ai/fixes.js
CHANGED
|
@@ -20,6 +20,9 @@ import { promiseAll } from './templates.js';
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
// Replaying a write sends it again, so it changes the dev database twice: reads only unless asked.
|
|
23
|
+
// Replays after a fix: warm-up runs first, then measured runs (reads only).
|
|
24
|
+
const WARMUPS = 3;
|
|
25
|
+
const SAMPLES = 5;
|
|
23
26
|
const DEFAULTS = { provider: null, ollamaModel: null, dailyBudgetUsd: 1, replay: true, replayWrites: false };
|
|
24
27
|
const EDIT_REPAIRS = 2;
|
|
25
28
|
const TYPE_REPAIRS = 2;
|
|
@@ -483,6 +486,12 @@ export function createAI(deps) {
|
|
|
483
486
|
return { ok: r.code === 0, errors: r.out };
|
|
484
487
|
}
|
|
485
488
|
|
|
489
|
+
const median = (list) => {
|
|
490
|
+
const xs = [...list].sort((a, b) => a - b);
|
|
491
|
+
const mid = Math.floor(xs.length / 2);
|
|
492
|
+
return Math.round((xs.length % 2 ? xs[mid] : (xs[mid - 1] + xs[mid]) / 2) * 100) / 100;
|
|
493
|
+
};
|
|
494
|
+
|
|
486
495
|
/** What a request cost: the numbers shown before and after a fix. */
|
|
487
496
|
function stats(trace) {
|
|
488
497
|
const count = (kind) => trace.spans.filter((x) => x.kind === kind).length;
|
|
@@ -556,20 +565,33 @@ export function createAI(deps) {
|
|
|
556
565
|
} else {
|
|
557
566
|
// The app has just restarted, so its first request pays for cold caches and new connections.
|
|
558
567
|
// A read is sent once to warm up, and the second one is measured.
|
|
559
|
-
|
|
560
|
-
|
|
568
|
+
// A just-restarted app is slow for its first requests (cold code, new database
|
|
569
|
+
// connections). A read is sent a few times to warm up, then measured several times and the
|
|
570
|
+
// median kept. A write runs once: replaying it changes data again.
|
|
571
|
+
const samples = [];
|
|
572
|
+
if (!isWrite) for (let i = 0; i < WARMUPS; i++) await replay(job, payload, deps.appPort());
|
|
573
|
+
for (let i = 0; i < (isWrite ? 1 : SAMPLES); i++) {
|
|
574
|
+
const t = await replay(job, payload, deps.appPort());
|
|
575
|
+
if (!t) break;
|
|
576
|
+
samples.push(t);
|
|
577
|
+
}
|
|
578
|
+
const trace = samples.at(-1);
|
|
561
579
|
if (!trace) {
|
|
562
580
|
r.status = 'fail';
|
|
563
581
|
r.detail = 'the replayed request did not finish within 20 s';
|
|
564
582
|
return finish(job, false, 'The replayed request did not come back.');
|
|
565
583
|
}
|
|
566
584
|
replayed = true;
|
|
567
|
-
const still = checkTrace(
|
|
585
|
+
const still = samples.some((t) => checkTrace(t).some((i) => sameIssue(i, job.issue)));
|
|
568
586
|
const sameClass = original ? Math.floor(trace.status / 100) === Math.floor(original.status / 100) : trace.status < 400;
|
|
569
|
-
|
|
587
|
+
// Before: the same route's recorded requests from before the change, not just one of them.
|
|
588
|
+
const earlier = (deps.routeTraces ? deps.routeTraces(job.issue.route) : []).filter((t) => t.status === (original || trace).status);
|
|
589
|
+
const beforeList = earlier.length ? earlier.slice(-SAMPLES) : original ? [original] : [];
|
|
590
|
+
const after = { ...stats(trace), ms: median(samples.map((t) => t.duration)) };
|
|
591
|
+
const before = beforeList.length ? { ...stats(original || beforeList.at(-1)), ms: median(beforeList.map((t) => t.duration)) } : null;
|
|
570
592
|
r.status = !still && sameClass ? 'done' : 'fail';
|
|
571
|
-
r.detail = `before: ${before}\nafter: ${
|
|
572
|
-
job.compare = { before
|
|
593
|
+
r.detail = `before: ${before ? `${before.status}, median ${before.ms} ms of ${beforeList.length} recorded, ${before.queries} queries` : 'no recorded request'}\nafter: ${after.status}, median ${after.ms} ms of ${samples.length} replays, ${after.queries} queries${still ? ', issue still there' : ', issue gone'}`;
|
|
594
|
+
job.compare = { before, after, beforeRuns: beforeList.length, afterRuns: samples.length, issueGone: !still, warmed: !isWrite };
|
|
573
595
|
job.afterTrace = trace;
|
|
574
596
|
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.';
|
|
575
597
|
if (r.status === 'fail') return finish(job, false, still ? 'The issue is still there after the change.' : `The request now answers ${trace.status}.`);
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -422,6 +422,12 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
422
422
|
return issues.find((i) => i.key === key) || (testRun.report ? testRun.report.issues.find((i) => i.key === key) : null);
|
|
423
423
|
},
|
|
424
424
|
getTrace: (id) => traces.find((t) => t.id === id) || testRun.traces.find((t) => t.id === id),
|
|
425
|
+
// Recorded requests on a route since the last fix kept there (the code they ran is the
|
|
426
|
+
// code being changed now), oldest first.
|
|
427
|
+
routeTraces: (route) => {
|
|
428
|
+
const since = Math.max(0, ...[...fixedAt].filter(([key]) => key.split('|')[1] === route).map(([, at]) => at));
|
|
429
|
+
return traces.filter((t) => t.at > since && routeOf(t.method, t.url, t.template) === route);
|
|
430
|
+
},
|
|
425
431
|
markFixed: (key, after) => {
|
|
426
432
|
fixedAt.set(key, Date.now());
|
|
427
433
|
if (after) {
|
package/ui.html
CHANGED
|
@@ -157,7 +157,8 @@
|
|
|
157
157
|
a.btn { text-decoration: none; display: inline-block; }
|
|
158
158
|
.compare-card { border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin: 4px 0 14px; }
|
|
159
159
|
.compare-card h4 { margin: 0 0 8px; font-size: 13px; }
|
|
160
|
-
.compare-card table { width: auto; border-collapse: collapse; font: 13px var(--mono); }
|
|
160
|
+
.compare-card table { width: auto; max-width: 100%; border-collapse: collapse; font: 13px var(--mono); margin-bottom: 10px; }
|
|
161
|
+
.compare-card td, .compare-card th { white-space: nowrap; }
|
|
161
162
|
.compare-card th { text-align: left; font-weight: 400; color: var(--sub); padding: 3px 18px 3px 0; }
|
|
162
163
|
.compare-card td { padding: 3px 10px 3px 0; }
|
|
163
164
|
.compare-card .arrow { color: var(--sub); }
|
|
@@ -769,20 +770,20 @@ function compareCard(j) {
|
|
|
769
770
|
const c = j.compare;
|
|
770
771
|
if (!c || !c.before || !['verified', 'failed', 'unverified', 'kept', 'reverted'].includes(j.stage)) return '';
|
|
771
772
|
const pct = (a, b) => (a ? Math.round(((b - a) / a) * 100) : 0);
|
|
772
|
-
const row = (label, a, b, fmt = (x) => x,
|
|
773
|
+
const row = (label, a, b, fmt = (x) => x, noise = 0) => {
|
|
773
774
|
const d = pct(a, b);
|
|
774
|
-
|
|
775
|
-
|
|
775
|
+
// Small time differences are noise, not a result: shown plain, not green or red.
|
|
776
|
+
const tone = a === b || Math.abs(b - a) <= noise ? 'same' : b < a ? 'better' : 'worse';
|
|
777
|
+
return `<tr><th>${label}</th><td>${fmt(a)}</td><td class="arrow">→</td><td class="${tone}">${fmt(b)}</td><td class="${tone} delta">${tone === 'same' ? '' : `${d > 0 ? '+' : ''}${d}%`}</td></tr>`;
|
|
776
778
|
};
|
|
777
|
-
const kind = esc((j.issueTitle || '').split(':')[0]);
|
|
778
779
|
return `<section class="compare-card"><h4>Same request, before and after the change</h4><table>
|
|
779
|
-
${row('time', c.before.ms, c.after.ms, (x) => ms(x))}
|
|
780
|
+
${row('time (median)', c.before.ms, c.after.ms, (x) => ms(x), Math.max(3, c.before.ms * 0.15))}
|
|
780
781
|
${row('database queries', c.before.queries, c.after.queries)}
|
|
781
782
|
${c.before.redis || c.after.redis ? row('redis calls', c.before.redis, c.after.redis) : ''}
|
|
782
783
|
${c.before.http || c.after.http ? row('outgoing http', c.before.http, c.after.http) : ''}
|
|
783
784
|
<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>
|
|
784
|
-
<tr><th>issue</th><td
|
|
785
|
-
</table><p class="note">
|
|
785
|
+
<tr><th>issue</th><td>found</td><td class="arrow">→</td><td class="${c.issueGone ? 'better' : 'worse'}">${c.issueGone ? 'gone' : 'still there'}</td><td></td></tr>
|
|
786
|
+
</table><p class="note">Before: ${c.beforeRuns} recorded request${c.beforeRuns === 1 ? '' : 's'}. After: ${c.afterRuns} replay${c.afterRuns === 1 ? '' : 's'}${c.warmed ? ', after warming the restarted app up' : ''}. Query counts are exact; a time change within a few ms is noise.</p></section>`;
|
|
786
787
|
}
|
|
787
788
|
|
|
788
789
|
// ---- creating a suggested index from the drawer ----------------------------------------
|