<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jura</title>
    <description>The latest articles on DEV Community by Jura (@__c500e8ac9bc2).</description>
    <link>https://dev.to/__c500e8ac9bc2</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1728609%2F0a2482f3-5a07-49de-b6f7-d76f4624fa51.jpg</url>
      <title>DEV Community: Jura</title>
      <link>https://dev.to/__c500e8ac9bc2</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/__c500e8ac9bc2"/>
    <language>en</language>
    <item>
      <title>Every feature in our catalog had at least one user. It was the same user every time - our own scanner.</title>
      <dc:creator>Jura</dc:creator>
      <pubDate>Fri, 25 Sep 2026 18:32:03 +0000</pubDate>
      <link>https://dev.to/__c500e8ac9bc2/every-feature-in-our-catalog-had-at-least-one-user-it-was-the-same-user-every-time-our-own-20mh</link>
      <guid>https://dev.to/__c500e8ac9bc2/every-feature-in-our-catalog-had-at-least-one-user-it-was-the-same-user-every-time-our-own-20mh</guid>
      <description>&lt;p&gt;Picture the cleanup meeting. The dead-features report says &lt;code&gt;export_to_notion&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a catalog tool learns that a feature exists
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;track("...")&lt;/code&gt; call - including ones behind wrappers, re-exports and framework templates - so the tool knows what features &lt;em&gt;exist&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eventra send&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ingest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trackBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toCreate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__scan__&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;_internal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cli&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cli&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cli&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cli&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;})));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;totalUses = 1&lt;/code&gt; and &lt;code&gt;uniqueUsers = 1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;firstUsed&lt;/code&gt; and &lt;code&gt;lastUsed&lt;/code&gt; set to the moment someone ran &lt;code&gt;eventra send&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;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&lt;/li&gt;
&lt;li&gt;two property stats from the marker property and the metadata blob, because the rollup stringifies property values and the nested object became &lt;code&gt;[object Object]&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #1: one definition of "synthetic", used everywhere
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CLI_SYNTHETIC_USER_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__scan__&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CLI_SYNTHETIC_MARKER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isCliSyntheticEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;CLI_SYNTHETIC_USER_ID&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;_internal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;CLI_SYNTHETIC_MARKER&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The obvious SQL is &lt;code&gt;NOT ("userId" = '__scan__' AND properties-&amp;gt;&amp;gt;'_internal' = 'scan')&lt;/code&gt;. It looks right and it's wrong. SQL uses three-valued logic: for an anonymous event, &lt;code&gt;"userId"&lt;/code&gt; 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, &lt;code&gt;NULL AND NULL&lt;/code&gt; is NULL, &lt;code&gt;NOT NULL&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NOT_CLI_SYNTHETIC_SQL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`
  NOT COALESCE(
    "userId" = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;CLI_SYNTHETIC_USER_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
      AND properties-&amp;gt;&amp;gt;'_internal' = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;CLI_SYNTHETIC_MARKER&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,
    false
  )
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;COALESCE(..., false)&lt;/code&gt; 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 &lt;code&gt;NOT COALESCE&lt;/code&gt;, because this is exactly the kind of line someone "simplifies" six months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #2: register the feature, don't use it
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="nv"&gt;"FeatureAggregate"&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"projectId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"totalUses"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"uniqueUsers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"firstUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"lastUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"createdAt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"updatedAt"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;  &lt;span class="c1"&gt;-- 0, 0, NULL, NULL for every registered name&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"projectId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DO NOTHING&lt;/code&gt; matters: if the feature already has real usage, or was already registered by an earlier scan, the registration changes nothing. And &lt;code&gt;lastUsed IS NULL&lt;/code&gt; is now the "never seen" state. It needed one schema change, making &lt;code&gt;lastUsed&lt;/code&gt; nullable, and it needed surprisingly little else. Every existing "active / dead / new / all" query already filtered on &lt;code&gt;totalUses &amp;gt; 0&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="nv"&gt;"firstUsed"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LEAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"FeatureAggregate"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"firstUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"firstUsed"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"firstUsed"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nv"&gt;"lastUsed"&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GREATEST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"FeatureAggregate"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"lastUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"lastUsed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Postgres, &lt;code&gt;GREATEST&lt;/code&gt; and &lt;code&gt;LEAST&lt;/code&gt; ignore NULL arguments, so &lt;code&gt;GREATEST(NULL, '2026-09-25 10:00')&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #3: repairing data you can't simply recompute
&lt;/h2&gt;

