<?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: Zenovay</title>
    <description>The latest articles on DEV Community by Zenovay (@zenovay).</description>
    <link>https://dev.to/zenovay</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%2F3772909%2F8faf186c-8dcf-4cb1-bfcf-c5554bca55c8.webp</url>
      <title>DEV Community: Zenovay</title>
      <link>https://dev.to/zenovay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zenovay"/>
    <language>en</language>
    <item>
      <title>Our Ahrefs Domain Rating Fell From 23 to 6</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Fri, 14 Aug 2026 17:25:01 +0000</pubDate>
      <link>https://dev.to/zenovay/our-ahrefs-domain-rating-fell-from-23-to-6-2a9n</link>
      <guid>https://dev.to/zenovay/our-ahrefs-domain-rating-fell-from-23-to-6-2a9n</guid>
      <description>&lt;p&gt;Our Ahrefs Domain Rating fell from 23 to 6. The number was hard to ignore. It was also not an explanation.&lt;/p&gt;

&lt;p&gt;That distinction shaped the investigation. A third party score can tell you that something changed in its model. It cannot tell you which technical decision caused the change, or whether the same movement appears in search performance. So we treated the drop as a prompt to inspect the site, not as a verdict.&lt;/p&gt;

&lt;p&gt;The inspection found a real problem at the root URL. For visitors without an existing language preference, the same URL could return a temporary redirect to different locale paths depending on the request location. That behavior was inconsistent with the permanent locale structure we wanted search engines to understand.&lt;/p&gt;

&lt;p&gt;We fixed it. We still cannot honestly say that this redirect caused the Domain Rating drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the score actually tells us
&lt;/h2&gt;

&lt;p&gt;Ahrefs defines Domain Rating as a relative measure of a website's backlink profile on a 0 to 100 scale. The calculation depends on the sites linking to a domain and on the wider Ahrefs index. A score can therefore move even when a site has not lost the same proportion of backlinks.&lt;/p&gt;

&lt;p&gt;Ahrefs also states that Domain Rating is not a Google ranking factor. It is useful for comparing backlink profiles and noticing changes, but it is not a direct measurement of how Google ranks a page.&lt;/p&gt;

&lt;p&gt;That left us with two separate facts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We observed the score fall from 23 to 6.&lt;/li&gt;
&lt;li&gt;Our root redirect behavior needed to be corrected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The facts existed at the same time. Their timing did not prove that one caused the other.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fftcqs7z9luw5wl43spnv.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fftcqs7z9luw5wl43spnv.webp" alt="One root request reaches different locale destinations" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the root URL was doing
&lt;/h2&gt;

&lt;p&gt;The old middleware selected a locale from the request country when no locale preference was already present. It then redirected a nonlocalized path such as the root URL to that locale with a 302 response.&lt;/p&gt;

&lt;p&gt;In simplified form, the relevant behavior looked like this:&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;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CF-IPCountry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LOCALE_MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;targetLocale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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 code was trying to be helpful. A visitor in Germany could be sent to the German locale, while a visitor in the United States could be sent to English.&lt;/p&gt;

&lt;p&gt;The problem was the crawler facing contract. One source URL did not have one stable destination. Its response depended on location, and the redirect said the move was temporary.&lt;/p&gt;

&lt;p&gt;This matters because Google treats permanent and temporary redirects differently. Its redirect guidance says that permanent redirects such as 301 and 308 are strong signals that the target should become canonical. With temporary redirects such as 302 and 307, the source generally remains the canonical URL in search results.&lt;/p&gt;

&lt;p&gt;Temporary redirects are valid when a change really is temporary. They are the wrong default when the site has a permanent locale hierarchy and needs a deterministic canonical entry point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why location dependent routing was risky
&lt;/h2&gt;

&lt;p&gt;Imagine two crawlers requesting the same root URL from different locations. One receives a redirect to &lt;code&gt;/en/&lt;/code&gt;. The other receives a redirect to &lt;code&gt;/de/&lt;/code&gt;. Both responses are temporary.&lt;/p&gt;

&lt;p&gt;Nothing in either response tells a stable story about the site's preferred root destination. The behavior can also make external tools record different chains depending on where and when they crawl.&lt;/p&gt;

&lt;p&gt;This does not automatically destroy authority, and a 302 is not a penalty. Search engines can interpret redirects using more than the status code alone. The issue was that our implementation mixed three concerns in one response:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The permanent structure of the site&lt;/li&gt;
&lt;li&gt;The visitor's likely language&lt;/li&gt;
&lt;li&gt;The visitor's saved preference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those concerns deserve different handling. The canonical route should be stable. Personalization should happen only when there is an explicit preference or after the stable page has loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deterministic fix
&lt;/h2&gt;

&lt;p&gt;The corrected middleware separates a new request from a returning visitor with a saved locale choice.&lt;/p&gt;

&lt;p&gt;For a cookieless request to a nonlocalized path, the server now returns a permanent 301 redirect to the English path. The destination is the same regardless of country:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://zenovay.com/  -&amp;gt;  https://zenovay.com/en/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a visitor who already has a locale preference cookie, the middleware can still honor that personal choice with a temporary redirect. That is a genuinely user specific response, not a claim about the site's canonical structure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fookfigf8uavvkr5je9zg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fookfigf8uavvkr5je9zg.webp" alt="Several uncertain paths resolve into one canonical route" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a small code change with a useful property: a crawler and a clean browser now see one predictable first hop. We verified the live root response with ordinary requests, Googlebot requests, and different language headers. Each returned a 301 to the same English URL.&lt;/p&gt;

&lt;p&gt;The fix does not require us to remove localized pages. Every locale still has its own URL. Language selectors and saved preferences still serve visitors. The change only makes the default entry path deterministic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we can conclude, and what we cannot
&lt;/h2&gt;

&lt;p&gt;We can conclude that the old root redirect was a technical flaw. It used a temporary status for a permanent site structure and allowed the destination to vary by request location. The current behavior is easier for crawlers, external auditing tools, and humans to reason about.&lt;/p&gt;

&lt;p&gt;We cannot conclude that the flaw caused the entire Ahrefs score change. We do not have a controlled experiment that isolates the redirect from changes in Ahrefs' index, the backlink graph, referring domains, or other site factors.&lt;/p&gt;

&lt;p&gt;We also cannot claim a recovery before the data shows one. Shipping the fix is the start of observation, not the end of the story.&lt;/p&gt;

&lt;p&gt;The responsible approach is to record the change date and watch several signals separately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Domain Rating and the referring domains behind it&lt;/li&gt;
&lt;li&gt;Newly discovered and lost backlinks&lt;/li&gt;
&lt;li&gt;Search impressions and clicks from first party search data&lt;/li&gt;
&lt;li&gt;Indexing and canonical selection for the root and locale pages&lt;/li&gt;
&lt;li&gt;The redirect response seen from clean requests over time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If those signals improve together after the fix, the evidence becomes more interesting. It still needs cautious interpretation.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical redirect audit
&lt;/h2&gt;

&lt;p&gt;You do not need a large SEO platform to catch this class of problem. Start with the HTTP response itself.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request the root URL without cookies and record the status and &lt;code&gt;Location&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;Repeat the request with different language headers and, if possible, from different regions.&lt;/li&gt;
&lt;li&gt;Confirm that permanent public routes have one deterministic destination.&lt;/li&gt;
&lt;li&gt;Follow the chain and check that it ends without extra hops or loops.&lt;/li&gt;
&lt;li&gt;Inspect the final page's canonical and alternate language links.&lt;/li&gt;
&lt;li&gt;Test a returning visitor with an explicit locale preference separately.&lt;/li&gt;
&lt;li&gt;Save the result and the deployment date so later metric changes have context.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhd6lie4kxidrm5bdd18t.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhd6lie4kxidrm5bdd18t.webp" alt="Four checkpoints along a redirect audit path" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A useful command line check is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://example.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then vary only one input at a time. Add an &lt;code&gt;Accept-Language&lt;/code&gt; header. Use a clean cookie jar. Run the request from another region. If the public URL is supposed to have a single canonical destination, the first hop should not become a geography lottery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;Metric drops create pressure for a quick story. A quick story is often where a technical observation becomes an unsupported causal claim.&lt;/p&gt;

&lt;p&gt;The better sequence is slower and more useful:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm what the metric measures.&lt;/li&gt;
&lt;li&gt;Inspect the system for concrete defects.&lt;/li&gt;
&lt;li&gt;Fix defects because they are defects.&lt;/li&gt;
&lt;li&gt;Separate verified behavior from hypotheses about impact.&lt;/li&gt;
&lt;li&gt;Watch primary and third party signals after the change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our Domain Rating drop led us to a real redirect problem. Correcting that problem gave the site a stable canonical entry path. Whether it explains the score movement remains an open question, and we would rather leave that question open than manufacture certainty.&lt;/p&gt;

&lt;p&gt;That is the kind of debugging record we trust: exact about the code, careful about causation, and clear about what to measure next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/search/docs/crawling-indexing/301-redirects" rel="noopener noreferrer"&gt;Google Search Central: Redirects and Google Search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://help.ahrefs.com/en/articles/1409408-what-is-domain-rating-dr" rel="noopener noreferrer"&gt;Ahrefs: What is Domain Rating?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ahrefs.com/blog/what-is-a-good-domain-rating/" rel="noopener noreferrer"&gt;Ahrefs: What is a good Domain Rating?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>cloudflare</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Traffic increased 18 percent is not an insight</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Thu, 06 Aug 2026 20:13:43 +0000</pubDate>
      <link>https://dev.to/zenovay/traffic-increased-18-percent-is-not-an-insight-aag</link>
      <guid>https://dev.to/zenovay/traffic-increased-18-percent-is-not-an-insight-aag</guid>
      <description>&lt;p&gt;Traffic increased 18 percent. Compared with what? Driven by whom? Did those visitors do anything useful? Is the change large enough to matter, and what should someone inspect next?&lt;/p&gt;

