<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[PDFTrackr Engineering]]></title><description><![CDATA[PDFTrackr Engineering]]></description><link>https://pdftrackr.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>PDFTrackr Engineering</title><link>https://pdftrackr.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 14 Oct 2026 16:06:42 GMT</lastBuildDate><atom:link href="https://pdftrackr.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Adding evidence of innocence made our sessions look more like bots]]></title><description><![CDATA[Originally published on DEV.
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, whet]]></description><link>https://pdftrackr.hashnode.dev/adding-evidence-of-innocence-made-our-sessions-look-more-like-bots</link><guid isPermaLink="true">https://pdftrackr.hashnode.dev/adding-evidence-of-innocence-made-our-sessions-look-more-like-bots</guid><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[analytics]]></category><dc:creator><![CDATA[Oleh Tsyupa]]></dc:creator><pubDate>Fri, 21 Aug 2026 16:21:26 GMT</pubDate><content:encoded><![CDATA[<p>Originally published on <a href="https://dev.to/oleh_tsyupa_bdb40da1bbe8a/adding-evidence-of-innocence-made-our-sessions-look-more-like-bots-16c2">DEV</a>.</p>
<p>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.</p>
<p>The whole product rests on one number being trustworthy: <strong>how many people actually opened this.</strong> 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.</p>
<p>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.</p>
<h2>The shape of the scorer</h2>
<p>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.</p>
<pre><code class="language-properties">ua-denylist              100
no-user-agent             80
zero-engagement           60
sub-second-single-page    40
beacon-no-pages           30
render-no-pages           30
gate-email-no-pages       30
</code></pre>
<p>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.</p>
<h2>Problem 1: a phone brand that matches /bot/</h2>
<p>The obvious user-agent test is a regex of <code>bot|crawl|spider|headless|curl|…</code>. It works until you meet a <strong>CUBOT</strong> — a real Android phone brand whose user agent contains the substring <code>bot</code>. Every CUBOT owner who opened a document got classified as a crawler.</p>
<p>The fix is unglamorous and has to stay narrow:</p>
<pre><code class="language-js">const DEVICE_BRAND_FALSE_POSITIVES = /cubot/gi;
// stripped from the UA before the denylist test
</code></pre>
<p>The rule we wrote next to it matters more than the fix: additions here must be <strong>full brand tokens, never generic words.</strong> The tempting move after being burned once is to soften the denylist. Softening it is how you stop catching crawlers.</p>
<h2>Problem 2: <code>twitterbot</code> is a crawler, <code>Twitter for iPhone</code> is a person</h2>
<p>The naive denylist includes bare app names — <code>twitter</code>, <code>telegram</code>, <code>discord</code>. Those tokens match the <strong>in-app browsers</strong> that recipients actually open share links in. "Twitter for iPhone" is a human holding a phone.</p>
<p>The link-preview crawlers all identify with the <code>-bot</code> suffix: <code>Twitterbot/1.0</code>, <code>TelegramBot (like TwitterBot)</code>, <code>Mozilla/5.0 (compatible; Discordbot/2.0…)</code>. So the denylist carries <code>telegrambot</code>, <code>discordbot</code>, <code>twitterbot</code> — never the bare names.</p>
<p>WhatsApp is the exception that proves it needs case-by-case work rather than a rule: its crawler is <code>WhatsApp/2.x</code> and its in-app browser doesn't carry the token, so <code>whatsapp</code> stays bare.</p>
<h2>Problem 3: the timestamp that updates without a browser</h2>
<p>Our first behavioural test for "did a person look at this" was <code>last_active_at &gt; started_at</code>, with a 1ms floor. It looked reasonable. It was wrong twice over.</p>
<p>That column gets bumped <strong>with no client JavaScript running at all</strong> — by a 30-minute identity-reuse path on a second plain HTTP hit, and by a public unauthenticated <code>POST /session-activity</code> endpoint. And of the sessions it was exonerating, thirteen had a bump under one second. The smallest was <strong>3ms</strong>.</p>
<p>A blink is not a reading beat. The floor became explicit:</p>
<pre><code class="language-js">export const MIN_PLAUSIBLE_DWELL_MS = 10_000;
</code></pre>
<p>Ten seconds, and only from a source a viewer's browser actually had to run to produce.</p>
<h2>Problem 4: the one that inverted</h2>
<p>Here is the interesting one.</p>
<p>A session with zero page-view rows is suspicious — <code>zero-engagement</code>, weight 60, exactly at the threshold. But there are three separate pieces of evidence that such a session might still be a person:</p>
<ol>
<li><p><strong>The close beacon.</strong> 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.</p>
</li>
<li><p><strong>The render stamp.</strong> 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 <em>delivery</em>, not reading.</p>
</li>
<li><p><strong>The gate email.</strong> 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.</p>
</li>
</ol>
<p>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.</p>
<p>Now read the arithmetic. A session that had <strong>both</strong> a close beacon <strong>and</strong> a render stamp would collect two flags: 30 + 30 = <strong>60</strong>. Threshold. The session is marked suspicious.</p>
<p><strong>Two independent pieces of evidence that a human read the document summed to exactly the score for having no evidence at all.</strong> More proof of innocence made the session look guiltier — not by a rounding error, but by the structure of the scoring.</p>
<p>The fix is to treat them as <em>alternative</em> evidence rather than <em>cumulative</em> evidence. The classifier emits exactly one flag for a zero-page session, strongest arm first:</p>
<pre><code class="language-js">if (input.pageViewCount === 0) {
  const closedItself =
    input.durationSource === "beacon" &amp;&amp;
    (input.totalDurationMs ?? 0) &gt;= MIN_PLAUSIBLE_DWELL_MS;
  if (closedItself) return ["beacon-no-pages"];
  if (input.hasDocumentRendered) return ["render-no-pages"];
  return input.hasViewerEmail ? ["gate-email-no-pages"] : ["zero-engagement"];
}
</code></pre>
<p>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.</p>
<p><strong>The generalisable lesson:</strong> 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.</p>
<h2>The absence that isn't evidence</h2>
<p>One more rule worth stating, because it took a measurement to earn.</p>
<p>Our session-end beacon was measured <strong>25% lossy.</strong> So the render stamp's <em>presence</em> is evidence the document reached the reader — but its <em>absence</em> is evidence of nothing at all. A quarter of the time it simply never arrives.</p>
<p>We wrote that into the source next to the field, in those words: it <strong>may exonerate and may never convict.</strong> 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.</p>
<h2>What it does in practice</h2>
<p>When we turned this on and backfilled history, <strong>14.31% of 3,046 sessions</strong> were flagged suspicious and dropped out of the owner-facing numbers.</p>
<p>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 <strong>show the owner what was excluded and why</strong>, so the judgement is auditable rather than trusted.</p>
<p>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.</p>
<hr />
<p><em>The classifier is ~145 lines and has no database dependency, which is what makes it testable. It's the counting engine behind</em> <a href="https://pdftrackr.com"><em>PDFTrackr</em></a> <em>— document analytics where the view counts have the automated traffic removed and itemised. Happy to answer questions about any of the above.</em></p>
]]></content:encoded></item></channel></rss>