<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Zenovay</title>
    <description>The latest articles on DEV Community by Zenovay (@zenovay).</description>
    <link>https://dev.to/zenovay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3772909%2F8faf186c-8dcf-4cb1-bfcf-c5554bca55c8.webp</url>
      <title>DEV Community: Zenovay</title>
      <link>https://dev.to/zenovay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zenovay"/>
    <language>en</language>
    <item>
      <title>The 25-hour day in your analytics</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sun, 01 Nov 2026 13:55:31 +0000</pubDate>
      <link>https://dev.to/zenovay/the-25-hour-day-in-your-analytics-1cc8</link>
      <guid>https://dev.to/zenovay/the-25-hour-day-in-your-analytics-1cc8</guid>
      <description>&lt;p&gt;Sunday had an extra hour in Switzerland. That matters when you compare daily traffic, not just when you change a clock.&lt;/p&gt;

&lt;p&gt;On 25 October 2026, Swiss clocks moved from 03:00 back to 02:00. The local calendar day lasted 25 elapsed hours. A report for that date in UTC covered a different window. Both can be valid, yet their totals need not match.&lt;/p&gt;

&lt;p&gt;Before calling a daily increase growth, check what the word “day” means in the report.&lt;/p&gt;

&lt;h2&gt;
  
  
  One date can describe two different windows
&lt;/h2&gt;

&lt;p&gt;The date 2026-10-25 is not a complete time range. It needs a time zone and boundaries.&lt;/p&gt;

&lt;p&gt;For a Europe/Zurich business day, start at local midnight and end at the next local midnight. Convert those two instants to UTC. The offset changes inside this particular interval, so its duration is not 24 hours.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Report definition&lt;/th&gt;
&lt;th&gt;Start in UTC, included&lt;/th&gt;
&lt;th&gt;End in UTC, excluded&lt;/th&gt;
&lt;th&gt;Elapsed hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25 October in Europe/Zurich&lt;/td&gt;
&lt;td&gt;2026-10-24 22:00&lt;/td&gt;
&lt;td&gt;2026-10-25 23:00&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25 October in UTC&lt;/td&gt;
&lt;td&gt;2026-10-25 00:00&lt;/td&gt;
&lt;td&gt;2026-10-26 00:00&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A hypothetical event at 2026-10-24T22:30:00Z belongs to 25 October in Zurich and 24 October in UTC. Nothing about the event changed. The reporting boundary did.&lt;/p&gt;

&lt;p&gt;The Swiss change is confirmed in the &lt;a href="https://www.blw.admin.ch/de/newnsb/-2L1HwGjBhUw" rel="noopener noreferrer"&gt;METAS announcement&lt;/a&gt;. The table is a worked example of reporting boundaries, not measured customer traffic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9upqn9c2z10i959qjcoo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9upqn9c2z10i959qjcoo.webp" alt="Three calculated local-day lengths: 23h, 24h and 25h" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Calculate the boundaries before the duration
&lt;/h2&gt;

&lt;p&gt;For a local calendar day, construct midnight on the chosen date and midnight on the following date in the same named time zone. Convert both to UTC, then subtract them to measure elapsed time. Do not replace the second boundary with the first plus 86,400 seconds.&lt;/p&gt;

&lt;p&gt;This Python example uses zoneinfo and the IANA time zone data available on the machine. It needs Python 3.9 or newer and installed time zone data. If that data is unavailable, install the first-party tzdata package rather than substituting a fixed UTC offset. The &lt;a href="https://docs.python.org/3/library/zoneinfo.html" rel="noopener noreferrer"&gt;Python documentation&lt;/a&gt; describes that dependency.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;zoneinfo&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ZoneInfo&lt;/span&gt;

&lt;span class="n"&gt;zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ZoneInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Europe/Zurich&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;utc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;combine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;combine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;start_utc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;end_utc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end_utc&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start_utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;h | &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;start_utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;end_utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;fold&lt;/span&gt; &lt;span class="ow"&gt;in&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&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="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;02:30 fold=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fold&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&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;The output is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-03-29: 23h | 2026-03-28T23:00:00+00:00 to 2026-03-29T22:00:00+00:00
2026-10-24: 24h | 2026-10-23T22:00:00+00:00 to 2026-10-24T22:00:00+00:00
2026-10-25: 25h | 2026-10-24T22:00:00+00:00 to 2026-10-25T23:00:00+00:00
02:30 fold=0: 2026-10-25T00:30:00+00:00
02:30 fold=1: 2026-10-25T01:30:00+00:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtraction is deliberately done after conversion to UTC. Python’s &lt;a href="https://docs.python.org/3/library/datetime.html#datetime-objects" rel="noopener noreferrer"&gt;datetime subtraction rules&lt;/a&gt; use wall-time arithmetic when two aware datetimes share the same tzinfo object. That is not the elapsed-time calculation shown here.&lt;/p&gt;

&lt;p&gt;The two fold values identify different occurrences of 02:30 during the repeated hour. A label containing only the local clock time cannot distinguish them. Preserve the event’s actual instant and show an offset or another unambiguous identifier where repeated local labels matter.&lt;/p&gt;

&lt;p&gt;This is a local calculation. It makes no analytics requests, changes no account settings, and does not demonstrate a timezone switch in a product.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5qk40657ltjngbyj0edn.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5qk40657ltjngbyj0edn.webp" alt="02:30 appears with two different UTC offsets" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep a calendar day separate from a rolling 24 hours
&lt;/h2&gt;

&lt;p&gt;“Yesterday in Zurich” and “the last 24 hours” answer different questions. The first follows calendar boundaries in a named region. The second is an elapsed-duration window ending at an instant.&lt;/p&gt;

&lt;p&gt;A current-day report is another case: it may be partial. Comparing today at 10:00 with all of yesterday adds an exposure difference even when there is no clock change.&lt;/p&gt;

&lt;p&gt;Keep the time zone, start, end and grouping convention with an export or comparison. A display label is not enough. If a chart shows local hours, inspect how it represents the repeated hour instead of assuming that 24 visible labels mean 24 elapsed hours.&lt;/p&gt;

&lt;p&gt;For a business-day analysis in another region, calculate that region’s boundaries. Do not carry Zurich’s offsets into another market or hard-code a one-hour change as a universal rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  More time can produce more events without a higher rate
&lt;/h2&gt;

&lt;p&gt;Suppose a hypothetical system records exactly 10 events per elapsed hour. A 24-hour window contains 240 events; a 25-hour window contains 250. The total is about 4.17 percent higher, while the hourly rate is unchanged.&lt;/p&gt;

&lt;p&gt;That is arithmetic, not a forecast of real traffic. People do not visit at a constant rate, and weekday, campaign and audience mix still matter. Dividing by hours can help investigate event intensity; it does not automatically make two days comparable.&lt;/p&gt;

&lt;p&gt;Nor should every metric be normalized by hours. Unique visitors are not an additive event counter. A conversion rate needs a clearly defined eligible population and observation window, not just an extra division by the day’s length. The &lt;a href="https://zenovay.com/en/blog/conversion-rate-denominator/" rel="noopener noreferrer"&gt;denominator can change the story&lt;/a&gt; even when the displayed percentage looks straightforward.&lt;/p&gt;

&lt;p&gt;Report both the total and the exposure when the distinction affects the decision. Keep hypothetical examples visibly separate from measured results.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvalfszuyo81shtwpfsrr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvalfszuyo81shtwpfsrr.webp" alt="Half-open interval notation includes the start and excludes the end" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the boundary part of the comparison
&lt;/h2&gt;

&lt;p&gt;For a new query, a half-open interval is a useful convention: include events at the start and exclude events at the next boundary. In other words, timestamp &amp;gt;= start AND timestamp &amp;lt; end. Adjacent intervals then share a boundary without counting an event at that boundary twice.&lt;/p&gt;

&lt;p&gt;Do not silently change an existing report’s contract to match this convention. When reconciling exports, check its actual end-time inclusivity, timestamp precision, filters, processing status and counting unit. The same date text does not establish that those rules agree.&lt;/p&gt;

&lt;p&gt;A practical test set includes an ordinary local day, the spring transition, the autumn transition, an event exactly on each boundary, and both occurrences of a repeated local time. Keep the expected UTC instants beside the test.&lt;/p&gt;

&lt;p&gt;The aim is not to force every dashboard onto one time zone. UTC can be the right operational basis; a local calendar day can be the right business basis. Name the one you use, and compare matching windows before interpreting the difference as growth.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>python</category>
      <category>timezone</category>
      <category>measurement</category>
    </item>
    <item>
      <title>A redirect can erase your campaign tags</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Mon, 26 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/zenovay/a-redirect-can-erase-your-campaign-tags-348b</link>
      <guid>https://dev.to/zenovay/a-redirect-can-erase-your-campaign-tags-348b</guid>
      <description>&lt;p&gt;You tag a newsletter link, click it, and arrive on the right page. The page looks fine. Later, the campaign report has less traffic than you expected.&lt;/p&gt;

