<?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: Oleh Tsyupa</title>
    <description>The latest articles on DEV Community by Oleh Tsyupa (@oleh_tsyupa_bdb40da1bbe8a).</description>
    <link>https://dev.to/oleh_tsyupa_bdb40da1bbe8a</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%2F3516723%2Ff656311b-b2a1-4c35-9811-00f45f61f027.jpg</url>
      <title>DEV Community: Oleh Tsyupa</title>
      <link>https://dev.to/oleh_tsyupa_bdb40da1bbe8a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oleh_tsyupa_bdb40da1bbe8a"/>
    <language>en</language>
    <item>
      <title>Adding evidence of innocence made our sessions look more like bots</title>
      <dc:creator>Oleh Tsyupa</dc:creator>
      <pubDate>Fri, 21 Aug 2026 16:07:27 +0000</pubDate>
      <link>https://dev.to/oleh_tsyupa_bdb40da1bbe8a/adding-evidence-of-innocence-made-our-sessions-look-more-like-bots-16c2</link>
      <guid>https://dev.to/oleh_tsyupa_bdb40da1bbe8a/adding-evidence-of-innocence-made-our-sessions-look-more-like-bots-16c2</guid>
      <description>&lt;p&gt;We build a document-analytics product. Someone shares a PDF as a link, and the owner wants to know who read it — which pages held attention, where the reader stopped, whether they came back.&lt;/p&gt;

&lt;p&gt;The whole product rests on one number being trustworthy: &lt;strong&gt;how many people actually opened this.&lt;/strong&gt; And that number is polluted by default. Corporate mail security opens every link in every inbound message. Slack, Teams, WhatsApp and LinkedIn fetch a preview card. Uptime monitors poll. If you count those as readers, you hand a salesperson a list of prospects to call who never existed.&lt;/p&gt;

&lt;p&gt;So we score each viewing session for bot-likeness. What follows is the part that was harder than expected, including one bug where the scoring logic inverted on itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the scorer
&lt;/h2&gt;

&lt;p&gt;Flags, each with a weight, summed and capped at 100. At or above 60, the session is marked suspicious and excluded from the owner's counts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;ua-denylist&lt;/span&gt;              &lt;span class="err"&gt;100&lt;/span&gt;
&lt;span class="err"&gt;no-user-agent&lt;/span&gt;             &lt;span class="err"&gt;80&lt;/span&gt;
&lt;span class="err"&gt;zero-engagement&lt;/span&gt;           &lt;span class="err"&gt;60&lt;/span&gt;
&lt;span class="err"&gt;sub-second-single-page&lt;/span&gt;    &lt;span class="err"&gt;40&lt;/span&gt;
&lt;span class="err"&gt;beacon-no-pages&lt;/span&gt;           &lt;span class="err"&gt;30&lt;/span&gt;
&lt;span class="err"&gt;render-no-pages&lt;/span&gt;           &lt;span class="err"&gt;30&lt;/span&gt;
&lt;span class="err"&gt;gate-email-no-pages&lt;/span&gt;       &lt;span class="err"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two arms feed it: one reads the user agent, one reads behaviour — page-view rows, dwell time, the furthest page reached, and how the session ended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: a phone brand that matches /bot/
&lt;/h2&gt;

&lt;p&gt;The obvious user-agent test is a regex of &lt;code&gt;bot|crawl|spider|headless|curl|…&lt;/code&gt;. It works until you meet a &lt;strong&gt;CUBOT&lt;/strong&gt; — a real Android phone brand whose user agent contains the substring &lt;code&gt;bot&lt;/code&gt;. Every CUBOT owner who opened a document got classified as a crawler.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous and has to stay narrow:&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;DEVICE_BRAND_FALSE_POSITIVES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/cubot/gi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// stripped from the UA before the denylist test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule we wrote next to it matters more than the fix: additions here must be &lt;strong&gt;full brand tokens, never generic words.&lt;/strong&gt; The tempting move after being burned once is to soften the denylist. Softening it is how you stop catching crawlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: &lt;code&gt;twitterbot&lt;/code&gt; is a crawler, &lt;code&gt;Twitter for iPhone&lt;/code&gt; is a person
&lt;/h2&gt;

&lt;p&gt;The naive denylist includes bare app names — &lt;code&gt;twitter&lt;/code&gt;, &lt;code&gt;telegram&lt;/code&gt;, &lt;code&gt;discord&lt;/code&gt;. Those tokens match the &lt;strong&gt;in-app browsers&lt;/strong&gt; that recipients actually open share links in. "Twitter for iPhone" is a human holding a phone.&lt;/p&gt;

&lt;p&gt;The link-preview crawlers all identify with the &lt;code&gt;-bot&lt;/code&gt; suffix: &lt;code&gt;Twitterbot/1.0&lt;/code&gt;, &lt;code&gt;TelegramBot (like TwitterBot)&lt;/code&gt;, &lt;code&gt;Mozilla/5.0 (compatible; Discordbot/2.0…)&lt;/code&gt;. So the denylist carries &lt;code&gt;telegrambot&lt;/code&gt;, &lt;code&gt;discordbot&lt;/code&gt;, &lt;code&gt;twitterbot&lt;/code&gt; — never the bare names.&lt;/p&gt;

&lt;p&gt;WhatsApp is the exception that proves it needs case-by-case work rather than a rule: its crawler is &lt;code&gt;WhatsApp/2.x&lt;/code&gt; and its in-app browser doesn't carry the token, so &lt;code&gt;whatsapp&lt;/code&gt; stays bare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: the timestamp that updates without a browser
&lt;/h2&gt;

