<?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/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>5 Reasons to Switch From Google Analytics in 2026</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Thu, 03 Sep 2026 20:21:03 +0000</pubDate>
      <link>https://dev.to/zenovay/5-reasons-to-switch-from-google-analytics-in-2026-1019</link>
      <guid>https://dev.to/zenovay/5-reasons-to-switch-from-google-analytics-in-2026-1019</guid>
      <description>&lt;p&gt;Google Analytics has been the default for over a decade. It is free, it is everywhere, and most teams set it up without questioning whether they should.&lt;/p&gt;

&lt;p&gt;But the product you are using today is not the product you installed five years ago. GA4 arrived in 2023, and with it came a completely different data model, a new interface, and a learning curve that turned experienced analysts into beginners. Two years later, plenty of teams are still struggling with it.&lt;/p&gt;

&lt;p&gt;This is not a hit piece. Google Analytics does things no competitor matches at zero cost. But “free” has conditions, and those conditions are getting harder to accept. Here are five reasons teams are switching, and one honest reason you might not want to.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The complexity tax is real
&lt;/h3&gt;

&lt;p&gt;GA4 replaced a page-view model with an event-based model. In theory, that is more flexible. In practice, it means the simple questions got harder.&lt;/p&gt;

&lt;p&gt;“How many people visited my pricing page last week?” In Universal Analytics, that was one click. In GA4, you might need a custom exploration, a filtered report, or a segment that you build from scratch each time.&lt;/p&gt;

&lt;p&gt;The interface has improved since launch, but it is still dense. Navigation is non-obvious. Reports that should be standard require configuration. If you are a founder or a small marketing team, the time you spend learning GA4’s interface is time you are not spending on your product.&lt;/p&gt;

&lt;p&gt;Most teams do not need 400 features. They need five metrics they can trust, presented clearly, refreshed in real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Privacy is not optional anymore
&lt;/h3&gt;

&lt;p&gt;Google Analytics uses cookies. That means you need a consent banner in the EU, the UK, and increasingly in other jurisdictions. Every visitor who declines cookies is a visitor you cannot track.&lt;/p&gt;

&lt;p&gt;Estimates vary, but consent rates in Europe typically range from 30% to 70%. That means your GA4 data may represent only half of your actual traffic. The data is not wrong, but it is incomplete, and incomplete data leads to incomplete decisions.&lt;/p&gt;

&lt;p&gt;Cookieless analytics tools avoid this problem entirely. No cookies means no consent banner is needed for analytics. Every visitor is counted, every page view is recorded, and your traffic numbers reflect reality instead of the subset that clicked “Accept.”&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hlwmt51p8elarjd8lmj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hlwmt51p8elarjd8lmj.webp" alt="Cookieless vs Cookie Tracking Signal Comparison" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is not just a European concern. California’s CCPA, Brazil’s LGPD, and Canada’s PIPEDA all impose obligations on cookie-based tracking. The regulatory direction is clear: tracking will get harder, not easier.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Data sampling hides the truth
&lt;/h3&gt;

&lt;p&gt;GA4 samples data when your query exceeds certain thresholds. You will see a small icon in the corner of your report, easy to miss, telling you that you are looking at an estimate based on a fraction of your actual data.&lt;/p&gt;

&lt;p&gt;For large sites, this happens constantly. For smaller sites, it happens less often, but when it does, you may not notice. The numbers look plausible. They are just not exact.&lt;/p&gt;

&lt;p&gt;Sampling is a reasonable engineering trade-off for a free product serving millions of sites. But if your business decisions depend on accurate numbers, and they should, you need to know whether you are looking at real data or an approximation.&lt;/p&gt;

&lt;p&gt;Most modern analytics alternatives process every event without sampling. What you see is what happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Your data trains the advertising machine
&lt;/h3&gt;

&lt;p&gt;Google is an advertising company. Google Analytics is a product that helps that advertising company understand the web. The data you send to GA4 feeds into Google’s broader ecosystem, informing ad targeting, benchmarks, and audience modeling.&lt;/p&gt;

&lt;p&gt;This is not a secret conspiracy. It is the business model. Google does not charge for GA4 because your data has value to them in other ways.&lt;/p&gt;

&lt;p&gt;If you are comfortable with that trade, fair enough. Many businesses are, and they get a capable product in return. But you should make that choice consciously, not by default because you set up GA years ago and never revisited the decision.&lt;/p&gt;

&lt;p&gt;Self-hosted and cookieless alternatives keep your data entirely under your control. The analytics vendor does not see it, does not aggregate it, and does not use it for anything except showing you your own numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The script weight adds up
&lt;/h3&gt;

&lt;p&gt;The Google Analytics gtag.js script weighs roughly 90 KB (compressed). On its own, that is not catastrophic. But it rarely arrives alone. It brings Google Tag Manager, additional tracking pixels, remarketing tags, and consent management scripts along for the ride.&lt;/p&gt;