&lt;p&gt;Before changing the attribution model, check what happened to the URL. A redirect can deliver the visitor to the correct page while leaving the campaign parameters behind. The click happened, but the destination page may never have received the labels attached to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the published link, not just the destination
&lt;/h2&gt;

&lt;p&gt;Start with the exact link a reader receives, including any shortener or email wrapper. Copying the final page URL into a new tab skips the handoffs you need to inspect.&lt;/p&gt;

&lt;p&gt;For example, a campaign link might contain utm_source=newsletter, utm_medium=email, and utm_campaign=autumn. Those values label the visit. They do not prove that the reader is a new person, that a purchase happened, or that the campaign caused one.&lt;/p&gt;

&lt;p&gt;Keep a small record of the journey:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Checkpoint&lt;/th&gt;
&lt;th&gt;What to inspect&lt;/th&gt;
&lt;th&gt;What it establishes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Published link&lt;/td&gt;
&lt;td&gt;The actual address and campaign values&lt;/td&gt;
&lt;td&gt;What the sender intended to pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Each redirect&lt;/td&gt;
&lt;td&gt;The response and its Location target&lt;/td&gt;
&lt;td&gt;What the next request will receive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Destination page&lt;/td&gt;
&lt;td&gt;The URL when the tracker runs&lt;/td&gt;
&lt;td&gt;Which labels are available there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics request&lt;/td&gt;
&lt;td&gt;The permitted request’s campaign fields&lt;/td&gt;
&lt;td&gt;What the browser attempted to send&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Report&lt;/td&gt;
&lt;td&gt;The campaign, time range, filters, and processing delay&lt;/td&gt;
&lt;td&gt;What is visible under that report’s rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A click, a loaded page, an attempted analytics request, and a reported visit are different checkpoints. Checking only the last one makes a missing label much harder to locate.&lt;/p&gt;

&lt;h2&gt;
  
  
  A redirect target does not automatically inherit the query
&lt;/h2&gt;

&lt;p&gt;Consider a request to /start?utm_source=newsletter. If the response redirects to /offer, the target names a new path without those query parameters. Do not assume the original query will be attached for you. Relative-reference resolution treats the path and query separately, as described in &lt;a href="https://www.rfc-editor.org/rfc/rfc3986.html#section-5.2" rel="noopener noreferrer"&gt;RFC 3986, section 5.2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is not a reason to replace every redirect with a different status code. For this GET example, the important difference is the target address. A change from one redirect status to another does not, by itself, add missing campaign values to that address.&lt;/p&gt;

&lt;p&gt;A hostname change, a language route, or a link wrapper is a place to inspect, not proof of a problem. Some handoffs preserve the query correctly. Others intentionally remove particular parameters. Read the actual response rather than diagnosing from the route’s name.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F77hyk4k8f8pi0oepa5zf.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F77hyk4k8f8pi0oepa5zf.webp" alt="Campaign information stops at a URL handoff while the route continues" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Run a small example before changing a live campaign
&lt;/h2&gt;

&lt;p&gt;The following Python example starts a server on your own machine. Both links reach the same offer page. One redirect drops the query; the other forwards only the five standard campaign parameters listed in the code. It makes no request to Zenovay, an email provider, or a customer website.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;http.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPServer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Thread&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;parse_qsl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urlsplit&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlopen&lt;/span&gt;

&lt;span class="n"&gt;ALLOWED&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;utm_source&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;utm_medium&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;utm_campaign&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;utm_term&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;utm_content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Demo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&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;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/drop&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;/keep&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_qsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_blank_values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ALLOWED&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/offer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/keep&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Length&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;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Length&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;5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Offer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;_args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;Demo&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;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;campaign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utm_source=newsletter&amp;amp;utm_medium=email&amp;amp;utm_campaign=autumn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;drop&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;keep&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server_port&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&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;as&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;geturl&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(empty)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;drop: (empty)
keep: utm_source=newsletter&amp;amp;utm_medium=email&amp;amp;utm_campaign=autumn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both pages are reachable. Only one final URL contains the campaign labels. This is a URL-retention demonstration, not a simulation of an analytics report or an attribution model.&lt;/p&gt;

&lt;p&gt;The allowlist is deliberate. Do not solve campaign loss by forwarding every incoming parameter to every destination. Links can contain tokens, personal information, or values that belong only to the original endpoint. Decide which fields should cross each boundary and encode them correctly. The example keeps a fixed local destination; it is not a general redirect service.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5nxzun6ved2jdzu533nl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5nxzun6ved2jdzu533nl.webp" alt="Campaign traces stay together through a change in direction" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Check when the tracker sees the URL
&lt;/h2&gt;

&lt;p&gt;A server redirect happens before the destination page’s script can run. Capturing parameters early on that page cannot recover labels that never reached it.&lt;/p&gt;

&lt;p&gt;There is a separate timing question after the page loads. A router or another script might clean the address before analytics reads it. Test the order in your own setup instead of treating a clean address bar as proof that nothing was captured.&lt;/p&gt;

&lt;p&gt;Zenovay’s browser tracker captures the five standard UTM fields from the page URL when the script loads. That gives it an early view of the address it receives. It is not a promise to reconstruct an earlier redirect URL or identify every campaign when the input is missing.&lt;/p&gt;

&lt;p&gt;Use your browser’s Network panel to preserve the navigation log. Follow the redirect chain, inspect the destination URL, and then inspect the analytics request if your test setup permits one. Seeing fields in a request proves an attempt to send them, not successful ingestion or appearance in a report. Check those later stages separately.&lt;/p&gt;

&lt;p&gt;Test the link as published on both a fresh visit and a normal returning visit. Existing session state can otherwise make a broken handoff look healthy. Do not weaken privacy settings or add personal information to URLs to make attribution look more complete.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsurfcq7j2qthgz1w0g01.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsurfcq7j2qthgz1w0g01.webp" alt="Three small checkpoints mark one continuous link journey" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Retest the whole path, then interpret the report
&lt;/h2&gt;

&lt;p&gt;After a change, repeat the original link journey. Confirm the approved campaign fields survive every intended handoff and reach the relevant analytics request. Record the test time and allow for the report’s normal processing and filtering rules.&lt;/p&gt;

&lt;p&gt;Use the same campaign spelling and a time range that includes the test. Do not use a report total as a substitute for inspecting one known request. Conversely, do not treat one successful test as proof that every email client, device, or route behaves the same way.&lt;/p&gt;

&lt;p&gt;If labels still disappear, keep the uncertainty specific: missing from the destination URL, present in the browser request but not yet verified downstream, or visible under a different report filter. Those findings lead to different next steps.&lt;/p&gt;

&lt;p&gt;Once the input survives the journey, there is still a modelling question. The &lt;a href="https://zenovay.com/en/blog/same-sale-five-attribution-models/" rel="noopener noreferrer"&gt;same sale can receive different credit under different attribution models&lt;/a&gt;. Preserving campaign tags improves the evidence available to that model. It does not turn a campaign label into proof of causation.&lt;/p&gt;

&lt;p&gt;The useful test is simple: follow the link someone will actually click, and check what survives each handoff. A page that loads correctly is only part of a working campaign link.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>webdev</category>
      <category>marketing</category>
      <category>debugging</category>
    </item>
    <item>
      <title>A green uptime check does not mean checkout works</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Mon, 19 Oct 2026 16:00:00 +0000</pubDate>
      <link>https://dev.to/zenovay/a-green-uptime-check-does-not-mean-checkout-works-30hh</link>
      <guid>https://dev.to/zenovay/a-green-uptime-check-does-not-mean-checkout-works-30hh</guid>
      <description>&lt;p&gt;A homepage returns 200. The uptime monitor receives the response it expects. A shopper opens checkout and cannot finish the order. All three observations can be true at the same time.&lt;/p&gt;

&lt;p&gt;A useful monitor has a clear contract: what it requests, what it checks, and which conclusion its result supports. The trouble starts when “the page responded” quietly becomes “customers can buy.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Say what the green check actually proves
&lt;/h2&gt;

&lt;p&gt;HTTP success is specific to a request. For a GET request, a 200 response describes the successful retrieval of a representation of the resource. It does not report the result of a later action that the request never performed. That distinction follows from the &lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#name-200-ok" rel="noopener noreferrer"&gt;HTTP semantics in RFC 9110&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A homepage request does not submit a checkout form. It does not establish that a browser loaded a payment widget, that a customer could authenticate, or that an order reached the system of record.&lt;/p&gt;