&lt;p&gt;Fixing the code only protects the next scan. Every project that had ever run &lt;code&gt;eventra send&lt;/code&gt; 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Raw events are kept for 12 months. The aggregates live forever. Replaying from raw would quietly &lt;em&gt;delete&lt;/em&gt; real history older than a year.&lt;/li&gt;
&lt;li&gt;Replaying means resetting the rollup's cursor, and the rollup runs every minute in production. Race that and you double-count everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the repair is a single migration that subtracts exactly what the synthetic events added, and nothing else. Its outline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;BEGIN&lt;/code&gt;, then take the same Postgres advisory lock the rollup takes on every tick (&lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;), so no rollup tick can run in the middle of the repair.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Subtract their uses from the daily, hourly and lifetime tables, and from the two synthetic property values.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Recompute &lt;code&gt;firstUsed&lt;/code&gt; and &lt;code&gt;lastUsed&lt;/code&gt; for 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 means &lt;code&gt;totalUses = 0&lt;/code&gt;, both dates NULL: never seen.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COMMIT&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three details came out of running the migration against a scratch database seeded with deliberately awkward data - through the real &lt;code&gt;prisma migrate deploy&lt;/code&gt;, the same way production applies it - instead of just reading it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timestamps without a time zone.&lt;/strong&gt; &lt;code&gt;firstUsed&lt;/code&gt; and &lt;code&gt;lastUsed&lt;/code&gt; are &lt;code&gt;timestamp&lt;/code&gt;, not &lt;code&gt;timestamptz&lt;/code&gt;, holding UTC by convention. My candidate values came from &lt;code&gt;timestamptz&lt;/code&gt; columns, and Postgres converts one to the other using the &lt;em&gt;session's&lt;/em&gt; 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 &lt;code&gt;AT TIME ZONE 'UTC'&lt;/code&gt;. The check that proves it: run the migration twice from scratch, once with the database's &lt;code&gt;TimeZone&lt;/code&gt; set to &lt;code&gt;UTC&lt;/code&gt; and once to &lt;code&gt;Asia/Tokyo&lt;/code&gt;, and diff the results. They're byte-identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lock you take by accident.&lt;/strong&gt; &lt;code&gt;ALTER TABLE ... DROP NOT NULL&lt;/code&gt; is a metadata-only change and runs instantly. But it takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; 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 &lt;code&gt;ALTER&lt;/code&gt; moved from the first line of the migration to just before the final update, the only statement that needs the column to be nullable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure has to mean nothing happened.&lt;/strong&gt; 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 &lt;code&gt;NOT NULL&lt;/code&gt; and every number is unchanged. One transaction, nothing half-applied.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;own&lt;/em&gt; property called &lt;code&gt;cli&lt;/code&gt; (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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "done" actually meant
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of what shipped with it
&lt;/h2&gt;

&lt;p&gt;Fixing "never seen" made it obvious how much else the dashboard was hiding, so it shipped alongside:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declining features.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exact unique users over time.&lt;/strong&gt; Counted with &lt;code&gt;COUNT(DISTINCT)&lt;/code&gt; from per-day user rows. Summing the stored per-feature counts would count someone who used three features three times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom date ranges, per-user pages, and drill-downs everywhere.&lt;/strong&gt; Every number on the overview links to the feature, user or event behind it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you want to see the never-seen state on real-looking data: &lt;a href="https://eventra.dev" rel="noopener noreferrer"&gt;eventra.dev&lt;/a&gt; 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.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>postgres</category>
      <category>typescript</category>
      <category>analytics</category>
    </item>
    <item>
      <title>A correct validation rule almost broke a request our existing customers had been making for months</title>
      <dc:creator>Jura</dc:creator>
      <pubDate>Thu, 10 Sep 2026 12:46:51 +0000</pubDate>
      <link>https://dev.to/__c500e8ac9bc2/a-correct-validation-rule-almost-broke-a-request-our-existing-customers-had-been-making-for-months-3m2m</link>
      <guid>https://dev.to/__c500e8ac9bc2/a-correct-validation-rule-almost-broke-a-request-our-existing-customers-had-been-making-for-months-3m2m</guid>
      <description>&lt;p&gt;You've shipped this exact change before, probably more than once: a feature that's the obvious mirror image of one you already have. You already flag features as "dead" after N days of no events - customers ask for the opposite, a "new" flag for features that just started getting used. Same table, same kind of threshold field, same UI slot. It reads like an afternoon of work, because the hard part - detecting a feature's usage lifecycle from raw events - is already solved. You're just adding the other half of a pair.&lt;/p&gt;

&lt;p&gt;That's exactly the case that's dangerous, because "the other half of a pair" is where code quietly assumes the pair will always relate to each other the way you're imagining right now, in the twenty minutes you're spending on this. We added the mirror image of an existing feature, wrote what looked like an obviously correct validation rule for it, and that rule would have rejected a request our existing customers had already been making successfully for months - a request that had nothing to do with the new field at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature: nothing surprising
&lt;/h2&gt;

&lt;p&gt;Eventra flags a feature "dead" when it's had no events for N days - 14 by default, configurable per project. We added "new": a feature is "new" for N days after its &lt;em&gt;first&lt;/em&gt; event - 7 by default, same per-project override. Same table, same kind of field, same kind of badge next to it in the UI. The actual detection logic is maybe fifteen lines: swap &lt;code&gt;lastUsed&lt;/code&gt; for &lt;code&gt;firstUsed&lt;/code&gt;, flip a comparison. This part shipped exactly as boring as it sounds.&lt;/p&gt;

&lt;p&gt;The two thresholds have to stay in a specific relationship, though: a feature can't reasonably be "new" (fired for the first time recently) and "dead" (hasn't fired in a while) at the same moment, which becomes possible the instant someone configures the new-threshold &lt;em&gt;larger&lt;/em&gt; than the dead-threshold. So the project-settings endpoint got a check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextNewThreshold&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;nextDeadThreshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;newFeatureThresholdDays must be less than or equal to deadFeatureThresholdDays&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it on its own and there's nothing wrong with it. It's the correct invariant, stated directly, rejected with a clear message. It's the kind of line that sails through review because reviewing it means checking "is this logically true," and it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that isn't visible from the rule itself
&lt;/h2&gt;

&lt;p&gt;Here's what the rule doesn't show you: the dead-threshold field already existed, already had months of live customer configurations, and its allowed minimum is 1 day. The new-threshold field is brand new, and every existing project got it from a migration with a default of 7 days - a default nobody chose, stamped onto every account retroactively, including accounts that will never open the settings page and notice it exists.&lt;/p&gt;

&lt;p&gt;Now replay an entirely ordinary support scenario. A customer who set their dead-feature threshold to 5 days three months ago - because their product ships fast and features go stale quickly - opens settings today and tries to lower it to 3 days. Same field they've edited before, same kind of change they've made before. The request now fails with a 400 about &lt;code&gt;newFeatureThresholdDays&lt;/code&gt; - a field this customer has never seen, sitting at a default value they never chose, silently in conflict with the one field they're actually trying to change.&lt;/p&gt;

&lt;p&gt;That's not a hypothetical edge case you'd need bad luck to hit. It's the default outcome of "add a symmetric field with a fixed default, then validate it against a pre-existing field whose live values already span a wider range than the new field's default." It passes every unit test that only exercises the new field's own valid range. It passes code review, because the validation logic is genuinely correct. It only surfaces the first time a real customer, with a low-enough pre-existing value in the &lt;em&gt;other&lt;/em&gt; field, touches that other field - which for a field that's been configurable for months, is not a matter of if.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: tell the two cases apart
&lt;/h2&gt;

&lt;p&gt;The rule was conflating "the user asked for an invalid new-threshold" with "the new-threshold just happens to be sitting in the way of something else they asked for." Those need different responses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextNewThreshold&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;nextDeadThreshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newFeatureThresholdDays&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// they explicitly asked for a value that doesn't fit - say so&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;newFeatureThresholdDays must be less than or equal to deadFeatureThresholdDays&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// they only touched deadFeatureThresholdDays - the new field was never&lt;/span&gt;
  &lt;span class="c1"&gt;// part of their request, so adjust it instead of rejecting theirs&lt;/span&gt;
  &lt;span class="nx"&gt;nextNewThreshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextDeadThreshold&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone explicitly sets an invalid &lt;code&gt;newFeatureThresholdDays&lt;/code&gt;, they still get told exactly why, immediately. If they never mentioned it and it just happens to sit above whatever they're lowering &lt;code&gt;deadFeatureThresholdDays&lt;/code&gt; to, it gets quietly pulled down to match instead - the request that always worked keeps working, and the invariant still holds afterward either way.&lt;/p&gt;

&lt;p&gt;That covers every request from here on. It doesn't cover the rows already sitting in the database with the same conflict, from before the check existed - a customer at dead=5 already has new=7 sitting there right now, invalid, untouched, waiting for the day they edit &lt;em&gt;either&lt;/em&gt; field and get a result they didn't ask for. So the migration that added the field got a follow-up: a plain backfill, applied once, capping the new field wherever it already exceeded the old one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="nv"&gt;"Project"&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="nv"&gt;"newFeatureThresholdDays"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;"deadFeatureThresholdDays"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"newFeatureThresholdDays"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;"deadFeatureThresholdDays"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it, the code-level fix only protects the next edit. The invariant would still be false, quietly, for every account that happens to already be in that state - correct from here forward is not the same claim as correct right now, and a migration is the only place you get to make the second claim true retroactively.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually catches this
&lt;/h2&gt;

&lt;p&gt;Nothing about this bug shows up by testing the new field in isolation. A fresh test suite's fixtures are exactly as consistent as whoever wrote them - the fixtures for a brand-new field are never going to include "an old field, from a real account, configured to a value from before this field existed," because you have to go looking for that scenario on purpose. The question that surfaces it isn't "does my new validation work for valid inputs to the new field," it's "for every row that already exists, is my new invariant already true, or could it already be violated before anyone touches anything" - and that second question only gets asked if you go look at what's actually sitting in the table, not just at what the migration's default value claims.&lt;/p&gt;

