backlens 0.2.3 → 0.2.5
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/analyze.js +4 -3
- package/package.json +1 -1
- package/server.js +34 -6
- package/ui.html +1 -1
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: see [CONTRIBUTING.md](CONTRIBUTING.md). To report a security problem privately, see [SECURITY.md](SECURITY.md).
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
[MIT](LICENSE) © Shayan Dutta
|
package/analyze.js
CHANGED
|
@@ -237,8 +237,8 @@ function percentile(values, p) {
|
|
|
237
237
|
|
|
238
238
|
/** Routes and issues across every trace kept. */
|
|
239
239
|
/**
|
|
240
|
-
* fixed: issue key -> when a fix for it was kept
|
|
241
|
-
* that moment shows
|
|
240
|
+
* fixed: issue key -> when a fix for it was kept, and 'route:<route>' -> when code behind that
|
|
241
|
+
* route changed. Such issues are hidden until a request after that moment shows them again.
|
|
242
242
|
*/
|
|
243
243
|
export function summarize(traces, known = [], advice = [], fixed = new Map()) {
|
|
244
244
|
const routes = new Map();
|
|
@@ -323,7 +323,8 @@ export function summarize(traces, known = [], advice = [], fixed = new Map()) {
|
|
|
323
323
|
if (!routes.has(route)) routes.set(route, { route, calls: 0, durations: [], queries: 0, redis: 0, http: 0, errors: 0, issueKeys: new Set(), last: 0 });
|
|
324
324
|
}
|
|
325
325
|
for (const [key, issue] of issues) {
|
|
326
|
-
|
|
326
|
+
const since = Math.max(fixed.get(key) || 0, fixed.get(`route:${issue.route}`) || 0);
|
|
327
|
+
if (since && since >= (issue.lastAt || 0)) {
|
|
327
328
|
issues.delete(key);
|
|
328
329
|
routes.get(issue.route)?.issueKeys.delete(key);
|
|
329
330
|
}
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -35,6 +35,20 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
35
35
|
// Issue key -> the index created for it from the dashboard, so Undo still knows it.
|
|
36
36
|
const createdIndexes = new Map();
|
|
37
37
|
|
|
38
|
+
/** Sends a recorded read to the app without recording it, so the app opens its connections. */
|
|
39
|
+
function warmUp(port, payload) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const r = http.request({ host: '127.0.0.1', port, method: payload.method, path: payload.url, headers: { ...payload.headers, 'x-backlens': 'skip' } }, (res) => {
|
|
42
|
+
res.resume();
|
|
43
|
+
res.on('end', resolve);
|
|
44
|
+
});
|
|
45
|
+
r.setTimeout(20000, () => r.destroy());
|
|
46
|
+
r.on('error', resolve);
|
|
47
|
+
r.on('close', resolve);
|
|
48
|
+
r.end();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
/** Sends one command to the recorder inside the app (see register.cjs). */
|
|
39
53
|
function appCommand(port, body) {
|
|
40
54
|
return new Promise((resolve, reject) => {
|
|
@@ -337,14 +351,24 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
337
351
|
if (!app || !app.port) return json(res, 409, { error: 'The app is not running. Start it first.' });
|
|
338
352
|
const body = (await readJson(req)) || {};
|
|
339
353
|
let index = null;
|
|
354
|
+
const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
355
|
+
const issue = issues.find((i) => i.key === body.key);
|
|
340
356
|
if (action !== 'info') {
|
|
341
|
-
const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
342
|
-
const issue = issues.find((i) => i.key === body.key);
|
|
343
357
|
index = issue && issue.index ? issue.index : createdIndexes.get(body.key);
|
|
344
358
|
if (!index) return json(res, 404, { error: 'That suggestion is gone. Refresh the list.' });
|
|
345
359
|
}
|
|
346
360
|
try {
|
|
347
|
-
|
|
361
|
+
let out;
|
|
362
|
+
try {
|
|
363
|
+
out = await appCommand(app.port, { action, ...(index || {}) });
|
|
364
|
+
} catch (err) {
|
|
365
|
+
// A restarted app has no database connection until a request uses one. Replay the
|
|
366
|
+
// request that showed the issue once, unrecorded, so it opens one, then try again.
|
|
367
|
+
const payload = issue && replays.get(issue.example);
|
|
368
|
+
if (!/not used Postgres yet/.test(err.message) || !payload || !['GET', 'HEAD'].includes(payload.method)) throw err;
|
|
369
|
+
await warmUp(app.port, payload);
|
|
370
|
+
out = await appCommand(app.port, { action, ...(index || {}) });
|
|
371
|
+
}
|
|
348
372
|
if (action === 'create') {
|
|
349
373
|
createdIndexes.set(body.key, index);
|
|
350
374
|
fixedAt.set(body.key, Date.now());
|
|
@@ -425,14 +449,18 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
425
449
|
// Recorded requests on a route since the last fix kept there (the code they ran is the
|
|
426
450
|
// code being changed now), oldest first.
|
|
427
451
|
routeTraces: (route) => {
|
|
428
|
-
const since =
|
|
452
|
+
const since = fixedAt.get(`route:${route}`) || 0;
|
|
429
453
|
return traces.filter((t) => t.at > since && routeOf(t.method, t.url, t.template) === route);
|
|
430
454
|
},
|
|
431
455
|
markFixed: (key, after) => {
|
|
432
|
-
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
fixedAt.set(key, now);
|
|
458
|
+
// The code behind this route changed: issues from its older requests point at old
|
|
459
|
+
// lines, so they wait for a request on the new code (the after-fix one below, or later).
|
|
460
|
+
fixedAt.set(`route:${key.split('|')[1]}`, now);
|
|
433
461
|
if (after) {
|
|
434
462
|
const { replayOf, ...trace } = after;
|
|
435
|
-
traces.push({ ...trace, afterFix: true });
|
|
463
|
+
traces.push({ ...trace, at: now + 1, afterFix: true });
|
|
436
464
|
received++;
|
|
437
465
|
if (traces.length > KEEP) replays.delete(traces.shift().id);
|
|
438
466
|
}
|
package/ui.html
CHANGED
|
@@ -790,7 +790,7 @@ function compareCard(j) {
|
|
|
790
790
|
let idx = {};
|
|
791
791
|
async function loadIndexInfo() {
|
|
792
792
|
idx.loading = true;
|
|
793
|
-
try { idx.db = await aiPost('/api/index/info', {}); } catch (err) { idx.dbError = err.message; }
|
|
793
|
+
try { idx.db = await aiPost('/api/index/info', { key: fixJob && fixJob.issueKey }); } catch (err) { idx.dbError = err.message; }
|
|
794
794
|
idx.loading = false;
|
|
795
795
|
renderFix();
|
|
796
796
|
}
|