&lt;p&gt;Our first behavioural test for "did a person look at this" was &lt;code&gt;last_active_at &amp;gt; started_at&lt;/code&gt;, with a 1ms floor. It looked reasonable. It was wrong twice over.&lt;/p&gt;

&lt;p&gt;That column gets bumped &lt;strong&gt;with no client JavaScript running at all&lt;/strong&gt; — by a 30-minute identity-reuse path on a second plain HTTP hit, and by a public unauthenticated &lt;code&gt;POST /session-activity&lt;/code&gt; endpoint. And of the sessions it was exonerating, thirteen had a bump under one second. The smallest was &lt;strong&gt;3ms&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A blink is not a reading beat. The floor became explicit:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_PLAUSIBLE_DWELL_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten seconds, and only from a source a viewer's browser actually had to run to produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: the one that inverted
&lt;/h2&gt;

&lt;p&gt;Here is the interesting one.&lt;/p&gt;

&lt;p&gt;A session with zero page-view rows is suspicious — &lt;code&gt;zero-engagement&lt;/code&gt;, weight 60, exactly at the threshold. But there are three separate pieces of evidence that such a session might still be a person:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The close beacon.&lt;/strong&gt; The viewer's browser ran all the way to teardown and reported a plausible reading duration. Our own telemetry, and the only signal carrying a magnitude — time actually spent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The render stamp.&lt;/strong&gt; The PDF finished downloading and PDF.js parsed it. Also our telemetry, and proof the bytes reached the reader — but silent about dwell. It evidences &lt;em&gt;delivery&lt;/em&gt;, not reading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gate email.&lt;/strong&gt; Somebody typed an address into the email gate. A deliberate act no generic crawler performs — but the gate is a public endpoint and the address is attacker-suppliable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each got a sub-threshold weight of 30: enough to pull a session below 60 and back into the counts, not enough to clear anyone on its own.&lt;/p&gt;

&lt;p&gt;Now read the arithmetic. A session that had &lt;strong&gt;both&lt;/strong&gt; a close beacon &lt;strong&gt;and&lt;/strong&gt; a render stamp would collect two flags: 30 + 30 = &lt;strong&gt;60&lt;/strong&gt;. Threshold. The session is marked suspicious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two independent pieces of evidence that a human read the document summed to exactly the score for having no evidence at all.&lt;/strong&gt; More proof of innocence made the session look guiltier — not by a rounding error, but by the structure of the scoring.&lt;/p&gt;

&lt;p&gt;The fix is to treat them as &lt;em&gt;alternative&lt;/em&gt; evidence rather than &lt;em&gt;cumulative&lt;/em&gt; evidence. The classifier emits exactly one flag for a zero-page session, strongest arm first:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageViewCount&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;closedItself&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationSource&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;beacon&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalDurationMs&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MIN_PLAUSIBLE_DWELL_MS&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;closedItself&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;beacon-no-pages&lt;/span&gt;&lt;span class="dl"&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasDocumentRendered&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;render-no-pages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasViewerEmail&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;gate-email-no-pages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zero-engagement&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;Precedence is by evidential strength, and we wrote down why each one ranks where it does: the beacon first because it carries a magnitude; the render stamp second because it proves delivery but says nothing about reading; the gate email last because a public endpoint accepting an attacker-supplied string is the weakest thing on the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The generalisable lesson:&lt;/strong&gt; in any additive risk score, exculpatory signals below the threshold are a trap. If two of them can co-occur, check what they sum to. Ours summed to exactly the number we were trying to stay under.&lt;/p&gt;

&lt;h2&gt;
  
  
  The absence that isn't evidence
&lt;/h2&gt;

&lt;p&gt;One more rule worth stating, because it took a measurement to earn.&lt;/p&gt;

&lt;p&gt;Our session-end beacon was measured &lt;strong&gt;25% lossy.&lt;/strong&gt; So the render stamp's &lt;em&gt;presence&lt;/em&gt; is evidence the document reached the reader — but its &lt;em&gt;absence&lt;/em&gt; is evidence of nothing at all. A quarter of the time it simply never arrives.&lt;/p&gt;

&lt;p&gt;We wrote that into the source next to the field, in those words: it &lt;strong&gt;may exonerate and may never convict.&lt;/strong&gt; Any signal delivered over a best-effort channel has this property, and it is easy to forget once the field exists and looks authoritative in a database column.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does in practice
&lt;/h2&gt;

&lt;p&gt;When we turned this on and backfilled history, &lt;strong&gt;14.31% of 3,046 sessions&lt;/strong&gt; were flagged suspicious and dropped out of the owner-facing numbers.&lt;/p&gt;

&lt;p&gt;We deliberately do not claim a filter accuracy rate, and we're not going to start. We have no labelled ground-truth set — nobody does for this — and any percentage we published would be a number about our own rules, not about reality. What we do instead is &lt;strong&gt;show the owner what was excluded and why&lt;/strong&gt;, so the judgement is auditable rather than trusted.&lt;/p&gt;

&lt;p&gt;That is also the honest limit of the whole approach. The user-agent arm catches what self-identifies. A headless browser that lies about its UA and scrolls pages on a timer is indistinguishable from a reader, and we don't pretend otherwise. What the classifier removes is the enormous, boring, automated majority — the scanners and preview fetchers that never claimed to be people in the first place.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The classifier is ~145 lines and has no database dependency, which is what makes it testable. It's the counting engine behind &lt;a href="https://pdftrackr.com" rel="noopener noreferrer"&gt;PDFTrackr&lt;/a&gt; — document analytics where the view counts have the automated traffic removed and itemised. Happy to answer questions about any of the above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>analytics</category>
    </item>
  </channel>
</rss>