&lt;p&gt;Different checks provide different evidence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;What it can establish&lt;/th&gt;
&lt;th&gt;What still needs checking&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP availability&lt;/td&gt;
&lt;td&gt;The requested URL returned the configured status within the check’s limits&lt;/td&gt;
&lt;td&gt;Whether the user journey works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response content&lt;/td&gt;
&lt;td&gt;The returned body contains an expected marker&lt;/td&gt;
&lt;td&gt;Whether scripts and interactions succeed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser journey&lt;/td&gt;
&lt;td&gt;The tested flow reaches a defined result in that environment&lt;/td&gt;
&lt;td&gt;Whether real production outcomes agree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production outcome&lt;/td&gt;
&lt;td&gt;A real outcome was recorded by its authoritative system&lt;/td&gt;
&lt;td&gt;Whether other users can complete the flow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In Zenovay, uptime configuration supports an expected HTTP status and an optional keyword check on the returned response body. A keyword adds a content assertion to the request check. It does not execute the page’s JavaScript or complete a browser journey.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbwzjcyka39r0cii4y3fj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbwzjcyka39r0cii4y3fj.webp" alt="A small request check covers only part of a longer journey" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A status and a keyword can both pass while checkout is unavailable
&lt;/h2&gt;

&lt;p&gt;A broad keyword can be present in an error page. “Checkout” might appear in a heading, navigation, or an unavailable message. Finding that word does not establish that checkout is ready.&lt;/p&gt;

&lt;p&gt;Here is a deliberately simplified local example. It makes one request to a temporary server on your own machine and calls no real checkout or payment service:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;http.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPServer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Thread&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlopen&lt;/span&gt;

&lt;span class="n"&gt;PAGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;h1&amp;gt;Checkout&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Payment service unavailable&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Demo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&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;text/html; charset=utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&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;PAGE&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PAGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;_args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;Demo&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;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handle_request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server_port&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/checkout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&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;as&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keyword_found:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Checkout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked_message:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Payment service unavailable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status: 200
keyword_found: True
blocked_message: True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status check passes. A keyword check for “Checkout” passes. The returned page still says the payment service is unavailable. This demonstrates the scope of those assertions, not a recommended way to encode an application failure.&lt;/p&gt;

&lt;p&gt;Choose a stable marker that appears only in the intended state. Avoid text shared by the normal page and its fallback. Keep the response status and body consistent with the endpoint’s contract. Even a good content assertion remains evidence about that response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the browser path to a meaningful result
&lt;/h2&gt;

&lt;p&gt;For an important journey, write down the smallest useful sequence: open the page, select the intended option, submit the form, and verify the result that matters.&lt;/p&gt;

&lt;p&gt;An enabled button or a click event is usually too early. A success banner can also be too early if the operation finishes asynchronously. Where appropriate, confirm the resulting order, installation, or other state in the system that owns it.&lt;/p&gt;

&lt;p&gt;Use isolated test accounts and a sandbox for synthetic purchase checks. Design repeatable inputs and cleanup so the test does not create real charges, pollute customer metrics, or consume limited inventory. A sandbox result describes that environment; it does not certify the entire production payment path.&lt;/p&gt;

&lt;p&gt;Separate browser testing complements a request monitor. It is a different check with its own environment, assertions, and maintenance needs. It should not be implied by the uptime badge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5mrrf4lucx3dyt02x5k7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5mrrf4lucx3dyt02x5k7.webp" alt="A user journey pauses before its final outcome" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep production outcomes alongside synthetic checks
&lt;/h2&gt;

&lt;p&gt;A synthetic check follows the path you chose. Real users bring other devices, accounts, networks, and inputs. Look at production outcomes as another source of evidence, with their own coverage limits.&lt;/p&gt;

&lt;p&gt;For checkout, an event named “purchase” should have a precise meaning. A click on Pay and a confirmed payment are different milestones. Reconcile the result with the authoritative order or payment state rather than assuming the browser event proves completion.&lt;/p&gt;

&lt;p&gt;A sudden drop in completed orders is worth investigating, but it is not an outage diagnosis on its own. Check eligible traffic, the observation period, tracking coverage, and whether people had enough time to finish. The same care applies to a &lt;a href="https://zenovay.com/en/blog/conversion-rate-denominator/" rel="noopener noreferrer"&gt;conversion rate that changes with the audience mix&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Availability, browser behavior, and completed outcomes can disagree without any one of the measurements being fabricated. The useful response is to investigate the gap between their scopes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1x8p1panu4s7liphfx8u.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1x8p1panu4s7liphfx8u.webp" alt="Availability and completed outcomes are separate signals" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Write an alert someone can act on
&lt;/h2&gt;

&lt;p&gt;An alert should identify the failed assertion. “Homepage returned an unexpected status” and “sandbox checkout did not reach confirmation” send the responder to different places.&lt;/p&gt;

&lt;p&gt;Include the checked URL or flow, the environment, the failed step, the observation time, and enough request or browser evidence to reproduce the result. Keep credentials and customer data out of alerts and screenshots.&lt;/p&gt;

&lt;p&gt;Choose the check frequency and retry policy around the failure you need to catch. A scheduled check observes specific moments; a green result between checks does not describe every request in the interval. Repeated failures can reduce noise, while also delaying detection. Make that tradeoff explicit.&lt;/p&gt;

&lt;p&gt;Before calling the service recovered, repeat the assertion that failed. A returning homepage response does not verify that a checkout failure has cleared. Recovery means the relevant check passes again, with production outcomes used to confirm the wider picture.&lt;/p&gt;

&lt;p&gt;A green indicator is useful when everyone knows what earned it. Name that evidence precisely, and the monitor becomes easier to trust and easier to act on.&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>uptime</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>When conversion rates rise overall but fall in every segment</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Fri, 09 Oct 2026 19:59:33 +0000</pubDate>
      <link>https://dev.to/zenovay/when-conversion-rates-rise-overall-but-fall-in-every-segment-4lco</link>
      <guid>https://dev.to/zenovay/when-conversion-rates-rise-overall-but-fall-in-every-segment-4lco</guid>
      <description>&lt;p&gt;Both acquisition groups convert less often. The overall conversion rate more than triples. There is no missing event and no arithmetic error.&lt;/p&gt;

&lt;p&gt;Here is a hypothetical funnel with two mutually exclusive groups, Email and Search. Use the same rule to assign each recorded entry to one group in both periods. These are illustrative counts, not Zenovay customer results or channel benchmarks.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Recorded entries&lt;/th&gt;
&lt;th&gt;Completions&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Search&lt;/td&gt;
&lt;td&gt;1,900&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;18%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Search&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;1.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;195&lt;/td&gt;
&lt;td&gt;9.75%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Email falls from 20 to 18 percent. Search falls from 2 to 1.5 percent. Yet the overall rate rises from 2.9 to 9.75 percent, with the same total number of entries.&lt;/p&gt;

&lt;p&gt;The difference is the audience mix: Email goes from 5 percent of entries to 50 percent. Its rate is lower than before, but it still converts more often than Search. Moving more of the traffic into that group changes the aggregate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsgcn8x73c662sei9fwyz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsgcn8x73c662sei9fwyz.webp" alt="Different entry populations converge on a measured outcome" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The overall rate is answering a different question
&lt;/h2&gt;

&lt;p&gt;This is an example of Simpson’s paradox: the direction of an aggregate comparison can reverse within every subgroup. It is a known statistical effect, not a new discovery or a defect in the calculation.&lt;/p&gt;

&lt;p&gt;The overall rate asks what share of this period’s recorded entries completed the funnel. The group rates ask what happened within Email and within Search. Those populations changed their relative size, so the aggregate is not holding the audience mix constant.&lt;/p&gt;

&lt;p&gt;The second period does produce more completions: 195 instead of 58. That may be a valuable business result. But it does not show that the flow became easier for either group. The observed rate fell in both.&lt;/p&gt;

&lt;p&gt;Before crediting a redesign, ask which result the claim is about: more total outcomes, a different audience, or better conversion within a comparable audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hold the audience mix fixed
&lt;/h2&gt;

&lt;p&gt;One useful descriptive check is to choose a reference mix and apply it to both periods’ group rates. Here, use period A’s mix: 5 percent Email and 95 percent Search.&lt;/p&gt;

&lt;p&gt;For period B, the calculation is 0.05 × 18% + 0.95 × 1.5% = 2.325%.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;Observed overall rate&lt;/th&gt;
&lt;th&gt;Rate with period A’s mix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;9.75%&lt;/td&gt;
&lt;td&gt;2.325%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With the mix held fixed, the rate falls. This separates the changing group weights from the changing rates within those groups.&lt;/p&gt;

&lt;p&gt;Choose and document the reference population before judging the result. Use the same mutually exclusive groups in both periods. If a group has no observed entries in one period, its rate is undefined; do not silently turn that missing rate into zero.&lt;/p&gt;