&lt;p&gt;A typical GA4 + GTM setup can add 200 to 400 KB of JavaScript to every page load. That matters for Core Web Vitals. It matters for mobile users on slow connections. It matters for your Lighthouse score, and that score affects how Google ranks you.&lt;/p&gt;

&lt;p&gt;There is an irony here: Google’s own analytics tool can hurt your ranking in Google’s own search engine.&lt;/p&gt;

&lt;p&gt;Lightweight alternatives typically ship scripts under 5 KB. The difference is measurable in your performance metrics.&lt;/p&gt;

&lt;h3&gt;
  
  
  One honest reason to stay
&lt;/h3&gt;

&lt;p&gt;If you run Google Ads at scale, GA4’s integration with the Google advertising ecosystem is genuinely hard to replace. Conversion tracking, audience building, and campaign attribution all flow through a shared data layer that competitors cannot replicate.&lt;/p&gt;

&lt;p&gt;For teams where paid Google advertising is the primary growth channel, switching away from GA4 means rebuilding that integration from scratch. That is a real cost, and it may not be worth it.&lt;/p&gt;

&lt;p&gt;But for teams that grow through organic search, content, referrals, or product-led growth, that integration is irrelevant. You are carrying the complexity and privacy cost of GA4 without using the one thing that justifies it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What switching actually looks like
&lt;/h3&gt;

&lt;p&gt;Switching analytics tools sounds like a big project. In practice, it is usually one script tag.&lt;/p&gt;

&lt;p&gt;Remove the GA4 snippet. Add a new one. Most modern analytics tools, including &lt;a href="https://zenovay.com/en/blog/getting-started-with-zenovay/" rel="noopener noreferrer"&gt;Zenovay&lt;/a&gt;, take under five minutes to install and start showing data immediately. There is no configuration wizard, no goal setup, no event taxonomy to define before you see your first page view.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzizk9cq5d5e269tuqarq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzizk9cq5d5e269tuqarq.webp" alt="Streamlined Lightweight Analytics Signal Processing Architecture" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You do not need to switch all at once. Run both tools in parallel for a month. Compare the numbers. When you are confident the new tool gives you what you need, remove GA4.&lt;/p&gt;

&lt;p&gt;The sunk cost of years of GA data feels like a reason to stay. But ask yourself: when was the last time you actually looked at data from two years ago? Most analytics decisions use the last 30 to 90 days. The historical archive rarely matters as much as it feels like it should.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing an alternative
&lt;/h3&gt;

&lt;p&gt;There is no single best alternative. The right choice depends on what you actually need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;If you want the simplest possible dashboard with no configuration, look at &lt;a href="https://zenovay.com/en/blog/best-privacy-analytics-tools-2026/" rel="noopener noreferrer"&gt;privacy-focused tools&lt;/a&gt; like Plausible or Fathom.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If you need revenue attribution, funnels, heatmaps, and session replay in one tool, &lt;a href="https://zenovay.com/en/" rel="noopener noreferrer"&gt;Zenovay&lt;/a&gt; covers all of those without cookies.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If you want full control and self-hosting, Matomo or Umami are solid options.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If you are evaluating multiple options, our &lt;a href="https://zenovay.com/en/blog/best-google-analytics-alternatives-2026/" rel="noopener noreferrer"&gt;comparison of GA alternatives&lt;/a&gt; breaks down the trade-offs in detail.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important thing is to make a deliberate choice. Google Analytics became the default because it was free and early. Those are not reasons to keep using something in 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frequently Asked Questions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Is Google Analytics really free?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GA4 is free to use, but the cost is indirect. Your website data feeds into Google's advertising ecosystem, and the complexity of the tool costs your team time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will I lose historical data if I switch?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your GA4 data stays in your Google account. You can export it before switching. However, most analytics decisions rely on the last 30 to 90 days of data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do cookieless analytics tools comply with GDPR?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cookieless analytics tools are designed for GDPR readiness because they do not store personal data or set cookies. Most do not require a consent banner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use a GA alternative alongside Google Ads?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. You can use a cookieless analytics tool for your website analytics while keeping a minimal GA4 or Google Tag setup specifically for ad conversion tracking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does it take to switch analytics tools?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most modern analytics tools can be installed in under five minutes. You add a single script tag to your site.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>privacy</category>
      <category>gdpr</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Boolean We Never Set to True Told Us Every Customer Was Inactive</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sun, 23 Aug 2026 09:35:52 +0000</pubDate>
      <link>https://dev.to/zenovay/a-boolean-we-never-set-to-true-told-us-every-customer-was-inactive-3pea</link>
      <guid>https://dev.to/zenovay/a-boolean-we-never-set-to-true-told-us-every-customer-was-inactive-3pea</guid>
      <description>&lt;p&gt;I found an analytics bug inside our own analytics product.&lt;/p&gt;

&lt;p&gt;The event pipeline worked. Real sites sent real events. An internal boolean still said the tracking script was not installed.&lt;/p&gt;

&lt;p&gt;That contradiction mattered because the flag was the first milestone in our activation score. If it stayed false, a working site could remain in the cold segment even after data arrived.&lt;/p&gt;

