An error tracker for the SQL your app sends, running on Cloudflare's free plan in your own account — so the app decides what is worth sending, and the backend rations every row it writes.
00 — QueryLens
QueryLens is an error tracker for database traffic. A small SDK inside a Node.js backend watches every statement its ORM sends to PostgreSQL, MySQL, SQLite or MongoDB, removes the values, and reports the requests that look wrong — N+1 queries, writes in a loop, slow, failing and oversized statements, chatty requests — to a backend the user deploys into their own Cloudflare account. The dashboard shows each statement as it arrives, with the line of code that ran it and the fix written for the app's ORM.
The whole backend is one Worker, two Durable Object classes and a 450 KB WebAssembly build of a Rust analysis core, and it is meant to run on Cloudflare's Workers Free plan. That plan allows 100,000 SQLite rows written a day and gives a Worker 10 ms of CPU per request. The Durable Object gets 30 seconds. Those two numbers are the brief, and almost every decision on this board follows from them.
The third default is about trust: no literal value from a query leaves the application process unless the user turns that on. Literals become question marks, bound parameters are reduced to a hash, and error messages lose the values they quote.
01 — The budget
The Workers Free plan, as of October 2026, allows 100,000 Worker requests, 100,000 Durable Object requests and 100,000 SQLite rows written a day. A Worker gets 10 ms of CPU per request; a Durable Object gets 30 seconds. QueryLens is meant to live inside those numbers in the user's own account, with no QueryLens server anywhere.
The obvious design ships every query to the backend and analyses it on arrival. It fails on every count: one stored row per query spends the day's budget after 100,000 queries; gunzip, JSON parsing and pattern detection do not fit in 10 ms of Worker CPU; every literal value in every query crosses the network; and a dashboard that polls spends Worker requests just by being open.
QueryLens splits the work the other way. The SDK, inside the app, does a cheap check on each finished request and sends only the ones that look wrong, plus a 5% sample of clean ones in production. Every statement is still counted, but as per-shape totals that cost one upsert per reporting period rather than a row per execution. The Worker only routes; the analysis is Rust compiled to WebAssembly, running inside the project's Durable Object where the CPU allowance is. Values are scrubbed before they reach the socket. Dashboards hold a hibernating WebSocket whose keep-alive pings are answered by the runtime without waking the object.
02 — What runs where
Code runs in two places. In the app's own Node.js process, querylens-node patches the database drivers at require time — pg, postgres.js, Neon's serverless driver, mysql2, PlanetScale's driver, better-sqlite3, sqlite3, node:sqlite, libSQL, the MongoDB driver and Mongoose — so whatever ORM sits on top (Sequelize, Prisma, TypeORM, Drizzle, Knex, MikroORM, Objection, or none) is covered without being touched. The hooks only time and record; the queries reach the database unchanged, and QueryLens itself never connects to it.
In the user's Cloudflare account there is one Worker, named querylens. It serves the SDK's ingest endpoint (POST /api/<project>/envelope, with the DSN key in a header), the dashboard's API under /api/0, an MCP server at /mcp for coding agents, and the React dashboard as static assets with a single-page-app fallback. Behind it are two SQLite-backed Durable Object classes: one Project object per monitored app, which holds that app's issues, history and statistics, runs the WebAssembly core and keeps the live WebSockets; and a single Registry object with the project list, the public URL that goes into every DSN, and the day's count of AI calls. Workers AI is called by the Worker on demand, for code rewrites and for drafting filters.
The envelope from the SDK to the Worker is the only edge that carries traffic all day. Everything else — the dashboard's API calls, the MCP tools, the AI — happens because a person or an agent asked for it.
03 — One request
Follow one HTTP request. When it arrives, the SDK — which wrapped http.Server's emit at startup — opens a transaction for it in AsyncLocalStorage, named by method and route with numeric and hex ids collapsed, so GET /orders/123 and GET /orders/456 are both GET /orders/:id.
When the ORM sends a statement, the patched driver starts a timer and captures the call stack, but does not walk it yet: turning a stack into the app's own file:line is the costly part, and it only happens if the request is eventually sent. When the statement settles, its SQL is scrubbed of literals, added to the running totals for its shape, and appended to the transaction, up to 1,000 statements per request.
When the response finishes, the SDK triages the request locally. It looks for a shape executed loopMin times (5 by default), a statement slower than slowMs (100 ms), an error, a result of 1,000 rows or more, or 50 or more statements in all. A flagged request is queued unless the same route with the same problems was queued in the last 30 seconds; a clean one is queued 5% of the time in production and always in other environments. This one check is where most of the free-plan budget is saved, and it is deliberately looser than the core's: it counts a shape's executions whether or not they are consecutive, a superset of what the core calls a loop, so nothing the core would report is dropped here.
At most every five seconds the queue — capped at 200 items and 4 MB — is written as newline-delimited JSON, gzipped, and posted to /api/<project>/envelope with the DSN key in the x-querylens-key header. The Worker checks the project id's shape and the body's size (4 MB as sent, 16 MB once inflated) and hands the text to that project's Durable Object over RPC. It does nothing else.
The Project object compares the key with every connected backend's key in constant time, then calls querylens-core, compiled to WebAssembly, through four exported functions: allocate, write the JSON request, digest, read the result. The core fingerprints each statement with FNV-1a over normalised SQL, finds loops, repeats and the other patterns, recognises the ORM by a majority vote of the statements' shapes, and writes each finding's fix in that ORM's API.
Back in the object, one synchronous storage transaction upserts the issues, records an event per finding and prunes each issue to its latest 10, inserts one history row per request, folds the statistics into hour buckets, and records releases and source code it has not seen. Then it broadcasts a single batch to every open dashboard socket and answers 202 with the project's settings. The SDK merges those into its own, so thresholds edited in the dashboard reach every app on its next envelope.
04 — A busy day
The request limit is not the problem. Each SDK process sends at most one envelope every five seconds, and only when it has something to send, so a process that is busy all day sends about 17,000 requests. Rows written are the problem, and every source of them has a throttle in front of it.
A request that is sent writes one row to the query history. The SDK sends a flagged request at most once per route and set of problems every 30 seconds, and a clean one 5% of the time in production; the backend trims the history by id every 500 inserts, keeping the latest 20,000 requests and at most 7 days. Each finding in a request costs three rows — the issue, the event, and the pruning of old events — and because issues are upserts on a unique (backend, environment, key), a problem that keeps happening updates one row rather than adding new ones.
Statistics are the clever part. Every statement is counted, but in the SDK: per-shape totals (up to 1,000 shapes) reported every 5 minutes in production and every minute elsewhere, which the object folds into one row per shape per hour with an upsert. The shape's own descriptive row has its last_seen refreshed only when it is an hour old, using an upsert whose WHERE clause makes the common case write nothing.
The rest are small and deliberate: a backend's last_seen is held in memory and written at most hourly; releases already seen are remembered in an in-memory set; source lines are sent once per process and rewritten only when the code changed; an AI rewrite is stored once per issue and reused while its input is unchanged. A dashboard that is watching costs nothing at all — the sockets hibernate and the runtime answers their keep-alive pings.
05 — Layering
The layering is strict and shallow. The Worker's entry point only routes. The dashboard API and the MCP server are two front doors to the same functions — fixing.ts writes a fix brief, an AI rewrite or a drafted filter for either — so an agent and a person always get the same answer. The Project Durable Object is storage: it decides what to keep, for how long, and who to tell, and contains no analysis.
Analysis lives in two pure places. querylens-core, in Rust, takes statements and returns findings with fixes: fingerprinting, detection that knows nothing about ORMs, and an advice layer with one module per ORM (Sequelize, Prisma, TypeORM, Drizzle, MikroORM, Objection, Knex, Mongoose and the MongoDB driver) behind one trait, with a generic fallback that names the right API but writes no code. Its own comment states the extension rule: adding an ORM is one module and one arm in a match. The ORM is recognised from the SQL's shape by vote, and named only with a 60% majority. The TypeScript side has its own pure modules — regressions, fix briefs, the model interface — tested with node --test and free of I/O.
The boundary between them is deliberately small: the WebAssembly module exports four functions and no generated glue. The host allocates, writes a JSON request, calls ql_digest and reads the JSON result. The Rust structs are the source of the TypeScript types, generated with ts-rs into cloud/bindings.
One rule holds the system together. The SDK scrubs values in JavaScript; the core normalises SQL in Rust; both must agree exactly, or a statement sent scrubbed and the same statement sent with values would get different fingerprints and land in two issues. They share one file of cases, test/scrub-cases.json, which the Node tests read and the Rust tests compile in with include_str!.
06 — Ten tables
Every monitored app has its own Durable Object and therefore its own SQLite database, with ten tables. Nothing in them carries a project id; the object is the project. Inside it, rows are keyed by backend — the services of the app, each with its own DSN key — and by environment.
Two tables matter most. issues is what people act on: unique on backend, environment and key, where the key is the finding's stable id (its kind and the statement's fingerprint, or for a chatty request its route). Its status is unresolved, resolved or ignored; a resolved issue that shows up again flips back to unresolved and is marked regressed; its severity only ever rises; it keeps the top five lines of code responsible. An index on status and last_seen serves the default list.
query_stats is what the system remembers about everything else: one row per backend, environment, statement fingerprint and hour, with calls, total time, maximum time, errors and rows, kept for 30 days by a daily alarm. Regressions are computed from it with statistics rather than a model. Each statement shape is compared with itself: the seven days before the latest release's first appearance against the hours after it, leaving the deploy hour out of both sides, or — when an environment reports no releases — the last day against the week before. A shape needs 30 calls on each side to be compared at all. It is slower if its mean is at least 1.5 times the old mean and at least 2 ms more per call; failing if at least 1% of calls fail, with at least 5 failures, and at least twice the old failure rate plus half a percentage point. Failures are listed first, then by cost per hour.
The other tables are supporting cast with fixed limits: the latest 10 events per issue, the request history (20,000 requests, 7 days), one descriptive row per statement shape, the code around up to 5,000 lines, the first sighting of each release, one stored AI rewrite per issue, up to 50 backends, and a key-value table for the project's name and thresholds. The Registry object holds the project list, the backend-wide settings and a one-row-per-day counter of AI calls; it is reached by the dashboard and MCP, never by ingest.
When backends were introduced, existing projects had one DSN key and no backend column. The object migrates itself on start: five tables are rebuilt with the new key and everything is assigned to a backend made from the old key, so DSNs already deployed in apps kept working.
07 — What isn't yours
QueryLens holds three things that are not its own: the values inside an app's queries, the app's source code, and a way into the dashboard and the ingest endpoint.
Values are removed by default, inside the app, before anything is queued. String, number and boolean literals become question marks by the same rules the core fingerprints with; bound parameters are dropped. What survives of the values is an 8-hex FNV-1a hash, which cannot be reversed but is enough for the core to tell an N+1 — the same statement with different values every time — from a plain duplicate with the same values. Error messages lose the values they quote. Sending values is a single opt-in switch.
Source code is never sent unless source context is turned on. Then the SDK reads, once per line and in the background, ten lines either side of each line that ran a reported query, only from files inside the project and under 1 MB, at most 300 characters a line. Before anything leaves the process it masks assignments to names like key, secret, token or password, credentials embedded in URLs, and quoted strings of 32 or more key-like characters.
There are two kinds of secret. The dashboard and MCP take the ADMIN_TOKEN Worker secret as a Bearer token; only the live WebSocket takes it as a query parameter, because browsers cannot set headers on a WebSocket. Each backend of a project has its own DSN key, 16 to 64 characters and 16 random bytes by default. Both comparisons run in constant time, and the lookup of a key compares it with every backend's key rather than stopping at the first match. Disconnecting a backend sets its key to NULL: its SDKs receive a 401 and turn monitoring off, while its history stays.
None of this passes through a third party. The Worker, both Durable Objects and the Workers AI model all run in the user's own Cloudflare account.
08 — When things fail
The SDK lives in somebody's production process, so its failure modes are chosen to cost the app nothing. Every few seconds flush() sends one gzipped batch, never two at once. A 202 resets the failure count and merges the settings the backend sent back. A 401 or 404 means the DSN is wrong or the backend was disconnected: monitoring turns itself off for the life of the process, with one console warning. A 400 or 413 means that batch can never be accepted, so it is dropped and the next one goes out normally. Anything else — a 5xx, a timeout, no network — backs off, honouring Retry-After when the backend sends one, otherwise for 5 seconds doubling per failure up to 5 minutes; the failed batch is put back newest-first, only as far as the 200-item, 4 MB queue limits allow.
Around that loop, every hook body is wrapped in try/catch, the HTTP post resolves instead of rejecting, the timers are unref()'d so monitoring never keeps a process alive, and a full queue drops its oldest line rather than grow. Serverless handlers call flush() before returning, since their process may be frozen afterwards.
On the backend, an envelope the core cannot parse is answered with a 400 and nothing is stored, and everything an envelope writes is applied in one synchronous storage transaction, so a request's issues, events, history row and statistics land together or not at all. A timestamp more than five minutes in the future, or not a number, is stored as the current time.
The dashboard's live socket reconnects with backoff from one second up to thirty, and reloads the latest page of history on every reconnect so nothing that arrived during the gap is missed. Its 30-second keep-alive pings are answered by the runtime itself, so a hibernating object stays asleep.
The AI rewrite is treated as untrusted output. The model must quote the lines it replaces, and those lines must be found, ignoring indentation, in the code the SDK actually sent; that match is also how the suggestion's file and line are determined, and the replacement is re-indented to fit. A rewrite of code the model was never shown is refused. Calls are limited to 80 a day across the backend, answered with a 429 until midnight UTC once spent, and a stored rewrite is reused for as long as the SHA-256 hash of the model name and prompt is unchanged.
09 — Deploy
Deploying is one command in the cloud directory: pnpm run deploy, which runs Cloudflare's cf deploy after a one-time cf auth login. It creates one Worker named querylens with the dashboard as static assets — a single-page-app fallback, with /api/* and /mcp sent to the Worker first — two Durable Object classes, Project and Registry, both backed by SQLite, and the Workers AI binding, which is remote even in local development because Workers AI has no local simulation. The only secret is ADMIN_TOKEN, set once with wrangler secret put. The enable_ctx_exports flag lets the Worker reach its own Durable Object classes through ctx.exports, so there are no Durable Object bindings to declare.
The WebAssembly build of the core is committed, 450 KB, so deploying needs Node.js and pnpm but not Rust. pnpm build:core rebuilds it after a change to the analysis.
Continuous integration runs three jobs on every push to main and every pull request: the Node SDK's tests on Node 22; the Rust workspace's tests followed by a release build of the WebAssembly module, uploaded as an artifact; and, after that, the backend and dashboard — which replaces the committed module with the one CI just compiled before typechecking, testing and building. What CI verifies is the Rust as written, not whichever build was last committed.
10 — The payoff
This shape buys four things. First, it is free to run by construction: a busy process sends at most about 17,000 envelopes a day, per-shape totals stand in for per-query rows, and staying inside the free plan is simply what the five-second flush, the thirty-second resend window, the 5% sample and the hourly upserts add up to — not a hope, and not an alert.
Second, the data never leaves the user's hands. Nothing sits in the path but the user's own Cloudflare account; values are scrubbed inside the app by default; source code is opt-in and masked; and the model that writes code changes runs in the user's own Workers AI.
Third, the advice is in the app's own words. Detection knows nothing about ORMs, but each finding's fix is written by the module for the ORM the traffic votes for — include in Sequelize, relations in TypeORM, with in Drizzle, populate in Mongoose — and the AI only applies that fix to code it was shown, with its answer checked against that code.
Fourth, regressions need no thresholds. Because every statement is counted, a query that became one and a half times slower after a deploy is caught even if it never crosses the 100 ms slow threshold. That is the reason the SDK counts what it does not send.
The costs are real and accepted. In production a clean request is seen only 5% of the time, and otherwise only through its totals. All envelopes for one app are processed by a single object, one at a time. And the triage runs on the app's own CPU — designed to be cheap, but not free.
Drawn from the QueryLens source at commit e758950 — backend 0.6.1, querylens-node 0.3.0 — October 2026. Every number on this board is read from the code.