&lt;p&gt;Until those questions have answers, 18 percent is a measurement. It is not an insight.&lt;/p&gt;

&lt;p&gt;This distinction matters because analytics products are getting better at turning charts into sentences. A sentence feels more complete than a chart label, but grammar does not create judgment. If a tool only restates the visible movement, it has saved you a glance and left the actual analysis untouched.&lt;/p&gt;

&lt;p&gt;The 18 percent in this article is an illustrative example. It is not a Zenovay traffic result.&lt;/p&gt;

&lt;h2&gt;
  
  
  An observation is not a decision
&lt;/h2&gt;

&lt;p&gt;Suppose a dashboard compares the last seven days with the seven days before them and reports:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Traffic increased 18 percent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That statement answers one narrow question: how did the total change between two windows?&lt;/p&gt;

&lt;p&gt;It does not tell you whether the earlier week was unusually quiet, whether one campaign created the increase, whether bot traffic distorted the count, whether the lift came from a useful market, or whether any of the new visitors engaged or converted.&lt;/p&gt;

&lt;p&gt;The number can be correct and still be unhelpful.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wrftmqgqo2k7afja9d5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wrftmqgqo2k7afja9d5.webp" alt="A signal reaches one observation point, then opens into several contextual paths" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The minimum useful unit is not a percentage. It is a chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Observation:&lt;/strong&gt; What changed, and by how much?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context:&lt;/strong&gt; Compared with which baseline, over which period, and with what normal variation?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explanation:&lt;/strong&gt; Which segments changed enough to plausibly account for the movement?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; Did the change affect a business outcome or only a surface metric?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next check:&lt;/strong&gt; What should a person inspect or test now?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That chain does not need to be long. It needs to be honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a baseline that deserves trust
&lt;/h2&gt;

&lt;p&gt;The previous period is convenient, but it is not always representative. A product launch, holiday, outage, paid campaign, or unusually strong post can make one week a poor baseline for the next.&lt;/p&gt;

&lt;p&gt;A better comparison uses recent history and keeps the time windows equivalent. For example, compare Tuesday through Monday with several previous Tuesday through Monday windows. If the business has a strong weekday pattern, comparing a Monday with a Sunday produces noise that looks like news.&lt;/p&gt;

&lt;p&gt;In Zenovay's current insight service, expected values are calculated from up to four recent nonempty weekly periods, with more weight given to the most recent weeks. The same service keeps the latest week available as a separate comparison. That design does not make the baseline perfect, but it makes a single unusual week less powerful.&lt;/p&gt;

&lt;p&gt;The practical question is simple: would the statement still look interesting if you changed the baseline?&lt;/p&gt;

&lt;p&gt;If an 18 percent rise becomes 2 percent against the recent average, the story changes. If it remains 18 percent across several sensible comparisons, it deserves attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decompose the total before you explain it
&lt;/h2&gt;

&lt;p&gt;Aggregate traffic is where analysis starts, not where it ends. The next step is to find where the change lives.&lt;/p&gt;

&lt;p&gt;Useful cuts often include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Referrer or campaign&lt;/li&gt;
&lt;li&gt;Landing page&lt;/li&gt;
&lt;li&gt;Country or region&lt;/li&gt;
&lt;li&gt;Device type&lt;/li&gt;
&lt;li&gt;New and returning visitors&lt;/li&gt;
&lt;li&gt;Engagement or value segment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyu7z8bbjwmun2m032bgd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyu7z8bbjwmun2m032bgd.webp" alt="One aggregate signal splits into four segment paths" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Imagine the total grew 18 percent, but almost all of the additional visits came from one referral source and landed on one article. That is a much more useful observation. It narrows the search and gives the team something concrete to inspect.&lt;/p&gt;

&lt;p&gt;Now compare that source with the rest of the traffic. Did its visitors interact? Did they reach important pages? Did they complete a goal? A volume increase with weaker visitor quality can be less valuable than a smaller increase from a source that reaches the intended audience.&lt;/p&gt;

&lt;p&gt;Zenovay's current insight path assembles total visitors, engagement rate, average value score, the share of high value visitors, and leading countries, devices, referrers, and landing pages before it asks for an interpretation. Those fields reflect what the code actually provides today. They are context for analysis, not proof of causation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat causes as hypotheses
&lt;/h2&gt;

&lt;p&gt;This is where many automated summaries become overconfident.&lt;/p&gt;

&lt;p&gt;If traffic rose after a campaign launched, the campaign may have contributed. The timing alone does not prove that it caused the full increase. Organic search, a referral, a returning audience, a tracking change, or simple variation may also be involved.&lt;/p&gt;

&lt;p&gt;A responsible insight separates evidence from inference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; Visits from the campaign source rose during the period and account for most of the total increase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis:&lt;/strong&gt; The campaign is the leading explanation for the lift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; Compare tagged campaign visits, landing pages, engagement, and goal completions with the prior baseline.&lt;/p&gt;

&lt;p&gt;That wording is slightly less dramatic. It is also far more useful because it tells the reader how to prove or reject the explanation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect traffic to an outcome
&lt;/h2&gt;

&lt;p&gt;More visits are not automatically good. They can be useful, irrelevant, expensive, accidental, or fraudulent. The meaning depends on what the site is meant to achieve.&lt;/p&gt;

&lt;p&gt;For a content site, the next signal might be meaningful interaction or a return visit. For a product site, it might be a signup, an activated account, or a qualified lead. For commerce, it might be revenue, order value, or a completed checkout.&lt;/p&gt;

&lt;p&gt;This is why the phrase “traffic increased 18 percent” feels unfinished. It describes movement at the top of a system while ignoring what happened next.&lt;/p&gt;

&lt;p&gt;A better statement might read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Traffic was 18 percent above the recent weekly baseline. Most of the lift came from one referral source and one landing page. Those visitors engaged at a lower rate than the site average, so check the source message and landing page fit before increasing spend.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That example contains a baseline, a segment, an outcome, and a next action. It still avoids claiming a cause that the data has not established.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every insight a next check
&lt;/h2&gt;

&lt;p&gt;An insight should reduce the distance between noticing and deciding. If the reader still has to ask “so what do I do now?”, the summary stopped too early.&lt;/p&gt;

&lt;p&gt;The next step should be specific enough to perform and modest enough to trust. Good examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compare the leading source with the recent weekly baseline&lt;/li&gt;
&lt;li&gt;Open the landing page that received the new traffic&lt;/li&gt;
&lt;li&gt;Check engagement and goal completion for that segment&lt;/li&gt;
&lt;li&gt;Verify whether a campaign, release, or tracking change happened in the same window&lt;/li&gt;
&lt;li&gt;Watch the pattern for another equivalent period before changing budget&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwkdzge6lhgejghrqhsk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwkdzge6lhgejghrqhsk.webp" alt="Four connected points move from observation toward a next action" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Zenovay's current AI insight format includes a metric name, current value, expected value, percentage change, description, severity, and recommendation. Its chart narrative path also combines trend, peak, low, recent data points, and a requested action. If the AI service is unavailable, the product can fall back to rule-based summaries. These implementation details matter because a useful product needs a defined output contract, relevant evidence, and a graceful failure mode. A clever sentence alone is not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  A five question test for any AI insight
&lt;/h2&gt;

&lt;p&gt;Before acting on an automated insight, ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What exactly changed?&lt;/strong&gt; The metric, window, and amount should be explicit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the baseline?&lt;/strong&gt; Previous period, recent average, forecast, or benchmark should not be hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where did the change happen?&lt;/strong&gt; Look for the source, page, device, geography, or visitor segment that explains the aggregate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What evidence supports the explanation?&lt;/strong&gt; Timing is useful evidence, but it is not causation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the next check?&lt;/strong&gt; The recommendation should be concrete and reversible when confidence is limited.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a statement fails these questions, treat it as a notification. Notifications can still be valuable. They tell you where to look. They should not pretend the looking has already been done.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard is useful uncertainty
&lt;/h2&gt;

&lt;p&gt;Good analytics does not eliminate uncertainty. It makes uncertainty visible and gives you a better next move.&lt;/p&gt;

&lt;p&gt;“Traffic increased 18 percent” sounds certain because the number is precise. The important parts remain uncertain: why it changed, whether it matters, and whether it will persist.&lt;/p&gt;

&lt;p&gt;A useful insight keeps the exact observation, adds the strongest available context, labels any explanation as a hypothesis, connects the change to an outcome, and proposes the next check.&lt;/p&gt;

&lt;p&gt;That is the difference between a chart rewritten as a sentence and analysis that helps someone decide.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>analytics</category>
      <category>product</category>
      <category>startup</category>
    </item>
    <item>
      <title>I Published 31 DEV Posts. The One With 300 Views Got Zero Reactions.</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sat, 25 Jul 2026 12:42:48 +0000</pubDate>
      <link>https://dev.to/zenovay/i-published-31-dev-posts-the-one-with-300-views-got-zero-reactions-28eh</link>
      <guid>https://dev.to/zenovay/i-published-31-dev-posts-the-one-with-300-views-got-zero-reactions-28eh</guid>
      <description>&lt;p&gt;I published 31 posts on DEV and collected 1,222 views.&lt;/p&gt;

