<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Zenovay</title>
    <description>The latest articles on DEV Community by Zenovay (@zenovay).</description>
    <link>https://dev.to/zenovay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3772909%2F8faf186c-8dcf-4cb1-bfcf-c5554bca55c8.webp</url>
      <title>DEV Community: Zenovay</title>
      <link>https://dev.to/zenovay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zenovay"/>
    <language>en</language>
    <item>
      <title>I Published 31 DEV Posts. The One With 300 Views Got Zero Reactions.</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sat, 25 Jul 2026 12:42:48 +0000</pubDate>
      <link>https://dev.to/zenovay/i-published-31-dev-posts-the-one-with-300-views-got-zero-reactions-28eh</link>
      <guid>https://dev.to/zenovay/i-published-31-dev-posts-the-one-with-300-views-got-zero-reactions-28eh</guid>
      <description>&lt;p&gt;I published 31 posts on DEV and collected 1,222 views.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

Wednesday:
Visitor returns through Google.

Friday:
Visitor opens the pricing page from a bookmark.

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;zv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the page can call &lt;code&gt;zv('event', {...})&lt;/code&gt; from the very first line, and nothing is lost even though the real implementation is not loaded yet. Every call lands in &lt;code&gt;zv.q&lt;/code&gt;.&lt;/p&gt;

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

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

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

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queued&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;realImplementation&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;queued&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;realImplementation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stub and the real implementation share one entry point, so the page never knows or cares which one it is talking to.&lt;/p&gt;

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

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

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

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

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/ingest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visibilitychange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visibilityState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Byte Budget
&lt;/h2&gt;

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

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

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

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

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




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

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

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

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




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

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




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

&lt;p&gt;Never use &lt;code&gt;Math.random&lt;/code&gt; to pick a variant. The same user would flicker between variants on every request, which destroys the experiment and the user experience. Hash a stable id instead, so a given user always lands in the same bucket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// take 4 bytes of the hash as an unsigned int&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DataView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getUint32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bucketFraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mh"&gt;0xffffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 0..1, stable for this user&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucketFraction&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// usage at the edge&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout_copy_v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;control&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;treatment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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




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

&lt;p&gt;You must record that a user was actually exposed to a variant, at the moment they were exposed. If you only look at who converted, you cannot compute a rate, because you do not know the denominator per variant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// fire once, when the variant is actually shown&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;logExposure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/exposure&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classic mistake is assigning a variant but only logging conversions. Then "treatment converted 40, control converted 30" tells you nothing without exposure counts.&lt;/p&gt;




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

&lt;p&gt;With exposure and conversion events, the rate per variant is straightforward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exposed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;converted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exposures&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
&lt;span class="k"&gt;left&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;conversions&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
 &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_at&lt;/span&gt;        &lt;span class="c1"&gt;-- only conversions after exposure&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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




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

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

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




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




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

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

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

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

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

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

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

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

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

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

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



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

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;looksLikeBotUA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// no UA at all is suspicious&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;BOT_UA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A regex on the UA is trivial to spoof, so treat it as a hint, not proof. It mostly clears out honest bots that identify themselves.&lt;/p&gt;

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DATACENTER_ASNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="c1"&gt;// a maintained list of cloud/hosting ASNs&lt;/span&gt;
  &lt;span class="mi"&gt;16509&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// aws&lt;/span&gt;
  &lt;span class="mi"&gt;15169&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// gcp&lt;/span&gt;
  &lt;span class="mi"&gt;8075&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// azure&lt;/span&gt;
  &lt;span class="mi"&gt;14061&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// digitalocean&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fromDatacenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;asn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;asn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// cloudflare provides this&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;asn&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;DATACENTER_ASNS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;asn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Careful: some legitimate corporate traffic and VPNs also exit through datacenter ranges, so do not hard drop on this alone. We flag, then combine with behavior.&lt;/p&gt;

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

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

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

&lt;/div&gt;



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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;looksLikeBotUA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fromDatacenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;behaviorIsBotty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// 0 to 1 = human, 2 = suspicious (flag), 3+ = bot (exclude from metrics)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;suspicious&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;human&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The decision that matters: drop or flag
&lt;/h2&gt;

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

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

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

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

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

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

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

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