&lt;p&gt;Here is the calculation as a self-contained PostgreSQL query. It uses only the four hypothetical rows above:&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;WITH&lt;/span&gt; &lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;)&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;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'A'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Email'&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;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'A'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Search'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;38&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'B'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'B'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Search'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;rates&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="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;entries&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;sample&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;baseline&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;segment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&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;weight&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sample&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'A'&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;period&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;SUM&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="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;SUM&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="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;3&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;observed_pct&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;SUM&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="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;3&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;fixed_mix_pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;rates&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt; &lt;span class="n"&gt;b&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;segment&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;period&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reweighting answers a comparison question. It does not establish what a redesign caused, or predict exactly how a future audience will behave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step rates have their own denominator
&lt;/h2&gt;

&lt;p&gt;The same care applies within a funnel. Suppose 1,000 recorded entries produce 400 signups and 100 final completions. Overall completion is 10 percent. Signup to completion is 25 percent. Both calculations are correct because their denominators differ.&lt;/p&gt;

&lt;p&gt;In Zenovay, the overall funnel rate compares recorded completions with recorded entries. For transitions after the first step, the step breakdown uses the preceding step for its conversion calculation. Read each rate with its counts and its scope.&lt;/p&gt;

&lt;p&gt;People, sessions, funnel entries, and event occurrences are different counting units. Someone can return several times or repeat an action. A recorded entry is not a verified individual. Keep the unit explicit when comparing reports.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flrllxytwdpxjqdj1srkl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flrllxytwdpxjqdj1srkl.webp" alt="One path with distinct scopes for a step and the complete journey" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the definitions and observation window fixed
&lt;/h2&gt;

&lt;p&gt;A smaller denominator can also raise a rate without adding a single completion: 100 out of 1,000 is 10 percent; 100 out of 500 is 20 percent. Moving the entry point closer to checkout changes the measurement, even if the report keeps the same name.&lt;/p&gt;

&lt;p&gt;Use the same entry rule, final event, counting unit, and group definitions. Record changes to URL rules or funnel steps and establish a new baseline when the meaning of the measure changes.&lt;/p&gt;

&lt;p&gt;Time matters too. Yesterday’s entrants have had less opportunity to finish a long journey than entrants from two weeks ago. For a cohort comparison, choose an entry period and a fixed follow-up window, then let both groups reach it. A calendar report of events and a cohort report following entrants are different views.&lt;/p&gt;

&lt;p&gt;That is a method for comparing cohorts, not a claim that every funnel report provides that control.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F93ztb56c03mxz8mhflrz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F93ztb56c03mxz8mhflrz.webp" alt="Equal observation windows beginning at different entry points" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this calculation can and cannot tell you
&lt;/h2&gt;

&lt;p&gt;The table demonstrates an aggregation reversal. It does not demonstrate why either group’s rate changed. Observed rates can also move through sampling variation, especially when the counts are small.&lt;/p&gt;

&lt;p&gt;Predefine the segments that matter to the decision. Show entries and completions alongside percentages. For a claim that a product change caused improvement, use a suitable experiment or another defensible causal design; a segmented before-and-after report alone does not supply that evidence.&lt;/p&gt;

&lt;p&gt;A conclusion someone else can check would be: “Completions rose from 58 to 195 across 2,000 recorded entries in each period. Both group rates fell, while Email’s share of entries rose from 5 to 50 percent. Holding period A’s mix fixed gives 2.325 percent for period B, compared with 2.9 percent before.”&lt;/p&gt;

&lt;p&gt;That is more useful than “conversion improved.” It tells the team what changed, which comparison improved, and which question still needs an answer.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>funnels</category>
      <category>conversion</category>
      <category>measurement</category>
    </item>
    <item>
      <title>The 19-year visit that wasn't real</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Wed, 30 Sep 2026 17:42:38 +0000</pubDate>
      <link>https://dev.to/zenovay/the-19-year-visit-that-wasnt-real-2cmc</link>
      <guid>https://dev.to/zenovay/the-19-year-visit-that-wasnt-real-2cmc</guid>
      <description>&lt;p&gt;One record in our analytics said a visitor had spent 619,637,320 seconds on a page. That is about 19.6 years. The number came from an automated scanner, not a remarkably patient reader.&lt;/p&gt;

&lt;p&gt;That scanner-generated duration was not evidence of a visit, but it could distort an average-time view. It shows why a number can be valid input without being a useful measurement.&lt;/p&gt;

&lt;p&gt;A browser timer is a defined measurement, not a direct reading of human attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  The outlier was data, but it was not evidence of attention
&lt;/h2&gt;

&lt;p&gt;A tracker has to accept information from a visitor's browser. That browser is outside the analytics server's control. A normal page script can report a duration, but a test client can send a number that no real visit could have produced.&lt;/p&gt;

&lt;p&gt;The scanner request reported the 19.6-year value in the &lt;code&gt;time_on_page&lt;/code&gt; field. Before a browser-reported duration can inform an average, it needs a plausibility boundary and an outlier policy. Otherwise, the average describes the data received, not how long people spent reading.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffoj6fzxo6n0rk4p73wsl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffoj6fzxo6n0rk4p73wsl.webp" alt="One impossible duration rises from a quiet trace" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The size of the number made this case obvious. Smaller errors are harder to notice. A five-hour "visit" might look plausible in a report even when it came from an abandoned tab, a stale clock, or a malformed client. That is why the boundary matters even if nobody ever sees another value measured in years.&lt;/p&gt;

&lt;h2&gt;
  
  
  An open tab is not an active session
&lt;/h2&gt;

&lt;p&gt;Elapsed time is simple to calculate: subtract the start timestamp from the current timestamp. It is not the same as time spent looking at a page. Someone can open a tab, switch to another app, have lunch, and return. A wall-clock timer would happily count all of it.&lt;/p&gt;

&lt;p&gt;Zenovay's current browser tracker treats that gap differently. Its heartbeat checks whether the document is visible and the window has focus. It does not accrue time while the page is hidden or unfocused. While visible, it also stops adding time after five minutes without input. When a hidden tab becomes visible again, it resets the timing reference so the time away is not added in one jump.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F720h1gatdlznwrsb2dhu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F720h1gatdlznwrsb2dhu.webp" alt="One time trace continues while the active trace pauses" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These are implementation choices, not a claim that we can measure attention perfectly. A person might read a long article without touching the mouse. Another might interact with a page without absorbing anything. The tracker can distinguish some browser states; it cannot see intent or comprehension.&lt;/p&gt;

&lt;p&gt;There is also a naming trap. In Zenovay today, &lt;code&gt;time_on_page&lt;/code&gt; can represent accumulated active session time, not a clean per-page stopwatch. The label alone should not be treated as the definition of the metric.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put a bound where browser data enters
&lt;/h2&gt;

&lt;p&gt;Zenovay now bounds client-reported durations when they arrive. Values above four hours are capped, with an over-limit signal retained separately for investigation. This prevents a multi-year duration from being stored as such, but the cap alone does not make every average meaningful.&lt;/p&gt;

&lt;p&gt;An otherwise valid pageview is not rejected solely because its reported duration is implausible. The timing value still needs careful interpretation: a cap is a guardrail, not evidence of genuine attention.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2y0rhihgnu1546012ack.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2y0rhihgnu1546012ack.webp" alt="A sharp outlier settles into a calm bounded trace" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four hours is a defensive ceiling, not our definition of a normal visit. Even a bounded outlier can skew an average, and a new boundary does not revise past reports. Any duration summary still needs an explicit definition and an outlier check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask what the metric is for
&lt;/h2&gt;

&lt;p&gt;Average time can be a useful clue. If a page's value changes sharply, it is worth checking the underlying events, outliers, traffic sources, and the exact definition of the timer. But a higher average is not automatically better. It can mean sustained reading, a confusing workflow, or simply a measurement problem.&lt;/p&gt;

&lt;p&gt;For a product page, the better question may be whether visitors reached the next step. For documentation, it may be whether they found an answer. For an analytics setup flow, it may be whether a real event arrived from the intended website. Duration can add context to those outcomes; it cannot replace them.&lt;/p&gt;

&lt;p&gt;The practical lesson is to define what the browser records, bound what a client can report, and inspect unusual values before treating an average as a story about people.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>measurement</category>
      <category>engagement</category>
      <category>engineering</category>
    </item>
    <item>
      <title>A signup is not activation</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Fri, 18 Sep 2026 18:29:48 +0000</pubDate>
      <link>https://dev.to/zenovay/a-signup-is-not-activation-327l</link>
      <guid>https://dev.to/zenovay/a-signup-is-not-activation-327l</guid>
      <description>&lt;p&gt;A new account is an encouraging notification. It is also a very small piece of evidence.&lt;/p&gt;