&lt;p&gt;On 21 July, production had 36 sites. Twenty one had received a first event. Only two had &lt;code&gt;script_installed&lt;/code&gt; set to true.&lt;/p&gt;

&lt;p&gt;Nineteen working installations were described as inactive.&lt;/p&gt;

&lt;p&gt;The problem was not a delayed queue or a failed tracker. We had created a state transition that no production path ever performed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The value was correct at creation
&lt;/h3&gt;

&lt;p&gt;When a customer created a site, &lt;code&gt;script_installed&lt;/code&gt; started as false. That was reasonable. At that moment, Zenovay had not received proof that the script was running.&lt;/p&gt;

&lt;p&gt;The missing part came later.&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%2F917fi52tlsi9cxhjnie9.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%2F917fi52tlsi9cxhjnie9.webp" alt="Three stages lead from site creation to the first event while the status field remains unlit" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Our system recorded the first event timestamp when data arrived, but nothing changed the installation flag. The flag had one writer for the initial false value and no writer for the later true value.&lt;/p&gt;

&lt;p&gt;Tests made the mistake easy to miss. They supplied fixtures where &lt;code&gt;script_installed&lt;/code&gt; was already true, then verified that the activation score handled the value correctly.&lt;/p&gt;

&lt;p&gt;The scoring function was tested. The transition that created its input was not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two fields described one fact
&lt;/h3&gt;

&lt;p&gt;We already had a stronger signal: &lt;code&gt;first_event_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That timestamp only exists after the ingestion path accepts a real analytics event for the site. It is evidence of something that happened. The boolean was only a description of that evidence.&lt;/p&gt;

&lt;p&gt;Once both fields existed, they could disagree:&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="k"&gt;count&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="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;websites&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;first_event_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;script_installed&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query returned 19.&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%2F3t1ohj41urlk0fof2ul0.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%2F3t1ohj41urlk0fof2ul0.webp" alt="The event state advances while the installation flag remains stuck, then both converge after the repair" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the risk of duplicate state. Each field can look valid in isolation. The contradiction appears only when the relationship between them is checked.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the damage stayed internal
&lt;/h3&gt;

&lt;p&gt;The customer facing installation check used a safer condition. It only warned when both the flag was false and no first event existed. Customers with real data were not incorrectly told to reinstall the tracker.&lt;/p&gt;

&lt;p&gt;The activation cron was also read only. It recorded telemetry and did not send lifecycle emails.&lt;/p&gt;

&lt;p&gt;That limited the blast radius, but it did not make the bug harmless. Internal activation reporting was classifying working teams incorrectly. Any decision based on that segment would start from false evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  The repair
&lt;/h3&gt;

&lt;p&gt;We attached the missing transition to the event that proves installation.&lt;/p&gt;

&lt;p&gt;When the first visitor event arrives, the same database trigger that records the first event now sets &lt;code&gt;script_installed&lt;/code&gt; to true and records when the script was first seen.&lt;/p&gt;

&lt;p&gt;The update is guarded. It runs only while verification is pending or the installation flag is not already true. Later page views do not rewrite the same site row or produce a new audit entry for every event.&lt;/p&gt;

&lt;p&gt;We then backfilled the 19 inconsistent rows from the existing first event evidence.&lt;/p&gt;

&lt;p&gt;After the repair, the historical production state had 21 sites with a first event and 21 with the installation flag set. The mismatch count was zero.&lt;/p&gt;

&lt;p&gt;A fresh production check on 20 August still returned zero sites with events but a false installation flag, and zero sites with a true flag but no first event.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we changed in the review process
&lt;/h3&gt;

&lt;p&gt;The code fix was small. The review change is more useful.&lt;/p&gt;

&lt;p&gt;First, activation milestones need an observable event. “The script is installed” is a label. “The first event was accepted at this time” is evidence.&lt;/p&gt;

&lt;p&gt;Second, tests need to cover the transition that produces state. A perfect unit test for the scoring function could not reveal that production never supplied the true value.&lt;/p&gt;

&lt;p&gt;Third, duplicated facts need an invariant. If two fields are expected to agree, run the contradiction query continuously. Do not wait for a manual audit.&lt;/p&gt;

&lt;p&gt;Fourth, internal analytics deserve the same skepticism as customer analytics. A polished funnel can still measure fiction when one milestone is derived from stale state.&lt;/p&gt;

&lt;h3&gt;
  
  
  The question we use now
&lt;/h3&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%2Fb3vxs8d4wub04ll24opb.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%2Fb3vxs8d4wub04ll24opb.webp" alt="One accepted event now updates both sides of the installation invariant" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We no longer ask only whether the installation flag is true.&lt;/p&gt;

&lt;p&gt;We ask which irreversible event proves the milestone happened.&lt;/p&gt;

&lt;p&gt;For tracker installation, that event is simple: the first event arrived.&lt;/p&gt;

&lt;p&gt;What status in your product is treated as fact even though no production event is responsible for changing it?&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <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>
  </channel>
</rss>