&lt;p&gt;You need two things per user: their cohort (first activity week) and every week they were active.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 1) each user's cohort week&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;first_seen&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'week'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cohort_week&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
  &lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="c1"&gt;-- 2) the distinct weeks each user was active&lt;/span&gt;
&lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;distinct&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'week'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;active_week&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;-- how many weeks after signup this activity is&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;epoch&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_week&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;604800&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;week_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;active_users&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;first_seen&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;week_number&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;week_number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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



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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CapturedError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;line&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;col&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unhandledrejection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CapturedError&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

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

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unhandledrejection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unhandled rejection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unhandledrejection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is most of the value right there. &lt;code&gt;error&lt;/code&gt; catches thrown exceptions, &lt;code&gt;unhandledrejection&lt;/code&gt; catches the async ones everyone forgets.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/errors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



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

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

&lt;p&gt;Two cheap filters save you from a flood:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dedupe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CapturedError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// already reported this page load&lt;/span&gt;
  &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IGNORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;/ResizeObserver loop/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/Script error&lt;/span&gt;&lt;span class="se"&gt;\.?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/extension/i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ignored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CapturedError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;IGNORE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;serializeNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;SerializedNode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodeType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TEXT_NODE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Element&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;textarea&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskValue&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;element&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;childNodes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serializeNode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: the masking rule is opt out, not opt in
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maskValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// default: never record raw input. replace with a same length placeholder&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NEVER_RECORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;credit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ssn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cvc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shouldDrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;autocomplete&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NEVER_RECORD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;hay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point: a developer using our snippet does not have to remember to mask. Masking is what happens unless they explicitly mark a field as safe to record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: record mutations, not frames
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MutationObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;attributes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attributeName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;attr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;maskValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;childList&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addedNodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;add&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;serializeNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;removedNodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;remove&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;childList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subtree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;attributeFilter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;class&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;style&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: batch and send, do not flood
&lt;/h2&gt;



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

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReplayEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/replay&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sendBeacon&lt;/code&gt; survives page unload, which a normal fetch does not.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

&lt;p&gt;We tested a lot of inputs against real conversion data. The ones that held up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SessionFeatures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;returnSessions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// distinct prior sessions. strongest signal by far&lt;/span&gt;
  &lt;span class="nl"&gt;viewedPricing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pricingThenReturned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// viewed pricing, left, came back&lt;/span&gt;
  &lt;span class="nl"&gt;docsDepth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// scroll + pages on docs/integrations&lt;/span&gt;
  &lt;span class="nl"&gt;pathShape&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;browse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;direct&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compare&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ones we dropped because they were noise: raw time on page (a backgrounded tab destroys it), single session scroll depth on its own, and device type (much weaker than people claim).&lt;/p&gt;

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

&lt;p&gt;For low volume sites we do not use a model. Weighted rules beat a model until you have enough data to train on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scoreSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SessionFeatures&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;returnSessions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// return visits dominate&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewedPricing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pricingThenReturned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// intent&lt;/span&gt;
  &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docsDepth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathShape&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compare&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is deliberately boring. A heuristic you can explain beats a black box you cannot, especially when a customer asks "why is this session a 78".&lt;/p&gt;

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

&lt;p&gt;Scoring runs on each event as it arrives, so the score updates in near real time instead of in a nightly batch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AnalyticsEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadSessionFeatures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;applyEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;scoreSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SESSIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// sessions expire, nothing persists about a person&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When we switch to a learned model
&lt;/h2&gt;

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

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

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

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




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

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Why do most SaaS dashboards still feel like enterprise software from 1998</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sun, 31 May 2026 14:31:15 +0000</pubDate>
      <link>https://dev.to/zenovay/why-do-most-saas-dashboards-still-feel-like-enterprise-software-from-1998-2ben</link>
      <guid>https://dev.to/zenovay/why-do-most-saas-dashboards-still-feel-like-enterprise-software-from-1998-2ben</guid>
      <description>&lt;p&gt;Question for the frontend and UX folks here. Why do most SaaS dashboards in 2026 still feel like enterprise software from 1998 in everything but the CSS?&lt;/p&gt;