&lt;p&gt;My most-viewed post reached &lt;strong&gt;300 views&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It got &lt;strong&gt;zero reactions and zero comments&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Another post reached only &lt;strong&gt;47 views&lt;/strong&gt;. It got &lt;strong&gt;3 reactions and 5 comments&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That changed how I think about “a successful post.”&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4px0dxmhxl8ngd88e1q9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4px0dxmhxl8ngd88e1q9.png" alt="One path expands into broad reach while another loops into concentrated resonance" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One post was a search engine
&lt;/h2&gt;

&lt;p&gt;The 300-view article was an 11-minute Cloudflare architecture deep-dive.&lt;/p&gt;

&lt;p&gt;It earned 112 views from Google and 20 from DuckDuckGo. Months later, people still find it because the title matches a specific technical problem.&lt;/p&gt;

&lt;p&gt;But its average read time was 123 seconds.&lt;/p&gt;

&lt;p&gt;It created discovery, not discussion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other post was a conversation
&lt;/h2&gt;

&lt;p&gt;The 47-view article was about a problem I felt while using coding agents:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AI can write code faster than I can responsibly review it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It had no tutorial and no giant code block. It had one tension, concrete examples, and a question readers could answer from their own experience.&lt;/p&gt;

&lt;p&gt;Its average read time was 79 seconds. More importantly, people added ideas that improved the original argument.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffh4wqfky6197fwxil6sk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffh4wqfky6197fwxil6sk.png" alt="Several discovery paths converge into a single point of meaningful attention" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  I was optimizing one number to do two jobs
&lt;/h2&gt;

&lt;p&gt;Search posts and discussion posts are different products.&lt;/p&gt;

&lt;p&gt;A search post needs a precise problem, concrete implementation details, and enough depth to deserve the query.&lt;/p&gt;

&lt;p&gt;A discussion post needs a sharp claim, a human consequence, and room for the reader to disagree.&lt;/p&gt;

&lt;p&gt;The mistake is expecting one article to maximize both.&lt;/p&gt;

&lt;p&gt;Now I want to alternate deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discovery posts&lt;/strong&gt; that answer one narrow technical question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discussion posts&lt;/strong&gt; that expose one real decision or failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you had to choose, which result would you rather publish: &lt;strong&gt;300 views with no visible response, or 47 views that start a real conversation?&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I used AI to help analyze my DEV dashboard, structure this post, and produce the visuals. I reviewed the source data, claims, and final draft.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devto</category>
      <category>writing</category>
      <category>analytics</category>
      <category>discuss</category>
    </item>
    <item>
      <title>AI Can Write Code Faster Than I Can Responsibly Review It</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Wed, 22 Jul 2026 17:30:30 +0000</pubDate>
      <link>https://dev.to/zenovay/ai-can-write-code-faster-than-i-can-responsibly-review-it-4ig4</link>
      <guid>https://dev.to/zenovay/ai-can-write-code-faster-than-i-can-responsibly-review-it-4ig4</guid>
      <description>&lt;p&gt;I use Claude Code almost every day while building Zenovay.&lt;/p&gt;

&lt;p&gt;It can scaffold a feature, trace a bug across several files, write tests, refactor an old module, and explain an unfamiliar part of the codebase before I have finished my coffee.&lt;/p&gt;

&lt;p&gt;That sounds like pure leverage.&lt;/p&gt;

&lt;p&gt;But it created a problem I did not expect:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI can produce code much faster than I can build a reliable mental model of it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The bottleneck is no longer writing code.&lt;/p&gt;

&lt;p&gt;It is human attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  A large diff still feels like progress
&lt;/h2&gt;

&lt;p&gt;There is something satisfying about watching an agent work through a task.&lt;/p&gt;

&lt;p&gt;Files change. Tests appear. Type errors disappear. The terminal keeps moving.&lt;/p&gt;

&lt;p&gt;Twenty minutes later, there is a 700-line diff and everything is green.&lt;/p&gt;

&lt;p&gt;It feels like a productive session.&lt;/p&gt;

&lt;p&gt;Then I open the diff and realize I cannot confidently explain every decision it made.&lt;/p&gt;

&lt;p&gt;Why was this abstraction added?&lt;/p&gt;

&lt;p&gt;Does this retry create duplicate events?&lt;/p&gt;

&lt;p&gt;Is the permission check happening at the API boundary, or only in the interface?&lt;/p&gt;

&lt;p&gt;What happens when the webhook arrives twice?&lt;/p&gt;

&lt;p&gt;Did the code preserve the privacy rule I described three prompts ago?&lt;/p&gt;

&lt;p&gt;The dangerous output is rarely code that obviously fails.&lt;/p&gt;

&lt;p&gt;It is code that compiles, passes the happy-path test, and quietly implements the wrong assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generation got cheap. Review did not.
&lt;/h2&gt;

&lt;p&gt;Before coding agents, the effort required to write a feature naturally limited its size.&lt;/p&gt;

&lt;p&gt;You had time to think while typing. You noticed awkward interfaces because you had to use them repeatedly. You remembered why a branch existed because you wrote it ten minutes earlier.&lt;/p&gt;

&lt;p&gt;AI removes much of that friction.&lt;/p&gt;

&lt;p&gt;That is useful, but the friction was also doing hidden work.&lt;/p&gt;

&lt;p&gt;It slowed the amount of new code entering the system.&lt;/p&gt;

&lt;p&gt;Now a solo developer can generate changes at something close to team velocity. The review capacity is still one person.&lt;/p&gt;

&lt;p&gt;That creates a kind of review debt.&lt;/p&gt;

&lt;p&gt;Every accepted line that I do not fully understand becomes a small future obligation. Maybe it is harmless. Maybe it becomes the function nobody wants to touch six months later because nobody remembers why it works.&lt;/p&gt;

&lt;p&gt;The code was generated quickly.&lt;/p&gt;

&lt;p&gt;The understanding was deferred.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some decisions should stay slow
&lt;/h2&gt;

&lt;p&gt;While building an analytics product, small technical decisions can carry real consequences.&lt;/p&gt;

&lt;p&gt;A mistake in attribution can show a customer the wrong acquisition channel.&lt;/p&gt;

&lt;p&gt;A mistake in session replay can capture something that should have been masked.&lt;/p&gt;

&lt;p&gt;A mistake in billing logic can affect real money.&lt;/p&gt;

&lt;p&gt;A mistake in authorization can expose one customer's data to another.&lt;/p&gt;

&lt;p&gt;I still use AI around all of these areas, but I do not let it silently decide the important boundaries.&lt;/p&gt;

&lt;p&gt;There are a few things I now treat differently:&lt;/p&gt;

&lt;h3&gt;
  
  
  Data and privacy boundaries
&lt;/h3&gt;

&lt;p&gt;The agent can help implement the rule.&lt;/p&gt;

&lt;p&gt;The rule itself has to come from me.&lt;/p&gt;

&lt;p&gt;I want to know exactly what enters the system, where it is transformed, how long it stays, and what must never be stored.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authorization and billing
&lt;/h3&gt;

&lt;p&gt;A plausible-looking permission check is not enough.&lt;/p&gt;

&lt;p&gt;For auth, subscriptions, invoices, refunds, and webhooks, I review the full path instead of only the changed function.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database migrations
&lt;/h3&gt;

&lt;p&gt;AI is very good at generating a migration that looks correct.&lt;/p&gt;

&lt;p&gt;It is less aware of the strange data already living in production, the old client still sending a deprecated field, or the rollback you will need at 2 AM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Product assumptions
&lt;/h3&gt;

&lt;p&gt;An agent will happily turn an unclear requirement into working code.&lt;/p&gt;

&lt;p&gt;That does not make the requirement correct.&lt;/p&gt;

&lt;p&gt;When the model fills in a missing product decision, it often chooses the most conventional answer. Conventional is not always what your product needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  My workflow changed
&lt;/h2&gt;

&lt;p&gt;I used to prompt for an implementation and review the result.&lt;/p&gt;

&lt;p&gt;Now I separate thinking from writing.&lt;/p&gt;

&lt;p&gt;First, I ask for the proposed approach, affected files, assumptions, failure cases, and the parts of the task that are still ambiguous.&lt;/p&gt;

&lt;p&gt;Only then do I let it edit.&lt;/p&gt;

&lt;p&gt;I also try to keep each change small enough that I can review it without scrolling through an entire afternoon of generated code.&lt;/p&gt;

&lt;p&gt;For critical paths, I ask the agent to attack its own solution:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Where can this fail silently?&lt;/li&gt;
&lt;li&gt;Which input breaks the current assumption?&lt;/li&gt;
&lt;li&gt;What happens during retries?&lt;/li&gt;
&lt;li&gt;What happens when two requests arrive at the same time?&lt;/li&gt;
&lt;li&gt;Which authorization check is missing?&lt;/li&gt;
&lt;li&gt;What data could be stored accidentally?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This catches useful issues, but it does not transfer responsibility to the model.&lt;/p&gt;

&lt;p&gt;Tests do not do that either.&lt;/p&gt;

&lt;p&gt;A test suite proves that the cases we thought about behave as expected. It says nothing about the cases nobody considered.&lt;/p&gt;

&lt;p&gt;At the end, I ask myself one simple question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Could I explain this change to another developer without mentioning the AI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer is no, I am not ready to merge it.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI did not remove the need for engineering judgment
&lt;/h2&gt;

&lt;p&gt;Coding agents are probably the biggest productivity improvement in my workflow.&lt;/p&gt;

&lt;p&gt;I am not going back.&lt;/p&gt;