&lt;p&gt;For an analytics product, someone can create an account, add a website, copy a script, and still never see a visit from their own site. Counting the first step as success hides all the work between signing up and getting something useful.&lt;/p&gt;

&lt;p&gt;Our July activation audit started with that gap. The working notes suggested that roughly half of the nominal free accounts had not created a website. But those notes also marked parts of the baseline as inferred and mixed internal, test, and external accounts. That is enough to start investigating. It is not enough to publish a precise conversion rate.&lt;/p&gt;

&lt;p&gt;This teardown is about what we can establish: the stages we need to separate, the installation checks the current code performs, and the evidence needed before calling an onboarding change an improvement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three steps that should never share one label
&lt;/h2&gt;

&lt;p&gt;Account creation tells us that someone completed registration. Website creation tells us that they configured an object in the product. A first recorded event tells us that data reached that website’s analytics pipeline.&lt;/p&gt;

&lt;p&gt;Those are different milestones. None, on its own, proves that the person understood the report or will return next week.&lt;/p&gt;

&lt;p&gt;For this audit, the first useful technical checkpoint is a real event recorded for the intended website. That is narrower than product activation, but much easier to verify than a vague label such as “setup complete.”&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh8fmkjnnhletzm5sz0j9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh8fmkjnnhletzm5sz0j9.webp" alt="Three separate milestones on a path" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Someone with no website needs help with the setup decision. Someone with a website and no events needs an installation diagnosis. Someone with events who never returns may have a different problem: the report did not answer a question worth coming back for.&lt;/p&gt;

&lt;p&gt;Sending all three people the same reminder assumes the diagnosis before making it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the implementation, not just the score
&lt;/h2&gt;

&lt;p&gt;Zenovay’s current activation scoring function evaluates four milestones: a script installation flag, a first event within 24 hours of website creation, a Stripe connection within seven days of team creation, and a return login after the first day and within fourteen days of team creation. Each achieved milestone contributes 25 points.&lt;/p&gt;

&lt;p&gt;The function counts achieved milestones independently. It does not require someone to complete a strict sequence before later milestones can count. That detail matters when interpreting a score: two accounts can reach the same total through different behavior.&lt;/p&gt;

&lt;p&gt;A first event arriving after 24 hours also fails the time-bounded milestone even though tracking now works. “Missed the activation window” and “installation is broken” need different responses.&lt;/p&gt;

&lt;p&gt;We previously wrote about &lt;a href="https://zenovay.com/en/blog/a-boolean-we-never-set-to-true/" rel="noopener noreferrer"&gt;an installation flag that did not reflect the underlying activity&lt;/a&gt;. The broader lesson still applies: a score summarizes its inputs. It cannot tell you whether those inputs are trustworthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  An empty chart has several possible causes
&lt;/h2&gt;

&lt;p&gt;The current tracking route checks the request origin against the website’s allowed domains. If no explicit list exists, it falls back to the registered domain. A mismatched origin can be rejected in production when domain verification is enabled.&lt;/p&gt;

&lt;p&gt;There is also an explicit localhost exception controlled by the website’s &lt;code&gt;allow_localhost&lt;/code&gt; setting. That exception belongs to the domain check. It is not a promise that every local test will produce a stored event: other checks still apply.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm4j5untelxn6jcus2ycl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm4j5untelxn6jcus2ycl.webp" alt="A thin path pauses before an open boundary" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is why “I pasted the script” is the beginning of verification, not the end. Check the actual page hostname, the configured website, the browser’s network response, and whether an event was recorded. Use a real browser visit for the user journey you are testing.&lt;/p&gt;

&lt;p&gt;Do not weaken a security check simply to make a setup indicator turn green. First find the rejected request and understand why it was rejected. The useful repair may be the configuration, the installation instructions, or the error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count people and websites separately
&lt;/h2&gt;

&lt;p&gt;Subtracting the number of websites from the number of signups does not give the number of people who abandoned onboarding.&lt;/p&gt;

&lt;p&gt;One person can create several websites. A colleague can join an existing team without creating any. Test accounts can distort a small sample.&lt;/p&gt;

&lt;p&gt;A useful cohort report starts with a defined set of eligible accounts or teams and follows that same set through the stages. Choose the unit, exclusions, and observation window before computing the percentage.&lt;/p&gt;

&lt;p&gt;For example, a report of new team owners could ask whether each owner created at least one website and whether any of those websites received a qualifying first event within a fixed window. Count each owner once. Report the underlying counts as well as the percentage. Keep invitations and internal testing separate.&lt;/p&gt;

&lt;p&gt;That is a proposed measurement contract, not a claim that the historical July estimate already followed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would count as an improvement?
&lt;/h2&gt;

&lt;p&gt;Before comparing two onboarding versions, give both cohorts the same amount of time to reach the checkpoint. A cohort from yesterday cannot fairly be compared with one that has had two weeks to finish setup.&lt;/p&gt;

&lt;p&gt;Track the missing step, not just the final total. Did more eligible owners create a website? Did more of those websites receive an event? Did people return after they had data? Record which changes shipped between cohorts, and keep acquisition sources in view.&lt;/p&gt;

&lt;p&gt;We are not claiming a measured uplift from this teardown. A source review can show how a check works and identify a plausible obstacle. It cannot establish why every person left, or prove that removing a step caused more people to stay.&lt;/p&gt;

&lt;p&gt;The next time a signup notification arrives, the useful follow-up is simple: did that person get data from their own website, and did it help them do anything?&lt;/p&gt;

&lt;p&gt;Until we can answer that, the account count is only the start of the story.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>onboarding</category>
      <category>activation</category>
      <category>product</category>
    </item>
    <item>
      <title>The Same $100 Sale Looks Different Under Every Attribution Model</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Mon, 14 Sep 2026 16:00:00 +0000</pubDate>
      <link>https://dev.to/zenovay/the-same-100-sale-looks-different-under-every-attribution-model-2cl7</link>
      <guid>https://dev.to/zenovay/the-same-100-sale-looks-different-under-every-attribution-model-2cl7</guid>
      <description>&lt;p&gt;A customer discovers you through organic search. Two weeks later, they click a social post. Then they return through a paid search ad, open an email, and finally type your address into the browser before buying.&lt;/p&gt;

&lt;p&gt;One journey. One $100 sale. Five channels touched it.&lt;/p&gt;

&lt;p&gt;Now ask a simple question: which channel generated the revenue?&lt;/p&gt;

&lt;p&gt;There is no neutral answer hiding in the data. There is only a rule for assigning credit. Change the rule, and the same sale tells a different story.&lt;/p&gt;

&lt;p&gt;That is the part attribution reports often make too easy to forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  One journey, five touchpoints
&lt;/h2&gt;

&lt;p&gt;For a concrete example, use this path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Organic Search, 28 days before the purchase&lt;/li&gt;
&lt;li&gt;Organic Social, 14 days before the purchase&lt;/li&gt;
&lt;li&gt;Paid Search, 7 days before the purchase&lt;/li&gt;
&lt;li&gt;Email, 2 days before the purchase&lt;/li&gt;
&lt;li&gt;Direct, at the moment of conversion&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxjd5s4e0mhrpd3ve2o1j.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxjd5s4e0mhrpd3ve2o1j.webp" alt="Five touchpoints lead to one conversion" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The order and timing stay fixed. The purchase value stays $100. Only the attribution model changes.&lt;/p&gt;

&lt;p&gt;Zenovay currently supports five models in the revenue view: last touch, first touch, linear, position based, and time decay. We ran this example through the same allocation function used by the product.&lt;/p&gt;

&lt;p&gt;Here is what each model reports.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Organic Search&lt;/th&gt;
&lt;th&gt;Organic Social&lt;/th&gt;
&lt;th&gt;Paid Search&lt;/th&gt;
&lt;th&gt;Email&lt;/th&gt;
&lt;th&gt;Direct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Last touch&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First touch&lt;/td&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linear&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Position based&lt;/td&gt;
&lt;td&gt;$40&lt;/td&gt;
&lt;td&gt;$6.67&lt;/td&gt;
&lt;td&gt;$6.67&lt;/td&gt;
&lt;td&gt;$6.66&lt;/td&gt;
&lt;td&gt;$40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time decay&lt;/td&gt;
&lt;td&gt;$2.37&lt;/td&gt;
&lt;td&gt;$9.50&lt;/td&gt;
&lt;td&gt;$18.99&lt;/td&gt;
&lt;td&gt;$31.16&lt;/td&gt;
&lt;td&gt;$37.98&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these rows changes the sale. Each row answers a different question about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Last touch asks who closed
&lt;/h2&gt;