&lt;p&gt;I noticed this hard when redesigning my own product (Zenovay, cookieless web analytics). The first version of our dashboard had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a left sidebar with 14 menu items&lt;/li&gt;
&lt;li&gt;a top bar with 6 controls&lt;/li&gt;
&lt;li&gt;8 chart widgets visible at first paint&lt;/li&gt;
&lt;li&gt;a "configure dashboard" modal with 23 options&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It looked exactly like every other analytics tool. I could not tell my dashboard apart from PostHog or Amplitude in a screenshot.&lt;/p&gt;

&lt;p&gt;Then I looked at how my actual users used it. The pattern was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;arrive at dashboard&lt;/li&gt;
&lt;li&gt;look at 2 numbers&lt;/li&gt;
&lt;li&gt;click one drill down&lt;/li&gt;
&lt;li&gt;leave&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total time spent in the dashboard: 47 seconds median. Almost nobody touched the sidebar after onboarding.&lt;/p&gt;

&lt;p&gt;So the 14 menu items, 6 controls, 8 widgets, and 23 configuration options were all competing for attention with the 2 numbers people actually came for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The redesign
&lt;/h2&gt;

&lt;p&gt;New version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2 numbers at the top, big, no chart, just numbers&lt;/li&gt;
&lt;li&gt;1 trend chart underneath&lt;/li&gt;
&lt;li&gt;3 drill in cards (top pages, top sources, top conversions)&lt;/li&gt;
&lt;li&gt;everything else moved to a "more" menu&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Median time on dashboard went up to 1m 50s. Pages per session went up. The "I can't find X" support emails dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this took 4 years across the industry to figure out
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis 1: founders look at their own tools through builder eyes, not user eyes.&lt;/strong&gt; We see all 14 menu items because we built them. A new user sees noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis 2: dashboard density correlates with perceived "powerful".&lt;/strong&gt; We're rewarded by the prospect's "wow this is comprehensive" reaction in the sales call. The customer then ignores 90% of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis 3: the B2B buying loop selects for tools that look like they have lots of features.&lt;/strong&gt; Procurement teams want checklists. Engineers want simplicity. The buyer is rarely the user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis 4: it's just hard.&lt;/strong&gt; Saying no to a chart widget that someone, somewhere, wants is a daily resistance pattern. The default is to add.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern I'm trying to follow now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First paint is sacred.&lt;/strong&gt; Whatever is on first paint is what the user thinks the product is. Choose ruthlessly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Power users get a different surface.&lt;/strong&gt; Don't try to make one UI that pleases the casual visitor and the power user. Casual visitor gets a minimal dashboard. Power user gets a separate "explorer" route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sidebar items are debt.&lt;/strong&gt; Every item is one more thing the user has to mentally ignore. Items must earn their place by being clicked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration is for users who asked for it.&lt;/strong&gt; Default to opinionated defaults. Configuration is a power user feature. The setting hidden in a submenu is rarely the same one that's exposed at first paint.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Counter argument I can hear
&lt;/h2&gt;

&lt;p&gt;"You're describing a consumer UI pattern. Enterprise customers want density." Maybe. But I think the gap between consumer and enterprise UI standards is closing fast, and the new buyer who is a millennial PM or technical founder is closer to consumer expectations than to circa 2010 enterprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real question for the thread
&lt;/h2&gt;

&lt;p&gt;What's a SaaS dashboard you actually enjoy using? Specifically: not respect, not "admire from a distance", but actually open with mild satisfaction. Curious where the bar actually is.&lt;/p&gt;