&lt;p&gt;But I no longer measure a good AI session by the number of files changed or how quickly the task reached a green checkmark.&lt;/p&gt;

&lt;p&gt;The useful metric is how much verified progress entered the codebase.&lt;/p&gt;

&lt;p&gt;That is usually less impressive than the generated diff.&lt;/p&gt;

&lt;p&gt;It is also much more valuable.&lt;/p&gt;

&lt;p&gt;AI can help write the code.&lt;/p&gt;

&lt;p&gt;It can suggest tests, point out risks, and challenge an implementation.&lt;/p&gt;

&lt;p&gt;But once that code reaches production, the model is not maintaining the promise behind it.&lt;/p&gt;

&lt;p&gt;I am.&lt;/p&gt;

&lt;p&gt;How are you handling this in your own workflow?&lt;/p&gt;

&lt;p&gt;Do you review every line of AI-generated code, or are there areas where you deliberately trust it more?&lt;/p&gt;




&lt;p&gt;Disclosure: I use AI heavily while building Zenovay and used it to help structure and edit this post. The opinions, examples, and final decisions are my own.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
      <category>discuss</category>
    </item>
    <item>
      <title>7 Sources Hiding Inside Your Direct Traffic</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Mon, 20 Jul 2026 10:57:18 +0000</pubDate>
      <link>https://dev.to/zenovay/7-sources-hiding-inside-your-direct-traffic-5h5o</link>
      <guid>https://dev.to/zenovay/7-sources-hiding-inside-your-direct-traffic-5h5o</guid>
      <description>&lt;p&gt;Open almost any web analytics dashboard and you will find a channel called &lt;strong&gt;Direct&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The usual explanation is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Direct traffic comes from people who typed your URL into the browser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That explanation is technically possible.&lt;/p&gt;

&lt;p&gt;It is also incomplete.&lt;/p&gt;

&lt;p&gt;A visitor is normally classified as Direct whenever the analytics system cannot identify a better source.&lt;/p&gt;

&lt;p&gt;That means Direct is not really one acquisition channel.&lt;/p&gt;

&lt;p&gt;It is a container for missing information.&lt;/p&gt;

&lt;p&gt;Inside that container, you may find:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Copied links&lt;/li&gt;
&lt;li&gt;Private messages&lt;/li&gt;
&lt;li&gt;Desktop applications&lt;/li&gt;
&lt;li&gt;Mobile applications&lt;/li&gt;
&lt;li&gt;AI assistants&lt;/li&gt;
&lt;li&gt;Documents&lt;/li&gt;
&lt;li&gt;Bookmarks&lt;/li&gt;
&lt;li&gt;Redirects&lt;/li&gt;
&lt;li&gt;Privacy-protected referrals&lt;/li&gt;
&lt;li&gt;Genuinely typed URLs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When Direct traffic becomes unusually large, the correct conclusion is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Our brand recognition must be amazing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The better conclusion is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We are losing acquisition context somewhere in the journey.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Direct traffic actually means
&lt;/h2&gt;

&lt;p&gt;A browser may send referral information when someone follows a link from one website to another.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;example-newsletter.com
        ↓
your-saas.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your analytics system can then classify the visit as a referral from the newsletter website.&lt;/p&gt;

&lt;p&gt;But when the browser sends no usable referral information and the URL contains no campaign parameters, the analytics system has little evidence to work with.&lt;/p&gt;

&lt;p&gt;The visit often becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Source: Direct
Medium: None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does not prove that the visitor manually entered the address.&lt;/p&gt;

&lt;p&gt;It only proves that the source could not be determined.&lt;/p&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;A growing Direct channel could indicate stronger brand awareness.&lt;/p&gt;

&lt;p&gt;It could also mean that your most effective newsletter, community, AI assistant, document, or mobile application is not being measured correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Links copied from private messages
&lt;/h2&gt;

&lt;p&gt;Consider this common journey:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Person A discovers your product on X
        ↓
Person A copies the link
        ↓
Person A sends it through WhatsApp
        ↓
Person B opens the link
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second visitor did not arrive directly from X.&lt;/p&gt;

&lt;p&gt;They arrived through WhatsApp.&lt;/p&gt;

&lt;p&gt;However, the browser may provide no useful referral information from that private conversation.&lt;/p&gt;

&lt;p&gt;The visit can therefore appear as Direct.&lt;/p&gt;

&lt;p&gt;This is commonly called &lt;strong&gt;dark social&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It can include traffic from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WhatsApp&lt;/li&gt;
&lt;li&gt;Telegram&lt;/li&gt;
&lt;li&gt;Signal&lt;/li&gt;
&lt;li&gt;Slack&lt;/li&gt;
&lt;li&gt;Discord&lt;/li&gt;
&lt;li&gt;Microsoft Teams&lt;/li&gt;
&lt;li&gt;Direct messages&lt;/li&gt;
&lt;li&gt;SMS&lt;/li&gt;
&lt;li&gt;Desktop email clients&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The term "dark" does not mean malicious or secret.&lt;/p&gt;

&lt;p&gt;It means the referral path is not visible to the analytics system.&lt;/p&gt;

&lt;p&gt;This is especially common for content that people naturally share with colleagues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Technical tutorials&lt;/li&gt;
&lt;li&gt;Pricing pages&lt;/li&gt;
&lt;li&gt;Comparison pages&lt;/li&gt;
&lt;li&gt;Free tools&lt;/li&gt;
&lt;li&gt;Templates&lt;/li&gt;
&lt;li&gt;Research&lt;/li&gt;
&lt;li&gt;Product launches&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A page with unusually high Direct traffic may simply be highly shareable in private conversations.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. AI assistants
&lt;/h2&gt;

&lt;p&gt;A visitor might ask an AI assistant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What is a privacy-friendly alternative to Google Analytics for a small SaaS?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The assistant may recommend several products and include links.&lt;/p&gt;

&lt;p&gt;Depending on the assistant, browser, and method used to open the link, the visit could arrive with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A recognizable AI referrer&lt;/li&gt;
&lt;li&gt;A normal referral&lt;/li&gt;
&lt;li&gt;Campaign parameters&lt;/li&gt;
&lt;li&gt;No referral information at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last case becomes Direct.&lt;/p&gt;

&lt;p&gt;There is another complication.&lt;/p&gt;

&lt;p&gt;The visitor may not click the link immediately.&lt;/p&gt;

&lt;p&gt;They might instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the recommendation&lt;/li&gt;
&lt;li&gt;Remember the product name&lt;/li&gt;
&lt;li&gt;Search for it later&lt;/li&gt;
&lt;li&gt;Open the website through Google&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The final visit becomes Organic Search, even though the original discovery happened inside an AI assistant.&lt;/p&gt;

&lt;p&gt;This is why AI influence and AI referral traffic are not identical.&lt;/p&gt;

&lt;p&gt;Referral traffic measures identifiable clicks.&lt;/p&gt;

&lt;p&gt;Influence can happen earlier in the journey without leaving a clean technical signal.&lt;/p&gt;

&lt;p&gt;You should therefore avoid silently classifying every suspicious Direct visit as AI traffic.&lt;/p&gt;

&lt;p&gt;A more honest report separates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Measured AI referrals
Likely AI-influenced visits
Unclassified Direct visits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second category is an estimate, not a fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Desktop and mobile applications
&lt;/h2&gt;

&lt;p&gt;Links opened from native applications do not always include normal web referral information.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Desktop email clients&lt;/li&gt;
&lt;li&gt;Notes applications&lt;/li&gt;
&lt;li&gt;Password managers&lt;/li&gt;
&lt;li&gt;PDF readers&lt;/li&gt;
&lt;li&gt;Project-management tools&lt;/li&gt;
&lt;li&gt;Mobile messaging applications&lt;/li&gt;
&lt;li&gt;Native social media applications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Imagine someone clicks your link from a task inside a desktop project-management application.&lt;/p&gt;

&lt;p&gt;The browser opens.&lt;/p&gt;

&lt;p&gt;Your website loads.&lt;/p&gt;

&lt;p&gt;No recognizable referrer arrives.&lt;/p&gt;

&lt;p&gt;The session becomes Direct.&lt;/p&gt;

&lt;p&gt;This is one reason the same campaign can appear differently across devices.&lt;/p&gt;

&lt;p&gt;The desktop application may produce Direct traffic, while its browser-based version produces a recognizable referral.&lt;/p&gt;

&lt;p&gt;From the visitor's perspective, both clicks came from the same product.&lt;/p&gt;

&lt;p&gt;From the analytics system's perspective, they may look like two unrelated channels.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Documents and presentations
&lt;/h2&gt;

&lt;p&gt;Links inside documents are another hidden source of Direct traffic.&lt;/p&gt;

&lt;p&gt;Someone might open your website from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A PDF&lt;/li&gt;
&lt;li&gt;A Word document&lt;/li&gt;
&lt;li&gt;A Google Docs export&lt;/li&gt;
&lt;li&gt;A slide deck&lt;/li&gt;
&lt;li&gt;A spreadsheet&lt;/li&gt;
&lt;li&gt;An internal knowledge base&lt;/li&gt;
&lt;li&gt;A downloaded report&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some document viewers pass referral information.&lt;/p&gt;

&lt;p&gt;Many do not.&lt;/p&gt;

&lt;p&gt;This is particularly relevant for B2B products.&lt;/p&gt;

&lt;p&gt;A potential customer might discover your product in a comparison document shared internally by their team.&lt;/p&gt;

&lt;p&gt;Several colleagues then click the same link.&lt;/p&gt;

&lt;p&gt;Your dashboard shows a group of Direct visitors.&lt;/p&gt;

