Inspiration
When you build an app, you are writing down what you want to happen. This screen leads to that one. This is the button that matters. People should get from here to there.
That is not just instructions for a machine. It is a description of the journey you intended, and it is completely explicit. It is sitting in your code.
Your analytics then tells you what actually happened.
Nobody has ever put those two things next to each other, because one lives in a repository and the other in an events database, and no tool reads both.
The people who need that bridge most cannot build it. They made their product with Lovable, Bolt or Replit. Real users, real revenue, and they have never read the code that runs their business. Every existing tool hands them a chart and assumes they will go and open the file. They cannot, so they guess.
For scale: 310,567 repositories on GitHub match Lovable's default README alone.
What it does
userdrift reads your source to see what was intended, photographs your running product with a headless browser, and joins both to the traffic you already collect.
It draws your whole app as a map, with real screenshots, your own colours and your own screen names, and the real numbers on it. Then it tells you where people give up, and what has never been used at all.
Here is the half no analytics tool can have. A measurement tool counts what happened. A screen nobody reached produces no record, so there is nothing to count and no session to replay. You cannot ask a question about an absence. You would have to already know the screen existed and already suspect it was dead, which is the knowledge you were hoping to buy.
That is not a gap anyone will patch. It is the shape of the problem, and it is why the intended half has to come from the code.
How we built it
Two Cloud Run services from one image, split by a role variable. One serves the API, one drains the read queue. A read takes minutes, so it is queued and claimed atomically. A restart cannot lose it or run it twice.
The reader uses the TypeScript compiler API. It parses customer source and never executes it, because a config file is not something to run in your own process. It resolves routes, guards, elements, the Tailwind theme and the page background, and produces a drawing of every screen.
The camera is Playwright driving a headless Chromium baked into the image. The reader supplies the addresses, including screens that nothing links to, and the browser supplies the pixels. Neither half does this alone. A screenshot tool follows links, so a screen nothing links to is invisible to exactly the tool meant to find it.
The agent is a Google ADK LlmAgent on Gemini 3.7 Flash through Vertex AI,
with seven tools and an InMemoryRunner. It wakes on a change and decides for
itself what to look at. record_decision is the only way out, and it commits to
one of four branches. One of those four is saying nothing at all.
Everything numeric is computed before the model is called. The graph and the findings are built in Postgres. The model is handed figures it cannot change and asked only to phrase them. It can write a sentence about a leak. It cannot produce the leak.
Postgres on Supabase, with row level security on all 20 tables, a vault for customer analytics keys, and a private bucket for screenshots served by signed links. The web app is a static export on Cloudflare Pages. The downloaded copy of your code is deleted the moment a read finishes. We keep the map, never the code.
Challenges we ran into
Every number a model writes has to be checked. Our validator collects every figure the tools returned and checks the comment against that set. A comment carrying a number no tool produced is withheld entirely rather than corrected, because a corrected number is still one nobody can trace.
Gemini 3's thinking tokens come out of maxOutputTokens. Our suggestion writer
had a 200 token ceiling. Thinking used 192 of them and emitted 8. Every suggestion
failed silently, and it stayed invisible until we logged the finish reason.
Errors were being recorded as indecision. The agent looked unreliable, with roughly half of runs recording "could not decide". The event loop was reading only message content and throwing away the error fields on the event, so a quota failure looked exactly like a model with nothing to say. Once we surfaced it, the agent committed to a branch 10 times out of 10 on identical input.
Reading a page file is not the same as reading a page. Next.js middleware can
rewrite what an address serves. On one product, / had a page file rendering a
spinner, which was dead code, while middleware served the onboarding screen. The
drawing and the photograph disagreed and nothing could say why. We read the
middleware now. 6 of the 35 repositories we tested have it.
Screens were being named after their guards. The first heading in a file is usually a loading or empty state, so one screen came out called "Nothing to go on yet", and that phrase then turned up inside every finding about it. We now rank headings by the size of the enclosing return, which is the same signal the drawing was already using.
Accomplishments that we're proud of
We tested the reader on 35 real repositories: 675 screens, 13,356 elements and zero crashes, plus real apps built with Lovable, Bolt and Replit pulled from GitHub.
Background colour was missing on 110 of those screens. It is now 12. Two
causes. First, oklch(), which is the default in Tailwind 4 and current shadcn and
which our colour parser did not understand. Second, apps that set the page
background on the <body> element in JSX rather than in CSS.
Lovable apps had the wrong addresses everywhere. A route written as
<Route element={<ProtectedRoute><Dashboard/></ProtectedRoute>}> bound to the
guard rather than the page, so screens fell back to filename routes: /Dashboard
instead of /dashboard. In a product whose findings are sentences about addresses,
every address was quietly wrong.
And silence is a real outcome. Most changes deserve no comment, and the agent records why it stayed quiet.
What we learned
A tool that goes quiet and cannot say why is indistinguishable from one that is broken. That one idea shaped the whole product. Every decision is recorded , including the silent ones, every screen says whether you are looking at a photograph or a drawing, and every declined patch says which check refused it.
Confidence is the failure mode, not error. A screen photographed after a redirect and filed under the name we asked for is worse than no photograph at all. It is confidently false, on the one product whose entire claim is "this is your screen".
Never predict a result. A screen losing people may be quietly turning away people who were never going to stay. Every suggestion is a question with a test attached, never an answer with a number attached to it.
What's next for userdrift
Reading more conventions, starting with Remix and Hydrogen routes, CSS Modules and styled components. Same-day wording for new screens, so "nothing links here" never fires on something shipped this morning. And watching a proposed change before it merges, rather than after.
Built With
- chromium
- cloud-build
- cloud-run
- cloudflare-pages
- express.js
- gemini
- github-api
- google-adk
- next.js
- node.js
- playwright
- postgresql
- react
- secret-manager
- supabase
- tailwindcss
- typescript
- vertex-ai
Log in or sign up for Devpost to join the conversation.