&lt;p&gt;I'm Valerio. Built &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;Zenovay&lt;/a&gt;, spent an embarrassing amount of time learning this lesson on my own product.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ux</category>
      <category>design</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Six weeks of dedicated distribution as a solo founder. What i underestimated, what i overestimated</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sat, 30 May 2026 10:43:35 +0000</pubDate>
      <link>https://dev.to/zenovay/six-weeks-of-dedicated-distribution-as-a-solo-founder-what-i-underestimated-what-i-overestimated-41dm</link>
      <guid>https://dev.to/zenovay/six-weeks-of-dedicated-distribution-as-a-solo-founder-what-i-underestimated-what-i-overestimated-41dm</guid>
      <description>&lt;p&gt;I'm Valerio, solo founder of Zenovay (cookieless web analytics). I spent the last 6 weeks doing dedicated distribution work. Not feature shipping. Just writing, posting, and engaging.&lt;/p&gt;

&lt;p&gt;Posting this on a saturday because the audience for this thread is other founders, and saturday morning is when we read things we should have read earlier in the week.&lt;/p&gt;




&lt;h2&gt;
  
  
  What i underestimated
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The time required to actually do distribution well.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I budgeted 10 hours per week. The honest number was 25. Writing a post that is worth reading takes time. Responding to comments takes time. Researching the platform's tone takes time. The act of clicking 'submit' is 5%. The rest is preparation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost of context switching.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I tried doing 2 hours of distribution and 6 hours of product in the same day. The distribution work was bad, the product work was worse. Both kinds of work need a different brain mode. I now schedule whole days for each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much hand drafting matters.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both reddit and hacker news react badly to anything that smells like ai generated text right now. I had to retrain myself to write the way i actually talk, including the awkward pauses and sentence fragments. it actually performs better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The slow accumulation of cred.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First few posts on a new community got buried. The third or fourth one started landing. There's a 'this person actually shows up here' signal that builds over weeks. No shortcut for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What i overestimated
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The importance of the perfect post.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I rewrote my first reddit post 6 times before posting. The one that actually got the most traffic that week was a 4 line comment on someone else's thread. Time spent perfecting the post was time not spent participating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reach of any single platform.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I thought hacker news would be the big lever. It was modest. The slow accumulation across 5 platforms outperformed any single home run. Diversification matters more than i thought.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The persuasive power of features.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I kept writing posts about cool features (3d globe, terminal cli, mcp server). They were interesting. They did not move signups much. The posts that moved signups were about outcomes (post launch numbers, what did and did not work, here's what i learned).&lt;/p&gt;




&lt;h2&gt;
  
  
  What i changed mid stream
&lt;/h2&gt;

&lt;p&gt;Around week 4 i shifted from 'tell people what we built' to 'tell people what i learned'. Same factual content underneath, different framing. Signups per post roughly doubled. The post is not 'this is our cli', it is 'here's what i discovered while building a cli for analytics that no one writes about'.&lt;/p&gt;




&lt;h2&gt;
  
  
  The hardest part
&lt;/h2&gt;

&lt;p&gt;Honesty in public. Sharing real numbers when the numbers are small. Admitting that a feature didn't work. Writing a post that contradicts a post you wrote 2 weeks ago because you learned something new.&lt;/p&gt;

&lt;p&gt;Every time i was tempted to round a number up or hide a failure, the post performed worse. Every time i shared the awkward truth, the post performed better. There is something deeply correct about the platforms' bullshit detectors.&lt;/p&gt;




&lt;h2&gt;
  
  
  What i'd tell my earlier self
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pick 3 platforms, not 5. Five is too many for one person to do well.&lt;/li&gt;
&lt;li&gt;Write 70% of the week's content on monday. Improvising mid week burns out faster than you'd think.&lt;/li&gt;
&lt;li&gt;Spend an hour reading other founders' posts before writing your own. Pattern match the tone.&lt;/li&gt;
&lt;li&gt;Track utm params from day one, not from when you remember.&lt;/li&gt;
&lt;li&gt;Reply to every comment in the first hour. Algorithms reward velocity, not lifetime engagement.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Zenovay at &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;zenovay.com&lt;/a&gt;. Web analytics with revenue attribution. Built by one person, distributed by the same person, both functions equally underbudgeted.&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>solopreneur</category>
      <category>productivity</category>
      <category>discuss</category>
    </item>
  </channel>
</rss>