&lt;p&gt;In reality, the traffic came from one highly influential internal document.&lt;/p&gt;

&lt;p&gt;Without campaign parameters, that source is almost impossible to recover.&lt;/p&gt;

&lt;p&gt;When you control the document, add a clear campaign identifier to its links.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/pricing
?utm_source=partner_report
&amp;amp;utm_medium=document
&amp;amp;utm_campaign=analytics_comparison
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not put names, email addresses, or other personal information into campaign parameters.&lt;/p&gt;

&lt;p&gt;URLs may appear in screenshots, logs, browser history, analytics systems, and shared documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Redirects and tracking links
&lt;/h2&gt;

&lt;p&gt;Redirects can remove or obscure acquisition context.&lt;/p&gt;

&lt;p&gt;A typical journey might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Social post
    ↓
Shortened link
    ↓
Tracking domain
    ↓
Marketing redirect
    ↓
Final landing page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every additional step creates another opportunity to lose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UTM parameters&lt;/li&gt;
&lt;li&gt;Click identifiers&lt;/li&gt;
&lt;li&gt;Referral information&lt;/li&gt;
&lt;li&gt;Path information&lt;/li&gt;
&lt;li&gt;Fragment values&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This happens frequently when several tools are involved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Social scheduling software&lt;/li&gt;
&lt;li&gt;Affiliate platforms&lt;/li&gt;
&lt;li&gt;Link shorteners&lt;/li&gt;
&lt;li&gt;Newsletter providers&lt;/li&gt;
&lt;li&gt;Redirect services&lt;/li&gt;
&lt;li&gt;Custom campaign domains&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One redirect may preserve the complete destination URL correctly.&lt;/p&gt;

&lt;p&gt;A later redirect may accidentally drop everything after the question mark.&lt;/p&gt;

&lt;p&gt;The visitor still reaches the correct page, but the attribution information disappears.&lt;/p&gt;

&lt;p&gt;Always test the final browser URL rather than checking only the link configured inside the campaign tool.&lt;/p&gt;

&lt;p&gt;A simple test process is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the real published campaign link&lt;/li&gt;
&lt;li&gt;Follow every redirect&lt;/li&gt;
&lt;li&gt;Inspect the final URL&lt;/li&gt;
&lt;li&gt;Confirm that the expected parameters remain&lt;/li&gt;
&lt;li&gt;Verify the visit inside your analytics tool&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do this before launching an important campaign.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Privacy and referrer restrictions
&lt;/h2&gt;

&lt;p&gt;Modern browsers deliberately limit the referral information websites receive.&lt;/p&gt;

&lt;p&gt;This is good for privacy.&lt;/p&gt;

&lt;p&gt;It also means analytics systems cannot always reconstruct the complete previous page.&lt;/p&gt;

&lt;p&gt;Referral information can be affected by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Browser privacy settings&lt;/li&gt;
&lt;li&gt;Referrer policies&lt;/li&gt;
&lt;li&gt;Private browsing&lt;/li&gt;
&lt;li&gt;Security software&lt;/li&gt;
&lt;li&gt;Browser extensions&lt;/li&gt;
&lt;li&gt;Cross-origin navigation rules&lt;/li&gt;
&lt;li&gt;Transitions between secure and insecure pages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A website can also define its own referrer policy.&lt;/p&gt;

&lt;p&gt;A strict policy may prevent external websites from seeing which exact page a visitor came from.&lt;/p&gt;

&lt;p&gt;This can reduce unnecessary data sharing.&lt;/p&gt;

&lt;p&gt;It can also make referral reporting less detailed.&lt;/p&gt;

&lt;p&gt;There is always a tradeoff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;More referral detail
        ↕
Less information shared across websites
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An analytics system cannot recover information that the browser intentionally did not provide.&lt;/p&gt;

&lt;p&gt;This is why attribution should be presented with confidence levels and limitations rather than as perfect truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Returning visitors and forgotten first touches
&lt;/h2&gt;

&lt;p&gt;The largest attribution problem often happens after the first visit.&lt;/p&gt;

&lt;p&gt;Consider this journey:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Monday:
Visitor discovers your SaaS through a newsletter.

Wednesday:
Visitor returns through Google.

Friday:
Visitor opens the pricing page from a bookmark.

Sunday:
Visitor signs up during a Direct session.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your analytics tool looks only at the signup session, the conversion may be attributed to Direct.&lt;/p&gt;

&lt;p&gt;But Direct did not create the original discovery.&lt;/p&gt;

&lt;p&gt;The newsletter did.&lt;/p&gt;

&lt;p&gt;This is the difference between a &lt;strong&gt;session source&lt;/strong&gt; and the &lt;strong&gt;complete customer journey&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A useful system preserves at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first known source&lt;/li&gt;
&lt;li&gt;The most recent known source&lt;/li&gt;
&lt;li&gt;The source immediately before conversion&lt;/li&gt;
&lt;li&gt;The ordered sequence of meaningful touchpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These can produce very different reports.&lt;/p&gt;

&lt;h3&gt;
  
  
  First-touch attribution
&lt;/h3&gt;

&lt;p&gt;The first identifiable source receives the credit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Newsletter: 100%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helps answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which channels introduce new customers?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Last-touch attribution
&lt;/h3&gt;

&lt;p&gt;The final eligible source before conversion receives the credit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct: 100%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helps answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which channels appear closest to conversion?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Multi-touch attribution
&lt;/h3&gt;

&lt;p&gt;Credit is distributed across several interactions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Newsletter: 40%
Google: 20%
Direct: 40%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helps answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which channels participated in the journey?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is no universally correct attribution model.&lt;/p&gt;

&lt;p&gt;The mistake is not choosing the "wrong" model.&lt;/p&gt;

&lt;p&gt;The mistake is showing a revenue number without stating which model produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to reduce unexplained Direct traffic
&lt;/h2&gt;

&lt;p&gt;You will never eliminate Direct traffic completely.&lt;/p&gt;

&lt;p&gt;That should not be the goal.&lt;/p&gt;

&lt;p&gt;The goal is to reduce avoidable attribution loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use consistent UTM parameters
&lt;/h2&gt;

&lt;p&gt;Whenever you control a link, add campaign information.&lt;/p&gt;

&lt;p&gt;A simple convention is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;utm_source
utm_medium
utm_campaign
utm_content
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/pricing
?utm_source=devto
&amp;amp;utm_medium=content
&amp;amp;utm_campaign=direct_traffic_article
&amp;amp;utm_content=article_cta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lowercase&lt;/li&gt;
&lt;li&gt;Predictable&lt;/li&gt;
&lt;li&gt;Documented&lt;/li&gt;
&lt;li&gt;Free of personal information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid using several names for the same source.&lt;/p&gt;

&lt;p&gt;These values should not all coexist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;devto
dev.to
DEV
dev-community
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose one convention and apply it consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve campaign parameters through redirects
&lt;/h2&gt;

&lt;p&gt;When a campaign link redirects to another page, forward its original parameters.&lt;/p&gt;

&lt;p&gt;Bad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/start?utm_source=newsletter
        ↓
/pricing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/start?utm_source=newsletter
        ↓
/pricing?utm_source=newsletter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This becomes especially important when authentication, regional routing, link shortening, or checkout pages sit between the landing page and conversion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Track meaningful conversion events
&lt;/h2&gt;

&lt;p&gt;Pageviews tell you that someone visited.&lt;/p&gt;

&lt;p&gt;They do not tell you whether the visit mattered.&lt;/p&gt;

&lt;p&gt;Track meaningful events such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;signup_started
signup_completed
project_created
integration_connected
trial_activated
checkout_started
subscription_created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact event names matter less than using them consistently.&lt;/p&gt;

&lt;p&gt;A useful acquisition report should connect sources not only to visits, but also to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Signups&lt;/li&gt;
&lt;li&gt;Activation&lt;/li&gt;
&lt;li&gt;Purchases&lt;/li&gt;
&lt;li&gt;Recurring revenue&lt;/li&gt;
&lt;li&gt;Retention&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otherwise, the channel with the most visitors may look like the best channel even when it produces no customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve the original source
&lt;/h2&gt;

&lt;p&gt;Do not replace the original source every time the visitor returns.&lt;/p&gt;

&lt;p&gt;Store first-touch information separately from current-session information.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;First source: dev.to
Current source: Direct
Conversion source: Email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three can be true.&lt;/p&gt;

&lt;p&gt;This gives you the flexibility to compare different attribution models later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use self-reported attribution carefully
&lt;/h2&gt;

&lt;p&gt;During signup, you can ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How did you first hear about us?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This can reveal sources that technical attribution misses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A podcast&lt;/li&gt;
&lt;li&gt;A private community&lt;/li&gt;
&lt;li&gt;A recommendation from a colleague&lt;/li&gt;
&lt;li&gt;An AI assistant&lt;/li&gt;
&lt;li&gt;A conference&lt;/li&gt;
&lt;li&gt;Word of mouth&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But self-reported attribution is also imperfect.&lt;/p&gt;

&lt;p&gt;People forget.&lt;/p&gt;

&lt;p&gt;They may remember the most recent interaction instead of the first one.&lt;/p&gt;

&lt;p&gt;They may select the easiest available option.&lt;/p&gt;

&lt;p&gt;Use it as an additional signal, not as the only source of truth.&lt;/p&gt;

&lt;p&gt;One useful comparison is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Measured source: Google
Self-reported source: ChatGPT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That difference tells you something important about the discovery journey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not fix Direct traffic by inventing certainty
&lt;/h2&gt;