&lt;p&gt;Last touch gives all credit to the final known channel. In this journey, Direct receives the full $100.&lt;/p&gt;

&lt;p&gt;That can be useful when the question is operational: what brought the customer back at the moment they bought? It is also simple to explain and works when only the current visit is available.&lt;/p&gt;

&lt;p&gt;But it can make discovery disappear. Organic Search introduced the product. Social, paid search, and email kept the journey moving. The report still gives them zero.&lt;/p&gt;

&lt;p&gt;There is another wrinkle with Direct. A direct visit is often real, but it does not necessarily mean the customer arrived without prior influence. They may have remembered the brand after seeing the earlier touches. Last touch cannot express that history when it assigns everything to the final visit.&lt;/p&gt;

&lt;h2&gt;
  
  
  First touch asks who introduced
&lt;/h2&gt;

&lt;p&gt;First touch reverses the story. Organic Search receives the full $100 because it was the first known channel in the journey.&lt;/p&gt;

&lt;p&gt;This view is useful for discovery. It helps answer which channels bring new people into the measurable path. It can be particularly helpful when evaluating content, communities, or campaigns whose main job is creating awareness.&lt;/p&gt;

&lt;p&gt;Its blind spot is the close. An early visit may have introduced the customer, but a later email or paid campaign may have done the work that converted interest into action. First touch cannot share that credit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Linear refuses to choose
&lt;/h2&gt;

&lt;p&gt;Linear attribution divides the sale evenly across the five distinct channels. Each receives $20.&lt;/p&gt;

&lt;p&gt;This is the calmest model in the set. It acknowledges every measured touch and avoids claiming that one position is always more important than another.&lt;/p&gt;

&lt;p&gt;That fairness is also its assumption. A quick social click receives the same credit as the email opened two days before purchase. Equal credit is not the absence of a judgment. It is a judgment that all measured touches deserve the same weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Position based rewards the beginning and the end
&lt;/h2&gt;

&lt;p&gt;Zenovay's position based model gives 40 percent to the first channel and 40 percent to the last. The remaining 20 percent is split across the channels in the middle.&lt;/p&gt;

&lt;p&gt;In our example, Organic Search and Direct receive $40 each. Organic Social, Paid Search, and Email share the remaining $20.&lt;/p&gt;

&lt;p&gt;This model fits a common marketing intuition: discovery and conversion matter most, while the middle assists. It preserves both ends of the journey without ignoring everything between them.&lt;/p&gt;

&lt;p&gt;But 40, 20, and 40 are still chosen weights. They are not facts observed in the visitor's mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time decay rewards recency
&lt;/h2&gt;

&lt;p&gt;Time decay gives more credit to touches closer to the conversion. Zenovay uses a seven day half life. A touch seven days before the sale receives half the raw weight of a touch at conversion. A touch 14 days before receives one quarter. A touch 28 days before receives one sixteenth.&lt;/p&gt;

&lt;p&gt;After normalizing those weights across this journey, Direct receives $37.98, Email $31.16, Paid Search $18.99, Organic Social $9.50, and Organic Search $2.37.&lt;/p&gt;

&lt;p&gt;This is useful when recent touches are more likely to influence a decision. It also avoids the hard cliff of last touch, where every earlier interaction instantly becomes worth zero.&lt;/p&gt;

&lt;p&gt;The model still embeds a belief: recent interactions deserve more credit, and seven days is the right pace of decay. That may fit one buying cycle and misrepresent another.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyf1l0tcvuyqxc10lixxi.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyf1l0tcvuyqxc10lixxi.webp" alt="One sale produces five different allocation patterns" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The model should follow the question
&lt;/h2&gt;

&lt;p&gt;Teams often ask which model is correct. A better question is what decision the report needs to support.&lt;/p&gt;

&lt;p&gt;Use first touch when you are studying discovery. Use last touch when you are studying the final return or close. Use linear when you want a neutral view of all measured participants. Use position based when the first and last touches are strategically important. Use time decay when recency should matter.&lt;/p&gt;

&lt;p&gt;The dangerous move is changing models until a preferred channel looks good. If a paid campaign disappoints under last touch, switching to linear may make its revenue rise without changing a single purchase. The new number can be valid under the new rule and still be a poor basis for comparison with the old one.&lt;/p&gt;

&lt;p&gt;Pick the question first. Keep the model visible. Do not compare periods that use different rules as if the measurement were unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  History limits matter before model choice
&lt;/h2&gt;

&lt;p&gt;A multi touch model can only allocate credit across touches the system can connect.&lt;/p&gt;

&lt;p&gt;In Zenovay, first party cookie tracking can preserve a journey across days. Cookieless mode has less history, so its multi touch result may be limited to the visits that can be linked within the available window. The revenue view marks this as limited history when appropriate.&lt;/p&gt;

&lt;p&gt;That does not make the calculation wrong. It makes the boundary of the evidence important. A precise allocation over an incomplete path is still incomplete.&lt;/p&gt;

&lt;p&gt;Last touch is less sensitive to missing earlier history because it needs only the final known touch. First touch changes meaning when earlier visits are unavailable. Linear, position based, and time decay can redistribute credit only among the touches they can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we compare in practice
&lt;/h2&gt;

&lt;p&gt;We do not use one model as a machine for discovering truth. We compare models to find where a conclusion is fragile.&lt;/p&gt;

&lt;p&gt;If Organic Search performs well under first touch but nearly disappears under time decay, it is probably strong at discovery and distant from conversion. If Email rises sharply under time decay, it may be helping late in the journey. If a channel remains meaningful across all five models, confidence in its role is stronger.&lt;/p&gt;

&lt;p&gt;This comparison is more useful than arguing about one universal winner. The disagreement between models is information.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F491iuh61wye655qb2d4x.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F491iuh61wye655qb2d4x.webp" alt="Five views illuminate different parts of the same journey" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before acting on an attribution report, we now ask four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which business question are we asking?&lt;/li&gt;
&lt;li&gt;Which allocation rule matches that question?&lt;/li&gt;
&lt;li&gt;How much visitor history was actually available?&lt;/li&gt;
&lt;li&gt;Would the decision change under another reasonable model?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the answer to the fourth question is yes, the conclusion needs more caution, not more decimal places.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribution is a lens, not a receipt
&lt;/h2&gt;

&lt;p&gt;The $100 sale happened once. The five reports are not five competing transaction records. They are five lenses placed over the same journey.&lt;/p&gt;

&lt;p&gt;Good attribution makes the lens explicit. It preserves the underlying conversion, documents the rule, and lets the reader see how much the answer depends on that rule.&lt;/p&gt;

&lt;p&gt;The goal is not to find a model that ends the discussion. It is to choose a model that makes the discussion honest.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;The missing part came later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F917fi52tlsi9cxhjnie9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F917fi52tlsi9cxhjnie9.webp" alt="Three stages lead from site creation to the first event while the status field remains unlit" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

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

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

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

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

&lt;p&gt;Once both fields existed, they could disagree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;websites&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;first_event_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;script_installed&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query returned 19.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3t1ohj41urlk0fof2ul0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3t1ohj41urlk0fof2ul0.webp" alt="The event state advances while the installation flag remains stuck, then both converge after the repair" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;h3&gt;
  
  
  The question we use now
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb3vxs8d4wub04ll24opb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb3vxs8d4wub04ll24opb.webp" alt="One accepted event now updates both sides of the installation invariant" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

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

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

</description>
      <category>debugging</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>Our Ahrefs Domain Rating Fell From 23 to 6</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Fri, 14 Aug 2026 17:25:01 +0000</pubDate>
      <link>https://dev.to/zenovay/our-ahrefs-domain-rating-fell-from-23-to-6-2a9n</link>
      <guid>https://dev.to/zenovay/our-ahrefs-domain-rating-fell-from-23-to-6-2a9n</guid>
      <description>&lt;p&gt;Our Ahrefs Domain Rating fell from 23 to 6. The number was hard to ignore. It was also not an explanation.&lt;/p&gt;

&lt;p&gt;That distinction shaped the investigation. A third party score can tell you that something changed in its model. It cannot tell you which technical decision caused the change, or whether the same movement appears in search performance. So we treated the drop as a prompt to inspect the site, not as a verdict.&lt;/p&gt;

&lt;p&gt;The inspection found a real problem at the root URL. For visitors without an existing language preference, the same URL could return a temporary redirect to different locale paths depending on the request location. That behavior was inconsistent with the permanent locale structure we wanted search engines to understand.&lt;/p&gt;

&lt;p&gt;We fixed it. We still cannot honestly say that this redirect caused the Domain Rating drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the score actually tells us
&lt;/h2&gt;

&lt;p&gt;Ahrefs defines Domain Rating as a relative measure of a website's backlink profile on a 0 to 100 scale. The calculation depends on the sites linking to a domain and on the wider Ahrefs index. A score can therefore move even when a site has not lost the same proportion of backlinks.&lt;/p&gt;

