Picture the cleanup meeting. The dead-features report says export_to_notion was used once, by one person, three weeks ago, and never again. So the conversation goes where it always goes with a feature like that: somebody tried it, it didn't work for them, and they never came back. Maybe the flow is confusing. Maybe it's buried. Someone takes an action item to look at the onboarding for it.
Now suppose the truth is different: nobody ever used it. Not once. The button sits behind a feature flag that never got turned on in production, and the "one user" is a bookkeeping record the analytics tool itself wrote when it discovered the feature in the code.
Those two stories need completely different fixes. "Used once and abandoned" is a product question - why did people leave? "Never fired at all" is an engineering question - is this code even reachable, and is the tracking call actually wired up? Sending a feature to the wrong one of those meetings wastes a week and still leaves the real problem in place. That second scenario isn't hypothetical for us: Eventra's dead-feature detector was sending every never-used feature to the wrong meeting, by design, and it took an embarrassingly long time to see it.
How a catalog tool learns that a feature exists
Eventra has two halves. A runtime SDK sends events when people actually use things. A CLI statically scans the codebase with the TypeScript compiler API and finds every track("...") call - including ones behind wrappers, re-exports and framework templates - so the tool knows what features exist, not just which ones happen to fire. That second half is the whole point: you can't notice that a feature is never used if the only features you know about are the ones that get used.
eventra send uploads that catalog. On the server, each name that isn't known yet has to be recorded somewhere, and the obvious "somewhere" was the pipeline that already existed. So the CLI endpoint pushed every newly discovered name through the normal ingest path as a synthetic event:
await this.ingest.trackBatch(workspaceId, projectId, toCreate.map((name) => ({
idempotencyKey: randomUUID(),
name,
userId: '__scan__',
properties: {
_internal: 'scan',
cli: { name: cli?.name, version: cli?.version, runtime: cli?.runtime },
},
timestamp: new Date().toISOString(),
})));
The billing code knew about these. It skipped anything carrying that sentinel user id and marker property, so nobody was ever charged for their own catalog. That's the part that got tested, because a billing bug is the kind that gets a support ticket.
Nothing else knew. The rollup job that turns raw events into per-feature numbers treated a synthetic event exactly like a real one. So every feature the CLI discovered got:
-
totalUses = 1anduniqueUsers = 1 -
firstUsedandlastUsedset to the moment someone raneventra send - a phantom row in the per-user tables under that sentinel user id, which also bumped the project's unique-user count and every adoption percentage
- two property stats from the marker property and the metadata blob, because the rollup stringifies property values and the nested object became
[object Object]
Then time did the rest. For the first 7 days, the feature got a "New" badge. Until day 14 it was "Active". After that it was "Dead" - "had usage before, nothing since", which is precisely the wrong story. A feature that was never used could not show up as never used, because by construction it had been used once.
The worst detail: our own homepage listed "Never seen - in your code, never reached the platform" as one of the lifecycle states. The product couldn't produce it. And the internal changelog from three weeks earlier had a line that said, more or less, "known limitation, not fixed here". We had written it down and moved on, because it didn't break anything visible. It just made the most useful signal the product has into a slightly wrong version of a different signal.
Fix #1: one definition of "synthetic", used everywhere
The first real problem was that "is this a CLI bookkeeping event?" was answered in exactly one place, inline in the billing code. Every other consumer had to know to ask, and none of them did. So the check moved into one small module that everything imports:
export const CLI_SYNTHETIC_USER_ID = '__scan__';
export const CLI_SYNTHETIC_MARKER = 'scan';
export function isCliSyntheticEvent(e: {
userId?: string | null;
properties?: Record<string, unknown> | null;
}): boolean {
return (
e.userId === CLI_SYNTHETIC_USER_ID &&
e.properties?._internal === CLI_SYNTHETIC_MARKER
);
}
Billing, the rollup and the CLI endpoint all use it now. The raw events feed and the "top users" query read the raw table directly in SQL, so they needed an SQL version of the same predicate, and that is where the one genuinely sharp edge in this whole fix was hiding.
The obvious SQL is NOT ("userId" = '__scan__' AND properties->>'_internal' = 'scan'). It looks right and it's wrong. SQL uses three-valued logic: for an anonymous event, "userId" is NULL, so that first comparison isn't false, it's NULL. If that event also has no properties, the other side is NULL too, NULL AND NULL is NULL, NOT NULL is still NULL, and a WHERE clause treats NULL as "no". Every real anonymous event without properties would have silently vanished from the events feed. Not the synthetic ones - the real ones.
export const NOT_CLI_SYNTHETIC_SQL = Prisma.sql`
NOT COALESCE(
"userId" = ${CLI_SYNTHETIC_USER_ID}
AND properties->>'_internal' = ${CLI_SYNTHETIC_MARKER},
false
)
`;
The COALESCE(..., false) says "if you can't tell, it isn't synthetic", which is the only safe default. The test for it asserts the SQL actually contains NOT COALESCE, because this is exactly the kind of line someone "simplifies" six months later.
Fix #2: register the feature, don't use it
The rollup now splits synthetic events out before any usage math happens. They still matter - they're how the tool learns the feature exists - but all they produce is an aggregate row with nothing in it:
INSERT INTO "FeatureAggregate"
("projectId","name","totalUses","uniqueUsers","firstUsed","lastUsed","createdAt","updatedAt")
VALUES (...) -- 0, 0, NULL, NULL for every registered name
ON CONFLICT ("projectId","name") DO NOTHING
DO NOTHING matters: if the feature already has real usage, or was already registered by an earlier scan, the registration changes nothing. And lastUsed IS NULL is now the "never seen" state. It needed one schema change, making lastUsed nullable, and it needed surprisingly little else. Every existing "active / dead / new / all" query already filtered on totalUses > 0, so registered-but-unused features dropped out of all of them without touching a single query. A new endpoint and a new dashboard page list them on their own, which is where they belonged in the first place.
The part I was nervous about was the transition: what happens when a never-seen feature finally gets its first real event? The normal upsert for real usage does this:
"firstUsed" = LEAST(COALESCE("FeatureAggregate"."firstUsed", EXCLUDED."firstUsed"), EXCLUDED."firstUsed"),
"lastUsed" = GREATEST("FeatureAggregate"."lastUsed", EXCLUDED."lastUsed")
In Postgres, GREATEST and LEAST ignore NULL arguments, so GREATEST(NULL, '2026-09-25 10:00') is just the timestamp. The first real event fills both dates in with no special case. I didn't want to trust that from memory, so it got run against a real database before anything else was built on it.
Fix #3: repairing data you can't simply recompute
Fixing the code only protects the next scan. Every project that had ever run eventra send already had phantom uses, a phantom user, and wrong dates sitting in its aggregate tables. The tempting fix is "replay everything from raw events". Two things make that a bad idea:
- Raw events are kept for 12 months. The aggregates live forever. Replaying from raw would quietly delete real history older than a year.
- Replaying means resetting the rollup's cursor, and the rollup runs every minute in production. Race that and you double-count everything.
So the repair is a single migration that subtracts exactly what the synthetic events added, and nothing else. Its outline:
-
BEGIN, then take the same Postgres advisory lock the rollup takes on every tick (pg_advisory_xact_lock), so no rollup tick can run in the middle of the repair. - Collect the synthetic raw events the rollup has already counted, meaning the ones at or before its cursor. Anything after the cursor is left for the new rollup code to handle correctly.
- Subtract their uses from the daily, hourly and lifetime tables, and from the two synthetic property values.
- Remove the sentinel user's rows from every per-user table, decrementing the matching unique-user counts first. Those rows survive past raw retention, so this part covers every age.
- Recompute
firstUsedandlastUsedfor each affected feature from what's left. For the first and last day with real usage, use the most precise source that still exists: a raw event if there is one, else the hourly bucket, else the start of the day. No usage left meanstotalUses = 0, both dates NULL: never seen. -
COMMIT.
Three details came out of running the migration against a scratch database seeded with deliberately awkward data - through the real prisma migrate deploy, the same way production applies it - instead of just reading it.
Timestamps without a time zone. firstUsed and lastUsed are timestamp, not timestamptz, holding UTC by convention. My candidate values came from timestamptz columns, and Postgres converts one to the other using the session's time zone. On a server whose session isn't UTC, every recomputed date would have been shifted by hours. Every candidate now goes through an explicit AT TIME ZONE 'UTC'. The check that proves it: run the migration twice from scratch, once with the database's TimeZone set to UTC and once to Asia/Tokyo, and diff the results. They're byte-identical.
The lock you take by accident. ALTER TABLE ... DROP NOT NULL is a metadata-only change and runs instantly. But it takes an ACCESS EXCLUSIVE lock that is held until the transaction commits, and inside a transaction that also scans a large raw-events table, that means the dashboard can't read aggregates for the whole scan. The ALTER moved from the first line of the migration to just before the final update, the only statement that needs the column to be nullable.
Failure has to mean nothing happened. One more run with the raw table deliberately renamed away, so a statement in the middle fails. The migration errors, and afterwards the column is still NOT NULL and every number is unchanged. One transaction, nothing half-applied.
The awkward seed data is what made these checks worth anything. It had a feature that only the CLI ever touched, a feature the CLI registered that real users adopted later, a real feature whose users set their own property called cli (it must survive untouched), anonymous events, a synthetic event newer than the rollup cursor, and a feature whose raw events were already past retention. After the migration, each one ended up exactly where it should. The last one also documents the one honest limitation: a synthetic event older than 12 months can't be subtracted, because the raw row it came from no longer exists. In practice that's one use per feature, and only for projects that ran the CLI more than a year ago.
What "done" actually meant
Unit tests pin the code down, but they can't prove much about a bug like this one, because every mock is written by the same person with the same mental model as the code - which is exactly how the original bug survived. The final check was the real pipeline instead: raw events written exactly the way the ingest path writes them, processed by the real rollup running in the real API, read through the real dashboard in a headless browser.
A CLI-only feature came out as 0 uses, 0 users and no dates, and appeared on the Never seen page. A feature the CLI registered and a real user then touched came out with exactly one use and one user, and none of the marker-property junk. The sentinel user appeared in no user table anywhere.
The rest of what shipped with it
Fixing "never seen" made it obvious how much else the dashboard was hiding, so it shipped alongside:
- Declining features. Still used, but at half or less of the previous period, measured on the same window as the dead threshold. It deliberately excludes today, since a half-finished day compared against full ones makes everything look like it's falling. It also skips features with too little history to have a baseline.
-
Exact unique users over time. Counted with
COUNT(DISTINCT)from per-day user rows. Summing the stored per-feature counts would count someone who used three features three times. - Custom date ranges, per-user pages, and drill-downs everywhere. Every number on the overview links to the feature, user or event behind it.
Try it
If you want to see the never-seen state on real-looking data: eventra.dev has a "Try live demo" link on the homepage, no signup, straight into a workspace with dead, declining and never-seen features in it. And if your own product has an "internal" or "system" user anywhere in its analytics, it's worth running one query to check how many of your numbers it's quietly part of.
Top comments (0)