&lt;p&gt;It is tempting to build rules such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No referrer + deep page = AI
No referrer + returning user = Bookmark
No referrer + mobile = Messaging app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These rules may produce useful estimates.&lt;/p&gt;

&lt;p&gt;They do not produce facts.&lt;/p&gt;

&lt;p&gt;A responsible analytics system should label results accordingly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Measured
Inferred
Unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unknown is not a failure.&lt;/p&gt;

&lt;p&gt;Unknown is often the most accurate answer the available evidence supports.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical Direct-traffic audit
&lt;/h2&gt;

&lt;p&gt;When Direct traffic looks suspiciously high, inspect it in this order.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Check the landing pages
&lt;/h3&gt;

&lt;p&gt;A Direct visit to your homepage may plausibly come from a typed URL or bookmark.&lt;/p&gt;

&lt;p&gt;A Direct visit to a deeply nested page such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/blog/how-to-connect-stripe-webhooks-to-revenue-attribution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is more likely to have been copied, shared, or opened from somewhere that removed the referrer.&lt;/p&gt;

&lt;p&gt;It is still not proof of one particular source.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Compare new and returning visitors
&lt;/h3&gt;

&lt;p&gt;Large amounts of returning Direct traffic can come from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bookmarks&lt;/li&gt;
&lt;li&gt;Browser history&lt;/li&gt;
&lt;li&gt;Saved applications&lt;/li&gt;
&lt;li&gt;Repeated internal usage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Large amounts of first-time Direct traffic to deep pages may suggest missing referral information.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Test every active campaign
&lt;/h3&gt;

&lt;p&gt;Open the real links from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Newsletters&lt;/li&gt;
&lt;li&gt;Scheduled social posts&lt;/li&gt;
&lt;li&gt;Advertisements&lt;/li&gt;
&lt;li&gt;Partner websites&lt;/li&gt;
&lt;li&gt;Documents&lt;/li&gt;
&lt;li&gt;QR codes&lt;/li&gt;
&lt;li&gt;Shortened links&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Confirm that the campaign parameters survive until the final page.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Review cross-domain journeys
&lt;/h3&gt;

&lt;p&gt;Check whether visitors move between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The marketing website&lt;/li&gt;
&lt;li&gt;The application&lt;/li&gt;
&lt;li&gt;Authentication pages&lt;/li&gt;
&lt;li&gt;Checkout&lt;/li&gt;
&lt;li&gt;Documentation&lt;/li&gt;
&lt;li&gt;Regional domains&lt;/li&gt;
&lt;li&gt;Subdomains&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A broken cross-domain journey can make later sessions appear as Direct or as self-referrals.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Compare Direct traffic with campaign dates
&lt;/h3&gt;

&lt;p&gt;Look for Direct traffic increases immediately after:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A newsletter&lt;/li&gt;
&lt;li&gt;A Product Hunt launch&lt;/li&gt;
&lt;li&gt;A community mention&lt;/li&gt;
&lt;li&gt;An AI citation&lt;/li&gt;
&lt;li&gt;A podcast appearance&lt;/li&gt;
&lt;li&gt;A partner announcement&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The campaign may be contributing more than the dashboard shows.&lt;/p&gt;

&lt;p&gt;Treat this as supporting evidence rather than exact attribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direct traffic is not useless
&lt;/h2&gt;

&lt;p&gt;Direct traffic is often described as a dirty bucket.&lt;/p&gt;

&lt;p&gt;That description is too negative.&lt;/p&gt;

&lt;p&gt;Direct still tells you something:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The visitor reached the website without providing enough acquisition information for a more specific classification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That can represent genuine brand strength.&lt;/p&gt;

&lt;p&gt;People may:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Remember your domain&lt;/li&gt;
&lt;li&gt;Return from browser history&lt;/li&gt;
&lt;li&gt;Use a bookmark&lt;/li&gt;
&lt;li&gt;Open your application daily&lt;/li&gt;
&lt;li&gt;Share your product privately&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The problem begins only when Direct is interpreted as one precise acquisition channel.&lt;/p&gt;

&lt;p&gt;It is not.&lt;/p&gt;

&lt;p&gt;It is a mixture of different behaviours that happen to produce the same technical result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;Web attribution is not a recording of objective reality.&lt;/p&gt;

&lt;p&gt;It is a reconstruction based on incomplete signals.&lt;/p&gt;

&lt;p&gt;Browsers protect information.&lt;/p&gt;

&lt;p&gt;Applications hide referral paths.&lt;/p&gt;

&lt;p&gt;People switch devices.&lt;/p&gt;

&lt;p&gt;Links move through private conversations.&lt;/p&gt;

&lt;p&gt;Discovery and conversion can happen days apart.&lt;/p&gt;

&lt;p&gt;A useful analytics system should therefore help you distinguish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What was measured
What was inferred
What remains unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The purpose of attribution is not to make every journey look perfectly explainable.&lt;/p&gt;

&lt;p&gt;It is to preserve enough trustworthy context to make better decisions.&lt;/p&gt;

&lt;p&gt;The next time Direct traffic rises, do not immediately celebrate your brand awareness.&lt;/p&gt;

&lt;p&gt;Open the landing pages.&lt;/p&gt;

&lt;p&gt;Check the campaigns.&lt;/p&gt;

&lt;p&gt;Inspect the redirects.&lt;/p&gt;

&lt;p&gt;Look at the complete customer journey.&lt;/p&gt;

&lt;p&gt;Your missing acquisition channel may already be hiding in plain sight.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I work on Zenovay, a privacy-first web analytics product. This article discusses a general analytics problem and does not depend on Zenovay-specific implementation details. AI was used to help edit and structure the article.&lt;/p&gt;

&lt;p&gt;What is the strangest source you have ever discovered hiding inside your Direct traffic?&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>webdev</category>
      <category>marketing</category>
      <category>privacy</category>
    </item>
    <item>
      <title>The tracking script that never blocks page load: a stub and queue pattern</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sat, 27 Jun 2026 10:54:00 +0000</pubDate>
      <link>https://dev.to/zenovay/the-tracking-script-that-never-blocks-page-load-a-stub-and-queue-pattern-3l1i</link>
      <guid>https://dev.to/zenovay/the-tracking-script-that-never-blocks-page-load-a-stub-and-queue-pattern-3l1i</guid>
      <description>&lt;p&gt;How we built an analytics client that captures everything without ever blocking paint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraint
&lt;/h2&gt;

&lt;p&gt;An analytics script that slows down the page is sabotage, especially on a marketing site where the conversion you are measuring is the thing you just hurt. So our hard rule: the client must never block page load, and it must never lose an event that happened before it finished loading. Those two goals fight each other, and the resolution is a stub and queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stub
&lt;/h2&gt;

&lt;p&gt;A tiny synchronous stub defines the global immediately and queues every call into an array. This is all that runs before the real script arrives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;function &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="nx"&gt;zv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;zv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&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;Now the page can call &lt;code&gt;zv('event', {...})&lt;/code&gt; from the very first line, and nothing is lost even though the real implementation is not loaded yet. Every call lands in &lt;code&gt;zv.q&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading the Real Script Without Blocking
&lt;/h2&gt;