&lt;p&gt;Ahrefs also states that Domain Rating is not a Google ranking factor. It is useful for comparing backlink profiles and noticing changes, but it is not a direct measurement of how Google ranks a page.&lt;/p&gt;

&lt;p&gt;That left us with two separate facts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We observed the score fall from 23 to 6.&lt;/li&gt;
&lt;li&gt;Our root redirect behavior needed to be corrected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The facts existed at the same time. Their timing did not prove that one caused the other.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fftcqs7z9luw5wl43spnv.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fftcqs7z9luw5wl43spnv.webp" alt="One root request reaches different locale destinations" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the root URL was doing
&lt;/h2&gt;

&lt;p&gt;The old middleware selected a locale from the request country when no locale preference was already present. It then redirected a nonlocalized path such as the root URL to that locale with a 302 response.&lt;/p&gt;

&lt;p&gt;In simplified form, the relevant behavior looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CF-IPCountry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LOCALE_MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;targetLocale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code was trying to be helpful. A visitor in Germany could be sent to the German locale, while a visitor in the United States could be sent to English.&lt;/p&gt;

&lt;p&gt;The problem was the crawler facing contract. One source URL did not have one stable destination. Its response depended on location, and the redirect said the move was temporary.&lt;/p&gt;

&lt;p&gt;This matters because Google treats permanent and temporary redirects differently. Its redirect guidance says that permanent redirects such as 301 and 308 are strong signals that the target should become canonical. With temporary redirects such as 302 and 307, the source generally remains the canonical URL in search results.&lt;/p&gt;

&lt;p&gt;Temporary redirects are valid when a change really is temporary. They are the wrong default when the site has a permanent locale hierarchy and needs a deterministic canonical entry point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why location dependent routing was risky
&lt;/h2&gt;

&lt;p&gt;Imagine two crawlers requesting the same root URL from different locations. One receives a redirect to &lt;code&gt;/en/&lt;/code&gt;. The other receives a redirect to &lt;code&gt;/de/&lt;/code&gt;. Both responses are temporary.&lt;/p&gt;

&lt;p&gt;Nothing in either response tells a stable story about the site's preferred root destination. The behavior can also make external tools record different chains depending on where and when they crawl.&lt;/p&gt;

&lt;p&gt;This does not automatically destroy authority, and a 302 is not a penalty. Search engines can interpret redirects using more than the status code alone. The issue was that our implementation mixed three concerns in one response:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The permanent structure of the site&lt;/li&gt;
&lt;li&gt;The visitor's likely language&lt;/li&gt;
&lt;li&gt;The visitor's saved preference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those concerns deserve different handling. The canonical route should be stable. Personalization should happen only when there is an explicit preference or after the stable page has loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deterministic fix
&lt;/h2&gt;

&lt;p&gt;The corrected middleware separates a new request from a returning visitor with a saved locale choice.&lt;/p&gt;

&lt;p&gt;For a cookieless request to a nonlocalized path, the server now returns a permanent 301 redirect to the English path. The destination is the same regardless of country:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://zenovay.com/  -&amp;gt;  https://zenovay.com/en/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a visitor who already has a locale preference cookie, the middleware can still honor that personal choice with a temporary redirect. That is a genuinely user specific response, not a claim about the site's canonical structure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fookfigf8uavvkr5je9zg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fookfigf8uavvkr5je9zg.webp" alt="Several uncertain paths resolve into one canonical route" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a small code change with a useful property: a crawler and a clean browser now see one predictable first hop. We verified the live root response with ordinary requests, Googlebot requests, and different language headers. Each returned a 301 to the same English URL.&lt;/p&gt;

&lt;p&gt;The fix does not require us to remove localized pages. Every locale still has its own URL. Language selectors and saved preferences still serve visitors. The change only makes the default entry path deterministic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we can conclude, and what we cannot
&lt;/h2&gt;

&lt;p&gt;We can conclude that the old root redirect was a technical flaw. It used a temporary status for a permanent site structure and allowed the destination to vary by request location. The current behavior is easier for crawlers, external auditing tools, and humans to reason about.&lt;/p&gt;

&lt;p&gt;We cannot conclude that the flaw caused the entire Ahrefs score change. We do not have a controlled experiment that isolates the redirect from changes in Ahrefs' index, the backlink graph, referring domains, or other site factors.&lt;/p&gt;

&lt;p&gt;We also cannot claim a recovery before the data shows one. Shipping the fix is the start of observation, not the end of the story.&lt;/p&gt;

&lt;p&gt;The responsible approach is to record the change date and watch several signals separately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Domain Rating and the referring domains behind it&lt;/li&gt;
&lt;li&gt;Newly discovered and lost backlinks&lt;/li&gt;
&lt;li&gt;Search impressions and clicks from first party search data&lt;/li&gt;
&lt;li&gt;Indexing and canonical selection for the root and locale pages&lt;/li&gt;
&lt;li&gt;The redirect response seen from clean requests over time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If those signals improve together after the fix, the evidence becomes more interesting. It still needs cautious interpretation.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical redirect audit
&lt;/h2&gt;

&lt;p&gt;You do not need a large SEO platform to catch this class of problem. Start with the HTTP response itself.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request the root URL without cookies and record the status and &lt;code&gt;Location&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;Repeat the request with different language headers and, if possible, from different regions.&lt;/li&gt;
&lt;li&gt;Confirm that permanent public routes have one deterministic destination.&lt;/li&gt;
&lt;li&gt;Follow the chain and check that it ends without extra hops or loops.&lt;/li&gt;
&lt;li&gt;Inspect the final page's canonical and alternate language links.&lt;/li&gt;
&lt;li&gt;Test a returning visitor with an explicit locale preference separately.&lt;/li&gt;
&lt;li&gt;Save the result and the deployment date so later metric changes have context.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhd6lie4kxidrm5bdd18t.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhd6lie4kxidrm5bdd18t.webp" alt="Four checkpoints along a redirect audit path" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A useful command line check is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://example.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then vary only one input at a time. Add an &lt;code&gt;Accept-Language&lt;/code&gt; header. Use a clean cookie jar. Run the request from another region. If the public URL is supposed to have a single canonical destination, the first hop should not become a geography lottery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;Metric drops create pressure for a quick story. A quick story is often where a technical observation becomes an unsupported causal claim.&lt;/p&gt;

&lt;p&gt;The better sequence is slower and more useful:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm what the metric measures.&lt;/li&gt;
&lt;li&gt;Inspect the system for concrete defects.&lt;/li&gt;
&lt;li&gt;Fix defects because they are defects.&lt;/li&gt;
&lt;li&gt;Separate verified behavior from hypotheses about impact.&lt;/li&gt;
&lt;li&gt;Watch primary and third party signals after the change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our Domain Rating drop led us to a real redirect problem. Correcting that problem gave the site a stable canonical entry path. Whether it explains the score movement remains an open question, and we would rather leave that question open than manufacture certainty.&lt;/p&gt;

&lt;p&gt;That is the kind of debugging record we trust: exact about the code, careful about causation, and clear about what to measure next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/search/docs/crawling-indexing/301-redirects" rel="noopener noreferrer"&gt;Google Search Central: Redirects and Google Search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://help.ahrefs.com/en/articles/1409408-what-is-domain-rating-dr" rel="noopener noreferrer"&gt;Ahrefs: What is Domain Rating?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ahrefs.com/blog/what-is-a-good-domain-rating/" rel="noopener noreferrer"&gt;Ahrefs: What is a good Domain Rating?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>cloudflare</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Traffic increased 18 percent is not an insight</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Thu, 06 Aug 2026 20:13:43 +0000</pubDate>
      <link>https://dev.to/zenovay/traffic-increased-18-percent-is-not-an-insight-aag</link>
      <guid>https://dev.to/zenovay/traffic-increased-18-percent-is-not-an-insight-aag</guid>
      <description>&lt;p&gt;Traffic increased 18 percent. Compared with what? Driven by whom? Did those visitors do anything useful? Is the change large enough to matter, and what should someone inspect next?&lt;/p&gt;

&lt;p&gt;Until those questions have answers, 18 percent is a measurement. It is not an insight.&lt;/p&gt;

&lt;p&gt;This distinction matters because analytics products are getting better at turning charts into sentences. A sentence feels more complete than a chart label, but grammar does not create judgment. If a tool only restates the visible movement, it has saved you a glance and left the actual analysis untouched.&lt;/p&gt;

&lt;p&gt;The 18 percent in this article is an illustrative example. It is not a Zenovay traffic result.&lt;/p&gt;

&lt;h2&gt;
  
  
  An observation is not a decision
&lt;/h2&gt;

