<?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: Patrick OBrien</title>
    <description>The latest articles on DEV Community by Patrick OBrien (@pobrien947).</description>
    <link>https://dev.to/pobrien947</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%2F4212903%2F2eabb86b-7608-4c92-b60d-ad5d1d458faf.jpg</url>
      <title>DEV Community: Patrick OBrien</title>
      <link>https://dev.to/pobrien947</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pobrien947"/>
    <language>en</language>
    <item>
      <title>My logs said 749 page views in launch week. The real number was 31.</title>
      <dc:creator>Patrick OBrien</dc:creator>
      <pubDate>Wed, 28 Oct 2026 11:13:25 +0000</pubDate>
      <link>https://dev.to/pobrien947/my-logs-said-749-page-views-in-launch-week-the-real-number-was-31-180j</link>
      <guid>https://dev.to/pobrien947/my-logs-said-749-page-views-in-launch-week-the-real-number-was-31-180j</guid>
      <description>&lt;p&gt;I launched a small SaaS and wanted to know whether anyone was visiting. I don't run a third-party analytics script, so I wrote a report over my CloudFront access logs instead. The first run said &lt;strong&gt;749 page views in four days&lt;/strong&gt;. That felt great for about ten minutes.&lt;/p&gt;

&lt;p&gt;Then I looked at what was behind it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;543 requests from a single "Firefox 135" in Lithuania&lt;/li&gt;
&lt;li&gt;246 hits from an iPhone on iOS 13.2.3, a version from 2019 that scanners love to fake&lt;/li&gt;
&lt;li&gt;probes for &lt;code&gt;/wp-content/plugins/...&lt;/code&gt;, &lt;code&gt;/debug/vars&lt;/code&gt;, &lt;code&gt;/_profiler/phpinfo&lt;/code&gt; and dozens of &lt;code&gt;.../images/images/images/cache.php&lt;/code&gt; paths. My site isn't WordPress or PHP, but it's a single-page app, so every unknown path returned the homepage with a 200 and looked like a page view&lt;/li&gt;
&lt;li&gt;a "visitor from Google" that read every page on the site, one every five seconds&lt;/li&gt;
&lt;li&gt;referrals from spam domains I'd never heard of&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After filtering: &lt;strong&gt;31 page views&lt;/strong&gt;, about 7 of them probably me. Two came from Google search and one from Hacker News.&lt;/p&gt;

&lt;p&gt;Here are the filters, in the order the report applies them. The report is a Python script over CloudFront's JSON log lines; the logs hold no IP addresses, so "a browser" below means the same user agent from the same country on the same day.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Only count pages that exist
&lt;/h2&gt;

&lt;p&gt;On a single-page app, every unknown path can come back as &lt;code&gt;index.html&lt;/code&gt; with a 200. So I check the path against the sitemap, plus the app's own routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;loc&amp;gt;([^&amp;lt;]+)&amp;lt;/loc&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sitemap&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/support&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;PAGE_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^/(dashboard(/.*)?|b/ws_[0-9a-z]+/?)$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PAGES&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;PAGE_PATTERN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;bots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;probe for a non-existent page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fixing the server is better still: my site now returns a real 404 for unknown paths, which also stops search engines from indexing junk URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Only count 200 and 304 responses for HTML
&lt;/h2&gt;

&lt;p&gt;Redirects (http to https, www to apex) aren't page views, and neither are the JavaScript and image requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Drop bots, and browsers that are years out of date
&lt;/h2&gt;

&lt;p&gt;The obvious part is a user-agent regex for bots, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;python&lt;/code&gt;, headless browsers and so on. The less obvious part: real browsers update themselves, but scanners hard-code old versions. Chrome ships every four weeks, so anything before Chrome 140 is over a year old:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;OLD_BROWSER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Chrome/([0-9]|[1-9][0-9]|1[0-3][0-9])\.|Firefox/([0-9]|[1-9][0-9]|10[0-9]|11[0-4])\.|iPhone OS (9|1[0-4])_|Mac OS X 10_(9|1[01])\)&lt;/span&gt;&lt;span class="sh"&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 gotcha that cost me a few days: CloudFront's logs encode spaces in the user agent as &lt;code&gt;%20&lt;/code&gt;. My &lt;code&gt;iPhone OS 13_&lt;/code&gt; pattern never matched &lt;code&gt;iPhone%20OS%2013_&lt;/code&gt;, so the fake iPhone kept counting as a visitor until I decoded the user agent first (&lt;code&gt;unquote()&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Drop anything that reads pages faster than a person
&lt;/h2&gt;

&lt;p&gt;In a single-page app, a person loads one HTML page and then navigates in the browser, so the server sees one HTML request per visit. A browser that fetches 4 HTML pages within a minute, or 20 in a day, is a crawler, whatever its user agent or referrer says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;BURST_PAGES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BURST_SECONDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DAILY_PAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;DAILY_PAGES&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;BURST_PAGES&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="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;BURST_SECONDS&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;BURST_PAGES&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="n"&gt;crawlers&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I started with 8 a day. That dropped a keen reader (and me, testing) as a crawler, so I raised it. Browsers that opened the logged-in dashboard that day are counted separately, so my own visits don't inflate the total.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The best one: did it run the app?
&lt;/h2&gt;

&lt;p&gt;A real browser downloads the app's JavaScript bundle just after the HTML, or already has it cached from an earlier visit. A script that grabs the HTML never does. The bundle has a hashed file name (&lt;code&gt;/assets/index-&amp;lt;hash&amp;gt;.js&lt;/code&gt;), so scanners guessing paths don't hit it either:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;app_js&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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="p"&gt;[])):&lt;/span&gt;
    &lt;span class="n"&gt;bots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTML only, never ran the app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the simplest test of all, and the hardest for a scanner to fake without running a full browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you read your own logs, week one's raw number is mostly noise. Check before you celebrate, or panic.&lt;/li&gt;
&lt;li&gt;Bots aren't traffic, but they're useful: Googlebot crawling my blog and tool pages told me indexing was working long before any person showed up.&lt;/li&gt;
&lt;li&gt;31 real views is humbling, but it's a baseline to improve on. 749 wasn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Has anyone else found their early numbers were mostly bots? What filters do you use?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://getusernote.com/?ref=devto" rel="noopener noreferrer"&gt;UserNote&lt;/a&gt;, a feedback board and public roadmap that visitors can vote on without an account. This report is how I find out whether anyone's visiting it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>analytics</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