&lt;p&gt;The concrete version of that, for a schema change specifically: after writing the migration, don't just check that it applies. Check what the data looks like the moment after it applies, for every row that was there before it ran, against whatever new rule you're about to enforce on top of it. If any existing row could already fail that rule, the rule needs a plan for those rows before it ships, not just for the next request that happens to touch them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The "new" feature status is live on &lt;a href="https://eventra.dev" rel="noopener noreferrer"&gt;eventra.dev&lt;/a&gt; now - features get flagged "new" for their first 7 days, configurable per project, right next to the existing dead-feature detection. If you want to see it on real data: the homepage has a "Try live demo" link, no signup, straight into a populated workspace.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>software</category>
      <category>softwaredevelopment</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We put a real, live dashboard on our homepage with no signup wall. Here's how it survives strangers.</title>
      <dc:creator>Jura</dc:creator>
      <pubDate>Tue, 01 Sep 2026 11:51:25 +0000</pubDate>
      <link>https://dev.to/__c500e8ac9bc2/we-put-a-real-live-dashboard-on-our-homepage-with-no-signup-wall-heres-how-it-survives-strangers-1ghe</link>
      <guid>https://dev.to/__c500e8ac9bc2/we-put-a-real-live-dashboard-on-our-homepage-with-no-signup-wall-heres-how-it-survives-strangers-1ghe</guid>
      <description>&lt;p&gt;You land on a SaaS product's homepage. It looks good. You want to see the actual dashboard before you decide whether five more minutes of your life are worth a signup form. There's no way to. "Book a demo." "Start free trial" (email required, sometimes a card). A carousel of static screenshots that were true eighteen months ago. So you close the tab, and the product loses you before you ever saw the product.&lt;/p&gt;

&lt;p&gt;We didn't want to be that tab-close. So we put a real, live, populated workspace behind a "Try live demo" button on the homepage - no email, no account, one click, straight into the actual dashboard with actual-looking data: usage charts, dead-feature detection, an events feed. Not screenshots. The real app.&lt;/p&gt;

&lt;p&gt;The problem with that idea is the second half of the sentence: &lt;em&gt;the real app&lt;/em&gt;, open to anyone. A dashboard is normally only as trustworthy as its access control, and we were about to hand out a session to whoever clicked a button, pointed at data other visitors are looking at too. Here's what actually had to be true before that button could ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Hide the buttons" is not a security model
&lt;/h2&gt;

&lt;p&gt;The lazy version of a public demo is a normal account with the delete/edit buttons hidden in the UI. It's also worthless as protection, because nothing stops a visitor from opening devtools, finding the real API call behind that hidden button, and firing it directly. UI-level restrictions are a UX feature, not an access-control boundary - they describe what a well-behaved client does, not what the server allows.&lt;/p&gt;

&lt;p&gt;So the read-only guarantee for our demo account isn't a frontend concern at all. It's a single interceptor sitting in front of every request the backend handles, checked after auth and before any handler runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SAFE_METHODS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HEAD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DemoReadOnlyInterceptor&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;NestInterceptor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;intercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CallHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Observable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;switchToHttp&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;getRequest&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AppRequest&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isDemoAccount&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;SAFE_METHODS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ForbiddenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The demo account is read-only.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's registered globally, once, next to the app's other cross-cutting interceptor. It doesn't know or care what the request was trying to do - create a project, delete a workspace, rotate an API key, invite a teammate. If the session belongs to the demo account and the method isn't GET/HEAD/OPTIONS, it's rejected before it reaches the controller. That's the whole surface. Every mutation endpoint in the app - workspaces, projects, invites, API keys, dashboard layout, even the admin routes - is covered by one check, not by remembering to add a guard to each one individually. The alternative (annotate every mutating endpoint by hand) is exactly the kind of thing that's correct on the day you write it and silently wrong the first time someone adds a new endpoint and forgets.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isDemoAccount&lt;/code&gt; itself is about as small as a security check gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isDemoAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEMO_ACCOUNT_EMAIL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One env var, one comparison, computed once per request in the auth guard and stamped onto &lt;code&gt;req.user&lt;/code&gt;. No table to keep in sync, no role to accidentally grant to the wrong person later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap that wasn't in the interceptor - it was next to it
&lt;/h2&gt;

&lt;p&gt;Here's the failure mode that actually worried us once the read-only guard existed: what if the demo account somehow &lt;em&gt;also&lt;/em&gt; ended up with platform-admin rights? Those are two completely independent booleans, computed from two completely independent env vars (&lt;code&gt;DEMO_ACCOUNT_EMAIL&lt;/code&gt;, &lt;code&gt;PLATFORM_ADMIN_EMAIL&lt;/code&gt;), by two completely independent checks. Nothing links them - which is fine, until someone misconfigures one operator's environment and sets both to the same address. The read-only interceptor only blocks &lt;em&gt;mutations&lt;/em&gt;. It says nothing about &lt;em&gt;reads&lt;/em&gt;, and platform-admin routes are gated purely by &lt;code&gt;isPlatformAdmin&lt;/code&gt;, not by &lt;code&gt;isDemoAccount&lt;/code&gt;. A misconfiguration like that would hand a public, no-login-required visitor read access to internal operator tooling - workspace-wide billing internals, every customer's account list, whatever else "admin" means in a growing app.&lt;/p&gt;

&lt;p&gt;That's not a bug you find by testing the happy path. It's a bug you find by asking "what has to be independently true for this to be safe, and is any of it actually coupled to anything else." The fix is one line, in the same place both flags get computed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;isPlatformAdmin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;demoAccount&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isPlatformAdmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Demo unconditionally wins. Even if an operator someday points both env vars at the same address, the account that resolves as "demo" can never also resolve as "platform admin" - it's not a policy documented in a runbook that says "don't do that," it's a line of code that makes the mistake structurally impossible instead of merely inadvisable.&lt;/p&gt;

&lt;h2&gt;
  
  
  A demo that doesn't visibly rot
&lt;/h2&gt;

&lt;p&gt;A demo account that never gets new data is a different kind of broken - it's harder to notice, but "last used: 4 months ago" on every feature is its own way of telling a visitor the product is dead. So the demo workspace reseeds itself every hour, on a cron, wiping and regenerating its own analytics tables (aggregates, daily/hourly stats, the raw event feed - synthesized in memory as one internally-consistent event stream, not generated table by table) so "last used" always looks like today, whatever time zone the visitor is in.&lt;/p&gt;

&lt;p&gt;The part worth calling out isn't the cron - it's what happens if two requests hit it at once, which will happen the moment you run more than one instance of the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;lockClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT pg_try_advisory_lock($1)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;LOCK_ID&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;pg_try_advisory_lock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Postgres session-level advisory lock, held for the entire multi-transaction refresh. If a second instance's cron fires while the first is still mid-reseed, it just checks the lock, sees it's held, and returns immediately - no queueing, no duplicate writes, no two processes racing to regenerate the same rows. And if the reseed itself throws partway through, it's caught and logged, never rethrown - a failed tick just leaves last hour's data in place instead of leaving the demo workspace half-rewritten, and the next tick tries again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "done" required before this shipped
&lt;/h2&gt;