&lt;p&gt;Suppose a dashboard compares the last seven days with the seven days before them and reports:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Traffic increased 18 percent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That statement answers one narrow question: how did the total change between two windows?&lt;/p&gt;

&lt;p&gt;It does not tell you whether the earlier week was unusually quiet, whether one campaign created the increase, whether bot traffic distorted the count, whether the lift came from a useful market, or whether any of the new visitors engaged or converted.&lt;/p&gt;

&lt;p&gt;The number can be correct and still be unhelpful.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wrftmqgqo2k7afja9d5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wrftmqgqo2k7afja9d5.webp" alt="A signal reaches one observation point, then opens into several contextual paths" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The minimum useful unit is not a percentage. It is a chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Observation:&lt;/strong&gt; What changed, and by how much?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context:&lt;/strong&gt; Compared with which baseline, over which period, and with what normal variation?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explanation:&lt;/strong&gt; Which segments changed enough to plausibly account for the movement?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; Did the change affect a business outcome or only a surface metric?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next check:&lt;/strong&gt; What should a person inspect or test now?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That chain does not need to be long. It needs to be honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a baseline that deserves trust
&lt;/h2&gt;

&lt;p&gt;The previous period is convenient, but it is not always representative. A product launch, holiday, outage, paid campaign, or unusually strong post can make one week a poor baseline for the next.&lt;/p&gt;

&lt;p&gt;A better comparison uses recent history and keeps the time windows equivalent. For example, compare Tuesday through Monday with several previous Tuesday through Monday windows. If the business has a strong weekday pattern, comparing a Monday with a Sunday produces noise that looks like news.&lt;/p&gt;

&lt;p&gt;In Zenovay's current insight service, expected values are calculated from up to four recent nonempty weekly periods, with more weight given to the most recent weeks. The same service keeps the latest week available as a separate comparison. That design does not make the baseline perfect, but it makes a single unusual week less powerful.&lt;/p&gt;

&lt;p&gt;The practical question is simple: would the statement still look interesting if you changed the baseline?&lt;/p&gt;

&lt;p&gt;If an 18 percent rise becomes 2 percent against the recent average, the story changes. If it remains 18 percent across several sensible comparisons, it deserves attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decompose the total before you explain it
&lt;/h2&gt;

&lt;p&gt;Aggregate traffic is where analysis starts, not where it ends. The next step is to find where the change lives.&lt;/p&gt;

&lt;p&gt;Useful cuts often include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Referrer or campaign&lt;/li&gt;
&lt;li&gt;Landing page&lt;/li&gt;
&lt;li&gt;Country or region&lt;/li&gt;
&lt;li&gt;Device type&lt;/li&gt;
&lt;li&gt;New and returning visitors&lt;/li&gt;
&lt;li&gt;Engagement or value segment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyu7z8bbjwmun2m032bgd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyu7z8bbjwmun2m032bgd.webp" alt="One aggregate signal splits into four segment paths" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Imagine the total grew 18 percent, but almost all of the additional visits came from one referral source and landed on one article. That is a much more useful observation. It narrows the search and gives the team something concrete to inspect.&lt;/p&gt;

&lt;p&gt;Now compare that source with the rest of the traffic. Did its visitors interact? Did they reach important pages? Did they complete a goal? A volume increase with weaker visitor quality can be less valuable than a smaller increase from a source that reaches the intended audience.&lt;/p&gt;

&lt;p&gt;Zenovay's current insight path assembles total visitors, engagement rate, average value score, the share of high value visitors, and leading countries, devices, referrers, and landing pages before it asks for an interpretation. Those fields reflect what the code actually provides today. They are context for analysis, not proof of causation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat causes as hypotheses
&lt;/h2&gt;

&lt;p&gt;This is where many automated summaries become overconfident.&lt;/p&gt;

&lt;p&gt;If traffic rose after a campaign launched, the campaign may have contributed. The timing alone does not prove that it caused the full increase. Organic search, a referral, a returning audience, a tracking change, or simple variation may also be involved.&lt;/p&gt;

&lt;p&gt;A responsible insight separates evidence from inference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; Visits from the campaign source rose during the period and account for most of the total increase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis:&lt;/strong&gt; The campaign is the leading explanation for the lift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; Compare tagged campaign visits, landing pages, engagement, and goal completions with the prior baseline.&lt;/p&gt;

&lt;p&gt;That wording is slightly less dramatic. It is also far more useful because it tells the reader how to prove or reject the explanation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect traffic to an outcome
&lt;/h2&gt;

&lt;p&gt;More visits are not automatically good. They can be useful, irrelevant, expensive, accidental, or fraudulent. The meaning depends on what the site is meant to achieve.&lt;/p&gt;

&lt;p&gt;For a content site, the next signal might be meaningful interaction or a return visit. For a product site, it might be a signup, an activated account, or a qualified lead. For commerce, it might be revenue, order value, or a completed checkout.&lt;/p&gt;

&lt;p&gt;This is why the phrase “traffic increased 18 percent” feels unfinished. It describes movement at the top of a system while ignoring what happened next.&lt;/p&gt;

&lt;p&gt;A better statement might read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Traffic was 18 percent above the recent weekly baseline. Most of the lift came from one referral source and one landing page. Those visitors engaged at a lower rate than the site average, so check the source message and landing page fit before increasing spend.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That example contains a baseline, a segment, an outcome, and a next action. It still avoids claiming a cause that the data has not established.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every insight a next check
&lt;/h2&gt;

&lt;p&gt;An insight should reduce the distance between noticing and deciding. If the reader still has to ask “so what do I do now?”, the summary stopped too early.&lt;/p&gt;

&lt;p&gt;The next step should be specific enough to perform and modest enough to trust. Good examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compare the leading source with the recent weekly baseline&lt;/li&gt;
&lt;li&gt;Open the landing page that received the new traffic&lt;/li&gt;
&lt;li&gt;Check engagement and goal completion for that segment&lt;/li&gt;
&lt;li&gt;Verify whether a campaign, release, or tracking change happened in the same window&lt;/li&gt;
&lt;li&gt;Watch the pattern for another equivalent period before changing budget&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwkdzge6lhgejghrqhsk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwkdzge6lhgejghrqhsk.webp" alt="Four connected points move from observation toward a next action" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Zenovay's current AI insight format includes a metric name, current value, expected value, percentage change, description, severity, and recommendation. Its chart narrative path also combines trend, peak, low, recent data points, and a requested action. If the AI service is unavailable, the product can fall back to rule-based summaries. These implementation details matter because a useful product needs a defined output contract, relevant evidence, and a graceful failure mode. A clever sentence alone is not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  A five question test for any AI insight
&lt;/h2&gt;

&lt;p&gt;Before acting on an automated insight, ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What exactly changed?&lt;/strong&gt; The metric, window, and amount should be explicit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the baseline?&lt;/strong&gt; Previous period, recent average, forecast, or benchmark should not be hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where did the change happen?&lt;/strong&gt; Look for the source, page, device, geography, or visitor segment that explains the aggregate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What evidence supports the explanation?&lt;/strong&gt; Timing is useful evidence, but it is not causation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the next check?&lt;/strong&gt; The recommendation should be concrete and reversible when confidence is limited.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a statement fails these questions, treat it as a notification. Notifications can still be valuable. They tell you where to look. They should not pretend the looking has already been done.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard is useful uncertainty
&lt;/h2&gt;

&lt;p&gt;Good analytics does not eliminate uncertainty. It makes uncertainty visible and gives you a better next move.&lt;/p&gt;

&lt;p&gt;“Traffic increased 18 percent” sounds certain because the number is precise. The important parts remain uncertain: why it changed, whether it matters, and whether it will persist.&lt;/p&gt;

&lt;p&gt;A useful insight keeps the exact observation, adds the strongest available context, labels any explanation as a hypothesis, connects the change to an outcome, and proposes the next check.&lt;/p&gt;

&lt;p&gt;That is the difference between a chart rewritten as a sentence and analysis that helps someone decide.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>analytics</category>
      <category>product</category>
      <category>startup</category>
    </item>
    <item>
      <title>I Published 31 DEV Posts. The One With 300 Views Got Zero Reactions.</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sat, 25 Jul 2026 12:42:48 +0000</pubDate>
      <link>https://dev.to/zenovay/i-published-31-dev-posts-the-one-with-300-views-got-zero-reactions-28eh</link>
      <guid>https://dev.to/zenovay/i-published-31-dev-posts-the-one-with-300-views-got-zero-reactions-28eh</guid>
      <description>&lt;p&gt;I published 31 posts on DEV and collected 1,222 views.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

</description>
      <category>devto</category>
      <category>writing</category>
      <category>analytics</category>
      <category>discuss</category>
    </item>
  </channel>
</rss>