&lt;p&gt;The real script loads async, so it never blocks parsing or paint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(){&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;zv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.example.com/zv.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;zv.js&lt;/code&gt; arrives, it drains the queue in order before handling anything new:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queued&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;realImplementation&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &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;args&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;queued&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;realImplementation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&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 stub and the real implementation share one entry point, so the page never knows or cares which one it is talking to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Push the Work to the Edge
&lt;/h2&gt;

&lt;p&gt;The browser stays thin on purpose. The heavy work runs on Cloudflare Workers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;per site config (which features are on) is cached in Workers KV and read at the edge, so the script does not round trip to an origin to configure itself&lt;/li&gt;
&lt;li&gt;events batch in the browser and flush with &lt;code&gt;sendBeacon&lt;/code&gt;, which survives the page being closed
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flush&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;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/ingest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visibilitychange&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="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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visibilityState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flush&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;h2&gt;
  
  
  The Byte Budget
&lt;/h2&gt;

&lt;p&gt;The whole client sits around 3.1kb gzipped, treated as a hard ceiling. Every feature either fits or loads on demand. Session replay, for instance, is a separate module loaded only when enabled, so customers who never use it never download it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The stub and queue is the entire trick for never blocking and never losing events. It is a few lines and it solves both goals at once.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sendBeacon&lt;/code&gt; on &lt;code&gt;visibilitychange&lt;/code&gt; is what makes the last events survive navigation. A normal fetch on unload gets cancelled.&lt;/li&gt;
&lt;li&gt;A hard byte budget forces good decisions. Without it, every feature quietly adds weight to the page you are paid to protect.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest Limit
&lt;/h2&gt;

&lt;p&gt;Stub and queue slightly delays the first few events until the real script drains the queue. For analytics that is invisible. For anything user facing, it would not be acceptable.&lt;/p&gt;




&lt;p&gt;If you ship a third party script, how do you keep it from becoming the slowest thing on your customers' pages?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I build &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;Zenovay&lt;/a&gt; with my co-founder, cookieless website analytics on Cloudflare Workers. This is how our client script stays out of the way.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>analytics</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Running A/B tests on top of edge feature flags</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Thu, 11 Jun 2026 18:20:33 +0000</pubDate>
      <link>https://dev.to/zenovay/running-ab-tests-on-top-of-edge-feature-flags-36a7</link>
      <guid>https://dev.to/zenovay/running-ab-tests-on-top-of-edge-feature-flags-36a7</guid>
      <description>&lt;p&gt;Once you have feature flags, an A/B test is a small step further: a flag with more than one variant, plus honest measurement. Here is how we do it at the edge, and the two bugs that quietly invalidate experiments.&lt;/p&gt;

&lt;p&gt;I build this for &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;Zenovay&lt;/a&gt; (web analytics). This assumes you already read flag config at the edge with no extra latency.&lt;/p&gt;




&lt;h2&gt;
  
  
  A flag is on/off. An experiment is a bucket.
&lt;/h2&gt;

&lt;p&gt;The only new pieces are: assigning each user to a variant consistently, and logging exposure so you can measure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deterministic assignment (bug #1 if you get it wrong)
&lt;/h2&gt;

&lt;p&gt;Never use &lt;code&gt;Math.random&lt;/code&gt; to pick a variant. The same user would flicker between variants on every request, which destroys the experiment and the user experience. Hash a stable id instead, so a given user always lands in the same bucket.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bucket&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="nx"&gt;experiment&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;variants&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&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;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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="s2"&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;digest&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// take 4 bytes of the hash as an unsigned int&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&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;DataView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getUint32&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bucketFraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mh"&gt;0xffffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 0..1, stable for this user&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucketFraction&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;variants&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// usage at the edge&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;bucket&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout_copy_v1&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;control&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="s2"&gt;treatment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Including the experiment name in the hash matters: it means a user is not correlated across different experiments. Without it, anyone in "treatment" for experiment A tends to be in "treatment" for B too, which confounds everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Log exposure, not just conversion (bug #2)
&lt;/h2&gt;

&lt;p&gt;You must record that a user was actually exposed to a variant, at the moment they were exposed. If you only look at who converted, you cannot compute a rate, because you do not know the denominator per variant.&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;// fire once, when the variant is actually shown&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;logExposure&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="nx"&gt;experiment&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;variant&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="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/exposure&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;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;The classic mistake is assigning a variant but only logging conversions. Then "treatment converted 40, control converted 30" tells you nothing without exposure counts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring the result
&lt;/h2&gt;

&lt;p&gt;With exposure and conversion events, the rate per variant is straightforward.&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;select&lt;/span&gt;
  &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exposed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;converted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exposures&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
&lt;span class="k"&gt;left&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;conversions&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
 &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_at&lt;/span&gt;        &lt;span class="c1"&gt;-- only conversions after exposure&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the join condition: only count a conversion if it happened after the user was exposed. A conversion before exposure is not caused by the variant.&lt;/p&gt;




&lt;h2&gt;
  
  
  When not to roll your own
&lt;/h2&gt;

&lt;p&gt;Do this yourself for simple, low-stakes tests. Reach for a real experimentation platform when you need sequential testing, guardrail metrics, automatic significance, or non-engineers launching tests.&lt;/p&gt;

&lt;p&gt;The hard part of A/B testing is not assignment — it is the statistics and not fooling yourself. The code above gives you rates, not confidence.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;Zenovay&lt;/a&gt;, which ties experiment exposure to downstream revenue so you can see which variant made money, not just which got clicks.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Do you stop tests on significance or on a fixed sample size?&lt;/strong&gt; Stopping the moment it looks significant is the most common way to ship a false winner.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>cloudflare</category>
      <category>typescript</category>
      <category>devops</category>
    </item>
    <item>
      <title>Filtering bot and spam traffic out of your analytics</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Tue, 09 Jun 2026 18:22:16 +0000</pubDate>
      <link>https://dev.to/zenovay/filtering-bot-and-spam-traffic-out-of-your-analytics-17ch</link>
      <guid>https://dev.to/zenovay/filtering-bot-and-spam-traffic-out-of-your-analytics-17ch</guid>
      <description>&lt;p&gt;If your analytics counts bots, every number you make decisions on is inflated. Conversion looks worse than it is, traffic looks better than it is. Here is the layered filter we run at ingestion, cheapest checks first.&lt;/p&gt;

&lt;p&gt;Same traffic, with bots counted and with them filtered out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With bots (inflated):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkocnfqj48xwypf6xl0t4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkocnfqj48xwypf6xl0t4.png" alt="Traffic chart with bot traffic counted, inflated numbers" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bots filtered out (honest):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0e71vhhoe6d7t6fywbu1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0e71vhhoe6d7t6fywbu1.png" alt="Same traffic chart with bots stripped out, lower and smoother" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I build this for Zenovay (web analytics). None of these checks is perfect alone, which is why they are layered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: it never ran JavaScript
&lt;/h2&gt;

&lt;p&gt;The single most effective filter. Most crawlers fetch HTML and leave. If your analytics fires from a script, a large class of bots is already excluded because they never execute it. This is free and catches a lot, but not headless browsers, which do run JS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: obvious user agent signatures
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BOT_UA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;bot|crawl|spider|slurp|headless|phantom|puppeteer|playwright|curl|wget|python-requests|axios&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;looksLikeBotUA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt; &lt;span class="o"&gt;=&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;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;ua&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;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// no UA at all is suspicious&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;BOT_UA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&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;A regex on the UA is trivial to spoof, so treat it as a hint, not proof. It mostly clears out honest bots that identify themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: datacenter and known ranges
&lt;/h2&gt;

&lt;p&gt;Real users come from residential and mobile networks. A burst from a cloud provider ASN is usually automation. At the edge you often get the ASN for free.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DATACENTER_ASNS&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="c1"&gt;// a maintained list of cloud/hosting ASNs&lt;/span&gt;
  &lt;span class="mi"&gt;16509&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// aws&lt;/span&gt;
  &lt;span class="mi"&gt;15169&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// gcp&lt;/span&gt;
  &lt;span class="mi"&gt;8075&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// azure&lt;/span&gt;
  &lt;span class="mi"&gt;14061&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// digitalocean&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fromDatacenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&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;asn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;asn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// cloudflare provides this&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;asn&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;DATACENTER_ASNS&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;asn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;Careful: some legitimate corporate traffic and VPNs also exit through datacenter ranges, so do not hard drop on this alone. We flag, then combine with behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: behavior that is not human
&lt;/h2&gt;

&lt;p&gt;The strongest signal after "did it run JS". Humans are slow and irregular. Bots are fast and uniform.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;behaviorIsBotty&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// many pageviews in an impossibly short time&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageviews&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;durationMs&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2000&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;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// zero mouse, scroll, or key events across a long visit&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationMs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;interactions&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&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;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// perfectly regular timing between events (scripted)&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;variance&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;interEventMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&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;true&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ua&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="nf"&gt;looksLikeBotUA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&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="nf"&gt;fromDatacenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&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="nf"&gt;behaviorIsBotty&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;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// 0 to 1 = human, 2 = suspicious (flag), 3+ = bot (exclude from metrics)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;suspicious&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="s2"&gt;human&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;h2&gt;
  
  
  The decision that matters: drop or flag
&lt;/h2&gt;

&lt;p&gt;Do not delete suspicious traffic. We keep everything but tag it, and exclude bots from the default metrics. That way if our filter is wrong, the data is recoverable, and you can audit how much you are filtering. A silent filter you cannot inspect is its own bug.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Zenovay, which does this filtering by default so your numbers are not inflated. The layered approach above is what runs under the hood.&lt;/p&gt;

&lt;p&gt;What is your highest signal bot tell? For us it is a long visit with literally zero interaction events.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>security</category>
      <category>analytics</category>
    </item>
    <item>
      <title>Retention cohort analysis with plain SQL</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Mon, 08 Jun 2026 18:09:38 +0000</pubDate>
      <link>https://dev.to/zenovay/retention-cohort-analysis-with-plain-sql-356c</link>
      <guid>https://dev.to/zenovay/retention-cohort-analysis-with-plain-sql-356c</guid>
      <description>&lt;p&gt;Everyone wants a retention chart and most reach for a tool. You can get the core cohort triangle from raw events with one SQL query. Here is the pattern we use, and the mistake that quietly makes it wrong.&lt;/p&gt;

&lt;p&gt;I build this for Zenovay (web analytics) where we show retention by signup cohort. The shape below is database agnostic, written for Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;A cohort is a group of users bucketed by when they first appeared (usually signup week). For each cohort you measure what fraction are still active N weeks later. Plot it as a triangle and you can see whether newer cohorts retain better than older ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two inputs
&lt;/h2&gt;

&lt;p&gt;You need two things per user: their cohort (first activity week) and every week they were active.&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="c1"&gt;-- 1) each user's cohort week&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;first_seen&lt;/span&gt; &lt;span class="k"&gt;as&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'week'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cohort_week&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
  &lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="c1"&gt;-- 2) the distinct weeks each user was active&lt;/span&gt;
&lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;distinct&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'week'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;active_week&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;events&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;-- how many weeks after signup this activity is&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;epoch&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_week&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;604800&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;week_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;active_users&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;first_seen&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;week_number&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;week_number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you a long table of (cohort_week, week_number, active_users). To turn it into a percentage you divide by the cohort size (week_number 0).&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning counts into retention percent
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="c1"&gt;-- the query above, as a CTE&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;cohort_size&lt;/span&gt; &lt;span class="k"&gt;as&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;cohort_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active_users&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;week_number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;week_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_users&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;retention_pct&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;cohort_size&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;week_number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The mistake that breaks it
&lt;/h2&gt;

&lt;p&gt;The subtle bug: defining "active" inconsistently between the cohort and the activity step. If your cohort is based on signup but your activity is based on, say, only paid events, week 0 retention will not be 100 percent and every number downstream is wrong. The fix is to make sure the week_number 0 cohort count equals the number of users who signed up that week. If it does not, your two definitions disagree.&lt;/p&gt;