&lt;p&gt;None of the above shipped on the strength of "looks right in a code review." Before we called it done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ran the actual seed script twice in a row against a local database and confirmed it doesn't duplicate the workspace, the projects, or double-count the current month's usage - a reseed job that isn't idempotent turns "every hour" into "growing forever."&lt;/li&gt;
&lt;li&gt;Hit the real endpoints with a manually issued demo session cookie: &lt;code&gt;GET /api/v1/auth/me&lt;/code&gt; (200 - reads work), &lt;code&gt;POST /api/v1/workspaces&lt;/code&gt; (403 - mutation blocked), &lt;code&gt;POST&lt;/code&gt; on the event-ingestion path (403 - can't be used to inject arbitrary events into the shared dataset either), &lt;code&gt;GET&lt;/code&gt; on the analytics endpoints (200, real numbers back).&lt;/li&gt;
&lt;li&gt;Wrote a dedicated collision test: set the demo-account env var and the platform-admin env var to the same address on purpose, and asserted the resulting session still comes back &lt;code&gt;isPlatformAdmin: false&lt;/code&gt;. Not just "the normal case works" - the specific misconfiguration scenario, provoked deliberately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The demo account also can't be used to bootstrap a live API key and start pushing real events into the shared workspace - there's deliberately no &lt;code&gt;ProjectApiKey&lt;/code&gt; row created for the demo project during seeding, so there's no key for a visitor to find and copy. That one's a narrower guarantee than the interceptor (it's "we never created the door," not "the door is locked"), which is worth being honest about rather than overstating: the day a demo project would need a live key issued for some other reason, that assumption needs revisiting, not just re-trusting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you want to see what any of this looks like from the visitor's side rather than the request-log side: &lt;a href="https://eventra.dev" rel="noopener noreferrer"&gt;eventra.dev&lt;/a&gt; has a "Try live demo" link on the homepage and the sign-in page. One click, no account, into a populated workspace that's never more than an hour stale. Poke at it, try to break something - it's read-only by construction, not by convention, so go ahead.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>saas</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Your feature-usage scanner doesn't know Vue, Svelte, or Astro exist. Here's how we fixed that without touching its core.</title>
      <dc:creator>Jura</dc:creator>
      <pubDate>Fri, 21 Aug 2026 09:32:28 +0000</pubDate>
      <link>https://dev.to/__c500e8ac9bc2/your-feature-usage-scanner-doesnt-know-vue-svelte-or-astro-exist-heres-how-we-fixed-that-1eif</link>
      <guid>https://dev.to/__c500e8ac9bc2/your-feature-usage-scanner-doesnt-know-vue-svelte-or-astro-exist-heres-how-we-fixed-that-1eif</guid>
      <description>&lt;p&gt;If a static analyzer only walks &lt;code&gt;.ts&lt;/code&gt;/&lt;code&gt;.tsx&lt;/code&gt;/&lt;code&gt;.js&lt;/code&gt;/&lt;code&gt;.jsx&lt;/code&gt;, every other file type isn't scanned badly - it's not scanned at all. A &lt;code&gt;.vue&lt;/code&gt; component, a &lt;code&gt;.svelte&lt;/code&gt; widget, an &lt;code&gt;.astro&lt;/code&gt; page: none of them exist to the tool. Not "low confidence." Not "partial support." Invisible, the same way an empty search result looks identical whether there's genuinely nothing to find or the search just never looked in the right place.&lt;/p&gt;

&lt;p&gt;That's exactly the gap Eventra's CLI had. It scans a codebase and tells you which tracked features are actually used - the whole pitch is "stop guessing which code is dead." Except if your team ships a Vue admin panel, a Svelte checkout widget, and an Astro marketing site around the same core app (which, if you've worked on more than one team, you've probably seen - nobody plans a multi-framework stack, it just accretes), the CLI would silently skip all three, report a clean scan, and never mention that it hadn't actually looked. The exact failure mode the product exists to prevent, happening inside the product itself.&lt;/p&gt;

&lt;p&gt;We'd already closed this gap once, for Vue. This month we closed it for Svelte and Astro too, and the interesting part isn't the frameworks - it's that adding two more meant touching exactly zero lines of the CLI's core analysis engine.&lt;/p&gt;




&lt;h2&gt;
  
  
  The trick: don't teach the core anything
&lt;/h2&gt;

&lt;p&gt;The CLI's core is a TypeScript-compiler-API engine: it builds a real program, walks real ASTs, resolves real symbols across files, and figures out which &lt;code&gt;.track("event_name")&lt;/code&gt; calls are statically reachable. It is, deliberately, framework-agnostic - it doesn't know what Vue is, and it shouldn't have to.&lt;/p&gt;

&lt;p&gt;So instead of teaching the core about &lt;code&gt;.vue&lt;/code&gt;/&lt;code&gt;.svelte&lt;/code&gt;/&lt;code&gt;.astro&lt;/code&gt;, each framework gets a small, separate plugin whose only job is a translation: take the framework file, hand back one virtual TypeScript module. A Vue &lt;code&gt;Checkout.vue&lt;/code&gt; becomes &lt;code&gt;Checkout.vue.ts&lt;/code&gt;. A Svelte &lt;code&gt;Cart.svelte&lt;/code&gt; becomes &lt;code&gt;Cart.svelte.ts&lt;/code&gt;. The core never sees the original file - it sees TypeScript, because by the time it looks, that's what's actually there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CliPluginTransformResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's most of the contract. A plugin declares which globs it wants (&lt;code&gt;**/*.svelte&lt;/code&gt;), a &lt;code&gt;match()&lt;/code&gt; to claim files, and a &lt;code&gt;transform()&lt;/code&gt; that returns virtual modules. The core registers the glob, calls &lt;code&gt;transform()&lt;/code&gt; on matching files, and feeds the result into the exact same incremental TypeScript program every &lt;code&gt;.ts&lt;/code&gt; file goes through. No &lt;code&gt;if (isVueFile)&lt;/code&gt; branch anywhere in the resolver, the propagation engine, or the wrapper detector. They don't know these files were ever anything but TypeScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that has to be a real compiler, not a regex
&lt;/h2&gt;

&lt;p&gt;The first version of the Vue plugin - before this month - parsed &lt;code&gt;.vue&lt;/code&gt; files with two regexes: one to grab &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; content, one to grab &lt;code&gt;event="..."&lt;/code&gt; attributes out of &lt;code&gt;&amp;lt;template&amp;gt;&lt;/code&gt;. It worked on every fixture, because every fixture was written to be exactly what that regex expected. It fell over on real code: dynamic bindings vanished silently, HTML comments got matched as if they were live markup, and &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; + &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; got concatenated with zero understanding of how Vue actually merges them.&lt;/p&gt;

&lt;p&gt;The fix - and the standard we held Svelte and Astro to from the start - is to parse with the framework's own real compiler. Not a library that looks similar. The actual thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vue: &lt;code&gt;@vue/compiler-sfc&lt;/code&gt;, the same package Vite and Nuxt build on.&lt;/li&gt;
&lt;li&gt;Svelte: &lt;code&gt;svelte/compiler&lt;/code&gt;'s &lt;code&gt;parse()&lt;/code&gt;, in its documented legacy-AST mode (&lt;code&gt;modern: false&lt;/code&gt;) - stable, tooling-oriented, and it gives you &lt;code&gt;IfBlock&lt;/code&gt;/&lt;code&gt;EachBlock&lt;/code&gt;/&lt;code&gt;AwaitBlock&lt;/code&gt; as real distinct node types instead of text to pattern-match.&lt;/li&gt;
&lt;li&gt;Astro: &lt;code&gt;@astrojs/compiler&lt;/code&gt;, the WASM parser the Astro toolchain itself uses, which hands back a typed AST with &lt;code&gt;element&lt;/code&gt;/&lt;code&gt;component&lt;/code&gt;/&lt;code&gt;expression&lt;/code&gt; nodes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three different compilers means three different AST shapes, and the differences turned out to matter more than expected. Svelte's legacy &lt;code&gt;&amp;lt;script context="module"&amp;gt;&lt;/code&gt; node has no &lt;code&gt;attributes&lt;/code&gt; field at all - recovering &lt;code&gt;lang="ts"&lt;/code&gt; meant regex-matching the raw opening-tag text, not asking the AST for it. Astro's attribute nodes carry a &lt;code&gt;kind&lt;/code&gt; (&lt;code&gt;quoted&lt;/code&gt; / &lt;code&gt;expression&lt;/code&gt; / &lt;code&gt;shorthand&lt;/code&gt; / &lt;code&gt;template-literal&lt;/code&gt; / …) and hand you the already-extracted expression source directly; Svelte makes you slice the original source string yourself using a &lt;code&gt;MustacheTag&lt;/code&gt;'s &lt;code&gt;expression.start&lt;/code&gt;/&lt;code&gt;.end&lt;/code&gt; offsets. And Astro's JSX shorthand - &lt;code&gt;&amp;lt;Button {event} /&amp;gt;&lt;/code&gt;, sugar for &lt;code&gt;event={event}&lt;/code&gt; - leaves the attribute's &lt;code&gt;.value&lt;/code&gt; empty for that specific &lt;code&gt;kind&lt;/code&gt;, so the plugin has to notice the shorthand case and substitute the attribute name back in as the expression. None of that is guessable from documentation; it's the kind of thing you only find by parsing real snippets and printing the actual AST.&lt;/p&gt;

&lt;h2&gt;
  
  
  One convention, three parsers
&lt;/h2&gt;

&lt;p&gt;Eventra's templates use a plain attribute, &lt;code&gt;event="checkout.cta"&lt;/code&gt;, to mark a tracked interaction directly in markup - no wrapper function needed for the simple case. It needed to mean the same thing in all three frameworks, expressed in whatever each one's own binding syntax actually is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Vue --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;event=&lt;/span&gt;&lt;span class="s"&gt;"checkout.cta"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;:event=&lt;/span&gt;&lt;span class="s"&gt;"computedName"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Svelte --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;event=&lt;/span&gt;&lt;span class="s"&gt;"checkout.cta"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;event=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;computedName&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;!-- Astro --&amp;gt;
&amp;lt;button event="checkout.cta" /&amp;gt;
&amp;lt;button event={computedName} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Literal or dynamic, each plugin turns every one of these into a synthetic function call inside the virtual module - &lt;code&gt;__eventra_svelte_template_event__("checkout.cta")&lt;/code&gt; for a literal, &lt;code&gt;__eventra_svelte_template_event__(computedName)&lt;/code&gt; for a dynamic one, unquoted. Because it's a real call sitting in the same module scope as the actual &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;/frontmatter, a dynamic binding that happens to reference a real constant defined a few lines up gets resolved through precisely the same symbol-resolution path a plain &lt;code&gt;tracker.track(someVariable)&lt;/code&gt; would use in any &lt;code&gt;.ts&lt;/code&gt; file. If it can't be resolved - say, an &lt;code&gt;{#each}&lt;/code&gt; loop variable - it's reported as a dynamic occurrence instead of silently vanishing, which was the entire bug that made the first Vue regex implementation dangerous.&lt;/p&gt;

&lt;p&gt;The plugin declares this callee as a &lt;code&gt;staticSink&lt;/code&gt; - &lt;code&gt;{ id, callee, eventNameArgumentIndex }&lt;/code&gt; - and the core converts that into its own internal sink detector. The plugin never imports anything from the CLI. The CLI never imports anything framework-specific. They meet in the middle through a plain data contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trusting a plugin without trusting arbitrary code
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;eventra.json&lt;/code&gt; lists which plugins to load, and it's a file that gets committed to git - which means it's PR-editable by anyone who can open a pull request. If the loader did &lt;code&gt;import(anyStringFromThatFile)&lt;/code&gt;, a one-line JSON edit in a PR would be arbitrary code execution on every machine that ever runs &lt;code&gt;eventra sync&lt;/code&gt;. The fix already existed for the Vue plugin and needed zero changes to cover the two new ones: only packages matching &lt;code&gt;@eventra_dev/cli-plugin-*&lt;/code&gt; - an npm scope only the maintainers can publish to - are ever dynamically imported. Anything else gets skipped with a warning. Adding Svelte and Astro meant the allowlist regex, written generically the first time, just worked; the only thing that proved it was a test asserting &lt;code&gt;cli-plugin-svelte&lt;/code&gt; matched it, written before the Svelte plugin itself existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it actually works, not just that the unit tests pass
&lt;/h2&gt;

&lt;p&gt;Unit tests for a plugin are cheap to fool - you write the fixture, you write the assertion, and if you got the mental model wrong in the same way twice, they both pass anyway. So after the plugins were built and green, we did the thing a real npm consumer would do: packed all four packages (SDK, CLI, and both new plugins) into actual &lt;code&gt;.tgz&lt;/code&gt; tarballs with &lt;code&gt;npm pack&lt;/code&gt;, installed them into a throwaway project exactly the way &lt;code&gt;npm install&lt;/code&gt; would from the registry, and ran the real compiled &lt;code&gt;eventra&lt;/code&gt; binary against a &lt;code&gt;.svelte&lt;/code&gt; and an &lt;code&gt;.astro&lt;/code&gt; fixture, each with one direct SDK call and one template &lt;code&gt;event="..."&lt;/code&gt; binding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"astro.checkout.cta"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"astro.checkout.started"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"svelte.checkout.cta"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"svelte.checkout.started"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four events in, four events out, through the actual dynamic-import loading path, not a mocked one. That's the difference between "the code that implements the feature is correct" and "the feature works when a stranger installs it," and it's cheap enough to do that there's no excuse not to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "done" actually required
&lt;/h2&gt;

&lt;p&gt;Each plugin ended up with roughly twenty tests and a hard 100% statement/branch/function/line coverage gate, and the coverage gate is what surfaced most of the interesting edge cases, not the other way around. A branch that never gets hit is either a real gap or a genuinely impossible case - and it's worth knowing which before shipping either way. A few turned out to be the second kind: &lt;code&gt;svelte/compiler&lt;/code&gt;'s attribute grammar guarantees a single-value attribute is either plain text or one expression, never a third thing, so the "neither" branch in that check is unreachable by construction, not untested by neglect. Those got a one-line comment explaining why, instead of a fabricated test pretending to exercise something the parser's own grammar rules out. The rest - a boolean-shorthand &lt;code&gt;event&lt;/code&gt; attribute with no value, an interpolated &lt;code&gt;event="a-{b}"&lt;/code&gt; that's neither a clean literal nor a clean expression, a &lt;code&gt;context="module"&lt;/code&gt; script block that's present but empty - were real gaps, and each one is a case someone's actual codebase would eventually hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add &lt;span class="nt"&gt;-D&lt;/span&gt; @eventra_dev/cli-plugin-svelte @eventra_dev/eventra-cli
&lt;span class="c"&gt;# or&lt;/span&gt;
pnpm add &lt;span class="nt"&gt;-D&lt;/span&gt; @eventra_dev/cli-plugin-astro @eventra_dev/eventra-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@eventra_dev/cli-plugin-svelte"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@eventra_dev/cli-plugin-astro"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same config shape as the Vue plugin, and you can list all three at once if your codebase actually is that mixed. &lt;a href="https://www.npmjs.com/package/@eventra_dev/cli-plugin-svelte" rel="noopener noreferrer"&gt;Svelte plugin on npm&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/@eventra_dev/cli-plugin-astro" rel="noopener noreferrer"&gt;Astro plugin on npm&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/@eventra_dev/cli-plugin-vue" rel="noopener noreferrer"&gt;Vue plugin on npm&lt;/a&gt; · &lt;a href="https://eventra.dev/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Angular's next. If your own tool has a hard-coded list of "supported" things, it's worth asking whether the boundary is actually load-bearing, or whether it's just the first three cases someone happened to write down.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>svelte</category>
      <category>astro</category>
    </item>
    <item>
      <title>Your product has features nobody uses. Here's how to find them automatically.</title>
      <dc:creator>Jura</dc:creator>
      <pubDate>Tue, 16 Jun 2026 14:56:50 +0000</pubDate>
      <link>https://dev.to/__c500e8ac9bc2/your-product-has-features-nobody-uses-heres-how-to-find-them-automatically-3baa</link>
      <guid>https://dev.to/__c500e8ac9bc2/your-product-has-features-nobody-uses-heres-how-to-find-them-automatically-3baa</guid>
      <description>&lt;p&gt;Eight months ago I was in a product review meeting where someone asked: "does anyone actually use the bulk export?"&lt;/p&gt;

&lt;p&gt;Nobody knew. The PM checked Mixpanel. The event wasn't there. Someone said it might be tracked under a different name. We ended up skipping the question and moving on.&lt;/p&gt;

&lt;p&gt;Two weeks later, the same thing happened with a different feature. Then again.&lt;/p&gt;

&lt;p&gt;That's when I understood the real problem — and it wasn't "we need better analytics."&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual problem isn't the data. It's the catalog.
&lt;/h2&gt;

&lt;p&gt;Every analytics tool I've used works the same way: you decide what to track, you add &lt;code&gt;.track()&lt;/code&gt; calls, data starts flowing. The tool shows you what events exist.&lt;/p&gt;

&lt;p&gt;But what about features that were tracked under the wrong name? What about the helper function someone added six months ago that wraps the SDK — did it ever get registered in Mixpanel? What about the feature that was renamed in code but the old event name is still in the dashboard, looking alive?&lt;/p&gt;

&lt;p&gt;You can't find dead features if you don't have a reliable catalog of what features exist.&lt;/p&gt;

&lt;p&gt;That's the gap I kept running into. And that's what I tried to fix with &lt;a href="https://eventra.dev" rel="noopener noreferrer"&gt;Eventra&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The insight: build the catalog from the code
&lt;/h2&gt;

&lt;p&gt;Instead of asking teams to manually define what they track, Eventra reads it from the TypeScript codebase itself.&lt;/p&gt;

&lt;p&gt;The CLI uses the TypeScript compiler API to scan your project and find every &lt;code&gt;eventra.track()&lt;/code&gt; call — including through wrapper functions, re-exports, and barrel files.&lt;/p&gt;

&lt;p&gt;Let me show you what that means in practice.&lt;/p&gt;

&lt;p&gt;Say you have this in your codebase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/analytics.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Eventra&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@eventra_dev/eventra-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sdk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Eventra&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EVENTRA_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;trackFeature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// features/export.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;trackFeature&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/analytics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleExport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;trackFeature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bulk_export&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most analytics tools would never know &lt;code&gt;trackFeature&lt;/code&gt; wraps the SDK. You'd have to manually register it. Eventra finds the chain automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @eventra_dev/eventra-cli &lt;span class="nb"&gt;sync&lt;/span&gt;
&lt;span class="c"&gt;# Scanning...&lt;/span&gt;
&lt;span class="c"&gt;# Found 14 events, 1 function wrapper (trackFeature → eventra.track)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does this by building a semantic model of your code — resolving imports, following the call graph, extracting what string each &lt;code&gt;name&lt;/code&gt; argument resolves to. Not a regex pass. The actual TypeScript compiler API.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dead features fall out naturally
&lt;/h2&gt;

&lt;p&gt;Once you have a complete, code-derived catalog, dead feature detection is trivial: which catalog entries have had no events in the last N days?&lt;/p&gt;

&lt;p&gt;The dashboard surfaces these in a dedicated view. No configuration. No manually defining what "dead" means. The data tells you.&lt;/p&gt;

&lt;p&gt;What's more useful is the three-state classification:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Active&lt;/strong&gt; — at least one event in the last 14 days&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead&lt;/strong&gt; — had events before, nothing recently. This is your deletion list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never seen&lt;/strong&gt; — in your code, never reached the platform. Misconfigured, unreachable, or behind a flag that never fires.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third state was the one that surprised me most when I built this. Features that exist in code but have never, in the history of the product, produced a single event. Either they're behind a toggle that was never turned on, or the tracking is broken, or the feature is so buried nobody found it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Keeping the catalog in sync: CI integration
&lt;/h2&gt;

&lt;p&gt;The catalog drifts. You add a feature, forget to run &lt;code&gt;sync&lt;/code&gt;, and now the dead feature list is incomplete. Or you remove a feature but the old name stays in the catalog, giving false confidence that something is being tracked when the code is gone.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;check&lt;/code&gt; command is the CI gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @eventra_dev/eventra-cli check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It compares the current state of the codebase against &lt;code&gt;eventra.json&lt;/code&gt; (the committed catalog snapshot) and exits 1 if they differ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Checking events and function wrappers...

Events:
+ new_feature_name   ← in code, not in catalog
- old_feature_name   ← in catalog, removed from code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add this to your CI pipeline and the catalog never drifts without someone noticing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The SDK side
&lt;/h2&gt;

&lt;p&gt;The CLI handles catalog management. The SDK handles runtime events.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Eventra&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@eventra_dev/eventra-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Eventra&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;eventra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bulk_export&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth mentioning about the SDK implementation that I'm happy with:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero dependencies.&lt;/strong&gt; The whole thing — batching, retry with exponential backoff, circuit breaker, browser localStorage persistence, multi-tab leader election — is implemented with no runtime dependencies. Works in browser, Node.js, edge runtimes, and serverless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Circuit breaker.&lt;/strong&gt; After 5 consecutive delivery failures, the SDK stops sending for 5 seconds. This prevents hammering a struggling API endpoint and burning through retry budgets. When the cooldown expires, one request goes through in half-open mode. If it succeeds, the circuit closes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser multi-tab coordination.&lt;/strong&gt; If you have multiple tabs open, only one tab (the "leader") flushes events to the server. The queue is shared via &lt;code&gt;localStorage&lt;/code&gt; and &lt;code&gt;BroadcastChannel&lt;/code&gt;. This prevents duplicate events from tab-heavy users and reduces server load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Graceful shutdown.&lt;/strong&gt; In Node.js, the SDK registers &lt;code&gt;SIGINT&lt;/code&gt;/&lt;code&gt;SIGTERM&lt;/code&gt; handlers and flushes the queue before the process exits. In the browser, it listens to &lt;code&gt;visibilitychange&lt;/code&gt; and &lt;code&gt;pagehide&lt;/code&gt;. Events sent just before the user closes the tab actually arrive.&lt;/p&gt;




&lt;h2&gt;
  
  
  The ingest pipeline
&lt;/h2&gt;

&lt;p&gt;On the server side, the bottleneck I was most worried about was write throughput. Feature events can arrive in bursts — a deploy goes out, users start using a new feature, thousands of events arrive in seconds.&lt;/p&gt;

&lt;p&gt;The solution is PostgreSQL's &lt;code&gt;COPY FROM STDIN&lt;/code&gt; with &lt;code&gt;pg-copy-streams&lt;/code&gt;. Instead of individual &lt;code&gt;INSERT&lt;/code&gt; statements, events are written to an in-memory buffer, drained in batches of up to 5,000 rows, streamed into a temp table, and atomically upserted with deduplication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"FeatureEvent"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"projectId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"projectId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tmp_events&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;registry_insert&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"projectId"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"projectId"&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"idempotencyKey"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"idempotencyKey"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;IdempotencyRegistry&lt;/code&gt; table handles deduplication: if the SDK sends the same event twice (retry after a timeout), the second insert is a no-op. Clients use UUID v4 as the idempotency key, generated client-side.&lt;/p&gt;

&lt;p&gt;If the database goes down, batches spill to disk (atomic tmp-rename write) and are recovered on restart. The API returns &lt;code&gt;{ success: true }&lt;/code&gt; as soon as events hit the in-memory buffer, so ingest latency stays low regardless of what the database is doing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned building this after work for 6 months
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The CLI was the hardest part.&lt;/strong&gt; The TypeScript compiler API is powerful but the documentation is thin. The wrapper propagation system — tracking which function parameter maps to the event name, across files, through re-exports — took multiple rewrites to get right. The breakthrough was treating &lt;code&gt;ts.Symbol&lt;/code&gt; as the canonical identity and using WeakMaps keyed on symbols for all caches. Once I had that foundation, incremental watch mode fell into place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dead features was always the core idea.&lt;/strong&gt; Everything else — the SDK, the dashboard, the billing, the workspaces — exists to support the dead feature detection. The catalog-from-code approach only works if there's a reliable way to discover what's in the code. The CLI is that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship less&lt;/strong&gt; is underrated as a product strategy. Every feature you remove is maintenance you never have to do, bugs that can't exist, and cognitive load you save your users. Eventra is a tool for shipping less. I find that more interesting than another tool for shipping more.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you have a TypeScript project and want to see what's dead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install the SDK&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @eventra_dev/eventra-sdk

&lt;span class="c"&gt;# Initialize the CLI config&lt;/span&gt;
npx @eventra_dev/eventra-cli init

&lt;span class="c"&gt;# Scan and sync&lt;/span&gt;
npx @eventra_dev/eventra-cli &lt;span class="nb"&gt;sync&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Free tier is 100k events/month, no credit card.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://eventra.dev" rel="noopener noreferrer"&gt;eventra.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The SDK and CLI are MIT-licensed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/and-1991/eventra-sdk" rel="noopener noreferrer"&gt;github.com/and-1991/eventra-sdk&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/and-1991/eventra-cli" rel="noopener noreferrer"&gt;github.com/and-1991/eventra-cli&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Examples:&lt;br&gt;
-&lt;a href="https://github.com/and-1991/eventra-examples" rel="noopener noreferrer"&gt;github.com/and-1991/eventra-examples&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments — especially about the static analysis approach or the ingest pipeline design.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>analytics</category>
    </item>
    <item>
      <title>I Built Eventra in 6 Months (After My Day Job). Here's How.</title>
      <dc:creator>Jura</dc:creator>
      <pubDate>Fri, 10 Apr 2026 19:17:25 +0000</pubDate>
      <link>https://dev.to/__c500e8ac9bc2/i-built-eventra-in-6-months-after-my-day-job-heres-how-2j9</link>
      <guid>https://dev.to/__c500e8ac9bc2/i-built-eventra-in-6-months-after-my-day-job-heres-how-2j9</guid>
      <description>&lt;h1&gt;
  
  
  Building Eventra After Work: A 6-Month Solo Developer Journey
&lt;/h1&gt;

&lt;p&gt;For the past six months, I've been building &lt;strong&gt;Eventra&lt;/strong&gt; after work.&lt;/p&gt;

&lt;p&gt;I have a full-time job that pays the bills.&lt;br&gt;
Eventra is something I build in the evenings and on weekends.&lt;/p&gt;

&lt;p&gt;I didn’t start with a startup idea.&lt;br&gt;
I didn’t start with validation.&lt;br&gt;
I just wanted to build something properly.&lt;/p&gt;

&lt;p&gt;Not a weekend project.&lt;br&gt;
Not a demo.&lt;br&gt;
Something real.&lt;/p&gt;

&lt;p&gt;That eventually became &lt;strong&gt;Eventra&lt;/strong&gt; — a feature analytics platform focused on understanding which features users actually use.&lt;/p&gt;


&lt;h1&gt;
  
  
  Why I Started Building Eventra
&lt;/h1&gt;

&lt;p&gt;When you build products long enough, you accumulate features.&lt;/p&gt;

&lt;p&gt;Some are used every day.&lt;br&gt;
Some occasionally.&lt;br&gt;
Some… never.&lt;/p&gt;

&lt;p&gt;Over time, you lose visibility.&lt;/p&gt;

&lt;p&gt;You don't know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which features matter&lt;/li&gt;
&lt;li&gt;Which ones nobody uses&lt;/li&gt;
&lt;li&gt;What can be removed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Analytics helps — but only partially.&lt;/p&gt;

&lt;p&gt;Analytics shows what happened.&lt;br&gt;
But it doesn't show what &lt;strong&gt;exists but never happens&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That idea stuck with me.&lt;/p&gt;


&lt;h1&gt;
  
  
  Building After Work
&lt;/h1&gt;

&lt;p&gt;Building Eventra while working full-time wasn't easy.&lt;/p&gt;

&lt;p&gt;Some days I wrote code for 8 hours at work, then another 2–3 hours at night.&lt;/p&gt;

&lt;p&gt;Some weekends were spent debugging rollups instead of going outside.&lt;/p&gt;

&lt;p&gt;Progress wasn't fast — but it was steady.&lt;/p&gt;

&lt;p&gt;Over six months, Eventra slowly took shape.&lt;/p&gt;


&lt;h1&gt;
  
  
  Step 1 — Building the Core Platform
&lt;/h1&gt;

&lt;p&gt;I started with the core platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Workspaces&lt;/li&gt;
&lt;li&gt;Projects&lt;/li&gt;
&lt;li&gt;Events&lt;/li&gt;
&lt;li&gt;Feature adoption&lt;/li&gt;
&lt;li&gt;Dead feature detection&lt;/li&gt;
&lt;li&gt;Alerts&lt;/li&gt;
&lt;li&gt;API keys&lt;/li&gt;
&lt;li&gt;Operations dashboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal was to build something complete — not just analytics, but a system focused on feature usage.&lt;/p&gt;


&lt;h1&gt;
  
  
  What the Dashboard Shows
&lt;/h1&gt;

&lt;p&gt;The main dashboard gives a quick overview of product usage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total events&lt;/li&gt;
&lt;li&gt;Unique users&lt;/li&gt;
&lt;li&gt;Active features&lt;/li&gt;
&lt;li&gt;Inactive features&lt;/li&gt;
&lt;li&gt;Usage over time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This helps quickly understand whether your product is actually being used and how feature adoption changes over time.&lt;/p&gt;


&lt;h1&gt;
  
  
  Feature Tracking
&lt;/h1&gt;

&lt;p&gt;Eventra tracks individual features and shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total usage&lt;/li&gt;
&lt;li&gt;Unique users&lt;/li&gt;
&lt;li&gt;First used&lt;/li&gt;
&lt;li&gt;Last used&lt;/li&gt;
&lt;li&gt;Usage timeline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can open any feature and see its detailed analytics and adoption patterns.&lt;/p&gt;

&lt;p&gt;This makes it easier to understand which parts of your product matter.&lt;/p&gt;


&lt;h1&gt;
  
  
  Dead Feature Detection
&lt;/h1&gt;

&lt;p&gt;One of the core ideas behind Eventra is detecting unused functionality.&lt;/p&gt;

&lt;p&gt;Eventra automatically detects features that haven't been used recently and marks them as inactive.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Feature not used in 14 days&lt;/li&gt;
&lt;li&gt;Feature used once but never again&lt;/li&gt;
&lt;li&gt;Feature abandoned after release&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This helps identify functionality that might be safe to remove.&lt;/p&gt;


&lt;h1&gt;
  
  
  Events Explorer
&lt;/h1&gt;

&lt;p&gt;You can also inspect raw events.&lt;/p&gt;

&lt;p&gt;This helps when debugging or understanding how users interact with your application.&lt;/p&gt;

&lt;p&gt;You can see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Event name&lt;/li&gt;
&lt;li&gt;User&lt;/li&gt;
&lt;li&gt;Timestamp&lt;/li&gt;
&lt;li&gt;Usage patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes it easier to understand what users actually do.&lt;/p&gt;


&lt;h1&gt;
  
  
  Workspaces and Projects
&lt;/h1&gt;

&lt;p&gt;Eventra supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple workspaces&lt;/li&gt;
&lt;li&gt;Multiple projects&lt;/li&gt;
&lt;li&gt;Team members&lt;/li&gt;
&lt;li&gt;Permissions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This allows you to organize analytics across different products.&lt;/p&gt;


&lt;h1&gt;
  
  
  API Keys and SDK Integration
&lt;/h1&gt;

&lt;p&gt;Each project has API keys for event ingestion.&lt;/p&gt;

&lt;p&gt;You can rotate keys, create multiple keys, and track usage.&lt;/p&gt;

&lt;p&gt;This allows safe integration with SDKs and backend systems.&lt;/p&gt;


&lt;h1&gt;
  
  
  Operations Dashboard
&lt;/h1&gt;

&lt;p&gt;I also built an internal operations dashboard.&lt;/p&gt;

&lt;p&gt;It shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ingest throughput&lt;/li&gt;
&lt;li&gt;Buffer size&lt;/li&gt;
&lt;li&gt;Rollup lag&lt;/li&gt;
&lt;li&gt;Processing delay&lt;/li&gt;
&lt;li&gt;System health&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This helps monitor the analytics pipeline.&lt;/p&gt;


&lt;h1&gt;
  
  
  Interactive Demo
&lt;/h1&gt;

&lt;p&gt;There's an &lt;strong&gt;interactive demo&lt;/strong&gt; on the homepage:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://eventra.dev" rel="noopener noreferrer"&gt;https://eventra.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can explore the UI, navigate between pages, and see how everything works.&lt;/p&gt;

&lt;p&gt;Some data is intentionally masked to avoid exposing real information.&lt;/p&gt;

&lt;p&gt;This makes it possible to explore the product without signing up.&lt;/p&gt;


&lt;h1&gt;
  
  
  Step 2 — The SDK
&lt;/h1&gt;

&lt;p&gt;Then I built the SDK.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Eventra&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@eventra_dev/eventra-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Eventra&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;YOUR_PROJECT_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;feature_used&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dashboard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch sending&lt;/li&gt;
&lt;li&gt;Retry logic&lt;/li&gt;
&lt;li&gt;Browser / Node / Edge runtimes&lt;/li&gt;
&lt;li&gt;TypeScript-first API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This worked well — but something was missing.&lt;/p&gt;

&lt;p&gt;The SDK only tracks events that actually happen.&lt;/p&gt;

&lt;p&gt;Which meant I still couldn't detect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Features that exist but aren't used&lt;/li&gt;
&lt;li&gt;Dead UI&lt;/li&gt;
&lt;li&gt;Forgotten code&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Step 3 — The CLI
&lt;/h1&gt;

&lt;p&gt;That's when I built the CLI.&lt;/p&gt;

&lt;p&gt;The CLI scans your codebase and finds all tracked events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @eventra_dev/eventra-cli init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scans your project&lt;/li&gt;
&lt;li&gt;Finds track() calls&lt;/li&gt;
&lt;li&gt;Detects wrapper components&lt;/li&gt;
&lt;li&gt;Saves configuration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now Eventra can compare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Events in code&lt;/li&gt;
&lt;li&gt;Events actually triggered&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And detect unused features.&lt;/p&gt;

&lt;p&gt;That's when Eventra became a complete product.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Hardest Parts
&lt;/h1&gt;

&lt;p&gt;Each part had its own challenges:&lt;/p&gt;

&lt;p&gt;Backend — rollup engine&lt;br&gt;
Frontend — workspace/project architecture&lt;br&gt;
CLI — AST parsing and wrapper detection&lt;/p&gt;

&lt;p&gt;The CLI ended up being one of the hardest parts technically.&lt;/p&gt;




&lt;h1&gt;
  
  
  Current Status
&lt;/h1&gt;

&lt;p&gt;After 6 months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dashboard — production-ready&lt;/li&gt;
&lt;li&gt;SDK — stable&lt;/li&gt;
&lt;li&gt;CLI — early version (0.0.4)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm still building this solo, after work.&lt;/p&gt;




&lt;h1&gt;
  
  
  Pricing
&lt;/h1&gt;

&lt;p&gt;I'm not trying to maximize revenue right now.&lt;/p&gt;

&lt;p&gt;The goal is validation and feedback.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://eventra.dev/pricing" rel="noopener noreferrer"&gt;https://eventra.dev/pricing&lt;/a&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  Try It
&lt;/h1&gt;

&lt;p&gt;Website&lt;br&gt;
&lt;a href="https://eventra.dev" rel="noopener noreferrer"&gt;https://eventra.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Docs&lt;br&gt;
&lt;a href="https://eventra.dev/docs" rel="noopener noreferrer"&gt;https://eventra.dev/docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;SDK&lt;br&gt;
&lt;a href="https://www.npmjs.com/package/@eventra_dev/eventra-sdk" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@eventra_dev/eventra-sdk&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;CLI&lt;br&gt;
&lt;a href="https://www.npmjs.com/package/@eventra_dev/eventra-cli" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@eventra_dev/eventra-cli&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub&lt;br&gt;
&lt;a href="https://github.com/and-1991/eventra-sdk" rel="noopener noreferrer"&gt;https://github.com/and-1991/eventra-sdk&lt;/a&gt;&lt;br&gt;
&lt;a href="https://github.com/and-1991/eventra-cli" rel="noopener noreferrer"&gt;https://github.com/and-1991/eventra-cli&lt;/a&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  Why I'm Sharing This
&lt;/h1&gt;

&lt;p&gt;I'm not trying to sell anything.&lt;/p&gt;

&lt;p&gt;I'm just building something useful after work.&lt;/p&gt;

&lt;p&gt;If you're also building something on the side — I'd love to hear about it.&lt;/p&gt;

&lt;p&gt;And if Eventra looks useful, I'd love your feedback.&lt;/p&gt;

</description>
      <category>node</category>
      <category>react</category>
      <category>typescript</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
