backlens 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +89 -2
- package/ai/context.js +151 -0
- package/ai/fixes.js +616 -0
- package/ai/providers.js +281 -0
- package/ai/router.js +97 -0
- package/ai/templates.js +135 -0
- package/analyze.js +441 -0
- package/bin/backlens.js +147 -0
- package/config.js +63 -0
- package/package.json +52 -4
- package/register.cjs +516 -0
- package/report.js +64 -0
- package/runner.js +115 -0
- package/server.js +397 -0
- package/ui.html +1047 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shayan Dutta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# backlens
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
See every request your Node backend handles, every query it makes, and what is wrong with them. No code changes, no account, nothing leaves your machine.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx backlens
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Run it in your server's folder. Your browser opens the dashboard; the first time, pick which package.json script starts the app and which runs the tests. From then on everything happens in the browser: start, stop and restart the app, read its logs, run the tests, accept known issues, compare before and after.
|
|
10
|
+
|
|
11
|
+
The link backlens prints carries a private token: keep it to yourself, it controls your app. The dashboard listens on 127.0.0.1 only, refuses requests from other sites, and only ever runs scripts from your package.json by name.
|
|
12
|
+
|
|
13
|
+
To run one exact command instead: `npx backlens -- node server.js`.
|
|
14
|
+
|
|
15
|
+
To keep it in the project, install it as a dev dependency with your package manager (`npm i -D backlens`, `pnpm add -D backlens`, `yarn add -D backlens`). In a pnpm or yarn workspace, use that tool: npm cannot install into a project whose package.json uses `workspace:` versions.
|
|
16
|
+
|
|
17
|
+
The dashboard uses port 7777, or the next free one if it is taken. `--port` picks one.
|
|
18
|
+
|
|
19
|
+
## What you get
|
|
20
|
+
|
|
21
|
+
- **Routes**: every endpoint your app registers (fastify and nest report them at startup; express routes appear once called), with calls, p50 and p95, and how many database, Redis and outgoing HTTP calls each request makes.
|
|
22
|
+
- **Requests**: a waterfall of any single request, with the line of your code behind each call.
|
|
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.
|
|
27
|
+
|
|
28
|
+
## In CI: fail the build on new issues
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
npx backlens test -- npx vitest run
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Runs your test command with recording on, prints every issue it found, and exits 1 when there is a new warning. Tests that call the app over HTTP (supertest, fetch) are recorded like real traffic.
|
|
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.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
backlens 90 requests (207 queries, 241 redis, 0 http out), 11 of 47 routes called, 3 issues, 2 new
|
|
42
|
+
new 3 calls are made twice in one request GET /v1/lessons/:id, 5/11 requests
|
|
43
|
+
src/modules/access/access.service.ts:75 → src/modules/access/access.service.ts:55
|
|
44
|
+
36 routes never called by these tests
|
|
45
|
+
backlens: 2 new issues. Fix them, or accept with --update-baseline.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Missing indexes
|
|
49
|
+
|
|
50
|
+
For each distinct SELECT, backlens asks Postgres for its plan with `EXPLAIN (GENERIC_PLAN)` (Postgres 16+). Plain EXPLAIN never runs the query, and the generic plan needs no parameter values. When a table is read row by row while filtering on columns that no index starts with, the Issues tab names the table and columns and gives the `CREATE INDEX` to add. It checks whether an index exists, not how fast the query was, so it is right on a small dev database too.
|
|
51
|
+
|
|
52
|
+
## Before and after
|
|
53
|
+
|
|
54
|
+
In the Compare tab, save a snapshot, change your code, restart, use the same pages, and compare per route: queries and Redis calls per request, p50 and issues. Snapshots are kept in `.backlens/snapshots` in your project; add `.backlens/` to `.gitignore`. Timings only count as a change with 5 or more calls on both sides.
|
|
55
|
+
|
|
56
|
+
## Explain and fix, with proof
|
|
57
|
+
|
|
58
|
+
Every issue has **Explain** and **Suggest fix**. A fix goes: a preview of exactly what would be sent and to whom, the model's answer as a diff, **Apply and check** (only on files with no uncommitted changes, with a backup), then backlens rebuilds if needed, restarts your app, replays the request that showed the issue and runs your tests. Only a fix that removes the issue and keeps the tests green is marked proven. Keep it, or revert to the exact files you had.
|
|
59
|
+
|
|
60
|
+
Who answers is chosen per issue, with the reasons shown:
|
|
61
|
+
|
|
62
|
+
- **Local model** (free, private): any model in [Ollama](https://ollama.com). Picked by default when Ollama is running.
|
|
63
|
+
- **Recorded answers** (free): no model; fixes come from `.backlens/ai-fixtures.json`. For trying the flow and for tests.
|
|
64
|
+
- **Claude with your own key**: set `ANTHROPIC_API_KEY` before starting backlens. 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. Missing indexes never use a model. A daily budget (default $1) is enforced before sending.
|
|
65
|
+
|
|
66
|
+
Values that look like secrets are masked, `.env` files and `node_modules` are never read, and requests kept for replay stay in memory only. Replaying a POST, PUT or DELETE runs it again against your dev database, so it is off unless you allow it in AI settings.
|
|
67
|
+
|
|
68
|
+
## How it works
|
|
69
|
+
|
|
70
|
+
`backlens` starts the dashboard, then runs your command with `NODE_OPTIONS=--require backlens/register`. Inside your app, the register script:
|
|
71
|
+
|
|
72
|
+
- 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;
|
|
73
|
+
- wraps `pg` (Client and Pool), `ioredis` and global `fetch`;
|
|
74
|
+
- 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.
|
|
75
|
+
|
|
76
|
+
Query values are never sent, only a short hash so identical questions can be told apart from different ones. Emails, IP addresses and ids in Redis keys are masked. The dashboard listens on 127.0.0.1 only and keeps the last 1000 requests in memory.
|
|
77
|
+
|
|
78
|
+
## Limits of this version
|
|
79
|
+
|
|
80
|
+
- Tested on macOS with Node 20+, on a nest + fastify + drizzle app and a Next.js 16 + drizzle app. Linux should work the same way; Windows is untested.
|
|
81
|
+
- Requests, `pg` and `ioredis` are recorded in CommonJS and ESM apps (vitest included). The up-front route list needs fastify loaded through CommonJS, as nest does.
|
|
82
|
+
- `pg`, `ioredis` and `fetch` only. No Prisma, mysql or mongo yet.
|
|
83
|
+
- The index advisor needs Postgres 16 or newer, and looks at equality filters only.
|
|
84
|
+
- 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.
|
|
85
|
+
|
|
86
|
+
## Develop
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
node --test test/
|
|
90
|
+
```
|
package/ai/context.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { tablesOf } from '../analyze.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Collects only the code an issue is about: the whole function around each place backlens
|
|
7
|
+
* recorded, plus how the tables involved are defined. Never reads .env files, node_modules or
|
|
8
|
+
* anything outside the project, and masks secrets in what it does read.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MAX_FILE = 1024 * 1024;
|
|
12
|
+
const MAX_LINES = 700;
|
|
13
|
+
const BLOCKED = /(^|[/\\])(node_modules|\.git|\.backlens|dist|build|coverage)([/\\]|$)|(^|[/\\])\.env/;
|
|
14
|
+
const FUNCTION_START = /^\s*(export\s+)?(default\s+)?(async\s+)?(function\b|(public|private|protected|static|async|get|set)\s+[\w$]+\s*\(|(?!(?:if|for|while|switch|catch|with|return|await|else|do|try|new|typeof)\b)[\w$]+\s*\([^)]*\)\s*(:[^={]+)?\{|(const|let|var)\s+[\w$]+\s*=\s*(async\s*)?(\([^)]*\)|[\w$]+)\s*=>|[\w$]+\s*:\s*(async\s*)?(function|\())/;
|
|
15
|
+
|
|
16
|
+
export function insideProject(root, file) {
|
|
17
|
+
const abs = resolve(root, file);
|
|
18
|
+
return (abs === root || abs.startsWith(root + sep)) && !BLOCKED.test(relative(root, abs)) ? abs : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Masks values that look like secrets, so they never reach a model. */
|
|
22
|
+
export function scrub(text) {
|
|
23
|
+
return text
|
|
24
|
+
.replace(/((?:api[_-]?key|secret|token|password|passwd|private[_-]?key|auth)\w*["']?\s*[:=]\s*)(["'`])[^"'`\n]{6,}\2/gi, '$1$2***$2')
|
|
25
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/[^:/\s"'`]+:)[^@\s"'`]+@/gi, '$1***@')
|
|
26
|
+
.replace(/\b(sk|pk|rk)[-_](live|test|ant|proj)?[-_]?[A-Za-z0-9_-]{16,}/g, '***')
|
|
27
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, '***')
|
|
28
|
+
.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '***');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const indent = (line) => line.match(/^\s*/)[0].length;
|
|
32
|
+
|
|
33
|
+
/** The enclosing function of a line: back to its signature, forward to its closing brace. */
|
|
34
|
+
export function enclosing(lines, lineNo) {
|
|
35
|
+
const at = Math.min(Math.max(lineNo - 1, 0), lines.length - 1);
|
|
36
|
+
let start = Math.max(0, at - 30);
|
|
37
|
+
for (let i = at; i >= Math.max(0, at - 120); i--) {
|
|
38
|
+
if (FUNCTION_START.test(lines[i]) && indent(lines[i]) <= indent(lines[at])) {
|
|
39
|
+
start = i;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let depth = 0;
|
|
44
|
+
let seen = false;
|
|
45
|
+
let end = Math.min(lines.length - 1, at + 30);
|
|
46
|
+
for (let i = start; i < Math.min(lines.length, start + 160); i++) {
|
|
47
|
+
for (const ch of lines[i].replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '').replace(/\/\/.*$/, '')) {
|
|
48
|
+
if (ch === '{') {
|
|
49
|
+
depth++;
|
|
50
|
+
seen = true;
|
|
51
|
+
} else if (ch === '}') depth--;
|
|
52
|
+
}
|
|
53
|
+
if (seen && depth <= 0) {
|
|
54
|
+
end = i;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { from: start + 1, to: Math.max(end + 1, lineNo) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function walk(dir, root, out, limit = 3000) {
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
65
|
+
} catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
if (out.length >= limit) return;
|
|
70
|
+
const p = join(dir, e.name);
|
|
71
|
+
if (BLOCKED.test(relative(root, p))) continue;
|
|
72
|
+
if (e.isDirectory()) walk(p, root, out, limit);
|
|
73
|
+
else if (/\.(ts|js|mjs|cjs|prisma)$/.test(e.name)) out.push(p);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Where each table is defined (drizzle pgTable/mysqlTable, prisma model), to show its columns and indexes. */
|
|
78
|
+
function schemaFor(root, tables) {
|
|
79
|
+
if (!tables.length) return [];
|
|
80
|
+
const files = [];
|
|
81
|
+
walk(root, root, files);
|
|
82
|
+
const found = [];
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
let text;
|
|
85
|
+
try {
|
|
86
|
+
if (statSync(file).size > MAX_FILE) continue;
|
|
87
|
+
text = readFileSync(file, 'utf8');
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const lines = text.split('\n');
|
|
92
|
+
for (const t of tables) {
|
|
93
|
+
const re = new RegExp(`(Table\\(\\s*["'\`]${t}["'\`]|@@map\\(\\s*"${t}"\\)|^model\\s+${t}\\b)`, 'i');
|
|
94
|
+
const i = lines.findIndex((l) => re.test(l));
|
|
95
|
+
if (i === -1 || found.some((f) => f.table === t)) continue;
|
|
96
|
+
const { from, to } = enclosing(lines, i + 1);
|
|
97
|
+
found.push({ table: t, path: relative(root, file), from: Math.min(from, i + 1), to: Math.min(to, i + 40) });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The context for one issue, with a preview of exactly what would be sent. */
|
|
104
|
+
export function buildContext(root, issue) {
|
|
105
|
+
const wanted = new Map();
|
|
106
|
+
const add = (file, from, to) => {
|
|
107
|
+
const list = wanted.get(file) || [];
|
|
108
|
+
list.push({ from, to });
|
|
109
|
+
wanted.set(file, list);
|
|
110
|
+
};
|
|
111
|
+
const frameSets = (issue.frames || []).filter(Boolean);
|
|
112
|
+
const sites = new Set();
|
|
113
|
+
for (const frames of frameSets) for (const f of frames.slice(0, 3)) sites.add(`${f.file}:${f.line}`);
|
|
114
|
+
for (const s of issue.sites || []) sites.add(s);
|
|
115
|
+
|
|
116
|
+
for (const site of sites) {
|
|
117
|
+
const m = site.match(/^(.*):(\d+)$/);
|
|
118
|
+
if (!m) continue;
|
|
119
|
+
const abs = insideProject(root, m[1]);
|
|
120
|
+
if (!abs || !existsSync(abs) || statSync(abs).size > MAX_FILE) continue;
|
|
121
|
+
const lines = readFileSync(abs, 'utf8').split('\n');
|
|
122
|
+
const { from, to } = enclosing(lines, Number(m[2]));
|
|
123
|
+
add(relative(root, abs), from, to);
|
|
124
|
+
}
|
|
125
|
+
const tables = new Set();
|
|
126
|
+
for (const line of String(issue.sql || '').split('\n')) for (const t of tablesOf(line).tables) tables.add(t);
|
|
127
|
+
for (const s of schemaFor(root, [...tables])) add(s.path, s.from, s.to);
|
|
128
|
+
|
|
129
|
+
// Merge overlapping ranges per file, then cut to the overall budget.
|
|
130
|
+
const files = [];
|
|
131
|
+
let budget = MAX_LINES;
|
|
132
|
+
for (const [path, ranges] of wanted) {
|
|
133
|
+
ranges.sort((a, b) => a.from - b.from);
|
|
134
|
+
const merged = [];
|
|
135
|
+
for (const r of ranges) {
|
|
136
|
+
const last = merged.at(-1);
|
|
137
|
+
if (last && r.from <= last.to + 3) last.to = Math.max(last.to, r.to);
|
|
138
|
+
else merged.push({ ...r });
|
|
139
|
+
}
|
|
140
|
+
const lines = readFileSync(join(root, path), 'utf8').split('\n');
|
|
141
|
+
for (const r of merged) {
|
|
142
|
+
if (budget <= 0) break;
|
|
143
|
+
const to = Math.min(r.to, r.from + budget - 1);
|
|
144
|
+
const text = lines.slice(r.from - 1, to).map((l, i) => `${String(r.from + i).padStart(5)} ${l}`).join('\n');
|
|
145
|
+
files.push({ path, from: r.from, to, text: scrub(text) });
|
|
146
|
+
budget -= to - r.from + 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const chars = files.reduce((n, f) => n + f.text.length, 0) + String(issue.sql || '').length + 2000;
|
|
150
|
+
return { files, tokens: Math.round(chars / 3.5), preview: files.map((f) => ({ path: f.path, from: f.from, to: f.to })) };
|
|
151
|
+
}
|