&lt;p&gt;The other one: time zones. date_trunc on a raw UTC timestamp buckets by UTC weeks. If your business thinks in a local week, convert before truncating, or your Monday is someone's Sunday and cohorts blur at the edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it fast
&lt;/h2&gt;

&lt;p&gt;Over raw events this gets slow quickly. We run it over a daily rollup table (one row per user per active day) instead of millions of raw events, so the cohort query touches thousands of rows. Same result, far cheaper. The rollup is a separate scheduled job.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Zenovay, a web analytics tool that does this so you do not have to write the SQL. But the query above is the whole idea, and you should understand it before trusting any tool's version.&lt;/p&gt;

&lt;p&gt;How do you define "active" for retention, any event or a meaningful one? That choice changes the whole chart.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>analytics</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Lightweight client side error tracking without a 90kb SDK</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sat, 06 Jun 2026 17:38:03 +0000</pubDate>
      <link>https://dev.to/zenovay/lightweight-client-side-error-tracking-without-a-90kb-sdk-40o3</link>
      <guid>https://dev.to/zenovay/lightweight-client-side-error-tracking-without-a-90kb-sdk-40o3</guid>
      <description>&lt;p&gt;The popular error tracking SDKs are great and also large. For our analytics tool we wanted error capture that adds a couple of kb, not ninety. Here is the whole thing. It is smaller than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture the two events that matter
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CapturedError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;message&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="nl"&gt;stack&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="nl"&gt;url&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="nl"&gt;line&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;col&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unhandledrejection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&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;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CapturedError&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&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;e&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="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&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;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stack&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;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;line&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;lineno&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;col&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;colno&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unhandledrejection&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;e&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&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;reason&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unhandled rejection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unhandledrejection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;schedule&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 is most of the value right there. &lt;code&gt;error&lt;/code&gt; catches thrown exceptions, &lt;code&gt;unhandledrejection&lt;/code&gt; catches the async ones everyone forgets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch, debounce, and survive unload
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&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;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flush&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;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&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;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/errors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pagehide&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sendBeacon&lt;/code&gt; is the trick. A normal fetch on unload gets cancelled. Beacon does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not drown in noise
&lt;/h2&gt;

&lt;p&gt;Two cheap filters save you from a flood:&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;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dedupe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CapturedError&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;col&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;seen&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;sig&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="c1"&gt;// already reported this page load&lt;/span&gt;
  &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&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;true&lt;/span&gt;&lt;span class="p"&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;IGNORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;/ResizeObserver loop/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/Script error&lt;/span&gt;&lt;span class="se"&gt;\.?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/extension/i&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;ignored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CapturedError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;IGNORE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Script error.&lt;/code&gt; with no detail is almost always a cross origin script with no CORS headers. &lt;code&gt;ResizeObserver loop limit exceeded&lt;/code&gt; is benign browser noise. Filter both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing you actually need a server for
&lt;/h2&gt;

&lt;p&gt;Minified stacks are useless without source maps. Upload your source maps at build time and resolve the stack server side. That, not the capture, is the part worth real effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Two event listeners, a batched beacon, dedupe, and an ignore list gets you most of the value of a heavy SDK in a couple of kb. Spend your effort on source map resolution, not on the capture.&lt;/p&gt;

&lt;p&gt;Disclosure: this is the error tracking we ship in Zenovay.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Building session replay that masks input data by default</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Wed, 03 Jun 2026 14:04:59 +0000</pubDate>
      <link>https://dev.to/zenovay/building-session-replay-that-masks-input-data-by-default-ia6</link>
      <guid>https://dev.to/zenovay/building-session-replay-that-masks-input-data-by-default-ia6</guid>
      <description>&lt;p&gt;Session replay sounds scary because the naive version records everything a user types, including passwords and personal data. We built ours to mask sensitive input by default, so the safe path is the default path. Here is the approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  What session replay actually is
&lt;/h2&gt;

&lt;p&gt;It is not a video. Recording video would be huge and would capture everything. Instead you record the DOM and its mutations, then replay them in an iframe later. Much smaller, and crucially, you control what gets captured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: serialize the initial DOM, masking as you go
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;serializeNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;SerializedNode&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;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodeType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TEXT_NODE&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;??&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Element&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;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;attrs&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="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;for &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;a&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&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;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;textarea&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;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskValue&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;element&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;childNodes&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;serializeNode&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;h2&gt;
  
  
  Step 2: the masking rule is opt out, not opt in
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maskValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// default: never record raw input. replace with a same length placeholder&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&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;NEVER_RECORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password&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="s2"&gt;email&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="s2"&gt;tel&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="s2"&gt;credit&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="s2"&gt;card&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="s2"&gt;ssn&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="s2"&gt;cvc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shouldDrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;autocomplete&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;NEVER_RECORD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;hay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&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 point: a developer using our snippet does not have to remember to mask. Masking is what happens unless they explicitly mark a field as safe to record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: record mutations, not frames
&lt;/h2&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;observer&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;MutationObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;mutations&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="k"&gt;for &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;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;mutations&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;attributes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attributeName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;attr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;maskValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;childList&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addedNodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;add&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;serializeNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;removedNodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;remove&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;childList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subtree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;attributeFilter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&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="s2"&gt;class&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="s2"&gt;style&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;h2&gt;
  
  
  Step 4: batch and send, do not flood
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReplayEvent&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;record&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;ReplayEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flush&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;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/replay&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&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;&lt;code&gt;sendBeacon&lt;/code&gt; survives page unload, which a normal fetch does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;canvas&lt;/code&gt; and &lt;code&gt;video&lt;/code&gt; cannot be serialized as DOM. We snapshot a placeholder.&lt;/li&gt;
&lt;li&gt;Shadow DOM needs explicit traversal, it is not in the normal tree.&lt;/li&gt;
&lt;li&gt;Cross origin iframes cannot be read at all. We mark them as opaque.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Session replay is a DOM recorder, not a screen recorder, which is exactly what lets you mask sensitive input by default. Make the safe behavior the default and developers do not have to think about it.&lt;/p&gt;

&lt;p&gt;Disclosure: this is from building the replay feature in our analytics tool, Zenovay.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Scoring anonymous web visitors without storing personal data</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Mon, 01 Jun 2026 19:20:38 +0000</pubDate>
      <link>https://dev.to/zenovay/scoring-anonymous-web-visitors-without-storing-personal-data-4n0f</link>
      <guid>https://dev.to/zenovay/scoring-anonymous-web-visitors-without-storing-personal-data-4n0f</guid>
      <description>&lt;p&gt;Most lead scoring assumes you know who the visitor is. We wanted to score how likely an anonymous session is to convert without storing anything that identifies a person. No third party cookie, no enrichment vendor, no PII. Here is how we did it for our analytics tool, including the parts that did not work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal
&lt;/h2&gt;

&lt;p&gt;Given a live, anonymous session, output a number from 0 to 100 that says "pay attention to this one". The constraint: we never store who the person is. We score the behavior of a session id that rotates, not a profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signals that actually correlate
&lt;/h2&gt;

&lt;p&gt;We tested a lot of inputs against real conversion data. The ones that held up:&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;type&lt;/span&gt; &lt;span class="nx"&gt;SessionFeatures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;returnSessions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// distinct prior sessions. strongest signal by far&lt;/span&gt;
  &lt;span class="nl"&gt;viewedPricing&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="nl"&gt;pricingThenReturned&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="c1"&gt;// viewed pricing, left, came back&lt;/span&gt;
  &lt;span class="nl"&gt;docsDepth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// scroll + pages on docs/integrations&lt;/span&gt;
  &lt;span class="nl"&gt;pathShape&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;browse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;direct&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compare&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 ones we dropped because they were noise: raw time on page (a backgrounded tab destroys it), single session scroll depth on its own, and device type (much weaker than people claim).&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoring function
&lt;/h2&gt;

&lt;p&gt;For low volume sites we do not use a model. Weighted rules beat a model until you have enough data to train on:&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;function&lt;/span&gt; &lt;span class="nf"&gt;scoreSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SessionFeatures&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&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;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;returnSessions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// return visits dominate&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewedPricing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;10&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pricingThenReturned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// intent&lt;/span&gt;
  &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docsDepth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathShape&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compare&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;100&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 is deliberately boring. A heuristic you can explain beats a black box you cannot, especially when a customer asks "why is this session a 78".&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it at the edge on the event stream
&lt;/h2&gt;

&lt;p&gt;Scoring runs on each event as it arrives, so the score updates in near real time instead of in a nightly batch:&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="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AnalyticsEvent&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;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadSessionFeatures&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionId&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;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;applyEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&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;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;scoreSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&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;SESSIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// sessions expire, nothing persists about a person&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;score&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;h2&gt;
  
  
  When we switch to a learned model
&lt;/h2&gt;

&lt;p&gt;Only once a site has enough conversions to train on. Below that, a model overfits to a handful of events and is worse than the rules. We gate it on volume, not on whether it sounds impressive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You are scoring a session, not a person. A high score might be a competitor doing research.&lt;/li&gt;
&lt;li&gt;No identity means no true cross device. We do not pretend otherwise.&lt;/li&gt;
&lt;li&gt;Low volume sites get scores barely better than a coin flip, and the product says so rather than faking confidence.
## Takeaway&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can get genuinely useful visitor scoring without cookies or PII, but start with explainable weighted rules, not a model. The model is the last step, not the first.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: my co founder and I build a web analytics tool called Zenovay that does this. The approach above is what we actually shipped. See zenovay.com&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
