<?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: Kent Spencer</title>
    <description>The latest articles on DEV Community by Kent Spencer (@drew_spencer_1066).</description>
    <link>https://dev.to/drew_spencer_1066</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4051962%2F14be0948-52ce-4c28-b9ef-32566971d353.png</url>
      <title>DEV Community: Kent Spencer</title>
      <link>https://dev.to/drew_spencer_1066</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/drew_spencer_1066"/>
    <language>en</language>
    <item>
      <title>GTM HTML5 Video &amp; Audio Engagement</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Sun, 26 Jul 2026 17:43:33 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/gtm-html5-video-audio-engagement-5924</link>
      <guid>https://dev.to/drew_spencer_1066/gtm-html5-video-audio-engagement-5924</guid>
      <description>&lt;p&gt;Published · Tags: GTM, GA4, HTML5 Video, HTML5 Audio, Brightcove, CSP, dataLayer, Event Listeners, Custom Templates, Video Engagement&lt;/p&gt;

&lt;h1&gt;
  
  
  HTML5  &amp;amp;  Engagement Tracking in GA4 via GTM — a 2026 Refresh of a 2016 Brightcove Recipe, Built on David Vallejo's Listener Pattern

&lt;/h1&gt;

&lt;p&gt;Track play, pause, progress-percent, and completion for any native HTML5 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; element — Brightcove, self‑hosted MP4, podcasts embedded as &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt;, or anything else that ultimately renders as one of those two tags — using a single Custom HTML listener tag and a native GA4 Event tag. One CSP hash, covers both media types, no per‑vendor JavaScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This recipe is a 2026 refactor of a video‑tracking container I originally built in 2016 for a client running Brightcove — a container that, remarkably, is still live on their site today. The underlying pattern is not mine: it is a direct implementation of &lt;a href="https://www.thyngster.com/tracking-html5-videos-gtm/" rel="noopener noreferrer"&gt;David Vallejo's excellent 2014 "Tracking HTML5 Videos with GTM" article on Thyngster&lt;/a&gt;, which first laid out the tag/rule/macro approach of listening to the native HTML5 media events and pushing them to the dataLayer. All credit for the original concept belongs to him. The JSON container export can be downloaded from Google Drive below.&lt;br&gt;
&lt;a href="https://drive.google.com/file/d/1Xs6xpN5FfHB3xDACmWFGimre1i6vBpB8/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Background and Rationale
&lt;/h2&gt;

&lt;p&gt;Back in 2016 I built a Brightcove video‑engagement container for a client, modeled closely on David Vallejo's 2014 Thyngster write‑up: listen for the browser's native &lt;code&gt;play&lt;/code&gt;, &lt;code&gt;pause&lt;/code&gt;, &lt;code&gt;timeupdate&lt;/code&gt;, and &lt;code&gt;ended&lt;/code&gt; events on every &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element on the page, bucket the progress into percent thresholds, and push everything to the dataLayer as a single reusable event. That container is, as of this writing, still running in production for that client a decade later — a good sign the underlying idea has aged well, even as Brightcove's own player markup and GTM's own tooling have moved on.&lt;/p&gt;

&lt;p&gt;This post refreshes that original implementation for 2026: it swaps Universal Analytics event pushes for a native GA4 Event tag, replaces vendor‑specific URL parsing with a reusable sandboxed Custom Template, and — the one functional addition beyond Vallejo's original scope — extends the listener to cover &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; elements as well as &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;, since podcast players and audio‑only embeds share the exact same HTMLMediaElement event API. Everything else — the tag/trigger/variable shape, the reliance on native browser events instead of polling or platform SDKs — is Vallejo's approach, unchanged in spirit.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported container (&lt;code&gt;gtm-html5-video-audio-engagement.json&lt;/code&gt;) lives in one folder, &lt;em&gt;HTML5 Video Engagement&lt;/em&gt;, alongside a shared &lt;em&gt;Analytics&lt;/em&gt; folder for the GA4 stream constant. It contains two tags, two triggers, a set of variables covering platform/URL parsing and event‑name mapping, and one Custom Template (&lt;em&gt;URL Component Extractor&lt;/em&gt;) that replaces hand‑written per‑vendor JavaScript.&lt;/p&gt;
&lt;h3&gt;
  
  
  Tags
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Fires On&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HTML5 Video Listener&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom HTML&lt;/td&gt;
&lt;td&gt;Window Loaded&lt;/td&gt;
&lt;td&gt;The only custom JavaScript in the recipe. Attaches native event listeners to every &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; element on the page and pushes a &lt;code&gt;gtm.video&lt;/code&gt; dataLayer event on start, pause, progress‑percent, and completion.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HTML5 Video Engagement Send Event&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GA4 Event (&lt;code&gt;gaawe&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Custom Event: &lt;code&gt;gtm.video&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Native, already‑sandboxed GA4 Event tag — not Custom HTML. Maps the built‑in Video variables plus the parsed URL variables onto GA4 event parameters using GA4 Enhanced Measurement's own naming (&lt;code&gt;video_start&lt;/code&gt;, &lt;code&gt;video_progress&lt;/code&gt;, &lt;code&gt;video_pause&lt;/code&gt;, &lt;code&gt;video_complete&lt;/code&gt;) for consistency with any Enhanced Measurement data already in the property.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Triggers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Conditions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Window Loaded - All Pages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Window Loaded&lt;/td&gt;
&lt;td&gt;Fires the single HTML5 Video Listener tag once per page. The listener itself uses a &lt;code&gt;MutationObserver&lt;/code&gt; to catch media elements that render after this point, so no per‑platform Element Visibility triggers are needed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Custom Event - gtm.video&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Event&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{{_event}}&lt;/code&gt; equals &lt;code&gt;gtm.video&lt;/code&gt;, filtered so &lt;code&gt;{{HTML5 Video Platform Lookup}}&lt;/code&gt; matches &lt;code&gt;^[a-zA-Z].*&lt;/code&gt; (i.e., a resolvable platform name). Uses the same event name GTM's own native YouTube video tracking uses. One trigger covers start/progress/pause/complete; branch in tags with &lt;code&gt;{{Video Status}}&lt;/code&gt; if you need per‑status tags, exactly as you would with a native YouTube trigger.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Note — scope this trigger to your video pages:&lt;/strong&gt; As published, &lt;code&gt;Window Loaded - All Pages&lt;/code&gt; fires on every page. Because the listener's own DOM query (&lt;code&gt;querySelectorAll('video, audio')&lt;/code&gt;) is cheap and a no‑op when nothing is found, this is safe — but it is wasteful to run it site‑wide. In your own build, add a page‑path or page‑hostname condition to this trigger (e.g., &lt;code&gt;Page Path&lt;/code&gt; matches RegEx a path prefix like &lt;code&gt;^/videos/&lt;/code&gt;, or &lt;code&gt;^/podcasts/&lt;/code&gt; for audio) so the listener only attaches on pages where you know your target &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; elements actually appear. This is also the point at which any applicable host names belong — if your video pages live on a specific subdomain or the Brightcove player is only embedded on a defined set of URLs, restrict the trigger there rather than letting it evaluate globally.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Listener Tag: How It Works
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;HTML5 Video Listener&lt;/code&gt; is static vanilla JavaScript with zero &lt;code&gt;{{GTM variable}}&lt;/code&gt; interpolation in the tag body. That's deliberate: because the script text never changes between page loads, a CSP &lt;code&gt;script-src 'sha256-...'&lt;/code&gt; hash computed for it never changes either. All configuration lives in a plain &lt;code&gt;CONFIG&lt;/code&gt; object at the top of the script and in optional &lt;code&gt;data-*&lt;/code&gt; attributes on the media element itself — never in GTM variables baked into the tag body.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;CONFIG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gtm.video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// same event GTM's native YouTube tracking uses&lt;/span&gt;
  &lt;span class="na"&gt;progressThresholds&lt;/span&gt;&lt;span class="p"&gt;:&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="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;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// % markers&lt;/span&gt;
  &lt;span class="na"&gt;watchForNewVideos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// MutationObserver, for players injected after load&lt;/span&gt;
  &lt;span class="na"&gt;mediaSelector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video, audio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// tracks both &amp;lt;video&amp;gt; and &amp;lt;audio&amp;gt; elements&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 'html5video' for &amp;lt;video&amp;gt;, 'html5audio' for &amp;lt;audio&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AUDIO&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;html5audio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;html5video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;play&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeupdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few implementation details worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Video and audio, one selector.&lt;/strong&gt; &lt;code&gt;document.querySelectorAll('video, audio')&lt;/code&gt; binds the same four listeners — &lt;code&gt;play&lt;/code&gt;, &lt;code&gt;pause&lt;/code&gt;, &lt;code&gt;ended&lt;/code&gt;, &lt;code&gt;timeupdate&lt;/code&gt; — to both element types. Every downstream piece (the 8 built‑in Video variables, the URL‑parsing variables, the GA4 tag) is unchanged and now covers audio for free; only &lt;code&gt;gtm.videoProvider&lt;/code&gt; branches on &lt;code&gt;tagName&lt;/code&gt; to report &lt;code&gt;html5video&lt;/code&gt; or &lt;code&gt;html5audio&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late‑rendered players are still caught.&lt;/strong&gt; A &lt;code&gt;MutationObserver&lt;/code&gt; watches for nodes added after the initial scan, which covers Brightcove‑style players that inject their own &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; node asynchronously, modal/lightbox players, and SPA route changes — without needing a separate Element Visibility trigger per platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Title and threshold overrides via &lt;code&gt;data-*&lt;/code&gt; attributes, not GTM variables.&lt;/strong&gt; An author can set &lt;code&gt;title=""&lt;/code&gt;, &lt;code&gt;data-video-title=""&lt;/code&gt;, or &lt;code&gt;data-video-progress-thresholds="10,50,90"&lt;/code&gt; directly on the media tag to override the auto‑derived title or the default percent markers, on a per‑page or per‑player basis, without ever touching this script or its CSP hash.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note — only this tag needs a CSP hash:&lt;/strong&gt; This is the &lt;em&gt;only&lt;/em&gt; custom JavaScript in the entire recipe. Because it contains no variable interpolation, you compute one &lt;code&gt;script-src 'sha256-...'&lt;/code&gt; hash for it and that hash never has to be recomputed as long as the listener code itself doesn't change. Every other tag in this container — the GA4 Event tag, and the URL‑parsing Custom Template variables — runs as either a native GTM tag type or Sandboxed JavaScript, neither of which is subject to CSP script‑hash requirements, since both execute inside GTM's own sandbox, already covered by the &lt;code&gt;https://www.googletagmanager.com&lt;/code&gt; script‑src entry your site already needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusing &lt;code&gt;gtm.click&lt;/code&gt; Instead of Reinventing It
&lt;/h2&gt;

&lt;p&gt;Where this recipe deliberately does &lt;em&gt;not&lt;/em&gt; add anything new: play/pause button interaction beyond the native media events. Because &lt;code&gt;&amp;lt;video controls&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;audio controls&amp;gt;&lt;/code&gt; expose native &lt;code&gt;play&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt; events directly on the element, there is no need to separately intercept clicks on the player's own play/pause button — the browser already tells you when playback starts and stops. If your implementation adds custom UI controls layered on top of the native player (a custom play button overlay, for example), that interaction is ordinary DOM interaction and GTM's existing &lt;code&gt;gtm.click&lt;/code&gt; / Click trigger machinery — the same built‑in click listener and click variables (&lt;code&gt;{{Click Element}}&lt;/code&gt;, &lt;code&gt;{{Click Classes}}&lt;/code&gt;, &lt;code&gt;{{Click ID}}&lt;/code&gt;, etc.) already ship with GTM — is the right tool, rather than adding bespoke click‑handling JavaScript to the listener tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform Detection: The URL Component Extractor Template
&lt;/h2&gt;

&lt;p&gt;Rather than hand‑writing regex against &lt;code&gt;{{Video URL}}&lt;/code&gt; for every vendor, one sandboxed Custom Template — &lt;em&gt;URL Component Extractor&lt;/em&gt; — is instantiated five times, each returning a different piece of the URL (host, path, last path segment, extension, query string) using the sandboxed &lt;code&gt;parseUrl&lt;/code&gt; API. Every instance references &lt;code&gt;{{Video URL}}&lt;/code&gt; only inside the sandbox, never inside a Custom HTML tag, so none of it is CSP‑relevant.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Feeds Into&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HTML5 Video URL Host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;host&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HTML5 Video Platform Lookup&lt;/code&gt; (below) and the GA4 &lt;code&gt;video_url_host&lt;/code&gt; parameter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HTML5 Video URL Path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pathname&lt;/td&gt;
&lt;td&gt;&lt;code&gt;video_url_path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HTML5 Video URL Filename&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;last path segment&lt;/td&gt;
&lt;td&gt;&lt;code&gt;video_url_filename&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HTML5 Video URL Extension&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;file extension&lt;/td&gt;
&lt;td&gt;&lt;code&gt;video_url_extension&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HTML5 Video URL Query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;query string&lt;/td&gt;
&lt;td&gt;&lt;code&gt;video_url_query&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;em&gt;HTML5 Video Platform Lookup&lt;/em&gt; variable is a Simple Lookup Table keyed off &lt;code&gt;{{HTML5 Video URL Host}}&lt;/code&gt;, matching wildcard host patterns such as &lt;code&gt;*brightcove*&lt;/code&gt;, &lt;code&gt;*bcove*&lt;/code&gt;, &lt;code&gt;*wistia*&lt;/code&gt;, &lt;code&gt;*youtube*&lt;/code&gt;, &lt;code&gt;*vimeo*&lt;/code&gt;, &lt;code&gt;*vidyard*&lt;/code&gt;, and &lt;code&gt;*soundcloud*&lt;/code&gt; to a friendly platform label. Its own note describes it as a config‑only replacement for what used to be a hand‑written "Coalesce BC ID and Self Hosted" Custom JS variable — &lt;strong&gt;this is the place to add a row, not edit code, when a new media platform shows up in your &lt;code&gt;currentSrc&lt;/code&gt; values.&lt;/strong&gt; This is also where any applicable host names belong: if your Brightcove CDN, self‑hosted media bucket, or CDN edge domain differs from the defaults shown here, add or edit rows in this lookup table rather than modifying the listener script.&lt;/p&gt;

&lt;h2&gt;
  
  
  GA4 Event Mapping
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;HTML5 Video Engagement Send Event&lt;/code&gt; tag is a native GA4 Event tag (type &lt;code&gt;gaawe&lt;/code&gt;), not Custom HTML — already sandboxed by Google. Its event name comes from &lt;em&gt;HTML5 Video Event Name Lookup&lt;/em&gt;, a Simple Lookup keyed on &lt;code&gt;{{Video Status}}&lt;/code&gt; that maps &lt;code&gt;start&lt;/code&gt; → &lt;code&gt;video_start&lt;/code&gt;, &lt;code&gt;progress&lt;/code&gt; → &lt;code&gt;video_progress&lt;/code&gt;, &lt;code&gt;pause&lt;/code&gt; → &lt;code&gt;video_pause&lt;/code&gt;, and &lt;code&gt;complete&lt;/code&gt; → &lt;code&gt;video_complete&lt;/code&gt; — the same names GA4 Enhanced Measurement's own native video tracking uses, so this recipe's rows sit consistently alongside any Enhanced Measurement video data already flowing into the property.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event Parameter&lt;/th&gt;
&lt;th&gt;Source Variable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;video_provider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Video Provider (built‑in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;video_title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Video Title (built‑in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;video_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Video URL (built‑in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;video_current_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Video Current Time (built‑in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;video_duration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Video Duration (built‑in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;video_percent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Video Percent (built‑in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;visible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Video Visible (built‑in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;video_status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Video Status (built‑in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;video_platform&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTML5 Video Platform Lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;video_url_host&lt;/code&gt; / &lt;code&gt;_path&lt;/code&gt; / &lt;code&gt;_filename&lt;/code&gt; / &lt;code&gt;_extension&lt;/code&gt; / &lt;code&gt;_query&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the five URL Component Extractor instances&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Replace the &lt;code&gt;{{Measurement Stream ID}}&lt;/code&gt; Constant variable (placeholder: &lt;code&gt;G-AAAAAAAA&lt;/code&gt;) with your live GA4 Measurement ID after import; the tag references it through &lt;code&gt;measurementIdOverride&lt;/code&gt; so it can send independently of an existing GA4 Configuration tag, or you can point it at your existing config tag's own Measurement ID variable if you'd rather not override per‑tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tested With: the Brightcove In‑Page Embed
&lt;/h2&gt;

&lt;p&gt;This recipe was validated against a standard Brightcove In‑Page embed — the same responsive‑wrapper pattern &lt;a href="https://codepen.io/broadridgemedia/pen/zwRqKL" rel="noopener noreferrer"&gt;published on CodePen by Broadridge Media&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- start brightcove In-Page embed --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"display: block; position: relative; max-width: 100%;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"padding-top: 56.25%;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;video&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width: 100%; height: 100%; position: absolute; top: 0px; bottom: 0px; right: 0px; left: 0px;border:0;border-radius:5px"&lt;/span&gt; &lt;span class="na"&gt;data-video-id=&lt;/span&gt;&lt;span class="s"&gt;"4093643993001"&lt;/span&gt; &lt;span class="na"&gt;data-account=&lt;/span&gt;&lt;span class="s"&gt;"1752604059001"&lt;/span&gt; &lt;span class="na"&gt;data-player=&lt;/span&gt;&lt;span class="s"&gt;"VyqgG8mql"&lt;/span&gt; &lt;span class="na"&gt;data-embed=&lt;/span&gt;&lt;span class="s"&gt;"default"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"video-js"&lt;/span&gt;
        &lt;span class="na"&gt;controls&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/video&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"//players.brightcove.net/1752604059001/VyqgG8mql_default/index.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- end brightcove In-Page embed --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Brightcove's player script replaces this markup with its own rendered &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element after the page has loaded — exactly the "player injected later" case the listener's &lt;code&gt;MutationObserver&lt;/code&gt; is there to catch. Note the &lt;code&gt;data-video-id&lt;/code&gt;, &lt;code&gt;data-account&lt;/code&gt;, and &lt;code&gt;data-player&lt;/code&gt; attributes Brightcove leaves on the element: depending on your implementation, you may want to add an additional GTM variable (a small Custom JavaScript or sandboxed template reading &lt;code&gt;{{Click Element}}.getAttribute('data-video-id')&lt;/code&gt; equivalent) to surface Brightcove's own video ID as its own event parameter, rather than relying solely on the parsed &lt;code&gt;currentSrc&lt;/code&gt;. Or, if your media platform exposes different metadata attributes altogether, the listener's &lt;code&gt;getVideoTitle()&lt;/code&gt; helper is the natural place to read them instead — this is implementation‑ and platform‑dependent, so treat the shipped script as a starting point rather than a final answer for your specific player.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON from the &lt;a href="https://drive.google.com/file/d/1Xs6xpN5FfHB3xDACmWFGimre1i6vBpB8/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt; above.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;gtm-html5-video-audio-engagement.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing container setup, and pick a new workspace so you can review the diff before publishing.&lt;/li&gt;
&lt;li&gt;Update the &lt;code&gt;Measurement Stream ID&lt;/code&gt; Constant variable to your live GA4 Measurement ID.&lt;/li&gt;
&lt;li&gt;Add a page‑path or hostname condition to the &lt;code&gt;Window Loaded - All Pages&lt;/code&gt; trigger so the listener only attaches on pages where your &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; elements actually live.&lt;/li&gt;
&lt;li&gt;Review and, if needed, extend the rows in &lt;code&gt;HTML5 Video Platform Lookup&lt;/code&gt; to match your actual player host names (Brightcove account/player subdomain, self‑hosted CDN, etc.).&lt;/li&gt;
&lt;li&gt;Compute a CSP &lt;code&gt;script-src 'sha256-...'&lt;/code&gt; hash for the &lt;code&gt;HTML5 Video Listener&lt;/code&gt; tag's script body if your site enforces a strict CSP, and add it to your policy. No other tag in this container needs one.&lt;/li&gt;
&lt;li&gt;Verify consent settings: the listener requires &lt;code&gt;analytics_storage&lt;/code&gt; in this export — adjust to match your CMP setup.&lt;/li&gt;
&lt;li&gt;Open GTM Preview mode, load a page containing your video/audio embed, and confirm the &lt;code&gt;gtm.video&lt;/code&gt; event fires in the dataLayer on play, at each progress threshold, on pause, and on completion, and that the GA4 Event tag fires alongside it.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;The full source — container JSON and documentation — is also published on GitHub. Renewed thanks to &lt;a href="https://www.thyngster.com/tracking-html5-videos-gtm/" rel="noopener noreferrer"&gt;David Vallejo / Thyngster&lt;/a&gt; for the original 2014 pattern this recipe is built on. If you extend the platform lookup table, add support for additional &lt;code&gt;data-*&lt;/code&gt; metadata attributes, or adapt this for a different media platform, open a pull request or issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1Xs6xpN5FfHB3xDACmWFGimre1i6vBpB8/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>brightcove</category>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googletagmanager</category>
    </item>
    <item>
      <title>GTM User Session Counter</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Fri, 24 Jul 2026 12:24:41 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/gtm-user-session-counter-38b0</link>
      <guid>https://dev.to/drew_spencer_1066/gtm-user-session-counter-38b0</guid>
      <description>&lt;h1&gt;
  
  
  Persistent User Session Counter in Google Tag Manager — A Complete Recipe
&lt;/h1&gt;

&lt;p&gt;Track how many times each visitor has started a new session — no server-side code, no CSP SHA-256 hashes required — and use the count to trigger conversion events, popups, and personalisation logic.&lt;/p&gt;

&lt;p&gt;Published · Tags: GTM, Google Analytics 4, Cookies, Sessions, Custom Templates, JavaScript, dataLayer&lt;/p&gt;

&lt;p&gt;Most analytics setups can tell you how many sessions a user has started in total across your property, but very few can answer the question at tag-fire time: &lt;em&gt;"Is this visitor on their first session? Their third? Their tenth?"&lt;/em&gt; This recipe wires up a fully sandboxed GTM solution that increments a persistent counter cookie on every new session, pushes the count to the &lt;code&gt;dataLayer&lt;/code&gt; as a named event, and extends the cookie's expiry on each return visit — all without a single line of Custom HTML or any SHA-256 CSP policy changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/12TIpdSpu7YTtWisXeSGGbQi_3_4s8g_C/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Session count is one of the most actionable engagement signals you can capture. Knowing that a visitor is on their &lt;em&gt;third&lt;/em&gt; session lets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fire a conversion-nudge popup only for returning visitors (not first-timers)&lt;/li&gt;
&lt;li&gt;Use the &lt;code&gt;dataLayer&lt;/code&gt; event as a GA4 custom event trigger for micro-conversion reporting&lt;/li&gt;
&lt;li&gt;Suppress first-session noise in paid-media conversion tracking&lt;/li&gt;
&lt;li&gt;Build GA4 audiences segmented by session depth (1 session, 2–5 sessions, 6+ sessions)&lt;/li&gt;
&lt;li&gt;Feed the count into other GTM tags as a variable — no extra API calls needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;No CSP changes required.&lt;/strong&gt; Because this recipe uses only GTM's sandboxed Custom Template API (&lt;code&gt;getCookieValues&lt;/code&gt;, &lt;code&gt;setCookie&lt;/code&gt;, &lt;code&gt;createQueue&lt;/code&gt;), there is no inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block whose SHA-256 hash would need to be declared in your Content-Security-Policy header. Everything runs inside GTM's own sandboxed runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported container JSON (&lt;code&gt;user-session-counter-measurement-recipe.json&lt;/code&gt;) bundles two tags, one trigger, and five custom templates across 25 variables:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Asset Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tag (Custom Template)&lt;/td&gt;
&lt;td&gt;User Session Counter&lt;/td&gt;
&lt;td&gt;Reads the persistent counter cookie, increments it, writes it back, and pushes a &lt;code&gt;dataLayer&lt;/code&gt; event — once per session via a session-scoped guard cookie&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (Custom Template)&lt;/td&gt;
&lt;td&gt;Extend Cookie Life Tag&lt;/td&gt;
&lt;td&gt;On DOM Ready, refreshes the persistent counter cookie's &lt;code&gt;max-age&lt;/code&gt; so a returning visitor resets the expiry clock without changing the count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (Google Tag)&lt;/td&gt;
&lt;td&gt;Google Analytics Configuration&lt;/td&gt;
&lt;td&gt;Standard GA4 config tag wired to environment-aware Stream IDs via a RegEx lookup variable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;Extend Cookie Life Trigger&lt;/td&gt;
&lt;td&gt;DOM Ready trigger — fires only when the Extend Cookie Life session-guard cookie is absent or falsy, ensuring one rewrite per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Template&lt;/td&gt;
&lt;td&gt;User Session Counter&lt;/td&gt;
&lt;td&gt;The sandboxed JS engine for reading, incrementing, and persisting the counter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Template&lt;/td&gt;
&lt;td&gt;Cookie Rewriter&lt;/td&gt;
&lt;td&gt;Sandboxed template that refreshes a list of persistent cookies to extend their &lt;code&gt;max-age&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Template&lt;/td&gt;
&lt;td&gt;If Else If – Advanced Lookup Table&lt;/td&gt;
&lt;td&gt;Environment-aware RegEx lookup for GA4 Stream ID routing (dev vs. prod)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Template&lt;/td&gt;
&lt;td&gt;Timestamp&lt;/td&gt;
&lt;td&gt;Millisecond timestamp utility used by session/user ID variables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Template&lt;/td&gt;
&lt;td&gt;Get Root Domain&lt;/td&gt;
&lt;td&gt;Resolves the root domain for cookie scoping across subdomains&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The flow across a visitor's two sessions illustrates how the guard-cookie + persistent-cookie pattern works:&lt;/p&gt;

&lt;h3&gt;
  
  
  Session 1 (first visit ever)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;GTM initialises.&lt;/strong&gt; The &lt;em&gt;User Session Counter&lt;/em&gt; tag fires (it has no limiting trigger — it fires on every container load by default, and its own session guard is the gatekeeper).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session guard is absent.&lt;/strong&gt; The tag reads the &lt;code&gt;_arusc&lt;/code&gt; cookie (Already Ran User Session Counter). It is absent, so &lt;code&gt;sessionIsZero&lt;/code&gt; is true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent counter is absent.&lt;/strong&gt; The tag reads &lt;code&gt;_i_vstcnt&lt;/code&gt;. It doesn't exist yet, so it is initialised to &lt;code&gt;0&lt;/code&gt; and a persistent cookie is written with a 720-day &lt;code&gt;max-age&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counter is incremented.&lt;/strong&gt; &lt;code&gt;0 + 1 = 1&lt;/code&gt;. The counter cookie is rewritten with value &lt;code&gt;1&lt;/code&gt; and a 720-day &lt;code&gt;max-age&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dataLayer event is pushed.&lt;/strong&gt; &lt;code&gt;{ event: '_i_vstcnt', userSessionCounter: 1 }&lt;/code&gt; fires. Any downstream tags listening for this event name (e.g. GA4 event tags, popup triggers) activate now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session guard is stamped.&lt;/strong&gt; &lt;code&gt;_arusc=1&lt;/code&gt; is written as a &lt;em&gt;session cookie&lt;/em&gt; (no &lt;code&gt;max-age&lt;/code&gt;, expires when the browser closes). This prevents the counter from incrementing again during the same session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DOM Ready fires.&lt;/strong&gt; The &lt;em&gt;Extend Cookie Life Trigger&lt;/em&gt; activates because &lt;code&gt;_arrewr&lt;/code&gt; is absent. The Cookie Rewriter tag refreshes the &lt;code&gt;max-age&lt;/code&gt; of &lt;code&gt;_i_vstcnt&lt;/code&gt; to another 720 days, then stamps &lt;code&gt;_arrewr=1&lt;/code&gt; for the rest of the session.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Session 2 (return visit, same browser)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_arusc&lt;/code&gt; is absent&lt;/strong&gt; (it was a session cookie — it expired with the browser close). The counter tag fires again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_i_vstcnt&lt;/code&gt; exists with value &lt;code&gt;1&lt;/code&gt;.&lt;/strong&gt; The tag reads it, increments to &lt;code&gt;2&lt;/code&gt;, writes it back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dataLayer push:&lt;/strong&gt; &lt;code&gt;{ event: '_i_vstcnt', userSessionCounter: 2 }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Cookie life is extended again on DOM Ready.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The User Session Counter Template — Sandboxed JS
&lt;/h2&gt;

&lt;p&gt;This is the heart of the recipe. It runs entirely inside GTM's sandboxed JavaScript environment — no &lt;code&gt;document.cookie&lt;/code&gt;, no &lt;code&gt;window&lt;/code&gt; access — using only approved GTM APIs. This is why &lt;strong&gt;no CSP SHA-256 hash is needed&lt;/strong&gt; : the sandbox, not a Custom HTML tag, is doing all the cookie work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logToConsole&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getCookieValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getCookieValues&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setCookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setCookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;createQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;createQueue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataLayerPush&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dataLayer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alreadyRanUserSessionCounter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alreadyRanUserSessionCounterName&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_arusc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userSessionCounter&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usrSsnCtr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCookieValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alreadyRanUserSessionCounter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionRaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionValues&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;sessionValues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;sessionValues&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionExists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionRaw&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionIsZero&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sessionExists&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionRaw&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sessionExists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alreadyRanUserSessionCounter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionIsZero&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;persistValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCookieValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;persistRaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;persistValues&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;persistValues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;persistValues&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;persistExists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;persistRaw&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;persistExists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max-age&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;persistentCookieExpires&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;currentCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;currentCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;persistRaw&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentCount&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="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newCount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max-age&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;persistentCookieExpires&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;dataLayerPush&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;userSessionCounter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newCount&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alreadyRanUserSessionCounter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gtmOnSuccess&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Dual-purpose dataLayer push:&lt;/strong&gt; The event name is set to the &lt;em&gt;cookie name itself&lt;/em&gt; (&lt;code&gt;_i_vstcnt&lt;/code&gt; by default). This means the same event can be listened to by a Custom Event trigger in GTM, enabling the session count to serve as a trigger for GA4 conversion events, timed popup dialogs, chat widget activations, or any other tag that should fire at specific session-depth thresholds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cookie Rewriter Template — Extending Cookie Life
&lt;/h2&gt;

&lt;p&gt;A 720-day &lt;code&gt;max-age&lt;/code&gt; is written when the counter cookie is first created, and again each time it is incremented — but what about sessions where the user returns after a long gap and the cookie would otherwise have been expiring soon? The &lt;em&gt;Cookie Rewriter&lt;/em&gt; (Extend Cookie Life Tag) handles this: on every session's DOM Ready, it reads the current value and rewrites the cookie with a fresh 720-day expiry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;getCookieValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getCookieValues&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;setCookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setCookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;makeString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;makeString&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logToConsole&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;SESSION_GUARD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SESSION_GUARD&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_arrewr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Run at most once per session&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;guardValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCookieValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SESSION_GUARD&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;guardValues&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;guardValues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;guardValues&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="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gtmOnSuccess&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;rootDomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;durationDays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationDays&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;cookieList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookieList&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;maxAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;durationDays&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookieList&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cookieList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cookieList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookieList&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCookieValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;values&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="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&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="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max-age&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;samesite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lax&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Stamp the session guard so this doesn't run again this session&lt;/span&gt;
&lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SESSION_GUARD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gtmOnSuccess&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cookies Written by This Recipe
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cookie Name&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Default Expiry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_i_vstcnt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;The actual session counter value (integer, increments each session)&lt;/td&gt;
&lt;td&gt;720 days (refreshed each visit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_arusc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Guard cookie — prevents multiple increments within a single session&lt;/td&gt;
&lt;td&gt;Browser close&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_arrewr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Guard cookie — prevents the Cookie Rewriter from running more than once per session&lt;/td&gt;
&lt;td&gt;Browser close&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Two types of guard cookies, two jobs:&lt;/strong&gt; The session-scoped &lt;code&gt;_arusc&lt;/code&gt; ensures the counter increments exactly once per browser session. The separate &lt;code&gt;_arrewr&lt;/code&gt; guard ensures the Cookie Rewriter runs exactly once per session. Because both guards are session cookies (no &lt;code&gt;max-age&lt;/code&gt;), they vanish naturally when the browser is closed — no cleanup needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Variables
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Key Constants
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable Name&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;User Session Counter Cookie Name&lt;/td&gt;
&lt;td&gt;Constant&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_i_vstcnt&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extend Cookie Life Already Ran Cookie Name&lt;/td&gt;
&lt;td&gt;Constant&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_arrewr&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Measurement Stream ID Development&lt;/td&gt;
&lt;td&gt;Constant&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;G-ZZZZZZZZ&lt;/code&gt; (replace with yours)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Measurement Stream ID Production&lt;/td&gt;
&lt;td&gt;Constant&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;G-AAAAAAAA&lt;/code&gt; (replace with yours)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Key Cookie-Reading Variables
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable Name&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Reads Cookie&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;User Session Counter Cookie Value&lt;/td&gt;
&lt;td&gt;1st-party cookie&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_i_vstcnt&lt;/code&gt; — the count, readable by any other GTM tag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extend Cookie Life Already Ran Cookie Value&lt;/td&gt;
&lt;td&gt;1st-party cookie&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_arrewr&lt;/code&gt; — used by the Extend Cookie Life Trigger condition&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Triggers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  User Session Counter Tag
&lt;/h3&gt;

&lt;p&gt;The &lt;em&gt;User Session Counter&lt;/em&gt; tag fires on &lt;strong&gt;All Pages&lt;/strong&gt; (or any early-firing trigger you choose). Its own internal session guard (&lt;code&gt;_arusc&lt;/code&gt;) is the gatekeeper — the guard logic is &lt;em&gt;inside&lt;/em&gt; the template rather than in an external trigger condition. This keeps the tag self-contained and avoids the need for a separate "has not already ran" initialization trigger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extend Cookie Life Trigger
&lt;/h3&gt;

&lt;p&gt;Fires on &lt;strong&gt;DOM Ready&lt;/strong&gt; , with one filter condition: &lt;code&gt;{{Extend Cookie Life Already Ran Cookie Value}}&lt;/code&gt; matches the regex &lt;code&gt;^(undefined|null|0|false|NaN|)$&lt;/code&gt;. In plain English: fire only when the &lt;code&gt;_arrewr&lt;/code&gt; cookie is absent or falsy. This ensures the Cookie Rewriter runs exactly once per session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dual-Purpose dataLayer Event
&lt;/h2&gt;

&lt;p&gt;The push made by the User Session Counter template serves two roles simultaneously:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cookie persistence:&lt;/strong&gt; The counter value is stored in &lt;code&gt;_i_vstcnt&lt;/code&gt; and survives across pages and sessions for up to 720 days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom event trigger:&lt;/strong&gt; The &lt;code&gt;dataLayer.push({ event: '_i_vstcnt', userSessionCounter: N })&lt;/code&gt; can be listened to by a GTM Custom Event trigger. Any tag that should react to session depth — a GA4 conversion event, a popup timer, a live-chat invite — can be wired to this trigger with a variable condition on &lt;code&gt;userSessionCounter&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Example use case:&lt;/strong&gt; Create a Custom Event trigger matching &lt;code&gt;_i_vstcnt&lt;/code&gt;, add a trigger condition where &lt;code&gt;{{User Session Counter Cookie Value}}&lt;/code&gt; equals &lt;code&gt;3&lt;/code&gt;, and wire it to a "show free trial popup" tag. The popup fires on the visitor's third session — and never on the first or second.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON from the &lt;a href="https://drive.google.com/file/d/12TIpdSpu7YTtWisXeSGGbQi_3_4s8g_C/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;user-session-counter-measurement-recipe.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing tags.&lt;/li&gt;
&lt;li&gt;Update the &lt;em&gt;Measurement Stream ID Development&lt;/em&gt; and &lt;em&gt;Measurement Stream ID Production&lt;/em&gt; constant variables to your actual GA4 Stream IDs.&lt;/li&gt;
&lt;li&gt;Update the &lt;em&gt;Root Domain&lt;/em&gt; variable if your site uses a non-standard subdomain structure.&lt;/li&gt;
&lt;li&gt;Preview in GTM Preview mode: open your site, check the Variables tab for &lt;code&gt;User Session Counter Cookie Value&lt;/code&gt;, and verify it increments to &lt;code&gt;1&lt;/code&gt; on first load.&lt;/li&gt;
&lt;li&gt;Close the browser tab entirely, reopen the site in Preview mode, and verify the counter increments to &lt;code&gt;2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Publish when satisfied.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Consent gating:&lt;/strong&gt; The persistent &lt;code&gt;_i_vstcnt&lt;/code&gt; counter cookie is a functional/analytics cookie. If your site uses a consent management platform, gate the User Session Counter tag on &lt;code&gt;analytics_storage&lt;/code&gt; or &lt;code&gt;functionality_storage&lt;/code&gt; consent as appropriate for your jurisdiction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Can Do With It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Suppress first-session users from conversion funnels to reduce noise in paid-media ROAS reporting&lt;/li&gt;
&lt;li&gt;Build GA4 audiences segmented by session depth: &lt;em&gt;New&lt;/em&gt; (1 session), &lt;em&gt;Returning&lt;/em&gt; (2–5), &lt;em&gt;Loyal&lt;/em&gt; (6+)&lt;/li&gt;
&lt;li&gt;Trigger a discount popup only on the third session, avoiding offer fatigue for first-time visitors&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;_i_vstcnt&lt;/code&gt; server-side (it's a first-party cookie) to personalise page content before GTM even fires&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;userSessionCounter&lt;/code&gt; as a GA4 event parameter to segment event reports by engagement depth&lt;/li&gt;
&lt;li&gt;Fire a live-chat proactive invite only for visitors on session 4 or higher&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The full source — including the container JSON, all custom template code, and a breakdown of every variable — is available on GitHub. Questions or improvements? Open an issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/12TIpdSpu7YTtWisXeSGGbQi_3_4s8g_C/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googletagmanager</category>
      <category>gtm</category>
    </item>
    <item>
      <title>GTM postMessage Bridge and Listener</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Wed, 22 Jul 2026 19:22:54 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/gtm-postmessage-bridge-and-listener-413j</link>
      <guid>https://dev.to/drew_spencer_1066/gtm-postmessage-bridge-and-listener-413j</guid>
      <description>&lt;h1&gt;
  
  
  postMessage Bridge for iframes in GTM — Sandboxed, Parameterized, and Client-ID Aware
&lt;/h1&gt;

&lt;p&gt;Published · Tags: GTM, Google Analytics 4, postMessage, iframe, Cross-Domain Attribution, Client ID, Sandboxed JavaScript, CSP, Pardot, Lead Generation&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This GTM container recipe is designed to work on Google Blogger / Blogspot as well as AEM, Sitecore, WordPress, React.js, and other platforms. It bridges &lt;code&gt;dataLayer&lt;/code&gt; events across an iframe boundary using &lt;code&gt;window.postMessage()&lt;/code&gt;, implemented with GTM Sandboxed JavaScript Custom Templates rather than hand-rolled inline JavaScript wherever possible. The JSON can be downloaded from Google Drive below.&lt;br&gt;
&lt;a href="https://drive.google.com/file/d/1keh7zePbSqWMoAul7Pl_coBuq71T2epd/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A third-party iframe embedded on your page — a Pardot form, a HubSpot form, a scheduling widget, a payment step — runs its own separate browsing context with its own GTM container and its own &lt;code&gt;dataLayer&lt;/code&gt;. Nothing that happens inside that iframe is visible to the parent page's GTM container, and nothing the parent page knows is visible to the iframe. They are two completely isolated tag-management setups that happen to share a rectangle of screen space. If a visitor clicks "Submit" inside that embedded form, your parent-site GA4 property never hears about it unless something deliberately carries that event across the origin boundary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;window.postMessage()&lt;/code&gt; is the browser API built for exactly this: two windows in different origins exchanging plain-text messages, with each side choosing which origins it trusts. Simo Ahava published the original GTM implementation of this pattern years ago, and I'm using it successfully in a live Pardot implementation today:&lt;/p&gt;

&lt;p&gt;▶ &lt;a href="https://youtu.be/g5J0aUA2Um8" rel="noopener noreferrer"&gt;Video walkthrough&lt;/a&gt;  ·  &lt;a href="https://www.teamsimmer.com/2023/05/02/how-do-i-use-the-postmessage-method-with-cross-site-iframes/" rel="noopener noreferrer"&gt;How Do I Use the postMessage Method With Cross-Site iframes? — Simo Ahava&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;His article remains the clearest explanation of the underlying mechanics — why &lt;code&gt;evt.origin&lt;/code&gt; checking matters, what &lt;code&gt;evt.source&lt;/code&gt; buys you, and why a sender/receiver pair of GTM tags is the right shape for the problem. If you haven't read it, start there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Built a Variant of This
&lt;/h2&gt;

&lt;p&gt;Inspired by Simo's original and earlier work, I reworked the implementation with three goals:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Less reliance on Custom HTML / hand-written JavaScript.&lt;/strong&gt; Nearly all of the sender and receiver logic now lives in GTM Sandboxed JavaScript Custom Templates instead of inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More reliance on Sandboxed JavaScript.&lt;/strong&gt; Template code executes inside GTM's own sandbox and is not subject to CSP &lt;code&gt;script-src&lt;/code&gt; SHA-256 hashing — it's already covered by the &lt;code&gt;www.googletagmanager.com&lt;/code&gt; allowance your site needs regardless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fully parameterized configuration.&lt;/strong&gt; Target origin, namespace, event allow/exclude lists, the iframe's element ID, and the wrap key are all template fields — not literal values buried in a script body. Porting the recipe to a new site or a new iframe means editing template fields, not rewriting JavaScript.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Decreasing reliance on Custom HTML shrinks — but does not eliminate — the CSP SHA-256 surface. Two small Custom HTML tags remain, one on each side: a Sender Bootstrap on the child/iframe site that performs the actual &lt;code&gt;window.top.postMessage()&lt;/code&gt; call, and a Bootstrap Listener on the parent site that registers the single &lt;code&gt;window.addEventListener('message', …)&lt;/code&gt; handler. Both exist only because sandboxed JavaScript's &lt;code&gt;access_globals&lt;/code&gt; permission cannot reach predefined browser globals like &lt;code&gt;top&lt;/code&gt;, &lt;code&gt;window&lt;/code&gt;, or &lt;code&gt;parent&lt;/code&gt; directly — a Custom Template is not allowed to call &lt;code&gt;window.top.postMessage()&lt;/code&gt; itself. Both scripts' literal text is fixed and configuration-free — they read everything they need from plain globals written by their companion Custom Template — so you compute each SHA-256 hash once and it stays valid indefinitely, no matter how many times you reconfigure origins, namespaces, or allow-lists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passing the Client ID
&lt;/h2&gt;

&lt;p&gt;This recipe also carries the parent page's GA4 &lt;code&gt;client_id&lt;/code&gt; into the iframe, via the &lt;code&gt;_ga&lt;/code&gt; query-string parameter on the iframe's &lt;code&gt;src&lt;/code&gt;. This uses the URL-decoration mechanism from an earlier recipe of mine (linked below) — the same permitted-host, same-config-object approach, just aimed at an &lt;code&gt;&amp;lt;iframe src&amp;gt;&lt;/code&gt; instead of an outbound &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Passing the client ID is principally for use with the GTM configuration settings inside the iframe's own GTM container. Reading that value back out on the child side gives the child site's GA4 config tag the option to use the &lt;em&gt;parent's&lt;/em&gt; &lt;code&gt;client_id&lt;/code&gt; instead of generating its own — which gives the receiving property the opportunity to associate the iframe's traffic with the same &lt;code&gt;pseudo_user_id&lt;/code&gt; as the parent site. It is &lt;strong&gt;not&lt;/strong&gt; the same session, and it is not full cross-domain session stitching — but it is enough to connect the embedded-content activity back to the same underlying visitor identity, where the receiving side chooses to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parent Site vs. Child Site — One Download, Two Destinations
&lt;/h2&gt;

&lt;p&gt;Everything in this export is labeled either Parent Site or Child Site. Elements marked Parent Site belong in the GTM container installed on the page that &lt;em&gt;hosts&lt;/em&gt; the &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;. Elements marked Child Site — along with the shared Google Tag configuration settings that set &lt;code&gt;client_id&lt;/code&gt; from the URL query string — belong in the GTM container installed &lt;em&gt;inside&lt;/em&gt; the iframe's own page.&lt;/p&gt;

&lt;p&gt;I've put both halves into a single container JSON purely for convenience of downloading and importing in one pass. To actually run both halves inside one shared container (rather than importing into two separate containers), do two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a hostname condition to the Parent Site triggers so they fire only when &lt;code&gt;{{Page Hostname}}&lt;/code&gt; equals your parent site's host.&lt;/li&gt;
&lt;li&gt;Add a hostname condition to the Child Site triggers so they fire only when &lt;code&gt;{{Page Hostname}}&lt;/code&gt; equals your child/iframe site's host.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without one of those two approaches, the Parent-Site and Child-Site logic will both attempt to evaluate on every page load in whichever container you import them into — which is harmless (the sender finds no matching dataLayer events on a page with no iframe events firing, and vice versa) but wasteful and confusing to debug in GTM Preview mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported container (&lt;code&gt;gtm-postmessage-bridge-listener.json&lt;/code&gt;) is organized into five folders, twelve tags, six triggers, and fourteen variables. Two Sandboxed JavaScript Custom Templates — &lt;em&gt;postMessage Bridge - Sender Configuration&lt;/em&gt; and &lt;em&gt;postMessage Bridge - Receiver&lt;/em&gt; — do the real work, supported by two small Custom HTML bootstrap tags and the UTM/URL-decoration components carried over from earlier recipes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Folders
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Folder&lt;/th&gt;
&lt;th&gt;Side&lt;/th&gt;
&lt;th&gt;Contains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postMessage Bridge Sender&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child Site&lt;/td&gt;
&lt;td&gt;The Sender Configuration template tag, the Sender Bootstrap Custom HTML tag, the diagnostic tag, the all-events custom trigger, the required (unattached) Click trigger, and the referral-host constant.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UTM &amp;amp; URL Processing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child Site&lt;/td&gt;
&lt;td&gt;Writes UTM query-string values to session cookies on the child page, plus the &lt;code&gt;_ga&lt;/code&gt; query-string reader/validator that feeds the GA4 config tag's &lt;code&gt;client_id&lt;/code&gt; override.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Analytics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared&lt;/td&gt;
&lt;td&gt;GA4 initialization tag, Browser Client ID variable, Measurement Stream ID constant, and the Google Tag Shared Configuration Settings variable used by the child-site GA4 config tag.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postMessage Bridge Receiver&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent Site&lt;/td&gt;
&lt;td&gt;The Receiver template tag, the Bootstrap Listener Custom HTML tag, the diagnostic tag, the Initialization trigger, and the custom event trigger that catches the bridged &lt;code&gt;iframe.gtm.click&lt;/code&gt; event.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UTM &amp;amp; URL Processing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent Site&lt;/td&gt;
&lt;td&gt;The URL Decoration Config, its "set global" tag, the DOM listener/scanner tag, the target-host constant, the UTM cookie-pair reader, and the trigger pair that decorates the iframe's &lt;code&gt;src&lt;/code&gt; with UTM values and the GA4 &lt;code&gt;client_id&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Tags
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Side&lt;/th&gt;
&lt;th&gt;Fires On&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postMessage Bridge - Sender Configuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child&lt;/td&gt;
&lt;td&gt;Custom Event — regex &lt;code&gt;.*&lt;/code&gt; (all events)&lt;/td&gt;
&lt;td&gt;Scans new &lt;code&gt;dataLayer&lt;/code&gt; entries since it last ran, drops anything in &lt;code&gt;excludeEvents&lt;/code&gt; (defaults to &lt;code&gt;gtm.js, gtm.dom, gtm.load&lt;/code&gt;), keeps only events in &lt;code&gt;eventAllowlist&lt;/code&gt; (&lt;code&gt;gtm.click&lt;/code&gt; as exported), wraps each surviving event under the &lt;code&gt;namespace&lt;/code&gt; key, and stages the result on &lt;code&gt;window. __gtmPmOutbox&lt;/code&gt; / &lt;code&gt;window.__ gtmPmTargetOrigin&lt;/code&gt;. A per-target cursor in template storage stops it from re-sending events it already staged.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postMessage Bridge - Sender Bootstrap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child&lt;/td&gt;
&lt;td&gt;Same trigger, &lt;em&gt;sequenced to run immediately after&lt;/em&gt; Sender Configuration (set as its setup tag)&lt;/td&gt;
&lt;td&gt;The one piece of unavoidable Custom HTML on this side. Reads &lt;code&gt;window. __gtmPmOutbox&lt;/code&gt; and &lt;code&gt;window.__ gtmPmTargetOrigin&lt;/code&gt; and calls &lt;code&gt;window.top.postMessage(JSON.stringify(item), origin)&lt;/code&gt; for each staged item, because sandboxed JS cannot reach &lt;code&gt;window.top&lt;/code&gt; directly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Diagnostic - Is postMessage Alive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child&lt;/td&gt;
&lt;td&gt;Same trigger&lt;/td&gt;
&lt;td&gt;Off by default. &lt;em&gt;Note in the tag:&lt;/em&gt; "diagnostic tag. turn it on to see in the dataLayer if the postMessage processing is alive." Logs outbox contents, target origin, and whether the page is actually running inside an iframe.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Write UTMs to session cookies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child&lt;/td&gt;
&lt;td&gt;Initialization&lt;/td&gt;
&lt;td&gt;Same UTM-cookie template used in the earlier URL-decoration recipe, scoped to &lt;code&gt;{{Page Hostname}}&lt;/code&gt;, session-duration by default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Analytics Initialization&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared&lt;/td&gt;
&lt;td&gt;Initialization&lt;/td&gt;
&lt;td&gt;Standard GA4 config tag, routed to &lt;code&gt;{{Measurement Stream ID}}&lt;/code&gt; with the Shared Configuration Settings variable applied (see below).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postMessage Bridge - Receiver&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent&lt;/td&gt;
&lt;td&gt;Initialization&lt;/td&gt;
&lt;td&gt;Writes &lt;code&gt;window.__gtmPmConfig&lt;/code&gt; (allowed origins + iframe element ID) and defines &lt;code&gt;window.__gtmPmReceiver()&lt;/code&gt;, which validates the origin and namespace prefix, de-duplicates an exact repeat of the previous payload, and pushes the unwrapped event to the parent's &lt;code&gt;dataLayer&lt;/code&gt;. &lt;em&gt;Note on this tag:&lt;/em&gt; "note the importance of correctly coding the &lt;code&gt;https://{{postMessage Target Host}}&lt;/code&gt;, the tag element you are tracking - iframe, and the kinds of events you want to receive from the iframe - the gtm.click events. these must all be correct for the solution to work."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postMessage Bridge - Bootstrap Listener&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent&lt;/td&gt;
&lt;td&gt;Initialization, sequenced after Receiver&lt;/td&gt;
&lt;td&gt;The parent-side Custom HTML counterpart. Registers a single &lt;code&gt;window.addEventListener('message', …)&lt;/code&gt; handler that checks the message's origin against &lt;code&gt;__gtmPmConfig.allowedOrigins&lt;/code&gt;, optionally confirms &lt;code&gt;evt.source&lt;/code&gt; matches the specific iframe element's &lt;code&gt;contentWindow&lt;/code&gt;, and hands off to &lt;code&gt;window.__ gtmPmReceiver()&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Diagnostic - Is postMessage Alive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent&lt;/td&gt;
&lt;td&gt;All Pages&lt;/td&gt;
&lt;td&gt;Off by default. &lt;em&gt;Note in the tag:&lt;/em&gt; "diagnostic tag. turn it on to see in the dataLayer if the postMessage processing is alive." Logs every raw incoming message, polls for the receiver config/function to appear, and watches &lt;code&gt;dataLayer.push&lt;/code&gt; for anything landing with an &lt;code&gt;iframe.&lt;/code&gt;-prefixed event name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Set URL Decoration Config Global&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent&lt;/td&gt;
&lt;td&gt;Setup tag only&lt;/td&gt;
&lt;td&gt;Writes &lt;code&gt;{{URL Decoration Config}}&lt;/code&gt; to &lt;code&gt;window.gtmUrlDecoratorConfig&lt;/code&gt; before the listener below runs. Carried over from the URL-decoration recipe.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;URL Decoration Listener&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent&lt;/td&gt;
&lt;td&gt;DOM Ready&lt;/td&gt;
&lt;td&gt;Scans the DOM for elements (including &lt;code&gt;&amp;lt;iframe src&amp;gt;&lt;/code&gt;) pointing at a permitted host and, if found, pushes &lt;code&gt;urlDecorationTargetFound&lt;/code&gt; to the dataLayer. This is what makes the iframe's &lt;code&gt;src&lt;/code&gt; a decoration target in the first place — it must find your iframe on the page.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;Decorate Target URLs&lt;/code&gt; tag and the &lt;code&gt;Populate form … EXAMPLE&lt;/code&gt; tags are also present as reused/example scaffolding: the former is the same tag documented in the URL-decoration recipe (it appends the UTM cookie pairs and the GA4 &lt;code&gt;client_id&lt;/code&gt; pair onto the iframe's &lt;code&gt;src&lt;/code&gt;); the latter is a paused, illustrative Custom HTML tag on both sides showing one way a downstream form could read the passed-through &lt;code&gt;_ga&lt;/code&gt;/&lt;code&gt;utm_campaign&lt;/code&gt; cookie values into hidden fields. Neither needs to be enabled for the bridge itself to work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Triggers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Side&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postMessage Bridge - Custom Event - All Events (regex .*)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child&lt;/td&gt;
&lt;td&gt;Custom Event, &lt;code&gt;{{_event}}&lt;/code&gt; matches RegEx &lt;code&gt;.*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Deliberately unfiltered at the trigger level. Filtering by event name (the exclude list and the allow-list) happens inside the Sender Configuration template instead, so you never have to edit a trigger's regex to change which events get bridged — you edit a tag field.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Click - Necessary to Create dataLayer gtm.click&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child&lt;/td&gt;
&lt;td&gt;Click (unattached to any tag)&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Note in the trigger:&lt;/em&gt; "if you are sending gtm.click from the iframe to the parent site, you must have at least the trigger for gtm.click in the iframe container - even if it is not attached to a tag. the absence of the trigger means the absence of the gtm.click event in the dataLayer and resultingly no iframe.gtm.click will be sent by postMessage." GTM only pushes &lt;code&gt;gtm.click&lt;/code&gt; into the dataLayer at all when at least one Click trigger exists in the container — this trigger exists purely to satisfy that requirement.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DOM Ready - All Pages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent&lt;/td&gt;
&lt;td&gt;DOM Ready&lt;/td&gt;
&lt;td&gt;As exported, unconditional — fires on every parent page. &lt;strong&gt;Change this&lt;/strong&gt; — see below.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Click from iframe - iframe.gtm.click&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent&lt;/td&gt;
&lt;td&gt;Custom Event, &lt;code&gt;{{_event}}&lt;/code&gt; equals &lt;code&gt;iframe.gtm.click&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Note in the trigger:&lt;/em&gt; "the custom event sent by postMessage from the iframe. note that the components tie back to the 'iframe' and 'gtm.click' configuration parameters in the child / iframe. you now have the full payload of the iframe gtm.click in the parent dataLayer. this can be altered for anything captured in the child / iframe based on the event_name in the child / iframe dataLayer." This is the hook point for any downstream parent-side GA4 event tag you want to fire off the bridged click.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;URL decoration target found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent&lt;/td&gt;
&lt;td&gt;Custom Event, equals &lt;code&gt;urlDecorationTargetFound&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Fires the decorator tag only once the DOM scan has actually found a qualifying element on the current page.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Window Loaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared&lt;/td&gt;
&lt;td&gt;Window Loaded&lt;/td&gt;
&lt;td&gt;Utility trigger present from the Analytics folder scaffolding; not wired to a tag in this export.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Key Variables
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Side&lt;/th&gt;
&lt;th&gt;Role &amp;amp; Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Referral Host - iframe Parent Site&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child&lt;/td&gt;
&lt;td&gt;Constant, default &lt;code&gt;drewspen.blogspot.com&lt;/code&gt;. &lt;em&gt;Note:&lt;/em&gt; "enter the host name of the parent / referral site here." Feeds the Sender Configuration tag's &lt;code&gt;targetOrigin&lt;/code&gt; field as &lt;code&gt;https://{{this}}&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postMessage Target Host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent&lt;/td&gt;
&lt;td&gt;Constant, default &lt;code&gt;postmessage-drewspen.blogspot.com&lt;/code&gt;. &lt;em&gt;Note:&lt;/em&gt; "the host name of the iframe source. note that not only this must match, but the iframe id must also be configured correctly to match in the tag configuration." Feeds the Receiver tag's &lt;code&gt;allowedOrigins&lt;/code&gt; field and the URL Decoration Config's &lt;code&gt;permittedHosts&lt;/code&gt; list.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Obtain _ga client id from URL query string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child&lt;/td&gt;
&lt;td&gt;Reads the &lt;code&gt;_ga&lt;/code&gt; query-string parameter off the child page's own URL. Its format-value fallback substitutes &lt;code&gt;{{Browser Client ID}}&lt;/code&gt; when the parameter is missing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;URL Query String _ga Fix&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Note:&lt;/em&gt; "necessary so that if the _ga value is blank or empty it is converted to a true undefined. a blank or empty will be used to 'erase' the client_id. an undefined will allow for GA to generate a value." A Regex Table validates the resolved value against the GA4 client-ID shape (&lt;code&gt;NNNNNNNNNN.NNNNNNNNNN&lt;/code&gt;) before it's trusted; anything malformed falls through to undefined.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Tag Shared Configuration Settings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child&lt;/td&gt;
&lt;td&gt;Sets the GA4 config tag's &lt;code&gt;client_id&lt;/code&gt; parameter to &lt;code&gt;{{URL Query String _ga Fix}}&lt;/code&gt;, &lt;code&gt;send_page_view&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;, and &lt;code&gt;debug_mode&lt;/code&gt; to the Preview-only wrapper variable. This is the actual mechanism that lets the child iframe's GA4 hits carry the parent's client ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Root Domain&lt;/code&gt; / &lt;code&gt;Root Domain - old&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Shared&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Note:&lt;/em&gt; "Root domain of .blogspot.com did not work for Google Blogger. I did not want to do .blogger.com, so changed to page URL host name." Same Blogger-specific decision documented in the earlier UTM/URL-decoration recipes — cookies are scoped to &lt;code&gt;{{Page Hostname}}&lt;/code&gt;, not a computed eTLD+1.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Debug Mode (Preview Only)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Note:&lt;/em&gt; "Wraps the built-in {{Debug Mode}} variable so the debug_mode config parameter is OMITTED (undefined) on normal traffic rather than explicitly sent. Some GA4/gtag implementations key off the parameter's mere presence, not its value, and omitting it avoids sending a needless param on every hit."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How the Full Round Trip Works
&lt;/h2&gt;

&lt;p&gt;Outbound (parent → child) and inbound (child → parent) run independently, but together they form a loop:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Outbound: decorating the iframe's &lt;code&gt;src&lt;/code&gt; (Parent Site)
&lt;/h3&gt;

&lt;p&gt;This reuses the URL-decoration recipe wholesale, aimed at the iframe element instead of an outbound link. On DOM Ready, the listener scans the page; if it finds an &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; whose &lt;code&gt;src&lt;/code&gt; host matches &lt;code&gt;{{postMessage Target Host}}&lt;/code&gt;, it fires the decorator, which appends the parent's UTM cookie values and its GA4 &lt;code&gt;client_id&lt;/code&gt; (as &lt;code&gt;_ga&lt;/code&gt;) onto the iframe's &lt;code&gt;src&lt;/code&gt; before — or as — it loads.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Child side picks up the client ID
&lt;/h3&gt;

&lt;p&gt;The child page's own GA4 config tag reads that &lt;code&gt;_ga&lt;/code&gt; query parameter (with the validation and fallback described above) and uses it as its own &lt;code&gt;client_id&lt;/code&gt;, instead of letting &lt;code&gt;gtag.js&lt;/code&gt; generate a fresh one. The two properties now share a &lt;code&gt;client_id&lt;/code&gt;, and by extension a &lt;code&gt;pseudo_user_id&lt;/code&gt; — though not a session.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Sender: staging events inside the iframe
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;postMessage Bridge - Custom Event - All Events&lt;/code&gt; trigger fires the Sender Configuration tag on every dataLayer push. It walks any dataLayer entries it hasn't seen yet, drops &lt;code&gt;gtm.js&lt;/code&gt;/&lt;code&gt;gtm.dom&lt;/code&gt;/&lt;code&gt;gtm.load&lt;/code&gt;, keeps only &lt;code&gt;gtm.click&lt;/code&gt; (as configured), tags each surviving event with the page URL and title, and stages the wrapped result — plus the target origin — on plain &lt;code&gt;window&lt;/code&gt; globals. Its setup tag, Sender Bootstrap, runs immediately after and does the one thing the sandbox can't: &lt;code&gt;window.top.postMessage(JSON.stringify(item), 'https://drewspen.blogspot.com')&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Receiver: unwrapping events on the parent
&lt;/h3&gt;

&lt;p&gt;At Initialization — before most other tags — the Receiver tag writes &lt;code&gt;window.__gtmPmConfig&lt;/code&gt; (the allowed origins and the tracked iframe's element ID) and defines &lt;code&gt;window.__gtmPmReceiver()&lt;/code&gt;. Its setup tag, Bootstrap Listener, registers the single &lt;code&gt;message&lt;/code&gt; event listener for the whole page. When a message arrives, the listener confirms the origin is allowed and, if an iframe element ID is configured, that &lt;code&gt;evt.source&lt;/code&gt; really is that specific iframe's &lt;code&gt;contentWindow&lt;/code&gt; — then calls the receiver function, which checks the origin again, confirms the event name starts with the configured namespace, drops an exact repeat of the previous payload, and pushes the unwrapped event (&lt;code&gt;iframe.gtm.click&lt;/code&gt;, wrapped under &lt;code&gt;postMessageData&lt;/code&gt;) into the parent's own &lt;code&gt;dataLayer&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Downstream on the parent
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;Click from iframe - iframe.gtm.click&lt;/code&gt; Custom Event trigger is now listening for exactly that event name. Attach any GA4 event tag (or additional trigger conditions on the bridged payload's fields) to that trigger to turn "someone clicked inside the embedded form" into a first-class parent-site GA4 event.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Need to Change Before Using This
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Change this — Host names
&lt;/h3&gt;

&lt;p&gt;Two Constant variables carry the placeholder host names in this export: &lt;code&gt;Referral Host - iframe Parent Site&lt;/code&gt; (Child, default &lt;code&gt;drewspen.blogspot.com&lt;/code&gt;) must be the real host of the page that will embed your iframe. &lt;code&gt;postMessage Target Host&lt;/code&gt; (Parent, default &lt;code&gt;postmessage-drewspen.blogspot.com&lt;/code&gt;) must be the real host the iframe's &lt;code&gt;src&lt;/code&gt; points at. Both origin checks are exact, case-insensitive hostname comparisons — no wildcards, no path matching.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change this — The tracked iframe element
&lt;/h3&gt;

&lt;p&gt;The Receiver tag's &lt;code&gt;iframeElementId&lt;/code&gt; field ships as &lt;code&gt;iframe-form-tracking&lt;/code&gt;. This must exactly match the &lt;code&gt;id&lt;/code&gt; attribute on the actual &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; element in your parent page's HTML — if it doesn't, the receiver's &lt;code&gt;evt.source&lt;/code&gt; check will silently reject every message. Leave it blank only if you're comfortable accepting messages from any frame at the allowed origin, regardless of which element it came from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change this — Which events get bridged
&lt;/h3&gt;

&lt;p&gt;The Sender Configuration tag's &lt;code&gt;eventAllowlist&lt;/code&gt; ships as &lt;code&gt;gtm.click&lt;/code&gt; only. Add other dataLayer event names (comma-separated) if you also want to bridge, for example, a custom &lt;code&gt;form_submit&lt;/code&gt; event the child container pushes. The Receiver's own &lt;code&gt;eventAllowlist&lt;/code&gt; field is a second, independent gate on the parent side — both allow-lists have to agree for an event to land in the parent's dataLayer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change this — Trigger scoping (fire only where your iframe actually is)
&lt;/h3&gt;

&lt;p&gt;As exported, &lt;code&gt;DOM Ready - All Pages&lt;/code&gt; (Parent) has no conditions and fires on every page, and the Child-side &lt;code&gt;Custom Event - All Events&lt;/code&gt; trigger is unconditional inside whatever container it's imported into. Neither is a correctness problem by itself — the decorator only acts when the DOM scan finds something, and the sender only stages events that match its allow-list — but both are worth tightening. On the parent, add a Page Path or Page URL condition to &lt;code&gt;DOM Ready - All Pages&lt;/code&gt; so the DOM scan only runs on the specific template(s) that actually embed your tracked iframe. On the child, if you're running a shared container per the two-hostname approach described above, add the child hostname condition to the same trigger. Scope both sides to the pages where your target iframes are actually found, rather than letting either side evaluate everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built On Two Earlier Recipes
&lt;/h2&gt;

&lt;p&gt;Part of this solution reuses components from two earlier posts on this blog, unchanged in mechanism:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://drewspen.blogspot.com/2026/07/gtm-url-decoration-to-transfer.html" rel="noopener noreferrer"&gt;GTM URL Decoration to Transfer Attribution&lt;/a&gt; — the permitted-hosts, config-global, DOM-scan-then-decorate pattern used here to decorate the iframe's &lt;code&gt;src&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://drewspen.blogspot.com/2026/06/utm-url-query-string-2-cookies.html" rel="noopener noreferrer"&gt;UTM &amp;amp; URL Query String 2 Cookies&lt;/a&gt; — the session-cookie writer that keeps UTM values available on the parent page beyond the original landing page, so they're still there to decorate the iframe with on a later page view.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Diagnostics
&lt;/h2&gt;

&lt;p&gt;Both sides ship a paused &lt;code&gt;Diagnostic - Is postMessage Alive&lt;/code&gt; tag. Enable the child-side one to confirm the outbox is being staged and the target origin is set correctly; enable the parent-side one to confirm the listener is registered, watch every raw incoming message (regardless of origin), and see bridged events land in the dataLayer in real time. Turn both back off before publishing — they're verbose &lt;code&gt;console.log&lt;/code&gt; tags meant for GTM Preview mode only.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON from the &lt;a href="https://drive.google.com/file/d/1keh7zePbSqWMoAul7Pl_coBuq71T2epd/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt; above.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;gtm-postmessage-bridge-listener.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing container setup, and pick a target Workspace.&lt;/li&gt;
&lt;li&gt;If importing into &lt;em&gt;two separate containers&lt;/em&gt; (recommended): import the whole file into both, then delete the Parent Site-labeled items from the child container and the Child Site-labeled items from the parent container.&lt;/li&gt;
&lt;li&gt;If importing into &lt;em&gt;one shared container&lt;/em&gt;: keep everything, but add the hostname trigger conditions described in "Parent Site vs. Child Site" above.&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;Referral Host - iframe Parent Site&lt;/code&gt; and &lt;code&gt;postMessage Target Host&lt;/code&gt; with your real hostnames.&lt;/li&gt;
&lt;li&gt;Update the Receiver tag's &lt;code&gt;iframeElementId&lt;/code&gt; to match your actual &lt;code&gt;&amp;lt;iframe id="…"&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;Measurement Stream ID&lt;/code&gt; with your real GA4 Measurement ID on both sides.&lt;/li&gt;
&lt;li&gt;Scope the &lt;code&gt;DOM Ready - All Pages&lt;/code&gt; trigger (and, if applicable, the child-side custom event trigger) to the pages that actually contain your tracked iframe.&lt;/li&gt;
&lt;li&gt;Confirm consent settings — the Sender Configuration and Sender Bootstrap tags require &lt;code&gt;functionality_storage&lt;/code&gt; as exported; adjust to match your CMP setup.&lt;/li&gt;
&lt;li&gt;Open GTM Preview mode on both the parent page and the child/iframe page. Turn on both diagnostic tags temporarily. Click something inside the iframe and confirm: &lt;code&gt;gtm.click&lt;/code&gt; fires in the child dataLayer → the outbox stages an item → &lt;code&gt;postMessage&lt;/code&gt; fires → the parent's listener receives it → &lt;code&gt;iframe.gtm.click&lt;/code&gt; lands in the parent dataLayer.&lt;/li&gt;
&lt;li&gt;Turn the diagnostic tags back off and publish.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;The full source — container JSON and documentation — is also published on GitHub. If you extend the recipe (additional bridged event types, a different namespace per iframe, or support for more than one tracked iframe on the same parent page), open a pull request or issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1keh7zePbSqWMoAul7Pl_coBuq71T2epd/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googletagmanager</category>
      <category>gtm</category>
    </item>
    <item>
      <title>GTM e-Commerce Implementation GA4 and Meta</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Sat, 18 Jul 2026 11:44:28 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/gtm-e-commerce-implementation-ga4-and-meta-gc8</link>
      <guid>https://dev.to/drew_spencer_1066/gtm-e-commerce-implementation-ga4-and-meta-gc8</guid>
      <description>&lt;h1&gt;
  
  
  GA4 Ecommerce to Meta Pixel &amp;amp; Conversions API Bridge in GTM — Standard Events From a Standard DataLayer
&lt;/h1&gt;

&lt;p&gt;Published · Tags: GTM, GA4, Ecommerce, Meta Pixel, Conversions API, dataLayer, Custom Event Trigger, Standard Events, Advanced Matching&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This solution utilizes the &lt;strong&gt;standard GTM/GA4 ecommerce data model&lt;/strong&gt; — the &lt;code&gt;ecommerce&lt;/code&gt; object and &lt;code&gt;items[]&lt;/code&gt; array documented in Google's GA4 ecommerce specification. Because the dataLayer strictly adheres to that spec, the same ecommerce payload is reused, unmodified, to power both the GA4 event tag and the Meta Pixel / Conversions API tag. The JSON container can be downloaded from Google Drive below.&lt;/p&gt;

&lt;p&gt;Sending ecommerce events to GA4 is only half the job on most sites — Meta (Facebook/Instagram) Ads also needs standard events like &lt;code&gt;ViewContent&lt;/code&gt;, &lt;code&gt;AddToCart&lt;/code&gt;, and &lt;code&gt;Purchase&lt;/code&gt; to build audiences and measure ROAS. The naive approach is to hand-build a second dataLayer, or a second set of GTM tags with their own hard-coded parameter mapping for every event, every time a new one is added to the site.&lt;/p&gt;

&lt;p&gt;This recipe avoids that duplication entirely. A single set of GTM variables translates whatever event name the site's dataLayer emits — whether it's a textbook GA4 name like &lt;code&gt;add_to_cart&lt;/code&gt; or a platform-flavored synonym like &lt;code&gt;product_added_to_cart&lt;/code&gt; — down to the canonical GA4 event name, and then across again to the matching Meta standard event name. Both the GA4 tag and the Meta tag read the &lt;strong&gt;same&lt;/strong&gt; &lt;code&gt;ecommerce&lt;/code&gt; object off the dataLayer. No parameter is mapped twice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1ZJ9ANZIn7dpNkMv85xIM2Zg2Mx-Yew55/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported container (&lt;code&gt;gtm-ecommerce-meta-measurement.json&lt;/code&gt;) is organised into three folders, three tags, three triggers, and a set of variables covering event-name translation, environment/stream routing, and shared GA4 configuration and event settings. One community Custom Template — the Meta Pixel &amp;amp; Conversions API tag from Meta's own GTM Gallery listing — powers both Meta tags.&lt;/p&gt;

&lt;h3&gt;
  
  
  Folders
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Folder&lt;/th&gt;
&lt;th&gt;Contains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;Environment/stream routing variables (&lt;code&gt;Measurement Stream RegEx Lookup&lt;/code&gt;), Browser Client ID, Google Tag shared configuration and event settings variables.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;e-Commerce&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;e-Commerce Send Events&lt;/code&gt; GA4 event tag, its firing trigger, and the &lt;code&gt;e-Commerce Event Name Translator&lt;/code&gt; lookup variable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meta&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;Meta e-Commerce Standard Event&lt;/code&gt; and &lt;code&gt;Meta Pageview&lt;/code&gt; tags, their triggers, the &lt;code&gt;Meta Standard Event Name Translator&lt;/code&gt;, the production-traffic gate, and the Meta Pixel ID constant.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Tags
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Fires On&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e-Commerce Send Events&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GA4 Event (&lt;code&gt;gaawe&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;e-Commerce Events&lt;/td&gt;
&lt;td&gt;Sends every recognized ecommerce dataLayer event to GA4. Event name comes from the &lt;em&gt;e-Commerce Event Name Translator&lt;/em&gt;; ecommerce data is read directly from the dataLayer; the measurement ID is resolved per-environment; shared event settings are appended.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Meta e-Commerce Standard Event&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Meta Pixel &amp;amp; CAPI (Custom Template)&lt;/td&gt;
&lt;td&gt;Meta Standard e-Commerce Event&lt;/td&gt;
&lt;td&gt;Sends the equivalent Meta standard event (ViewContent, AddToCart, Purchase, etc.) using the same GA4-shaped ecommerce object (&lt;code&gt;useGA4Ecommerce: true&lt;/code&gt;), with Advanced Matching and server-side Conversions API forwarding enabled.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Meta Pageview&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Meta Pixel &amp;amp; CAPI (Custom Template)&lt;/td&gt;
&lt;td&gt;Meta DOM Ready&lt;/td&gt;
&lt;td&gt;Fires the standard Meta &lt;code&gt;PageView&lt;/code&gt; event once per page, gated to production traffic only, with the same Advanced Matching parameters as the ecommerce tag.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Triggers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Conditions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e-Commerce Events&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Event&lt;/td&gt;
&lt;td&gt;Event name matches any recognized ecommerce event pattern (GA4-standard and common platform synonyms) &lt;strong&gt;AND&lt;/strong&gt; the &lt;em&gt;e-Commerce Event Name Translator&lt;/em&gt; output is non-empty. Fires the GA4 tag on every environment.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Meta Standard e-Commerce Event&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Event&lt;/td&gt;
&lt;td&gt;Same event-name pattern as above &lt;strong&gt;AND&lt;/strong&gt; the &lt;em&gt;Meta Standard Event Name Translator&lt;/em&gt; resolves to a real Meta event name &lt;strong&gt;AND&lt;/strong&gt; &lt;code&gt;is Production Traffic&lt;/code&gt; equals &lt;code&gt;true&lt;/code&gt;. This is the key gate: GA4 receives events from every environment, but Meta only ever receives production traffic.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Meta DOM Ready&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DOM Ready&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;is Production Traffic&lt;/code&gt; equals &lt;code&gt;true&lt;/code&gt;. Fires the Meta base pixel / PageView once the DOM is ready, on production only.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The GA4 Ecommerce Data Model
&lt;/h2&gt;

&lt;p&gt;Everything in this recipe hinges on the site's dataLayer following Google's standard GA4 ecommerce schema. Each ecommerce event pushes a top-level &lt;code&gt;event&lt;/code&gt; name plus an &lt;code&gt;ecommerce&lt;/code&gt; object. Item-level detail always lives in an &lt;code&gt;items&lt;/code&gt; array, and event-level detail (currency, value, transaction ID, etc.) lives alongside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Reset the ecommerce object first — see note below&lt;/span&gt;
&lt;span class="nx"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ecommerce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;add_to_cart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ecommerce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;59.98&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SKU_12345&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;item_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Classic Denim Jacket&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;item_brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme Apparel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;item_category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Outerwear&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;item_variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Indigo / Medium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;29.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;quantity&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="na"&gt;index&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="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key parts of the model that this recipe depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;items[]&lt;/code&gt;:&lt;/strong&gt; one object per product line, carrying identity (&lt;code&gt;item_id&lt;/code&gt;, &lt;code&gt;item_name&lt;/code&gt;), classification (&lt;code&gt;item_category&lt;/code&gt;, &lt;code&gt;item_brand&lt;/code&gt;, &lt;code&gt;item_variant&lt;/code&gt;), and transaction detail (&lt;code&gt;price&lt;/code&gt;, &lt;code&gt;quantity&lt;/code&gt;, &lt;code&gt;discount&lt;/code&gt;, &lt;code&gt;coupon&lt;/code&gt;, &lt;code&gt;index&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event-level fields:&lt;/strong&gt; &lt;code&gt;currency&lt;/code&gt; and &lt;code&gt;value&lt;/code&gt; appear on every commerce event; &lt;code&gt;purchase&lt;/code&gt; additionally carries &lt;code&gt;transaction_id&lt;/code&gt;, &lt;code&gt;tax&lt;/code&gt;, and &lt;code&gt;shipping&lt;/code&gt;; &lt;code&gt;view_item_list&lt;/code&gt; and &lt;code&gt;select_item&lt;/code&gt; carry &lt;code&gt;item_list_id&lt;/code&gt; / &lt;code&gt;item_list_name&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standard event names:&lt;/strong&gt; &lt;code&gt;view_item_list&lt;/code&gt;, &lt;code&gt;select_item&lt;/code&gt;, &lt;code&gt;view_item&lt;/code&gt;, &lt;code&gt;add_to_cart&lt;/code&gt;, &lt;code&gt;remove_from_cart&lt;/code&gt;, &lt;code&gt;view_cart&lt;/code&gt;, &lt;code&gt;begin_checkout&lt;/code&gt;, &lt;code&gt;add_shipping_info&lt;/code&gt;, &lt;code&gt;add_payment_info&lt;/code&gt;, &lt;code&gt;purchase&lt;/code&gt;, &lt;code&gt;refund&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;ecommerce: null&lt;/code&gt; reset:&lt;/strong&gt; GTM's dataLayer merges new pushes into previous state rather than replacing it. Without clearing &lt;code&gt;ecommerce&lt;/code&gt; before every push, stale &lt;code&gt;items&lt;/code&gt; from a prior event can leak into the next one. This reset is standard GA4 implementation guidance and is assumed by every tag in this container.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the GA4 tag's &lt;code&gt;getEcommerceDataFrom&lt;/code&gt; parameter is set to &lt;code&gt;dataLayer&lt;/code&gt;, and the Meta tag's &lt;code&gt;useGA4Ecommerce&lt;/code&gt; parameter is enabled, both tags parse this exact same object. No product data is manually re-mapped for Meta — it is passed straight through and reshaped internally by the Meta template into Pixel/CAPI's own &lt;code&gt;content_ids&lt;/code&gt; / &lt;code&gt;contents&lt;/code&gt; format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Event Name Translation: One DataLayer, Two Vocabularies
&lt;/h2&gt;

&lt;p&gt;Sites rarely emit ecommerce events under a single naming convention. This container's trigger matches both the canonical GA4 names and common alternate naming conventions seen in Shopify, Segment-style, and headless-commerce integrations, then normalizes everything through two chained Simple Lookup Table variables.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — &lt;code&gt;e-Commerce Event Name Translator&lt;/code&gt; (site event name → canonical GA4 name)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Incoming dataLayer Event&lt;/th&gt;
&lt;th&gt;Canonical GA4 Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;product_viewed&lt;/code&gt; / &lt;code&gt;item_viewed&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;view_item&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cart_viewed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;view_cart&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;product_added_to_cart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;add_to_cart&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;checkout_progress&lt;/code&gt; / &lt;code&gt;checkout_contact_info_submitted&lt;/code&gt; / &lt;code&gt;set_checkout_option&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;begin_checkout&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checkout_address_info_submitted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;add_shipping_info&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;payment_info_submitted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;add_payment_info&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checkout_completed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;purchase&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anything else (e.g. already-standard names like &lt;code&gt;view_item&lt;/code&gt;, &lt;code&gt;purchase&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Passed through unchanged (default value = &lt;code&gt;{{Event}}&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Step 2 — &lt;code&gt;Meta Standard Event Name Translator&lt;/code&gt; (canonical GA4 name → Meta standard event)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Canonical GA4 Event&lt;/th&gt;
&lt;th&gt;Meta Standard Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;view_item&lt;/code&gt; / &lt;code&gt;select_content&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ViewContent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;add_to_cart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AddToCart&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;begin_checkout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InitiateCheckout&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;add_payment_info&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AddPaymentInfo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;purchase&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Purchase&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;generate_lead&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Lead&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;add_to_wishlist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AddToWishlist&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anything else&lt;/td&gt;
&lt;td&gt;No default value — the Meta trigger's regex condition requires a non-empty result, so unmapped events simply never fire the Meta tag.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Chaining the two lookups this way means a new GA4-standard event only has to be added &lt;em&gt;once&lt;/em&gt;, to the second table, to also start reaching Meta — the first table only exists to normalize non-standard names down to something the second table already understands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meta Advanced Matching &amp;amp; the Production Traffic Gate
&lt;/h2&gt;

&lt;p&gt;Both Meta tags enable &lt;strong&gt;Advanced Matching&lt;/strong&gt; , passing an &lt;code&gt;external_id&lt;/code&gt; parameter populated from the same &lt;code&gt;Browser Client ID&lt;/code&gt; variable used for GA4's &lt;code&gt;client_id&lt;/code&gt;. This gives Meta a stable, first-party identifier to match against without relying solely on third-party cookies, and keeps the identity signal consistent across both platforms.&lt;/p&gt;

&lt;p&gt;Both Meta tags also declare &lt;code&gt;consent: true&lt;/code&gt; and require &lt;code&gt;ad_storage&lt;/code&gt;, &lt;code&gt;ad_user_data&lt;/code&gt;, &lt;code&gt;ad_personalization&lt;/code&gt;, and &lt;code&gt;analytics_storage&lt;/code&gt; — so they respect whatever Consent Mode signals are set on the page before firing, in addition to the &lt;code&gt;optInMetaCAPI&lt;/code&gt; and &lt;code&gt;dpoLDU&lt;/code&gt; (Limited Data Use) template parameters, which govern server-side Conversions API forwarding and Limited Data Use flagging respectively.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;is Production Traffic&lt;/code&gt; variable resolves to &lt;code&gt;true&lt;/code&gt; only when &lt;code&gt;Measurement Stream RegEx Lookup&lt;/code&gt; resolves to the production GA4 Measurement ID — meaning Meta's tags are gated behind the same environment detection already used for GA4 stream routing. The net effect: GA4 sees events from every environment (dev, staging, production) routed to the correct property, while Meta only ever receives production traffic, keeping ad-platform audiences and CAPI events clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment-Aware Stream Routing
&lt;/h2&gt;

&lt;p&gt;Like other recipes in this series, the container automatically routes GA4 hits to the correct property:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;Measurement Stream RegEx Lookup&lt;/code&gt; checks &lt;code&gt;{{Page URL}}&lt;/code&gt;. Non-production hostname patterns (&lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;uat&lt;/code&gt;, &lt;code&gt;qa&lt;/code&gt;, &lt;code&gt;stg&lt;/code&gt;/&lt;code&gt;staging&lt;/code&gt;, &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;local&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;aem&lt;/code&gt;, the GTM preview/render endpoints) resolve to &lt;code&gt;Measurement Stream ID Development&lt;/code&gt; (placeholder: &lt;code&gt;G-ZZZZZZZZZZ&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Everything else falls through to &lt;code&gt;Measurement Stream ID Production&lt;/code&gt; (placeholder: &lt;code&gt;G-AAAAAAAAAA&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Update the two Constant variables with your real GA4 Measurement IDs before publishing, and update &lt;code&gt;Meta Tracking ID&lt;/code&gt; (placeholder: &lt;code&gt;1000000000000000&lt;/code&gt;) with your real Meta Pixel ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared Configuration &amp;amp; Event Settings
&lt;/h2&gt;

&lt;p&gt;Two Google Tag configuration variables keep GA4 settings DRY across the container rather than repeating parameters on every tag:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Applies&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Tag Shared Configuration Settings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;debug_mode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Debug Mode (Preview Only)&lt;/code&gt; — only sets &lt;code&gt;true&lt;/code&gt; while inside GTM Preview mode.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Tag Shared Configuration Settings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;send_page_view&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;false&lt;/code&gt; — pageviews are handled deliberately elsewhere rather than automatically on config load.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Tag Shared Event Settings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;e_client_id&lt;/code&gt; (event param), &lt;code&gt;u_client_id&lt;/code&gt; (user property)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Browser Client ID Append Underscore&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Tag Shared Event Settings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;page_title&lt;/code&gt; (event param)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Page Title&lt;/code&gt; (&lt;code&gt;document.title&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Community Custom Templates Used
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Meta Pixel &amp;amp; Conversions API&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/facebook/GoogleTagManager-WebTemplate-For-FacebookPixel" rel="noopener noreferrer"&gt;facebook / GTM Gallery&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Powers both Meta tags — client-side Pixel firing plus optional server-side Conversions API forwarding, standard event mapping, and Advanced Matching parameters.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;If Else If — Advanced Lookup Table&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/sublimetrix/gtm-template-ifelseif" rel="noopener noreferrer"&gt;sublimetrix&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Imported alongside the Meta template as a general-purpose multi-condition lookup utility. Not actively wired into a live tag in this export, but available for future branching logic (e.g. country/state-conditional Data Processing Options).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Project context:&lt;/strong&gt; This solution was originally developed as part of an employment evaluation project. Building it end-to-end was a useful demonstration of just how streamlined the GTM tag configuration becomes once the dataLayer strictly adheres to the standard GA4 ecommerce specification — one &lt;code&gt;ecommerce&lt;/code&gt; object, one &lt;code&gt;items[]&lt;/code&gt; schema, reused without modification by every downstream destination tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON from the &lt;a href="https://drive.google.com/file/d/1ZJ9ANZIn7dpNkMv85xIM2Zg2Mx-Yew55/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt; above.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;gtm-ecommerce-meta-measurement.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing container setup.&lt;/li&gt;
&lt;li&gt;Update the &lt;code&gt;Measurement Stream ID Production&lt;/code&gt; and &lt;code&gt;Measurement Stream ID Development&lt;/code&gt; Constant variables with your real GA4 Measurement IDs.&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;Meta Tracking ID&lt;/code&gt; with your real Meta Pixel ID.&lt;/li&gt;
&lt;li&gt;Confirm the Meta template is authorized to communicate with the Meta Pixel/CAPI endpoint domains, and, if using server-side forwarding, add your Meta Conversions API access token via the template's CAPI configuration fields.&lt;/li&gt;
&lt;li&gt;Verify consent settings on both Meta tags (&lt;code&gt;ad_storage&lt;/code&gt;, &lt;code&gt;ad_user_data&lt;/code&gt;, &lt;code&gt;ad_personalization&lt;/code&gt;, &lt;code&gt;analytics_storage&lt;/code&gt;) match your CMP's consent categories.&lt;/li&gt;
&lt;li&gt;Confirm your site's dataLayer pushes follow the standard GA4 ecommerce schema — &lt;code&gt;ecommerce: null&lt;/code&gt; reset, then &lt;code&gt;event&lt;/code&gt; + &lt;code&gt;ecommerce.items[]&lt;/code&gt; — for every ecommerce interaction.&lt;/li&gt;
&lt;li&gt;Open GTM Preview mode and trigger an ecommerce event (e.g. add to cart). Confirm &lt;code&gt;e-Commerce Send Events&lt;/code&gt; fires with the translated event name and the expected &lt;code&gt;items&lt;/code&gt; payload.&lt;/li&gt;
&lt;li&gt;On a production hostname, confirm &lt;code&gt;Meta e-Commerce Standard Event&lt;/code&gt; and &lt;code&gt;Meta Pageview&lt;/code&gt; also fire, and verify the events appear in Meta Events Manager (both Pixel and, if enabled, server-side CAPI).&lt;/li&gt;
&lt;li&gt;On a dev/staging hostname, confirm GA4 still receives events (routed to the development property) while the Meta tags do &lt;strong&gt;not&lt;/strong&gt; fire — this confirms the production traffic gate is working.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;The full source — container JSON and documentation — is also published on GitHub. If you add support for additional GA4 ecommerce events or additional Meta standard events, extend the two lookup tables rather than adding new tags — that's the entire point of the chained-translator pattern.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1ZJ9ANZIn7dpNkMv85xIM2Zg2Mx-Yew55/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googleecommerce</category>
    </item>
    <item>
      <title>Microsoft Clarity GTM Integration</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Thu, 16 Jul 2026 20:33:56 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/microsoft-clarity-gtm-integration-5c7b</link>
      <guid>https://dev.to/drew_spencer_1066/microsoft-clarity-gtm-integration-5c7b</guid>
      <description>&lt;h1&gt;
  
  
  Microsoft Clarity via GTM — Free Behavioral Analytics That Sits Right Next to GA4
&lt;/h1&gt;

&lt;p&gt;Install Microsoft Clarity through Google Tag Manager in minutes, chain it cleanly off your existing GA4 event tags using Markus Baersch's &lt;em&gt;clarity-events&lt;/em&gt; template, and get heatmaps, session recordings, and JavaScript error tracking for the cost of zero dollars.&lt;/p&gt;

&lt;p&gt;Published · Tags: GTM, Google Analytics 4, Microsoft Clarity, Heatmaps, Session Recordings, Tag Sequencing, Custom Templates, dataLayer, Free Analytics&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This GTM container recipe installs Microsoft Clarity's official Sandboxed JavaScript template alongside a standard GA4 setup, then uses tag sequencing ("fire after") to relay selected GA4 events into Clarity as custom events via the community _clarity-events_template. The container works on Google Blogger / Blogspot as well as AEM, Sitecore, WordPress, React.js, and other platforms. The JSON can be downloaded from Google Drive below.&lt;/p&gt;

&lt;p&gt;Google Analytics tells you &lt;em&gt;what&lt;/em&gt; happened — sessions, conversions, traffic sources. It does not show you &lt;em&gt;why&lt;/em&gt;. Why did users abandon the checkout form? Why does that CTA button get almost no clicks even though it sits above the fold? Why is the bounce rate on one landing page twice as high as a nearly identical one? Those questions need eyes on the page, not just numbers in a report — and that is exactly the gap Microsoft Clarity fills.&lt;/p&gt;

&lt;p&gt;This recipe is built around three simple facts: Clarity is free, it drops into a GTM container with one official Custom Template, and once it's in, you can pipe your existing GA4 event data into it with almost no extra work using a community template built specifically for this purpose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1GEjbuUW9lFjtrGWqUns4vpe_PRbEENDX/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Incidental code in this container:&lt;/strong&gt; This export is a Clarity recipe first, but two of its tags — the CookieYes consent-interaction event and the session-duration milestone event — belong to their own complete, previously-published recipes, included here only to illustrate a realistic container. For the full build-out of each, see&lt;a href="///2026/07/cookieyes-consent-implementation-in-gtm.html"&gt;CCPA-Style CookieYes Consent Implementation in GTM&lt;/a&gt;and&lt;a href="///2026/07/session-duration-timer-in-gtm.html"&gt;Session Duration Timer in GTM&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Clarity Is Free — the Same Way GA Is Free
&lt;/h2&gt;

&lt;p&gt;Microsoft Clarity carries no paid tier. There is no traffic cap, no session-recording quota, no feature gate that suddenly asks for a credit card once your site gets popular. Heatmaps, unlimited session recordings, funnels, filters, and the Copilot AI insights layer are all included from the moment you create a project — the same "free at any scale" model that makes GA4 the default choice for web analytics. Sign-in only requires a Microsoft, Google, or Facebook account, and no billing details are collected anywhere in the setup flow.&lt;/p&gt;

&lt;p&gt;That matters for the same reason GA4's pricing model matters: it removes the cost conversation entirely. Adding Clarity next to GA4 isn't a budget decision or a tooling trade-off — it's just a checkbox.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Clarity Integrates Easily Through GTM
&lt;/h2&gt;

&lt;p&gt;Microsoft publishes an official Community Gallery template — &lt;strong&gt;Microsoft Clarity - Official&lt;/strong&gt; — that handles the entire base install. It's a Sandboxed JavaScript Custom Template, so like the other recipes on this blog, it does not inject inline&lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; content and does not require a CSP SHA-256 hash beyond what your existing &lt;code&gt;googletagmanager.com&lt;/code&gt; / &lt;code&gt;www.clarity.ms&lt;/code&gt; allow-listing already covers.&lt;/p&gt;

&lt;p&gt;The template takes a single required field — your Clarity &lt;strong&gt;Project ID&lt;/strong&gt; — plus an optional group of custom-identifier fields (User Id, Session Id, Page Id, Friendly Name) that let you stitch a Clarity session to a known user, and a simple key/value table for custom tags you can filter recordings and heatmaps by later.&lt;/p&gt;

&lt;p&gt;In this container, the base tag is configured like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;projectId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Microsoft Clarity Project ID}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your Clarity project, stored as a Constant variable so it's easy to swap per environment.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;userId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Browser Client ID}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reuses the same GA4 client ID already resolved elsewhere in the container, so a Clarity session and a GA4 client ID point at the same visitor.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sessionId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Browser Session ID}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Aligns the Clarity session identifier with the GA4 session ID for cross-tool debugging.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The base &lt;strong&gt;Microsoft Clarity&lt;/strong&gt; tag itself carries no firing trigger of its own — it's set up as a &lt;em&gt;teardown tag&lt;/em&gt; on the &lt;strong&gt;Google Analytics Configuration&lt;/strong&gt; tag (the standard GA4 Google Tag) with "stop teardown on failure" enabled. In plain terms: Clarity loads immediately after the GA4 config tag succeeds, on every page, with no duplicated trigger to maintain and no risk of Clarity firing before consent or configuration settings have been resolved.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Sending Additional Events to Clarity with Markus Baersch's Template
&lt;/h2&gt;

&lt;p&gt;The base install gets you heatmaps and recordings for free, but the real value comes from telling Clarity about the events you already care about in GA4 — a CMP interaction, a completed form, a session-duration milestone. Rather than hand-write separate event logic for Clarity, this recipe uses &lt;a href="https://github.com/mbaersch/clarity-events" rel="noopener noreferrer"&gt;Markus Baersch's&lt;code&gt;clarity-events&lt;/code&gt; Community Template&lt;/a&gt; (Custom Template ID &lt;code&gt;cvt_WV8T5&lt;/code&gt;), which wraps Clarity's &lt;code&gt;clarity('event', ...)&lt;/code&gt;, &lt;code&gt;clarity('set', ...)&lt;/code&gt;, and related JS-API calls in a simple GTM tag with fields for event name, custom tags, session upgrade, and consent state — no Custom HTML and no direct calls to the global&lt;code&gt;clarity()&lt;/code&gt; queue required.&lt;/p&gt;

&lt;p&gt;The interesting part is &lt;strong&gt;how&lt;/strong&gt; these tags fire. Rather than duplicating the trigger already attached to each GA4 Event tag, this container uses GTM's built-in &lt;strong&gt;tag sequencing&lt;/strong&gt; to chain the Clarity event tag as a teardown of the corresponding GA4 event tag:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Event Tag (fires first)&lt;/th&gt;
&lt;th&gt;Teardown → Clarity Event Tag&lt;/th&gt;
&lt;th&gt;Clarity Event Name&lt;/th&gt;
&lt;th&gt;Full Recipe&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CookieYes Interaction Send Event&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Microsoft Clarity CookieYes Interaction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmp_click&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="///2026/07/cookieyes-consent-implementation-in-gtm.html"&gt;CookieYes Consent Implementation in GTM&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Session Duration Send Event&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Microsoft Clarity Session Duration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;session_duration_{n}sec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="///2026/07/session-duration-timer-in-gtm.html"&gt;Session Duration Timer in GTM&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is what the "clean up" part of the chaining buys you: the Clarity event tag has_no firing trigger of its own_. It exists purely as a teardown, wired directly to the GA4 event tag whose data it mirrors. There is exactly one place — the GA4 tag's trigger — that decides when the pair fires. Change the trigger, adjust a filter, or retire the event entirely, and there's only one tag to touch. Nothing can drift out of sync between the two tags because there is only one source of truth for "when."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GA4 Event Tag: CookieYes Interaction Send Event
  Trigger: CookieYes Click Interaction
  Teardown → Microsoft Clarity CookieYes Interaction
                   (stop teardown on failure: true)

GA4 Event Tag: Session Duration Send Event
  Trigger: Session Duration Event
  Teardown → Microsoft Clarity Session Duration
                   (stop teardown on failure: true)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Stop teardown on failure" means if the GA4 tag itself fails (blocked by consent, network error, and so on), the Clarity event never fires either — which keeps the two platforms honest about what actually happened on the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This isn't the only valid pattern.&lt;/strong&gt; Teardown chaining is a convenience, not a requirement. You could just as easily give the Clarity event tag the _identical trigger_already attached to the GA4 event tag (&lt;code&gt;CookieYes Click Interaction&lt;/code&gt; or&lt;code&gt;Session Duration Event&lt;/code&gt; in this container) and let both tags fire independently off the same condition. The trade-off is duplication: two tags now reference the same trigger, so a future change to firing logic has to be applied twice. Teardown chaining trades that duplication for a small ordering dependency (the Clarity tag always fires slightly after the GA4 tag). Either approach gets the event into both tools — pick whichever fits how your team likes to maintain triggers.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. What Clarity Adds on Top of GA4
&lt;/h2&gt;

&lt;p&gt;Once the events are flowing, Clarity isn't a duplicate of GA4 — it's a different lens on the same visitors. Where GA4 aggregates events into counts and funnels, Clarity shows you the page itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Heatmaps&lt;/strong&gt; — click, scroll, and area maps generated automatically for every page, no configuration or tagging required, showing exactly where attention concentrates and where it drops off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session recordings&lt;/strong&gt; — DOM-based (not video) replays of real visits: every click, scroll, and mouse movement, playable back at variable speed and filterable by the custom events this container sends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript error tracking&lt;/strong&gt; — Clarity logs JS errors automatically and lets you jump straight to the session recording at the exact timestamp the error occurred, which turns "something broke for some users" into a five-minute investigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rage clicks, dead clicks &amp;amp; quick backs&lt;/strong&gt; — automatic detection of frustrated repeated clicking, clicks on non-interactive elements, and users who land on a page and immediately bounce back — UX friction signals GA4 has no equivalent for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excessive scrolling detection&lt;/strong&gt; — flags visitors who scroll back and forth repeatedly, often a sign they can't find what they're looking for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copilot AI summaries&lt;/strong&gt; — an AI-generated text summary of each session (traffic source, key actions, behavioral anomalies) and of aggregate heatmap patterns, so you don't have to watch every recording manually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smart Funnels&lt;/strong&gt; — funnel analysis built from Smart Events and page visits, showing exactly where a flow like checkout or signup loses people.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segments&lt;/strong&gt; — saved combinations of filters (device, region, referral source, custom tags like the ones this container's &lt;code&gt;customTags&lt;/code&gt; field sets) for repeatable analysis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Official GA4 cross-linking&lt;/strong&gt; — Microsoft's own GA4 integration adds a&lt;code&gt;clarity_session_url&lt;/code&gt; parameter to GA4 events, so a session recording is one click away from inside Google Analytics itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy-first by default&lt;/strong&gt; — password and payment fields are masked automatically, no PII is collected, and Clarity is built to be GDPR- and CCPA-aligned out of the box.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this replaces GA4's job of counting sessions, attributing conversions, and feeding Explorations and Audiences. It answers the question GA4 was never built to answer: _why_the numbers look the way they do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Community &amp;amp; Official Templates Used
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template&lt;/th&gt;
&lt;th&gt;Author&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Microsoft Clarity - Official&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Microsoft&lt;/td&gt;
&lt;td&gt;Base install — injects the Clarity tracking script and handles identify/custom-tag calls.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Microsoft Clarity Events&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/mbaersch/clarity-events" rel="noopener noreferrer"&gt;mbaersch&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Sends additional named events, custom tags, session upgrades, and consent signals to an already-running Clarity session.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CookieYes CMP&lt;/code&gt; &amp;amp; consent tags &lt;em&gt;(incidental)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;cookieyeshq / Kent Spencer / Simo Ahava&lt;/td&gt;
&lt;td&gt;Provides the CMP interaction this recipe relays into Clarity — full build-out in the &lt;a href="///2026/07/cookieyes-consent-implementation-in-gtm.html"&gt;CookieYes post&lt;/a&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Session or User Seconds Duration&lt;/code&gt; &lt;em&gt;(incidental)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;Kent Spencer (drewspen)&lt;/td&gt;
&lt;td&gt;Provides the duration milestone this recipe relays into Clarity — full build-out in the &lt;a href="///2026/07/session-duration-timer-in-gtm.html"&gt;Session Duration post&lt;/a&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON from the &lt;a href="https://drive.google.com/file/d/1GEjbuUW9lFjtrGWqUns4vpe_PRbEENDX/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt; above.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload the container JSON.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing GA4 setup.&lt;/li&gt;
&lt;li&gt;Update the &lt;code&gt;Microsoft Clarity Project ID&lt;/code&gt; Constant variable with your real Clarity project ID (found in your project URL: &lt;code&gt;clarity.microsoft.com/projects/view/&amp;lt;projectId&amp;gt;/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Confirm the &lt;strong&gt;Microsoft Clarity&lt;/strong&gt; tag's teardown relationship to &lt;strong&gt;Google Analytics Configuration&lt;/strong&gt; is intact after import — teardown/setup relationships are preserved by Merge but worth a visual check in the tag list.&lt;/li&gt;
&lt;li&gt;Open GTM Preview mode and load a page. Confirm the GA4 config tag fires, followed immediately by the Microsoft Clarity base tag in the same tag sequence.&lt;/li&gt;
&lt;li&gt;Trigger one of the events this container relays (a CookieYes consent click, or the session duration threshold) and confirm both the GA4 event tag and its paired Clarity event teardown tag fire in order. See the linked posts above if you want the full CookieYes or Session Duration logic rather than just the Clarity relay.&lt;/li&gt;
&lt;li&gt;In the Clarity dashboard, open &lt;strong&gt;Recordings&lt;/strong&gt; and confirm the custom event names (&lt;code&gt;cmp_click&lt;/code&gt;, &lt;code&gt;session_duration_…sec&lt;/code&gt;) appear as filterable events on live sessions.&lt;/li&gt;
&lt;li&gt;If you'd rather not rely on tag sequencing, re-point the Clarity event tags at the identical trigger already used by their paired GA4 event tag — both approaches are valid, see the trade-off discussion above.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;The full source — container JSON and documentation — is also published on GitHub. If you adapt this pattern to relay additional GA4 events into Clarity, or switch from teardown chaining to shared triggers, open a pull request or issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1GEjbuUW9lFjtrGWqUns4vpe_PRbEENDX/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googletagmanager</category>
      <category>gtm</category>
    </item>
    <item>
      <title>Session Duration Timer in GTM</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Thu, 09 Jul 2026 22:46:40 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/session-duration-timer-in-gtm-598e</link>
      <guid>https://dev.to/drew_spencer_1066/session-duration-timer-in-gtm-598e</guid>
      <description>&lt;h1&gt;
  
  
  Session Duration Timer in Google Tag Manager — A Complete Recipe Using Sandboxed JS
&lt;/h1&gt;

&lt;p&gt;Measure seconds spent on your site per session — or across all sessions — with no CSP SHA-256 hashes, no external libraries, and a single importable container JSON.&lt;/p&gt;

&lt;p&gt;Published · Tags: GTM, Google Analytics 4, Session Duration, Cookies, dataLayer, Custom Template, JavaScript&lt;/p&gt;

&lt;p&gt;Most GTM engagement-time solutions either lean on GA4's built-in engagement time (which you can't trigger mid-session) or inject inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags that break under a strict Content Security Policy. This recipe solves both problems. It uses a &lt;strong&gt;GTM Sandboxed JavaScript custom template&lt;/strong&gt; — the &lt;em&gt;Session or User Seconds Duration&lt;/em&gt; tag — to run a per-second &lt;code&gt;callLater&lt;/code&gt; loop that writes the running count to a first-party cookie &lt;em&gt;and&lt;/em&gt; pushes a named dataLayer event at every tick. The result is importable in one JSON file, zero SHA-256 exemptions required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1LBYjGwngLw2g2er3wwS582Xhlc0wjOxG/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported GTM recipe (&lt;code&gt;session-duration-timer-measurement-recipe.json&lt;/code&gt;) contains the following assets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Asset Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tag (Custom Template)&lt;/td&gt;
&lt;td&gt;Session Duration&lt;/td&gt;
&lt;td&gt;Sandboxed JS timer loop — increments a counter each second, writes to cookie, pushes &lt;code&gt;durationSeconds&lt;/code&gt; to the dataLayer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (GA4 Event)&lt;/td&gt;
&lt;td&gt;Session Duration Send Event&lt;/td&gt;
&lt;td&gt;Fires a dynamically named GA4 event (&lt;code&gt;session_duration_Nsec&lt;/code&gt;) at configured second milestones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (Google Tag)&lt;/td&gt;
&lt;td&gt;Google Analytics Configuration&lt;/td&gt;
&lt;td&gt;Standard GA4 config tag routed via environment-aware stream ID lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;Session Duration Event&lt;/td&gt;
&lt;td&gt;Custom event trigger — fires when &lt;code&gt;durationSeconds&lt;/code&gt; matches the regex `^(6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Template (Tag)&lt;/td&gt;
&lt;td&gt;Session or User Seconds Duration&lt;/td&gt;
&lt;td&gt;The engine: GTM sandboxed JS template by &lt;em&gt;drewspen&lt;/em&gt; — no {% raw %}&lt;code&gt;document.cookie&lt;/code&gt;, no inline script, no CSP issues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Template (Variable)&lt;/td&gt;
&lt;td&gt;If Else If – Advanced Lookup Table&lt;/td&gt;
&lt;td&gt;Environment-aware regex lookup for GA4 Stream ID routing (by &lt;em&gt;Sublimetrix&lt;/em&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Template (Variable)&lt;/td&gt;
&lt;td&gt;Timestamp&lt;/td&gt;
&lt;td&gt;Millisecond timestamp provider (by &lt;em&gt;luratic&lt;/em&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Template (Variable)&lt;/td&gt;
&lt;td&gt;Get Root Domain&lt;/td&gt;
&lt;td&gt;Extracts registrable root domain for cookie scoping (by &lt;em&gt;mbaersch&lt;/em&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variables (many)&lt;/td&gt;
&lt;td&gt;Session Duration Cookie Name, Session Duration dataLayer, is Session Duration Event, Browser Session/Client ID, Timestamps…&lt;/td&gt;
&lt;td&gt;Supporting variable layer for stream routing, session identity, and event detection&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;Here is how the timer flows from page load to GA4:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Page loads.&lt;/strong&gt; The &lt;em&gt;Session Duration&lt;/em&gt; tag fires on GTM container load (All Pages trigger or your chosen trigger).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie is read.&lt;/strong&gt; The template calls GTM's sandboxed &lt;code&gt;getCookieValues(cookieName)&lt;/code&gt;. If a count already exists in the &lt;code&gt;_i_vstdur&lt;/code&gt; cookie — including from a previous page in the same session — the counter resumes from that number rather than zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timer loop starts.&lt;/strong&gt; &lt;code&gt;callLater(tick)&lt;/code&gt; schedules a 1-second recursive loop using GTM's sandboxed &lt;code&gt;callLater&lt;/code&gt; API. Each tick: increments the counter, writes it back to the cookie with &lt;code&gt;setCookie()&lt;/code&gt;, and pushes a dataLayer event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dataLayer is updated every second.&lt;/strong&gt; Each push follows the pattern: &lt;code&gt;{ event: '_i_vstdur', durationSeconds: N }&lt;/code&gt;. The event name is the cookie name itself, enabling the Simple Value Map lookup variable &lt;em&gt;is Session Duration Event&lt;/em&gt; to confirm the event identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger fires at milestones.&lt;/strong&gt; The &lt;em&gt;Session Duration Event&lt;/em&gt; custom event trigger checks two conditions: &lt;code&gt;durationSeconds&lt;/code&gt; matches &lt;code&gt;^(6|11)$&lt;/code&gt; AND &lt;code&gt;is Session Duration Event&lt;/code&gt; equals &lt;code&gt;true&lt;/code&gt;. At second 6 and second 11 (configurable), the trigger fires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GA4 event is sent.&lt;/strong&gt; The &lt;em&gt;Session Duration Send Event&lt;/em&gt; tag fires a dynamically named GA4 event: &lt;code&gt;session_duration_6sec&lt;/code&gt; or &lt;code&gt;session_duration_11sec&lt;/code&gt;. A matching event parameter (&lt;code&gt;session_duration_6sec_count: 1&lt;/code&gt;) is sent alongside it, making these countable as conversion metrics in GA4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loop halts at maxSeconds.&lt;/strong&gt; The template's &lt;code&gt;if (counter &amp;gt;= maxSecondsNum) return;&lt;/code&gt; guard ensures the loop stops cleanly at your configured ceiling (11 seconds by default), preventing runaway calls.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Core Engine — Session or User Seconds Duration (Custom Template)
&lt;/h2&gt;

&lt;p&gt;This is the heart of the recipe. Rather than injecting a raw &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag — which would require a CSP SHA-256 hash for every GTM publish — this is built as a &lt;strong&gt;GTM sandboxed JavaScript custom template tag&lt;/strong&gt;. It uses only GTM's permitted sandboxed APIs: no &lt;code&gt;document.cookie&lt;/code&gt;, no &lt;code&gt;setInterval&lt;/code&gt;, no &lt;code&gt;window&lt;/code&gt; access. This is the key reason the solution is CSP-friendly out of the box.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logToConsole&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getCookieValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getCookieValues&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setCookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setCookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;createQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;createQueue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataLayerPush&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dataLayer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;callLater&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;callLater&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getTimestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getTimestamp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationCookie&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;duration&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maxSecondsNum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxSeconds&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookieDuration&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;persistentCookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max-age&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;persistentCookieExpires&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Resume from existing cookie value if present&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existingValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCookieValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingValues&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;existingValues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;existingValues&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existingNum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;existingNum&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;existingNum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;existingNum&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cookieOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastTick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTimestamp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;maxSecondsNum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTimestamp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastTick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;lastTick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// avoids unary + operator (sandboxed constraint)&lt;/span&gt;
    &lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cookieOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;dataLayerPush&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// e.g. '_i_vstdur'&lt;/span&gt;
      &lt;span class="na"&gt;durationSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;maxSecondsNum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;callLater&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;callLater&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;callLater&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gtmOnSuccess&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;counter - (-1)&lt;/code&gt; instead of &lt;code&gt;counter++&lt;/code&gt;?&lt;/strong&gt; GTM's sandboxed JS environment forbids certain operators. Subtracting negative one is a safe equivalent to incrementing that passes the sandboxed linter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No CSP SHA-256 required.&lt;/strong&gt; Because this logic lives inside a sandboxed custom template (not a Custom HTML tag), GTM loads it as part of the container's own script bundle. There is no separate inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block that a Content Security Policy would need to whitelist with a hash. Publish freely without touching your CSP headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Template Parameters
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rootDomain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Select (macro)&lt;/td&gt;
&lt;td&gt;Cookie domain scope. Defaults to &lt;code&gt;{{Root Domain}}&lt;/code&gt;. Example: &lt;code&gt;.example.com&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cookieDuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Radio&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sessionCookie&lt;/code&gt; — clears when browser closes. &lt;code&gt;persistentCookie&lt;/code&gt; — survives across sessions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;persistentCookieExpires&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Number&lt;/td&gt;
&lt;td&gt;Days until expiry when using &lt;code&gt;persistentCookie&lt;/code&gt;. Defaults to 365. Unused for session cookies.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;durationCookie&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Text&lt;/td&gt;
&lt;td&gt;Cookie name. Populated from the &lt;em&gt;Session Duration Cookie Name&lt;/em&gt; constant: &lt;code&gt;_i_vstdur&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maxSeconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Number&lt;/td&gt;
&lt;td&gt;Ceiling at which the loop halts. Set to 11 in this recipe (configurable to any value).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Dual Output: Cookie + dataLayer Event
&lt;/h2&gt;

&lt;p&gt;Every second the template does &lt;em&gt;two things simultaneously&lt;/em&gt;, and the combination unlocks several use cases that neither output enables alone:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Cookie — &lt;code&gt;_i_vstdur&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The running second count is written to a first-party cookie on every tick. Because cookies are domain-scoped and persist across page navigations, the counter &lt;strong&gt;carries across pages within the same session&lt;/strong&gt;. A visitor who spends 4 seconds on Page A and then navigates to Page B will resume at 4 on Page B — the total session engagement time accumulates naturally.&lt;/p&gt;

&lt;p&gt;Switching &lt;code&gt;cookieDuration&lt;/code&gt; from &lt;code&gt;sessionCookie&lt;/code&gt; to &lt;code&gt;persistentCookie&lt;/code&gt; extends this behaviour across sessions entirely. A returning visitor's cumulative seconds on site will pick up from where they left off — even days later — enabling lifetime engagement measurement without a login or backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. dataLayer Event — &lt;code&gt;_i_vstdur&lt;/code&gt; with &lt;code&gt;durationSeconds: N&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;dataLayerPush&lt;/code&gt; call each second creates a GTM custom event. This event is independently valuable in several ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Conversion trigger:&lt;/strong&gt; The included &lt;em&gt;Session Duration Event&lt;/em&gt; trigger fires a GA4 event at second 6 and second 11. In GA4, mark &lt;code&gt;session_duration_6sec&lt;/code&gt; as a conversion — now your GA4 reports show how many users reached meaningful engagement thresholds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Popup / dialog trigger:&lt;/strong&gt; Any other GTM tag can listen for the same custom event trigger. Fire a newsletter signup popup at second 30, an exit-intent offer at second 60, or a user satisfaction survey at second 120 — all configurable from the trigger's regex without touching a line of JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personalization signal:&lt;/strong&gt; Read &lt;code&gt;durationSeconds&lt;/code&gt; from the dataLayer in other tags or variables to conditionally show content to engaged vs. bounced visitors in real time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The trigger regex is the configuration knob.&lt;/strong&gt; The current trigger fires at &lt;code&gt;^(6|11)$&lt;/code&gt;. To fire at 30, 60, and 120 seconds instead, change the regex to &lt;code&gt;^(30|60|120)$&lt;/code&gt; — no code change, no republish of the template, just a trigger edit and a container publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GA4 Event — Session Duration Send Event
&lt;/h2&gt;

&lt;p&gt;The GA4 event tag produces dynamically named events using GTM variable interpolation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Event Property&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Example at 6 sec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Event Name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;session_duration_{{Session Duration dataLayer}}sec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;session_duration_6sec&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event Parameter (name)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;session_duration_{{Session Duration dataLayer}}sec_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;session_duration_6sec_count&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event Parameter (value)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Measurement ID Override&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Measurement Stream RegEx Lookup}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Routes to dev or prod stream automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sending a parameter with a constant value of &lt;code&gt;1&lt;/code&gt; alongside each event is intentional. In GA4 Explore reports, summing this parameter gives you the total count of users (or sessions) who reached each second milestone — a simple, reliable engagement funnel without requiring calculated metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trigger Detail — Session Duration Event
&lt;/h2&gt;

&lt;p&gt;This custom event trigger uses two conditions that must both pass:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Seconds match milestone&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{{Session Duration dataLayer}}&lt;/code&gt; (DataLayer variable reading &lt;code&gt;durationSeconds&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Matches regex `^(6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event identity confirmed&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;{{is Session Duration Event}}&lt;/code&gt; (Simple Value Map: event name → &lt;code&gt;true&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Equals &lt;code&gt;true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second condition prevents false positives if another dataLayer event happens to contain a &lt;code&gt;durationSeconds&lt;/code&gt; key. Only events whose name matches the &lt;em&gt;Session Duration Cookie Name&lt;/em&gt; constant (&lt;code&gt;_i_vstdur&lt;/code&gt;) will resolve the Simple Value Map to &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session vs. Persistent Cookie — Cross-Session Measurement
&lt;/h2&gt;

&lt;p&gt;The template ships with &lt;code&gt;cookieDuration&lt;/code&gt; set to &lt;code&gt;sessionCookie&lt;/code&gt;, which clears when the browser tab or window closes. This measures &lt;em&gt;per-session&lt;/em&gt; engagement time — the most common use case. To measure &lt;em&gt;lifetime&lt;/em&gt; user engagement across sessions, switch the radio to &lt;code&gt;persistentCookie&lt;/code&gt; and set &lt;code&gt;persistentCookieExpires&lt;/code&gt; to your desired retention window (365 days is the default). The logic is unchanged; only the cookie's &lt;code&gt;max-age&lt;/code&gt; attribute is affected.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Cookie Type&lt;/th&gt;
&lt;th&gt;What It Measures&lt;/th&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default&lt;/td&gt;
&lt;td&gt;Session cookie (no expiry)&lt;/td&gt;
&lt;td&gt;Seconds per browser session&lt;/td&gt;
&lt;td&gt;Standard engagement depth, bounce quality, conversion timing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;Persistent cookie (N days)&lt;/td&gt;
&lt;td&gt;Cumulative seconds across all sessions&lt;/td&gt;
&lt;td&gt;Lifetime engagement, loyalty scoring, subscriber depth measurement&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Environment-Aware Stream Routing
&lt;/h2&gt;

&lt;p&gt;The container includes a dual-layer regex lookup system so the same container file works across dev and production without manual edits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;URL pattern check:&lt;/strong&gt; &lt;em&gt;Measurement Stream RegEx Lookup&lt;/em&gt; scans the Page URL for patterns like &lt;code&gt;dev.&lt;/code&gt;, &lt;code&gt;staging.&lt;/code&gt;, &lt;code&gt;uat.&lt;/code&gt;, &lt;code&gt;local.&lt;/code&gt;, and the GTM preview URL (&lt;code&gt;gtm-msr.appspot.com&lt;/code&gt;). Matches route to the development stream ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GTM environment name fallback:&lt;/strong&gt; &lt;em&gt;Environment Stream ID&lt;/em&gt; checks the GTM environment name for a &lt;code&gt;^liv.*&lt;/code&gt; pattern (Live) or pre-release environment names as a secondary signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default:&lt;/strong&gt; If neither pattern matches, the production stream ID is used.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Update &lt;code&gt;Measurement Stream ID Production&lt;/code&gt; and &lt;code&gt;Measurement Stream ID Development&lt;/code&gt; with your actual GA4 measurement IDs. The regex patterns need no changes for most standard setups.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON from the &lt;a href="https://drive.google.com/file/d/1LBYjGwngLw2g2er3wwS582Xhlc0wjOxG/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;session-duration-timer-measurement-recipe.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing tags.&lt;/li&gt;
&lt;li&gt;Update &lt;em&gt;Measurement Stream ID Production&lt;/em&gt; and &lt;em&gt;Measurement Stream ID Development&lt;/em&gt; constants to your actual GA4 stream IDs.&lt;/li&gt;
&lt;li&gt;Adjust &lt;code&gt;maxSeconds&lt;/code&gt; and the trigger regex &lt;code&gt;^(6|11)$&lt;/code&gt; to your desired milestone seconds.&lt;/li&gt;
&lt;li&gt;If you want cross-session measurement, switch &lt;code&gt;cookieDuration&lt;/code&gt; from &lt;code&gt;sessionCookie&lt;/code&gt; to &lt;code&gt;persistentCookie&lt;/code&gt; in the Session Duration tag parameters.&lt;/li&gt;
&lt;li&gt;Preview in GTM Preview mode, confirm cookie writes and dataLayer events at each second, then publish.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Consent:&lt;/strong&gt; The session duration cookie and GA4 event should be gated on &lt;code&gt;analytics_storage&lt;/code&gt; consent in your GTM consent settings if your site is subject to GDPR or similar regulation. The tag will not fire until consent is granted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Can Do With It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Mark &lt;code&gt;session_duration_30sec&lt;/code&gt; as a GA4 conversion to track meaningful engagement as a conversion metric in campaign reports&lt;/li&gt;
&lt;li&gt;Use the custom event trigger at any second to fire a popup, chat widget, or modal without any additional JavaScript&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;_i_vstdur&lt;/code&gt; in other GTM tags or personalization scripts to segment engaged vs. disengaged visitors in real time on the current page&lt;/li&gt;
&lt;li&gt;Switch to persistent cookies to build a lifetime engagement score for registered users or newsletter subscribers&lt;/li&gt;
&lt;li&gt;Combine with the geolocation recipe to correlate session depth with geography — do users from certain cities engage longer?&lt;/li&gt;
&lt;li&gt;Use the &lt;code&gt;durationSeconds&lt;/code&gt; dataLayer variable in custom dimensions or BigQuery exports for cohort analysis in Looker Studio&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The full source — including the container JSON, all custom template code, variable definitions, and trigger configuration — is available on GitHub. Questions or improvements? Open an issue or submit a pull request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1LBYjGwngLw2g2er3wwS582Xhlc0wjOxG/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googletagmanager</category>
      <category>gtm</category>
    </item>
    <item>
      <title>GTM Form Engagement &amp; Abandonment</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Sun, 05 Jul 2026 20:55:00 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/gtm-form-engagement-abandonment-47bi</link>
      <guid>https://dev.to/drew_spencer_1066/gtm-form-engagement-abandonment-47bi</guid>
      <description>&lt;h1&gt;
  
  
  GTM Form Engagement Waterfall: Visibility, Field, Submit &amp;amp; Response
&lt;/h1&gt;

&lt;p&gt;Four Google Analytics 4 events, one shared event name, and a single &lt;code&gt;contact_step_label&lt;/code&gt; parameter — enough to build a true form-abandonment funnel from nothing but Google Tag Manager triggers.&lt;/p&gt;

&lt;p&gt;Published · Tags: GTM, Google Analytics 4, Form Tracking, Form Abandonment, Element Visibility, dataLayer, Funnel Exploration, GA4&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Standing on the shoulders of giants.&lt;/strong&gt; This recipe is a direct continuation of two earlier posts on this blog. The field-engagement stage below is built entirely on the generic &lt;code&gt;document.addEventListener&lt;/code&gt; pattern that &lt;a href="https://www.simoahava.com" rel="noopener noreferrer"&gt;Simo Ahava&lt;/a&gt; first published in 2013 and that I adapted for GA4 in &lt;a href="///2026/06/listen-to-any-browser-event-in-gtm.html"&gt;Listen to Any Browser Event in GTM&lt;/a&gt;. The element-identification logic — the coalesce chains that walk up the DOM to find a usable class, id, or label — comes straight from &lt;a href="///2026/06/gtm-universal-click-no-javascript.html"&gt;GTM Universal Click – No JavaScript&lt;/a&gt;. Neither of those posts touched forms directly. This one bolts both techniques onto a real Blogger contact form widget and adds two more stages — visibility and response — to turn them into a complete waterfall.&lt;br&gt;
Most GTM form recipes stop at one event: &lt;code&gt;gtm.formSubmit&lt;/code&gt;. That tells you a form was submitted, but it tells you nothing about the funnel that led there — how many visitors even saw the form, which field they touched last before giving up, or whether the submission actually succeeded once it reached the server. A single submit event is a snapshot. A waterfall is a story. This recipe sends four GA4 events, in page order, all sharing one event name — &lt;code&gt;contact_form_step&lt;/code&gt; — so they stack cleanly into a GA4 Funnel Exploration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Visible&lt;/strong&gt; — the contact form widget scrolls into view&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field&lt;/strong&gt; — a visitor changes a field's value (Simo's 2013 pattern, adapted for forms)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submit&lt;/strong&gt; — the visitor clicks the Send button&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response&lt;/strong&gt; — the page shows a success or error message
Compare step 1 against step 3 and you get a scroll-to-submit rate. Compare step 2 against step 3 and you learn whether visitors who engage with a field actually finish. Compare step 3 against step 4 and you catch silent submission failures — the gap that almost no analytics setup measures, because it depends on which DOM element the form's own JavaScript reveals after the request resolves. The illustration form for this recipe is Blogger's own built-in contact form widget — the same three-field &lt;strong&gt;Name / Email* / Message*&lt;/strong&gt; form running in this blog's sidebar.
&lt;a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjrHJdCDhoVGNjAUINZRpde7xMTYujom0BbENtspj9i7pZ4nPNxJhUfbk-Ig6nPCPGq2tJ5bMv1TwYxEB1ALGcLRN5mqYcJ_Pkx7Tnab5t1mC61PAhSQqpWBbXLHali0e_QO5YXPVEB-JAb1vR3eSUJwzwrhyxylri3JoRKAN0x4FBIDvhuVd4flYBHkE25/s634/gtm-waterfall-form-field-engagement.png" rel="noopener noreferrer"&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%2Fwdntv0ngrbepvh93czix.png" width="320" height="229"&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;⬇ &lt;a href="https://drive.google.com/file/d/1qd7NaPa_jHRcv5teajioMAndh3PNW6GG/view?usp=drivesdk" rel="noopener noreferrer"&gt;Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported container groups the recipe into two folders: &lt;strong&gt;Analytics&lt;/strong&gt; (the shared GA4 configuration and environment routing infrastructure covered in earlier posts) and &lt;strong&gt;Contact Form&lt;/strong&gt; (everything specific to the waterfall). A third folder, &lt;strong&gt;Element Hierarchy&lt;/strong&gt; , supplies the coalesce chains reused from the click-tracking recipe, and &lt;strong&gt;Custom Events&lt;/strong&gt; holds the onChange listener carried over from the 2013 pattern.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Asset Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tag (Custom HTML)&lt;/td&gt;
&lt;td&gt;onChange Listener&lt;/td&gt;
&lt;td&gt;Attaches a native &lt;code&gt;change&lt;/code&gt; event listener to &lt;code&gt;document&lt;/code&gt;; fires on &lt;code&gt;gtm.js&lt;/code&gt; load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (GA4 Event)&lt;/td&gt;
&lt;td&gt;contact form step visible&lt;/td&gt;
&lt;td&gt;Stage 1 — sends &lt;code&gt;contact_form_step&lt;/code&gt; with &lt;code&gt;contact_step_label = "visible"&lt;/code&gt; when the form scrolls into view&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (GA4 Event)&lt;/td&gt;
&lt;td&gt;contact form step field&lt;/td&gt;
&lt;td&gt;Stage 2 — sends &lt;code&gt;contact_form_step&lt;/code&gt; with the changed field's name as the label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (GA4 Event)&lt;/td&gt;
&lt;td&gt;contact form step submit&lt;/td&gt;
&lt;td&gt;Stage 3 — sends &lt;code&gt;contact_form_step&lt;/code&gt; with &lt;code&gt;contact_step_label = "submit"&lt;/code&gt; on Send click&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (GA4 Event)&lt;/td&gt;
&lt;td&gt;contact form step response&lt;/td&gt;
&lt;td&gt;Stage 4 — sends &lt;code&gt;contact_form_step&lt;/code&gt; with a dynamic &lt;code&gt;"success"&lt;/code&gt; or &lt;code&gt;"error"&lt;/code&gt; label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (GA4 Event)&lt;/td&gt;
&lt;td&gt;contact form conversion metric&lt;/td&gt;
&lt;td&gt;Fires alongside stage 4 — a separate, discretely named conversion event for GA4's Conversions list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (GA4 Event)&lt;/td&gt;
&lt;td&gt;onChange Event Push&lt;/td&gt;
&lt;td&gt;Legacy tag from the original 2013-pattern recipe; left in the container but paused, superseded by "contact form step field"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag (Google Tag)&lt;/td&gt;
&lt;td&gt;Google Analytics Configuration&lt;/td&gt;
&lt;td&gt;Standard GA4 config tag with environment-aware Stream ID routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger (Element Visibility)&lt;/td&gt;
&lt;td&gt;contact-form-widget&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;div[class="contact-form-widget"]&lt;/code&gt; — fires stage 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger (Custom Event)&lt;/td&gt;
&lt;td&gt;gtm.js change&lt;/td&gt;
&lt;td&gt;Matches &lt;code&gt;gtm.change&lt;/code&gt; pushed by the onChange listener — fires stage 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger (Click)&lt;/td&gt;
&lt;td&gt;contact-form-button-submit&lt;/td&gt;
&lt;td&gt;Matches the Send button's Click ID and Click Classes — fires stage 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger (Element Visibility)&lt;/td&gt;
&lt;td&gt;contact-form-success-message-with-border&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;p[class="contact-form-success-message-with-border"]&lt;/code&gt; — fires stage 4 (success branch)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger (Element Visibility)&lt;/td&gt;
&lt;td&gt;contact-form-error-message-with-border&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;p[class="contact-form-error-message-with-border"]&lt;/code&gt; — fires stage 4 (error branch)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger (Custom Event)&lt;/td&gt;
&lt;td&gt;gtm.js load&lt;/td&gt;
&lt;td&gt;Fires the onChange Listener Custom HTML tag when GTM initializes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variable (Custom JS)&lt;/td&gt;
&lt;td&gt;generic event handler&lt;/td&gt;
&lt;td&gt;Simo Ahava's 2013 callback pattern — pushes element metadata to the dataLayer on any native browser event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variable (Custom Template)&lt;/td&gt;
&lt;td&gt;coalesce className / coalesce id&lt;/td&gt;
&lt;td&gt;If Else If lookups — return the first non-empty class or id walking up the DOM tree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variable (Custom Template)&lt;/td&gt;
&lt;td&gt;Coalesce Click Elements&lt;/td&gt;
&lt;td&gt;Returns the first human-readable label: Click Text → Title → Name → Value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variable (Switch Lookup)&lt;/td&gt;
&lt;td&gt;contact form step conversion status&lt;/td&gt;
&lt;td&gt;Maps the visible message's Form ID to the literal string &lt;code&gt;"success"&lt;/code&gt; or &lt;code&gt;"error"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variable (Switch Lookup)&lt;/td&gt;
&lt;td&gt;contact form conversion metric name&lt;/td&gt;
&lt;td&gt;Maps the same Form ID to a distinct GA4 event name for the conversion tag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variable (Auto-Event)&lt;/td&gt;
&lt;td&gt;Form ID / Form Text / Form Classes&lt;/td&gt;
&lt;td&gt;Built-in GTM variable types, repurposed here to read visibility-trigger context (see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Template&lt;/td&gt;
&lt;td&gt;If Else If – Advanced Lookup Table&lt;/td&gt;
&lt;td&gt;Powers every coalesce variable (by Sublimetrix)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Architecture Overview: The Four-Stage Waterfall
&lt;/h2&gt;

&lt;p&gt;Every stage shares the same GA4 event name, &lt;code&gt;contact_form_step&lt;/code&gt;, and the same four parameters: &lt;code&gt;contact_step_class&lt;/code&gt;, &lt;code&gt;contact_step_name&lt;/code&gt;, &lt;code&gt;contact_step_id&lt;/code&gt;, and &lt;code&gt;contact_step_label&lt;/code&gt;. Only the trigger and the value of &lt;code&gt;contact_step_label&lt;/code&gt; change from stage to stage. That single design decision is what makes the waterfall possible — GA4's Funnel Exploration can group all four stages under one event and let &lt;code&gt;contact_step_label&lt;/code&gt; define the funnel steps in page order.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Widget enters the viewport.&lt;/strong&gt; The &lt;em&gt;contact-form-widget&lt;/em&gt; Element Visibility trigger fires the first time the form's container is 100% on screen. The &lt;em&gt;contact form step visible&lt;/em&gt; tag sends &lt;code&gt;contact_form_step&lt;/code&gt; with &lt;code&gt;contact_step_label = "visible"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A visitor changes a field.&lt;/strong&gt; The &lt;em&gt;onChange Listener&lt;/em&gt; Custom HTML tag — registered once at &lt;code&gt;gtm.js&lt;/code&gt; load — has already attached a native &lt;code&gt;change&lt;/code&gt; listener to &lt;code&gt;document&lt;/code&gt;. When a visitor commits a value in Name, Email, or Message, the &lt;em&gt;generic event handler&lt;/em&gt; variable pushes a &lt;code&gt;gtm.change&lt;/code&gt; event carrying the changed element's id, class, and name. The &lt;em&gt;gtm.js change&lt;/em&gt; Custom Event trigger catches it and fires &lt;em&gt;contact form step field&lt;/em&gt;, which sends &lt;code&gt;contact_form_step&lt;/code&gt; with &lt;code&gt;contact_step_label&lt;/code&gt; set to the field's own name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The visitor clicks Send.&lt;/strong&gt; The &lt;em&gt;contact-form-button-submit&lt;/em&gt; Click trigger matches the exact Click ID and Click Classes of Blogger's submit button. The &lt;em&gt;contact form step submit&lt;/em&gt; tag sends &lt;code&gt;contact_form_step&lt;/code&gt; with &lt;code&gt;contact_step_label = "submit"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The form responds.&lt;/strong&gt; Blogger's own form-handling script reveals one of two hidden &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; elements once the submission resolves — a success message or an error message. Two Element Visibility triggers watch both elements. Whichever one appears fires two tags at once: &lt;em&gt;contact form step response&lt;/em&gt;, which closes out the waterfall with a dynamic &lt;code&gt;"success"&lt;/code&gt; or &lt;code&gt;"error"&lt;/code&gt; label, and &lt;em&gt;contact form conversion metric&lt;/em&gt;, which sends a separate, discretely named GA4 event so the success case can be marked as a Conversion independently of the funnel.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Stage 1 — Form Visibility
&lt;/h2&gt;

&lt;p&gt;The entry point is a standard GTM Element Visibility trigger targeting the form's wrapping &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Selector type&lt;/td&gt;
&lt;td&gt;CSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Element selector&lt;/td&gt;
&lt;td&gt;&lt;code&gt;div[class="contact-form-widget"]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When to fire this trigger&lt;/td&gt;
&lt;td&gt;Many per element&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum percent visible&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observe DOM changes&lt;/td&gt;
&lt;td&gt;Enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Many per element, not once per element:&lt;/strong&gt; most visibility recipes use "Once per element" so a single impression is logged. This one deliberately uses &lt;em&gt;Many per element&lt;/em&gt;, because a visitor who scrolls the form on and off screen repeatedly while deciding whether to fill it out is itself a signal — that behavior is visible in GA4 as multiple &lt;code&gt;visible&lt;/code&gt; steps against a single submit, and it's a legitimate hesitation signal worth keeping rather than filtering out.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;contact form step visible&lt;/em&gt; tag reads the form's own identifying attributes using the same coalesce variables introduced in the click-tracking recipe — &lt;code&gt;{{coalesce className}}&lt;/code&gt;, &lt;code&gt;{{coalesce id}}&lt;/code&gt;, and &lt;code&gt;{{Coalesce Click Elements}}&lt;/code&gt; — so the event carries a usable label even if Blogger's theme wraps the widget in extra nested containers with no class of their own.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Parameter&lt;/th&gt;
&lt;th&gt;GTM Variable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_class&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{coalesce className}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Coalesce Click Elements}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{coalesce id}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_label&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"visible"&lt;/code&gt; (static string)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Stage 2 — Field Engagement (Simo Ahava's 2013 Pattern, Applied to Forms)
&lt;/h2&gt;

&lt;p&gt;This is the stage that makes the waterfall a &lt;em&gt;waterfall&lt;/em&gt; and not just a funnel. Visibility and submit only tell you whether someone saw the form and whether they finished it. Field-level engagement tells you where, between those two moments, a visitor actually stopped.&lt;/p&gt;

&lt;p&gt;The mechanism is identical to the one documented in &lt;a href="///2026/06/listen-to-any-browser-event-in-gtm.html"&gt;Listen to Any Browser Event in GTM&lt;/a&gt;: a Custom HTML tag attaches a native &lt;code&gt;change&lt;/code&gt; listener to &lt;code&gt;document&lt;/code&gt; once, at &lt;code&gt;gtm.js&lt;/code&gt; load, and every field on the page — Name, Email, Message — bubbles its change events up to that single listener automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;eventType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="nx"&gt;generic&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attachEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;on&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="nx"&gt;generic&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;}});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/script&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;generic event handler&lt;/strong&gt; variable is Simo's 2013 callback, unmodified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gtm.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gtm.element&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gtm.elementClasses&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gtm.elementId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gtm.elementTarget&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gtm.elementUrl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gtm.originalEvent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="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;When a visitor tabs out of the Email field having typed something, the browser's native &lt;code&gt;change&lt;/code&gt; event fires, the handler pushes &lt;code&gt;gtm.change&lt;/code&gt;, and the &lt;em&gt;gtm.js change&lt;/em&gt; Custom Event trigger catches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reuse trick:&lt;/strong&gt; the &lt;em&gt;contact form step field&lt;/em&gt; tag doesn't need any new variables to identify which field changed. Because the handler pushes &lt;code&gt;gtm.element&lt;/code&gt;, &lt;code&gt;gtm.elementClasses&lt;/code&gt;, and &lt;code&gt;gtm.elementId&lt;/code&gt; — the exact same dataLayer keys that GTM's built-in Click auto-event variables read — &lt;code&gt;{{Click ID}}&lt;/code&gt;, &lt;code&gt;{{Click Classes}}&lt;/code&gt;, and &lt;code&gt;{{Element Name}}&lt;/code&gt; populate correctly even though no click ever happened. GTM's auto-event variable families don't actually care which trigger fired; they read whatever is sitting in the generic &lt;code&gt;gtm.element*&lt;/code&gt; keys at the time the tag evaluates. That's the same principle this container leans on again in stage 4.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Parameter&lt;/th&gt;
&lt;th&gt;GTM Variable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_class&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Click Classes}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Element Name}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Click ID}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_label&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{{Element Name}}&lt;/code&gt; — the field's own &lt;code&gt;name&lt;/code&gt; attribute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;contact_step_label&lt;/code&gt; repeats &lt;code&gt;contact_step_name&lt;/code&gt; here:&lt;/strong&gt; in a funnel exploration, the label is the axis GA4 groups steps on. Using the field's name — &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email-message&lt;/code&gt;, and so on — as the label means each field becomes its own funnel step in the report, letting you see the drop-off between "engaged with Name" and "engaged with Message" directly, rather than collapsing all field interactions into one generic step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Housekeeping note:&lt;/strong&gt; the container also ships the original &lt;em&gt;onChange Event Push&lt;/em&gt; tag from the 2013-pattern recipe, wired to the same &lt;em&gt;gtm.js change&lt;/em&gt; trigger. It's left in place but paused — its &lt;code&gt;onChange&lt;/code&gt; event name doesn't fit the shared &lt;code&gt;contact_form_step&lt;/code&gt; naming needed for the waterfall, so &lt;em&gt;contact form step field&lt;/em&gt; supersedes it for this use case. Unpause it separately if you also want a parallel, form-agnostic &lt;code&gt;onChange&lt;/code&gt; event stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3 — Form Submit
&lt;/h2&gt;

&lt;p&gt;The submit stage uses a standard GTM Click trigger with two AND-combined filter conditions, matching Blogger's own submit button precisely:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Filter&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{Click ID}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;equals &lt;code&gt;ContactForm1_contact-form-submit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{Click Classes}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;equals &lt;code&gt;contact-form-button contact-form-button-submit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both conditions together avoid false positives from other buttons on the page that might share one attribute but not the other. The &lt;em&gt;contact form step submit&lt;/em&gt; tag then sends the same &lt;code&gt;contact_form_step&lt;/code&gt; event with a static label:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Parameter&lt;/th&gt;
&lt;th&gt;GTM Variable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_class&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Click Classes}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Element Name}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Click ID}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_label&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"submit"&lt;/code&gt; (static string)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note what this stage deliberately does &lt;em&gt;not&lt;/em&gt; use: GTM's built-in Form Submission trigger. Blogger's contact form widget submits via its own script rather than a native form POST that GTM's form trigger reliably detects, so a Click trigger on the button itself is the dependable signal here. This mirrors the point made in &lt;a href="///2026/06/listen-to-any-browser-event-in-gtm.html"&gt;Listen to Any Browser Event in GTM&lt;/a&gt; about GTM's built-in triggers not always covering every framework's submission behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4 — Success or Error Response
&lt;/h2&gt;

&lt;p&gt;This is the stage almost no form-tracking setup includes, and it's the one that turns "form submitted" into "form actually worked." Blogger's widget keeps two message elements in the DOM at all times — one for success, one for error — and reveals whichever one applies only after its own script resolves the submission:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Element Selector&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;contact-form-success-message-with-border&lt;/td&gt;
&lt;td&gt;&lt;code&gt;p[class="contact-form-success-message-with-border"]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contact-form-error-message-with-border&lt;/td&gt;
&lt;td&gt;&lt;code&gt;p[class="contact-form-error-message-with-border"]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both are Element Visibility triggers at 100% on-screen ratio with DOM-change observation enabled, since the elements only become visible after a script mutates the page — there's no separate page load or click to hang a trigger off. Two tags fire from these triggers:&lt;/p&gt;

&lt;h3&gt;
  
  
  contact form step response — closes the waterfall
&lt;/h3&gt;

&lt;p&gt;This tag completes the funnel with the same &lt;code&gt;contact_form_step&lt;/code&gt; event name used by every earlier stage. Its label isn't static — it's resolved dynamically by a Switch/Lookup Table variable, &lt;strong&gt;contact form step conversion status&lt;/strong&gt; , keyed on which message element became visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if Form ID == "ContactForm1_contact-form-success-message" → "success"
if Form ID == "ContactForm1_contact-form-error-message" → "error"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Parameter&lt;/th&gt;
&lt;th&gt;GTM Variable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_class&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Form Classes}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Form Text}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Form ID}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_step_label&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{{contact form step conversion status}}&lt;/code&gt; — &lt;code&gt;"success"&lt;/code&gt; or &lt;code&gt;"error"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Why Form ID, Form Text, and Form Classes work here even though no form was submitted:&lt;/strong&gt; these are GTM's built-in auto-event variables, normally populated by the Form Submission trigger family. But like &lt;code&gt;{{Click ID}}&lt;/code&gt; in stage 2, they don't actually require that specific trigger — they read the generic &lt;code&gt;gtm.elementId&lt;/code&gt;, &lt;code&gt;gtm.elementText&lt;/code&gt;, and &lt;code&gt;gtm.elementClasses&lt;/code&gt; dataLayer keys, which GTM's Element Visibility trigger populates on its own every time it fires. Because the visible element in this case is the message &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; tag itself, &lt;code&gt;{{Form ID}}&lt;/code&gt; resolves to that paragraph's id, and &lt;code&gt;{{Form Text}}&lt;/code&gt; resolves to its visible text — the exact success or error copy Blogger renders. This is the same principle documented in &lt;a href="///2026/06/gtm-universal-click-no-javascript.html"&gt;GTM Universal Click – No JavaScript&lt;/a&gt;: GTM's auto-event variable families are really just labeled windows onto a shared set of generic dataLayer keys, and any trigger that populates those keys can feed any variable family that reads them.&lt;/p&gt;

&lt;h3&gt;
  
  
  contact form conversion metric — a discrete conversion event
&lt;/h3&gt;

&lt;p&gt;The waterfall step above is useful for funnel analysis, but GA4's Conversions list works best with a single, unambiguous event name per conversion — not one event with a dynamic label buried in a parameter. So the same trigger pair also fires a second tag that sends a distinct event name, resolved by a companion lookup variable, &lt;strong&gt;contact form conversion metric name&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if Form ID == "ContactForm1_contact-form-success-message" → "contact-form-success"
if Form ID == "ContactForm1_contact-form-error-message" → "contact-form-error"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Parameter&lt;/th&gt;
&lt;th&gt;GTM Variable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Event name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{contact form conversion metric name}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;conversion_label&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Form ID}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;conversion_value&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Form Text}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;conversion_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{contact form conversion metric name}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Mark &lt;code&gt;contact-form-success&lt;/code&gt; as a Conversion in GA4 admin and you have a clean, single-purpose conversion metric — independent of the funnel, and unaffected by how you later restructure the waterfall's step labels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full Event Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Event Name&lt;/th&gt;
&lt;th&gt;&lt;code&gt;contact_step_label&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Trigger Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Visible&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contact_form_step&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;visible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Element Visibility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Field&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contact_form_step&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;field's &lt;code&gt;name&lt;/code&gt; attribute&lt;/td&gt;
&lt;td&gt;Custom Event (&lt;code&gt;gtm.change&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Submit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contact_form_step&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;submit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Click&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Response&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contact_form_step&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;success&lt;/code&gt; / &lt;code&gt;error&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Element Visibility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4b. Conversion&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;contact-form-success&lt;/code&gt; / &lt;code&gt;contact-form-error&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;— (own event, not a step)&lt;/td&gt;
&lt;td&gt;Element Visibility (same trigger as 4)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Building the GA4 Funnel Exploration
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In GA4, go to &lt;strong&gt;Explore → Funnel exploration&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Add a new step for each waterfall stage, all filtered on &lt;code&gt;Event name = contact_form_step&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On each step, add a second condition on the &lt;code&gt;contact_step_label&lt;/code&gt; parameter: &lt;code&gt;visible&lt;/code&gt; for step 1, your field names for step 2 (add one sub-step per field if you want field-level resolution), &lt;code&gt;submit&lt;/code&gt; for step 3, and &lt;code&gt;success&lt;/code&gt; for step 4.&lt;/li&gt;
&lt;li&gt;Enable &lt;strong&gt;"Open funnel"&lt;/strong&gt; if you want to include visitors who entered mid-funnel (e.g. arrived already scrolled past the form), or leave it closed to require strict step 1 entry.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;contact_step_id&lt;/code&gt; or &lt;code&gt;contact_step_name&lt;/code&gt; as a breakdown dimension on the field step to see exactly which fields are engaged most and least before drop-off.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Register &lt;code&gt;contact_step_class&lt;/code&gt;, &lt;code&gt;contact_step_name&lt;/code&gt;, &lt;code&gt;contact_step_id&lt;/code&gt;, and &lt;code&gt;contact_step_label&lt;/code&gt; as custom event-scoped dimensions under &lt;strong&gt;Admin → Custom definitions → Custom dimensions&lt;/strong&gt; before they'll appear as report and exploration options.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON from the &lt;a href="https://drive.google.com/file/d/1qd7NaPa_jHRcv5teajioMAndh3PNW6GG/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;In GTM go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;gtm-waterfall-form-field-engagement.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; to preserve your existing configuration.&lt;/li&gt;
&lt;li&gt;Update the &lt;em&gt;Measurement Stream ID Development&lt;/em&gt; and &lt;em&gt;Measurement Stream ID Production&lt;/em&gt; constants with your own GA4 Measurement IDs.&lt;/li&gt;
&lt;li&gt;If your form isn't Blogger's contact widget, update the four CSS selectors in the Element Visibility triggers and the Click ID / Click Classes filter on the submit trigger to match your own markup.&lt;/li&gt;
&lt;li&gt;In GA4, register &lt;code&gt;contact_step_class&lt;/code&gt;, &lt;code&gt;contact_step_name&lt;/code&gt;, &lt;code&gt;contact_step_id&lt;/code&gt;, and &lt;code&gt;contact_step_label&lt;/code&gt; as event-scoped custom dimensions.&lt;/li&gt;
&lt;li&gt;Mark &lt;code&gt;contact-form-success&lt;/code&gt; as a Conversion under &lt;strong&gt;Admin → Events&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Open GTM Preview mode, scroll to the form, change a field, submit it, and confirm all four &lt;code&gt;contact_form_step&lt;/code&gt; events fire in order in the Tag Assistant panel — followed by either &lt;code&gt;contact-form-success&lt;/code&gt; or &lt;code&gt;contact-form-error&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Publish the container.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What You Can Do With It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Build a GA4 Funnel Exploration to see exactly where visitors abandon the form — after seeing it, after starting a field, or after submitting&lt;/li&gt;
&lt;li&gt;Compare field-level engagement rates to spot a specific field (often Message) that causes disproportionate drop-off&lt;/li&gt;
&lt;li&gt;Catch silent submission failures that a submit-only setup would report as successful conversions&lt;/li&gt;
&lt;li&gt;Segment the &lt;code&gt;contact-form-error&lt;/code&gt; conversion event by page path to find which landing pages produce the most failed submissions&lt;/li&gt;
&lt;li&gt;Adapt the same four-stage pattern to any form — checkout, newsletter signup, gated content — by swapping the CSS selectors and Click filter values&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The full source — container JSON, all variable code, and the complete event reference — is available via the Google Drive link. This recipe builds directly on the DOM traversal and change-event techniques from the two posts linked above; read those first if any of the coalesce or auto-event variable behavior here is unfamiliar.&lt;/p&gt;

&lt;p&gt;⬇ &lt;a href="https://drive.google.com/file/d/1qd7NaPa_jHRcv5teajioMAndh3PNW6GG/view?usp=drivesdk" rel="noopener noreferrer"&gt;Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>formabandonment</category>
      <category>formengagement</category>
      <category>ga4</category>
      <category>googleanalytics</category>
    </item>
    <item>
      <title>Site Landing &amp; Site Referrer Preservation</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Wed, 24 Jun 2026 12:41:44 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/site-landing-site-referrer-preservation-47aa</link>
      <guid>https://dev.to/drew_spencer_1066/site-landing-site-referrer-preservation-47aa</guid>
      <description>&lt;h1&gt;
  
  
  Site Landing Page &amp;amp; Site Referrer to Cookies in Google Tag Manager — A Complete Recipe Without Custom JavaScript
&lt;/h1&gt;

&lt;p&gt;Persist the user's landing page host, landing page path, and referral source into session or long-lived persistent cookies using only GTM sandboxed Custom Templates. No Custom HTML tags. No new CSP hashes.&lt;/p&gt;

&lt;p&gt;Published · Tags: GTM, Google Analytics 4, Cookies, Site Referrer, Landing Page, First-Touch Attribution, User Journey, dataLayer, CSP, GA4&lt;/p&gt;

&lt;p&gt;Where did this user first land on your site — and what referred them there? Those two pieces of information are foundational to understanding any user journey. But they only exist at the moment of the first page load. Navigate to a second page, return in a new browser session, or submit a form three days after the original visit, and the browser's native referrer is gone and the landing URL has long since changed. Without a persistence layer, you are flying blind.&lt;/p&gt;

&lt;p&gt;This recipe solves that problem cleanly. On the very first Initialisation event — before any other tag fires — it captures the site landing host, landing page path, and referral host, and writes them into browser cookies. Session cookies cover the current visit; persistent cookies with a &lt;code&gt;_1&lt;/code&gt; suffix survive for up to 730 days, recording the user's &lt;em&gt;first ever&lt;/em&gt; landing and referral. Both cookie sets flow automatically into every GA4 hit via Shared Event Settings and GA4 User Properties. No Custom HTML tags. No new Content Security Policy SHA-256 hashes required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1gGGLwncgBRXL6LJ9etkZuCdYndIXUhot/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Problem: Landing Context Vanishes After the First Page
&lt;/h2&gt;

&lt;p&gt;A visitor arrives at &lt;code&gt;https://example.com/pricing&lt;/code&gt; referred from a partner site at &lt;code&gt;https://partner.example.org/blog/recommended-tools&lt;/code&gt;. GA4 records the referral for the session. But the user does not convert. They return three days later by typing the URL directly. GA4 now attributes that second session to &lt;em&gt;direct&lt;/em&gt;. The referral source that actually initiated the relationship is invisible.&lt;/p&gt;

&lt;p&gt;The same problem applies to your own site's landing pages. Knowing that a user's &lt;em&gt;first ever&lt;/em&gt; landing was &lt;code&gt;/pricing&lt;/code&gt; versus &lt;code&gt;/blog/use-case-study&lt;/code&gt; gives you a completely different picture of their intent and where they are in the funnel — information that is lost the moment they navigate away from that first page.&lt;/p&gt;

&lt;p&gt;Cookies are the browser's built-in persistence mechanism for exactly this purpose. This recipe writes the landing and referral context on Initialisation (the very first GTM event) and makes those values available across every subsequent page view, event, and session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters beyond utm_source:&lt;/strong&gt; UTM parameters tell you the campaign-level source a marketer chose to declare. But &lt;code&gt;utm_source&lt;/code&gt; is a free-text field — there is nothing enforcing that it contains an actual traffic source. A campaign tagged &lt;code&gt;utm_source=spring-promo&lt;/code&gt; tells you nothing about where traffic originated. The site referrer, by contrast, is set by the browser and reflects the actual page the user navigated from. Capturing the referral host independently of UTM tagging gives you a ground-truth signal of traffic origin that is not subject to marketer naming conventions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for user journey analysis:&lt;/strong&gt; The user's first landing page and first referral source are the starting coordinates of their entire journey on your site. Persisting them in long-lived cookies lets you attach first-touch landing context to any downstream event — form submission, purchase, demo request — regardless of how many sessions have elapsed since that first visit. This enables cohort analysis, funnel analysis by entry point, and lifetime value attribution by acquisition source, none of which are possible if you only have the landing data from the current session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why no new CSP hashes are required:&lt;/strong&gt; The traditional approach to writing cookies in GTM is a Custom HTML tag containing an inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block. Any such script requires its SHA-256 hash to be listed in your &lt;code&gt;Content-Security-Policy&lt;/code&gt; header's &lt;code&gt;script-src&lt;/code&gt; directive. Every time the script changes — even whitespace — the hash changes and must be redeployed. This recipe uses only GTM sandboxed Custom Templates, which execute inside GTM's own sandbox and require zero additions to your CSP. The cookie-writing logic runs entirely within the GTM runtime — no new hashes, no security team review cycles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported container (&lt;code&gt;site-landing-and-referrer-2-cookies-recipe.json&lt;/code&gt;) is organised into three folders, plus the existing Analytics infrastructure and sandboxed Custom Templates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Folders
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Folder&lt;/th&gt;
&lt;th&gt;Contains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;GA4 configuration tag, shared settings variables, environment and stream routing variables, and client ID and timestamp utilities.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process site landing &amp;amp; site referrer 2 cookies&lt;/td&gt;
&lt;td&gt;Two tags (session and user/persistent), plus eight Cookie Variable variables reading the landing host, landing path, referral host, and referral path for each track. Fires on Initialisation — the earliest possible GTM event — to capture the landing context before any other tag can fire.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extend Cookie Life&lt;/td&gt;
&lt;td&gt;One tag and two supporting variables. Rewrites the persistent cookies on every page visit to refresh their expiry date, ensuring the 730-day window resets on each visit rather than counting from first write.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Tags
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Analytics Configuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Google Tag (&lt;code&gt;googtag&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Standard GA4 configuration tag. Shared Event Settings and User Properties automatically forward landing and referral cookie values on every GA4 hit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Write site landing &amp;amp; site referrer 2 cookies user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Template — &lt;em&gt;Write site landing &amp;amp; site referrer 2 cookies&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;Fires on Initialisation, but &lt;strong&gt;only if the landing host and referral host persistent cookies do not already exist&lt;/strong&gt; (trigger condition: both &lt;code&gt;{{site Referral Host user}}&lt;/code&gt; and &lt;code&gt;{{site Landing Host user}}&lt;/code&gt; match the empty/null regex). Writes four persistent cookies expiring in 730 days, with the &lt;code&gt;_1&lt;/code&gt; suffix: &lt;code&gt;landHost_1&lt;/code&gt;, &lt;code&gt;landPath_1&lt;/code&gt;, &lt;code&gt;referHost_1&lt;/code&gt;, &lt;code&gt;referPath_1&lt;/code&gt;. Because the trigger only fires when the cookies are absent, these values are set exactly once — on the user's first ever visit — and never overwritten.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Write site landing &amp;amp; site referrer 2 cookies session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Template — &lt;em&gt;Write site landing &amp;amp; site referrer 2 cookies&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;Fires on Initialisation, but only if the session-level cookies are absent (both &lt;code&gt;{{site Referral Host session}}&lt;/code&gt; and &lt;code&gt;{{site Landing Host session}}&lt;/code&gt; are empty). Writes four session cookies — &lt;code&gt;landHost&lt;/code&gt;, &lt;code&gt;landPath&lt;/code&gt;, &lt;code&gt;referHost&lt;/code&gt;, &lt;code&gt;referPath&lt;/code&gt; — that expire when the browser closes. Refreshed on each new browser session.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Extend Cookie Life Tag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Template — &lt;em&gt;Cookie Rewriter&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;Fires on DOM Ready, guarded by the &lt;code&gt;_arrewr&lt;/code&gt; sentinel cookie. Rewrites the persistent landing and referral cookies with a refreshed expiry of 720 days from now, without altering their values.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Triggers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Write site landing &amp;amp; site referrer 2 cookies user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Initialisation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{{site Referral Host user}}&lt;/code&gt; AND &lt;code&gt;{{site Landing Host user}}&lt;/code&gt; both match `^(undefined&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;Write site landing &amp;amp; site referrer 2 cookies session&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Initialisation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{{site Referral Host session}}&lt;/code&gt; AND &lt;code&gt;{{site Landing Host session}}&lt;/code&gt; both match `^(undefined&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;Extend Cookie Life Trigger&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;DOM Ready&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{{Extend Cookie Life Already Ran Cookie Value}}&lt;/code&gt; matches `^(undefined&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Custom Templates
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template&lt;/th&gt;
&lt;th&gt;Author&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;Write site landing &amp;amp; site referrer 2 cookies&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;drewspen&lt;/td&gt;
&lt;td&gt;Reads the current page's host, page path, document referrer host, and document referrer path, and writes them to browser cookies with configurable expiry (session or persistent), a cookie name suffix, root domain scoping, and optional URI decoding. Runs entirely within the GTM sandboxed template runtime — no Custom HTML, no CSP implications.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cookie Rewriter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;drewspen&lt;/td&gt;
&lt;td&gt;Rewrites a specified list of existing cookies with a refreshed expiry date, converting static expiry windows into rolling windows that extend on each visit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;If Else If — Advanced Lookup Table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sublimetrix&lt;/td&gt;
&lt;td&gt;Powers coalesce and routing logic elsewhere in the container.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Timestamp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sublimetrix&lt;/td&gt;
&lt;td&gt;Reads Unix timestamps from GA4 session and client cookies for shared event parameters.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Get Root Domain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;drewspen&lt;/td&gt;
&lt;td&gt;Extracts the registrable root domain for cookie scoping, ensuring cookies are accessible across subdomains (e.g. &lt;code&gt;www.example.com&lt;/code&gt; and &lt;code&gt;app.example.com&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Two Tracks: Session Cookies and Persistent Cookies
&lt;/h2&gt;

&lt;p&gt;The recipe deliberately writes each landing and referral value twice — once as a session cookie and once as a persistent cookie. This gives you two distinct attribution windows with a single implementation: what happened in &lt;em&gt;this&lt;/em&gt; session, and what happened on the user's &lt;em&gt;first ever&lt;/em&gt; visit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Session Cookies — Current Visit Context
&lt;/h3&gt;

&lt;p&gt;Session cookies expire automatically when the browser is closed. They record the landing page and referral source for the current visit only. This is ideal for within-session attribution: if a user arrives from a partner site, browses three pages, and completes a download, the session cookie carries the referral host through the entire session — even though &lt;code&gt;document.referrer&lt;/code&gt; changes on every page navigation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cookie Name&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;landHost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Hostname of the first page the user landed on this session (e.g. &lt;code&gt;example.com&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;landPath&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Page path of the first landing page this session (e.g. &lt;code&gt;/pricing&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;referHost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Hostname of the referring site at session start (e.g. &lt;code&gt;partner.example.org&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;referPath&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Full referrer path at session start (URI-decoded)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Persistent Cookies — First-Touch Attribution Across Visits
&lt;/h3&gt;

&lt;p&gt;Persistent cookies survive browser restarts and are retained for up to 730 days (2 years) from initial write. The &lt;code&gt;_1&lt;/code&gt; suffix is the distinguishing convention — these cookies hold the values from the user's &lt;em&gt;very first visit&lt;/em&gt;. Because the user-level trigger only fires when both cookies are absent, the values are written exactly once and never overwritten by subsequent visits. When the Cookie Rewriter runs on later visits, it refreshes the expiry without touching the values, preserving the original first-touch context.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cookie Name&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Expiry&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;landHost_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;730 days (rolling)&lt;/td&gt;
&lt;td&gt;Hostname of the user's first ever landing page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;landPath_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;730 days (rolling)&lt;/td&gt;
&lt;td&gt;Page path of the user's first ever landing page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;referHost_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;730 days (rolling)&lt;/td&gt;
&lt;td&gt;Hostname of the referring site on the user's first ever visit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;referPath_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;730 days (rolling)&lt;/td&gt;
&lt;td&gt;Full referrer path on the user's first ever visit (URI-decoded)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Rolling Expiry: How the Cookie Rewriter Works
&lt;/h2&gt;

&lt;p&gt;A persistent cookie written once with a 730-day expiry will expire 730 days after it was first written, regardless of how often the user returns. For most use cases, a rolling expiry is preferable — active users should never lose their first-touch attribution simply because they have not visited in 730 days since acquisition.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Extend Cookie Life Tag&lt;/code&gt; achieves rolling expiry by running the Cookie Rewriter template on every page visit. It reads each of the four persistent landing and referral cookies and rewrites them with the same value but a new expiry date set 720 days from the current time. The first-touch values are preserved; only the expiry date changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Sentinel Guard: Firing Exactly Once Per Page
&lt;/h3&gt;

&lt;p&gt;To avoid the Cookie Rewriter firing multiple times per page load, the recipe uses a sentinel cookie named &lt;code&gt;_arrewr&lt;/code&gt;. The &lt;code&gt;Extend Cookie Life Trigger&lt;/code&gt; condition checks &lt;code&gt;{{Extend Cookie Life Already Ran Cookie Value}}&lt;/code&gt; against the regex &lt;code&gt;^(undefined|null|0|false|NaN|)$&lt;/code&gt;. The tag fires only when the sentinel is absent or empty. The Cookie Rewriter template sets &lt;code&gt;_arrewr&lt;/code&gt; on its first run, preventing any subsequent firing within the same page load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cookie Variables: Reading the Values Back
&lt;/h2&gt;

&lt;p&gt;Eight Cookie Variable variables are included in the Process site landing &amp;amp; site referrer 2 cookies folder, one for each cookie. These use GTM's built-in First-Party Cookie variable type and require no custom code. The &lt;code&gt;landPath&lt;/code&gt; and &lt;code&gt;referPath&lt;/code&gt; variables are configured with URI decoding enabled; the host variables are not, as hostnames do not require decoding.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable Name&lt;/th&gt;
&lt;th&gt;Cookie Read&lt;/th&gt;
&lt;th&gt;Decode&lt;/th&gt;
&lt;th&gt;Track&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;site Landing Host session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;landHost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;site Landing Page Path session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;landPath&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;site Referral Host session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;referHost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;site Referral Page Path session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;referPath&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;site Landing Host user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;landHost_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;First touch (persistent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;site Landing Page Path user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;landPath_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;First touch (persistent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;site Referral Host user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;referHost_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;First touch (persistent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;site Referral Page Path user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;referPath_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;First touch (persistent)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How Landing and Referral Data Flow Into Every GA4 Hit
&lt;/h2&gt;

&lt;p&gt;The cookie variable values are wired into the &lt;strong&gt;Google Tag Shared Event Settings&lt;/strong&gt; variable as event-scoped parameters and into the GA4 User Properties block. This means the values are forwarded on every GA4 event sent by the container — page views, scroll events, clicks, form submissions, and conversions alike — without any need to touch individual tag configurations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared Event Settings — Landing and Referral Parameters
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Event Parameter&lt;/th&gt;
&lt;th&gt;GTM Variable&lt;/th&gt;
&lt;th&gt;Attribution Track&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_cur_landing_host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Landing Host session}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Landing hostname for the current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_cur_landing_path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Landing Page Path session}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Landing page path for the current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_cur_referral_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Referral Host session}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Referral hostname for the current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_1st_landing_host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Landing Host user}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Landing hostname on user's first ever visit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_1st_landing_path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Landing Page Path user}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Landing page path on user's first ever visit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_1st_referral_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Referral Host user}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Referral hostname on user's first ever visit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  GA4 User Properties — Landing and Referral
&lt;/h3&gt;

&lt;p&gt;In addition to event-scoped parameters, the Shared Event Settings variable also populates GA4 User Properties. User Properties persist at the user level in GA4 and are available in audience definitions, Explorations, and BigQuery exports.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 User Property&lt;/th&gt;
&lt;th&gt;GTM Variable&lt;/th&gt;
&lt;th&gt;Attribution Track&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;u_cur_landing_host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Landing Host session}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Landing hostname for the current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;u_cur_landing_path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Landing Page Path session}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Landing page path for the current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;u_cur_referral_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Referral Page Path session}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Referral page path for the current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;u_1st_landing_host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Landing Host user}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Landing hostname on user's first ever visit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;u_1st_landing_path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Landing Page Path user}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Landing page path on user's first ever visit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;u_1st_referral_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{site Referral Page Path user}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Referral page path on user's first ever visit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Architecture: How a Landing Becomes a Cookie Becomes a GA4 Parameter
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;User lands on the site for the first time.&lt;/strong&gt; GTM fires the Initialisation event — the very earliest event in the GTM lifecycle, before page view, before DOM Ready, before any other tag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both user-level persistent cookie triggers evaluate.&lt;/strong&gt; Because &lt;code&gt;landHost_1&lt;/code&gt; and &lt;code&gt;referHost_1&lt;/code&gt; do not yet exist, both Cookie Variable variables return empty strings matching the null regex. The trigger fires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user-level write tag runs.&lt;/strong&gt; The &lt;em&gt;Write site landing &amp;amp; site referrer 2 cookies user&lt;/em&gt; tag reads the current page hostname, current page path, document referrer hostname, and document referrer path, and writes them as persistent cookies scoped to the root domain (e.g. &lt;code&gt;.example.com&lt;/code&gt;), expiring 730 days from now. These values are permanently set for this user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The session-level write tag also runs.&lt;/strong&gt; If the session cookies are also absent (new session), the &lt;em&gt;Write site landing &amp;amp; site referrer 2 cookies session&lt;/em&gt; tag writes the same four values as session cookies (no expiry — browser-close expiry).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GTM fires DOM Ready.&lt;/strong&gt; The &lt;code&gt;Extend Cookie Life Trigger&lt;/code&gt; checks the &lt;code&gt;_arrewr&lt;/code&gt; sentinel. If absent, the &lt;em&gt;Extend Cookie Life Tag&lt;/em&gt; rewrites the four persistent cookies with a new 720-day expiry from now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie Variable variables are evaluated on every GTM event.&lt;/strong&gt; The eight cookie variables read the current values from the browser for every subsequent event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared Event Settings deliver values to GA4.&lt;/strong&gt; The six event parameters (&lt;code&gt;e_cur_*&lt;/code&gt; and &lt;code&gt;e_1st_*&lt;/code&gt;) and six User Properties (&lt;code&gt;u_cur_*&lt;/code&gt; and &lt;code&gt;u_1st_*&lt;/code&gt;) are included in every GA4 hit automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days or weeks later, the user returns directly.&lt;/strong&gt; Their persistent cookies still hold the original first-touch landing and referral values. The session cookies are absent (browser closed), so the session-level write tag fires again, capturing the new session's landing context. The persistent cookies are extended by the Cookie Rewriter but their values are unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A conversion event fires.&lt;/strong&gt; GA4 receives both the current-session landing context and the first-ever landing context on the same event hit, allowing multi-session journey analysis in a single query.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why Sandboxed Templates Do Not Require New CSP Hashes
&lt;/h2&gt;

&lt;p&gt;GTM's sandboxed Custom Template runtime accesses browser APIs through GTM's own internal permission model — not through Content Security Policy. A template that writes a cookie uses GTM's &lt;code&gt;setCookie&lt;/code&gt; sandbox API, which executes within code already authorised by the GTM container's existing CSP allowance (the allowance granted to &lt;code&gt;googletagmanager.com&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;In contrast, a Custom HTML tag writes a literal &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; element into the DOM. CSP's &lt;code&gt;script-src&lt;/code&gt; directive applies to every inline script element. Unless you have &lt;code&gt;unsafe-inline&lt;/code&gt; enabled (which defeats much of CSP's purpose) or a matching SHA-256 hash in your CSP header, the browser blocks execution. Every change to that script — adding a comment, reformatting a line, changing a cookie name — changes the hash and requires a CSP header redeployment and typically a security team review cycle.&lt;/p&gt;

&lt;p&gt;With sandboxed templates, your CSP never changes when GTM tag logic changes. The entire landing and referral cookie implementation in this recipe is CSP-transparent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Use Cases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Identifying the True Referral Source When utm_source Is Not Reliable
&lt;/h3&gt;

&lt;p&gt;Google's convention is that &lt;code&gt;utm_source&lt;/code&gt; should identify the actual traffic source — the platform, publisher, or channel that sent the visitor. In practice, many campaigns use &lt;code&gt;utm_source&lt;/code&gt; as a free-text label: &lt;code&gt;utm_source=spring-promo&lt;/code&gt;, &lt;code&gt;utm_source=email-footer&lt;/code&gt;, &lt;code&gt;utm_source=sales-team&lt;/code&gt;. These values describe the campaign intent, not the origin of the traffic.&lt;/p&gt;

&lt;p&gt;The referral host cookie, set by the browser from &lt;code&gt;document.referrer&lt;/code&gt;, reflects where the user actually navigated from — independent of any UTM tagging. A visit tagged &lt;code&gt;utm_source=spring-promo&lt;/code&gt; that shows &lt;code&gt;referHost=email.mailprovider.com&lt;/code&gt; tells you it was an email campaign. A visit tagged &lt;code&gt;utm_source=newsletter&lt;/code&gt; with &lt;code&gt;referHost=t.co&lt;/code&gt; tells you the link was actually shared on X (Twitter). The referral host provides a ground-truth origin signal that complements — and sometimes corrects — the UTM source value.&lt;/p&gt;

&lt;h3&gt;
  
  
  First-Touch Landing Page Analysis for User Journey Mapping
&lt;/h3&gt;

&lt;p&gt;The user's first ever landing page is one of the most predictive signals of their intent and their position in the funnel. A user whose first landing was &lt;code&gt;/pricing&lt;/code&gt; is on a fundamentally different journey from one whose first landing was &lt;code&gt;/blog/what-is-x&lt;/code&gt;. But without persistent cookies, you can only know the first landing page if the user converted in the same session — which is rare in B2B and considered-purchase categories.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;landPath_1&lt;/code&gt; persisted for up to two years, you can join any downstream event — a contact form submission, a free trial activation, a purchase — to the user's original entry point. In GA4 Explorations or BigQuery, this enables cohort analysis by first landing page, funnel analysis by acquisition path, and lifetime value modelling by initial intent signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  B2B Multi-Session Lead Attribution
&lt;/h3&gt;

&lt;p&gt;A prospect clicks a LinkedIn post, lands on &lt;code&gt;/case-studies/enterprise&lt;/code&gt;, reads for ten minutes, and leaves. The referral host and landing path are stored in persistent cookies: &lt;code&gt;referHost_1=linkedin.com&lt;/code&gt;, &lt;code&gt;landPath_1=/case-studies/enterprise&lt;/code&gt;. Two weeks later, they return directly, read the pricing page, and submit a demo request form. Your CRM receives hidden form fields populated from the cookie values — and your sales team knows the lead came from LinkedIn via the enterprise case study page, not from direct traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON file from the &lt;a href="https://drive.google.com/file/d/1gGGLwncgBRXL6LJ9etkZuCdYndIXUhot/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;site-landing-and-referrer-2-cookies-recipe.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing tags and triggers.&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;Measurement Stream ID Production&lt;/code&gt; and &lt;code&gt;Measurement Stream ID Development&lt;/code&gt; constant variables to your actual GA4 Measurement IDs.&lt;/li&gt;
&lt;li&gt;Verify that &lt;code&gt;{{Root Domain}}&lt;/code&gt; resolves correctly for your site — this controls the cookie's domain scope. Check its value in GTM Preview mode on your site before publishing.&lt;/li&gt;
&lt;li&gt;In GA4, register the following as custom event-scoped dimensions under &lt;strong&gt;Admin → Custom definitions → Custom dimensions&lt;/strong&gt; : &lt;code&gt;e_cur_landing_host&lt;/code&gt;, &lt;code&gt;e_cur_landing_path&lt;/code&gt;, &lt;code&gt;e_cur_referral_source&lt;/code&gt;, &lt;code&gt;e_1st_landing_host&lt;/code&gt;, &lt;code&gt;e_1st_landing_path&lt;/code&gt;, &lt;code&gt;e_1st_referral_source&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Register the six User Properties under &lt;strong&gt;Admin → Custom definitions → Custom dimensions&lt;/strong&gt; (selecting User scope): &lt;code&gt;u_cur_landing_host&lt;/code&gt;, &lt;code&gt;u_cur_landing_path&lt;/code&gt;, &lt;code&gt;u_cur_referral_source&lt;/code&gt;, &lt;code&gt;u_1st_landing_host&lt;/code&gt;, &lt;code&gt;u_1st_landing_path&lt;/code&gt;, &lt;code&gt;u_1st_referral_source&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Open GTM Preview mode on a URL with an external referrer and confirm the cookie variables populate in the Tag Assistant panel under the Initialisation event.&lt;/li&gt;
&lt;li&gt;Navigate to a second page and confirm that the session and persistent cookie values persist (the landing host should remain the first page URL, not the current page URL).&lt;/li&gt;
&lt;li&gt;Close the browser, reopen, and revisit the site directly. Confirm the session cookies are absent (triggering a new session write) and the &lt;code&gt;_1&lt;/code&gt; persistent cookies still hold the original first-visit values.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Cookie consent:&lt;/strong&gt; Writing persistent cookies that track user journey and referral attribution may require consent under GDPR, CCPA, or equivalent regulations depending on your jurisdiction and how the data is used. Review with your legal team. The recipe writes cookies on the Initialisation event (the very first GTM event); if you operate a consent-managed site, gate both Write tags behind a consent trigger that only fires after the user has accepted analytics or marketing cookies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Can Do With It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Attach first-touch landing page and referral source to CRM lead records via hidden form fields — link pipeline and revenue to the user's original entry point without server-side infrastructure&lt;/li&gt;
&lt;li&gt;Compare &lt;code&gt;e_cur_referral_source&lt;/code&gt; (this session's referrer) against &lt;code&gt;e_1st_referral_source&lt;/code&gt; (first-ever referrer) in GA4 Explorations to identify multi-channel journeys and mid-funnel referral sources&lt;/li&gt;
&lt;li&gt;Segment GA4 audiences by &lt;code&gt;u_1st_landing_path&lt;/code&gt; to create intent-based audiences (e.g. users whose first landing was &lt;code&gt;/pricing&lt;/code&gt;) for remarketing and personalisation&lt;/li&gt;
&lt;li&gt;Build BigQuery attribution models joining on &lt;code&gt;e_1st_referral_source&lt;/code&gt; to compare first-touch referral attribution against GA4's session-based default attribution&lt;/li&gt;
&lt;li&gt;Identify referral traffic where &lt;code&gt;utm_source&lt;/code&gt; does not match the actual &lt;code&gt;referHost&lt;/code&gt; — a powerful quality signal for your UTM hygiene audits&lt;/li&gt;
&lt;li&gt;Measure cohort differences in conversion rate, session depth, and time-to-convert between users by first landing page — enabling landing page optimisation based on downstream outcome data rather than session-level metrics&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;e_1st_landing_path&lt;/code&gt; as a dimension in GA4 Explorations funnels to understand which entry points drive users towards high-value conversion paths&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The full source — including the container JSON and the drewspen sandboxed Custom Templates — is available on GitHub and via the Google Drive download below. If you extend the implementation with additional cookie naming conventions or add complementary UTM cookie tracks, open a pull request or issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1gGGLwncgBRXL6LJ9etkZuCdYndIXUhot/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googletagmanager</category>
      <category>gtm</category>
    </item>
    <item>
      <title>UTM &amp; URL Query String 2 Cookies</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Wed, 24 Jun 2026 02:58:06 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/utm-url-query-string-2-cookies-bem</link>
      <guid>https://dev.to/drew_spencer_1066/utm-url-query-string-2-cookies-bem</guid>
      <description>&lt;h1&gt;
  
  
  UTM &amp;amp; URL Query String to Cookies in Google Tag Manager — A Complete Recipe Without Custom JavaScript
&lt;/h1&gt;

&lt;p&gt;Persist any URL query string parameter — UTM values, click IDs, or custom campaign codes — into session or long-lived persistent cookies using only GTM sandboxed Custom Templates. No Custom HTML tags. No new CSP hashes.&lt;/p&gt;

&lt;p&gt;Published · Tags: GTM, Google Analytics 4, UTM, Cookies, Query String, dataLayer, CSP, GA4, First-Touch Attribution, Lead Attribution&lt;/p&gt;

&lt;p&gt;UTM parameters tell you exactly how a visitor arrived at your site. But they only exist in the URL on the landing page. The moment a user navigates to a second page, clicks an internal link, or returns to the site in the same browser session, those parameters vanish from the address bar. If your marketing team needs to know which campaign drove a lead form submission that happened three days after the original visit — a common scenario in B2B with longer consideration cycles — you cannot read the UTMs from the URL. They are gone.&lt;/p&gt;

&lt;p&gt;This recipe solves that problem cleanly. On the landing page visit, it reads any nominated URL query string parameter and writes its value into a browser cookie — either a session cookie that expires when the browser closes, or a persistent cookie that survives for up to 730 days. Those cookie values are then available to every subsequent page view and every GTM event throughout the user's future visits, ready to be attached to GA4 events, read by form scripts, or forwarded in dataLayer pushes. No custom JavaScript. No new Content Security Policy SHA-256 hashes required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1YOIJseTGI_XH5O-HPkHOEcbHK01bQCpm/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Problem: UTMs Live in the URL, Not in the Session
&lt;/h2&gt;

&lt;p&gt;A visitor lands on &lt;code&gt;https://example.com/?utm_source=newsletter&amp;amp;utm_medium=email&amp;amp;utm_campaign=spring-offer&lt;/code&gt;. GA4 picks up those parameters automatically for the page_view event. Three days later, in a fresh browser session, the same visitor returns directly to &lt;code&gt;https://example.com/pricing&lt;/code&gt; and submits a lead form. What UTM values does your CRM receive? Without a persistence layer: none. GA4's session attribution helps for analytics, but it does not make the original UTMs available to your form submission handler or marketing automation platform.&lt;/p&gt;

&lt;p&gt;Cookies are the browser's built-in persistence mechanism for exactly this purpose. Writing the UTM values to cookies on the first visit and reading them back on the form submission page gives you first-touch attribution data that survives across sessions, browser restarts, and multi-day gaps — without requiring any server-side infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for CSP:&lt;/strong&gt; The traditional approach to writing cookies in GTM is a Custom HTML tag containing an inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block. Any such script requires its SHA-256 hash to be listed in your &lt;code&gt;Content-Security-Policy&lt;/code&gt; header's &lt;code&gt;script-src&lt;/code&gt; directive. Every time the script changes — even whitespace — the hash changes and must be redeployed. This recipe uses only GTM sandboxed Custom Templates, which execute inside GTM's own sandbox and require zero additions to your CSP. The cookie-writing and cookie-rewriting logic runs entirely within the GTM runtime — no new hashes, no security team review cycles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported container (&lt;code&gt;utm-and-url-query-string-2-cookies-recipe.json&lt;/code&gt;) is organised into three folders of assets, plus the existing Analytics infrastructure and three sandboxed Custom Templates authored by &lt;em&gt;drewspen&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Folders
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Folder&lt;/th&gt;
&lt;th&gt;Contains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;GA4 configuration tag, shared settings variables (including UTM cookie reads), environment/stream routing variables, and client ID and timestamp utilities.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process URL query strings 2 cookies&lt;/td&gt;
&lt;td&gt;Two tags (session and user/persistent), plus ten Cookie Variable variables reading the values back from each cookie. Fires at page load to capture incoming UTM parameters.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extend Cookie Life&lt;/td&gt;
&lt;td&gt;One tag and two supporting variables. Rewrites the persistent UTM cookies on every page visit to refresh their expiry date, ensuring the 730-day window resets on each visit rather than counting from first write.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Tags
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Analytics Configuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Google Tag (&lt;code&gt;googtag&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Standard GA4 config tag, fires once per load. Shared Event Settings automatically forward UTM cookie values as GA4 event parameters on every hit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Write certain URL query strings 2 cookies session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Template — &lt;em&gt;Write URL query strings 2 cookies&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;Fires on all pages. Reads &lt;code&gt;utm_source&lt;/code&gt;, &lt;code&gt;utm_medium&lt;/code&gt;, &lt;code&gt;utm_campaign&lt;/code&gt;, &lt;code&gt;utm_content&lt;/code&gt;, and &lt;code&gt;utm_term&lt;/code&gt; from the URL query string and writes each as a session cookie (expires when the browser closes). Cookie names: &lt;code&gt;utm_source&lt;/code&gt;, &lt;code&gt;utm_medium&lt;/code&gt;, &lt;code&gt;utm_campaign&lt;/code&gt;, &lt;code&gt;utm_content&lt;/code&gt;, &lt;code&gt;utm_term&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Write certain URL query strings 2 cookies user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Template — &lt;em&gt;Write URL query strings 2 cookies&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;Fires on all pages (configured with no trigger override — uses the All Pages trigger inherited from the session tag). Reads the same five UTM parameters and writes them as persistent cookies expiring in 730 days (2 years). Cookie names have a &lt;code&gt;_1&lt;/code&gt; suffix: &lt;code&gt;utm_source_1&lt;/code&gt;, &lt;code&gt;utm_medium_1&lt;/code&gt;, &lt;code&gt;utm_campaign_1&lt;/code&gt;, &lt;code&gt;utm_content_1&lt;/code&gt;, &lt;code&gt;utm_term_1&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Extend Cookie Life Tag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Template — &lt;em&gt;Cookie Rewriter&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;Fires on DOM Ready, but only if it has not already run in the current page load (guarded by the &lt;code&gt;_arrewr&lt;/code&gt; sentinel cookie). Rewrites all ten persistent UTM cookies — both the &lt;code&gt;_1&lt;/code&gt; and &lt;code&gt;_u&lt;/code&gt; variants — resetting their expiry to 720 days from now.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Triggers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;All Pages&lt;/code&gt; (built-in)&lt;/td&gt;
&lt;td&gt;Page View&lt;/td&gt;
&lt;td&gt;Fires the two Write query strings tags on every page load so incoming UTM parameters are always captured.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Extend Cookie Life Trigger&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DOM Ready&lt;/td&gt;
&lt;td&gt;Fires the Cookie Rewriter tag, but only when the &lt;code&gt;_arrewr&lt;/code&gt; sentinel cookie is absent or empty — ensuring the cookie extension runs exactly once per page, not on every DOM mutation.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Custom Templates (by drewspen)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Write URL query strings 2 cookies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads nominated URL query string parameters and writes them to browser cookies, with configurable expiry (session or persistent), cookie name suffix, root domain scoping, and optional URI decoding. Runs entirely within the GTM sandboxed template runtime — no Custom HTML, no CSP implications.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cookie Rewriter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rewrites a specified list of existing cookies with a refreshed expiry date, allowing persistent cookies to behave as rolling windows that extend on each visit rather than fixed windows from first write.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;If Else If — Advanced Lookup Table&lt;/code&gt; (Sublimetrix)&lt;/td&gt;
&lt;td&gt;Powers coalesce and routing logic elsewhere in the container.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Timestamp&lt;/code&gt; (Sublimetrix)&lt;/td&gt;
&lt;td&gt;Reads Unix timestamps from GA4 session and client cookies for shared event parameters.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Get Root Domain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Extracts the registrable root domain for cookie scoping, ensuring cookies are accessible across subdomains (e.g. &lt;code&gt;www.example.com&lt;/code&gt; and &lt;code&gt;app.example.com&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Two Tracks: Session Cookies and Persistent Cookies
&lt;/h2&gt;

&lt;p&gt;The recipe deliberately writes each UTM parameter twice — once as a session cookie and once as a persistent cookie. This gives you two distinct attribution windows in a single implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Session Cookies — Current Visit Attribution
&lt;/h3&gt;

&lt;p&gt;Session cookies expire automatically when the browser is closed. They hold the UTM values for the current visit only. This makes them ideal for "last-touch within session" attribution: if a user clicks a paid search ad, lands on the site, navigates through three pages, and completes a transaction, the session cookie carries &lt;code&gt;utm_source=google&lt;/code&gt; and &lt;code&gt;utm_medium=cpc&lt;/code&gt; all the way through the checkout flow — even though only the landing page URL contained those parameters.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cookie Name&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Value of &lt;code&gt;utm_source&lt;/code&gt; query parameter on landing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_medium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Value of &lt;code&gt;utm_medium&lt;/code&gt; query parameter on landing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_campaign&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Value of &lt;code&gt;utm_campaign&lt;/code&gt; query parameter on landing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Value of &lt;code&gt;utm_content&lt;/code&gt; query parameter on landing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_term&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Value of &lt;code&gt;utm_term&lt;/code&gt; query parameter on landing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Persistent Cookies — First-Touch Attribution Across Visits
&lt;/h3&gt;

&lt;p&gt;Persistent cookies survive browser restarts and are retained for up to 730 days (2 years) from initial write. The &lt;code&gt;_1&lt;/code&gt; suffix in the cookie name is the distinguishing convention: these cookies hold the values from the &lt;em&gt;first&lt;/em&gt; tracked landing, making them the foundation of first-touch attribution. When the Cookie Rewriter runs on subsequent visits, it refreshes the expiry without overwriting the values — the original campaign data is preserved.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cookie Name&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Expiry&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_source_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;730 days (rolling)&lt;/td&gt;
&lt;td&gt;Value of &lt;code&gt;utm_source&lt;/code&gt; on first UTM-tagged landing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_medium_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;730 days (rolling)&lt;/td&gt;
&lt;td&gt;Value of &lt;code&gt;utm_medium&lt;/code&gt; on first UTM-tagged landing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_campaign_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;730 days (rolling)&lt;/td&gt;
&lt;td&gt;Value of &lt;code&gt;utm_campaign&lt;/code&gt; on first UTM-tagged landing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_content_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;730 days (rolling)&lt;/td&gt;
&lt;td&gt;Value of &lt;code&gt;utm_content&lt;/code&gt; on first UTM-tagged landing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_term_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;730 days (rolling)&lt;/td&gt;
&lt;td&gt;Value of &lt;code&gt;utm_term&lt;/code&gt; on first UTM-tagged landing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;_u&lt;/code&gt; variants:&lt;/strong&gt; The Extend Cookie Life Tag also refreshes cookies with a &lt;code&gt;_u&lt;/code&gt; suffix (&lt;code&gt;utm_campaign_u&lt;/code&gt;, &lt;code&gt;utm_source_u&lt;/code&gt;, etc.). These are placeholders for a future-use "last UTM-tagged visit" persistent track — write the user-level tag a second time with a &lt;code&gt;_u&lt;/code&gt; suffix to maintain a rolling record of the most recent campaign that drove a visit, distinct from the first-touch &lt;code&gt;_1&lt;/code&gt; values. The extension infrastructure is already in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rolling Expiry: How the Cookie Rewriter Works
&lt;/h2&gt;

&lt;p&gt;A persistent cookie written once with a 730-day expiry will expire 730 days after it was first written, regardless of how often the user returns. For long sales cycles — a B2B prospect who first visited from a newsletter eleven months ago and finally requests a demo today — a static expiry is often sufficient. But many implementations prefer a &lt;em&gt;rolling&lt;/em&gt; expiry: every visit resets the clock, so an active user's attribution never expires.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Extend Cookie Life Tag&lt;/code&gt; achieves this by running the Cookie Rewriter template on every page visit. It reads each of the ten persistent UTM cookies and rewrites them with the same value but a new expiry date set 720 days from the current time. The result is a rolling 720-day window that resets on each visit.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Sentinel Guard: Firing Exactly Once Per Page
&lt;/h3&gt;

&lt;p&gt;To avoid the Cookie Rewriter firing multiple times per page — once for each DOM Ready event if the page triggers several — the recipe uses a sentinel cookie named &lt;code&gt;_arrewr&lt;/code&gt;. The &lt;code&gt;Extend Cookie Life Trigger&lt;/code&gt; condition checks this cookie's value against the regex &lt;code&gt;^(undefined|null|0|false|NaN|)$&lt;/code&gt;. The tag fires only when the cookie is absent or empty. The Cookie Rewriter template sets &lt;code&gt;_arrewr&lt;/code&gt; on its first run, preventing any subsequent firing within the same page load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cookie Variables: Reading the Values Back
&lt;/h2&gt;

&lt;p&gt;Ten Cookie Variable variables are included in the Process URL query strings 2 cookies folder, one for each cookie. These are GTM's built-in First-Party Cookie variable type and require no custom code.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable Name&lt;/th&gt;
&lt;th&gt;Cookie Read&lt;/th&gt;
&lt;th&gt;Track&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_source cookie session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utm_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_medium cookie session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utm_medium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_campaign cookie session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utm_campaign&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_content cookie session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utm_content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_term cookie session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utm_term&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_source cookie user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utm_source_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;First touch (persistent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_medium cookie user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utm_medium_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;First touch (persistent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_campaign cookie user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utm_campaign_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;First touch (persistent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_content cookie user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utm_content_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;First touch (persistent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_term cookie user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utm_term_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;First touch (persistent)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How UTM Cookies Flow Into Every GA4 Hit
&lt;/h2&gt;

&lt;p&gt;The cookie variable values are wired directly into the &lt;strong&gt;Google Tag Shared Event Settings&lt;/strong&gt; variable. Because this variable is referenced by the Google Analytics Configuration tag, its parameter table is merged into every GA4 event sent by the container — page views, scroll events, clicks, form submissions, and conversions alike. You receive both the current-session UTMs and the first-touch UTMs on every event, without touching individual tag configurations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared Event Settings — UTM Parameters
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Parameter&lt;/th&gt;
&lt;th&gt;GTM Variable&lt;/th&gt;
&lt;th&gt;Attribution Track&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_cur_utm_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{utm_source cookie session}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Source from current session's landing page UTMs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_cur_utm_medium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{utm_medium cookie session}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Medium from current session's landing page UTMs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_cur_utm_campaign&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{utm_campaign cookie session}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Campaign from current session's landing page UTMs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_1st_utm_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{utm_source cookie user}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Source from user's first ever UTM-tagged visit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_1st_utm_medium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{utm_medium cookie user}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Medium from user's first ever UTM-tagged visit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_1st_utm_campaign&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{utm_campaign cookie user}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Campaign from user's first ever UTM-tagged visit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Note on utm_content and utm_term:&lt;/strong&gt; The five cookies are written for all UTM parameters including &lt;code&gt;utm_content&lt;/code&gt; and &lt;code&gt;utm_term&lt;/code&gt;, and their cookie variables are available in the container. The Shared Event Settings above are pre-wired for &lt;code&gt;source&lt;/code&gt;, &lt;code&gt;medium&lt;/code&gt;, and &lt;code&gt;campaign&lt;/code&gt; — the most commonly used dimensions. Add &lt;code&gt;e_cur_utm_content&lt;/code&gt;, &lt;code&gt;e_cur_utm_term&lt;/code&gt;, &lt;code&gt;e_1st_utm_content&lt;/code&gt;, and &lt;code&gt;e_1st_utm_term&lt;/code&gt; to the Shared Event Settings table if your reporting requires them.&lt;/p&gt;

&lt;h2&gt;
  
  
  All Available Query String Parameters
&lt;/h2&gt;

&lt;p&gt;The &lt;em&gt;Write URL query strings 2 cookies&lt;/em&gt; template supports a wide range of standard and platform-specific click tracking parameters. The container ships with UTM parameters enabled and all click IDs disabled — a sensible default for most implementations. Enable additional parameters by toggling their checkboxes in the tag configuration UI.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Platform / Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ Enabled&lt;/td&gt;
&lt;td&gt;Standard UTM — traffic source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_medium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ Enabled&lt;/td&gt;
&lt;td&gt;Standard UTM — traffic medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_campaign&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ Enabled&lt;/td&gt;
&lt;td&gt;Standard UTM — campaign name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ Enabled&lt;/td&gt;
&lt;td&gt;Standard UTM — ad content / variant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_term&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ Enabled&lt;/td&gt;
&lt;td&gt;Standard UTM — keyword&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gclid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Google Ads — auto-tagged click ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gad_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Google Ads — ad source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gad_campaignid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Google Ads — campaign ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gbraid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Google Ads — iOS app conversion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wbraid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Google Ads — web-to-app conversion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gclsrc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Google — DoubleClick import source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dclid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Google Display &amp;amp; Video 360 click ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;msclkid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Microsoft Advertising click ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fbclid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Meta (Facebook/Instagram) click ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;igshid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Instagram share ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ttclid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;TikTok click ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ttcid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;TikTok campaign ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;twclid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;X (Twitter) click ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;li_fat_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;LinkedIn first-party ad tracking ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ScCID&lt;/code&gt; / &lt;code&gt;ScCid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Snapchat click ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rdt_cid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Reddit click ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;srsltid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Google Shopping — surface result listing ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;UTM campaign ID (GA4 extended)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_source_platform&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;UTM source platform (GA4 extended)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_marketing_tactic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;UTM marketing tactic (GA4 extended)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utm_creative_format&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;UTM creative format (GA4 extended)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;epic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Custom campaign / partner click ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;☐ Disabled&lt;/td&gt;
&lt;td&gt;Custom contact or campaign ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Architecture: How a Click Becomes a Cookie Becomes a GA4 Parameter
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;User clicks a UTM-tagged link&lt;/strong&gt; — e.g. from a newsletter, paid ad, or social post — and lands on your site with query parameters in the URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GTM fires on All Pages.&lt;/strong&gt; The &lt;em&gt;Write certain URL query strings 2 cookies session&lt;/em&gt; tag runs immediately. For each enabled parameter, it reads the query string value and writes a session cookie scoped to the root domain (e.g. &lt;code&gt;.example.com&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user tag also runs.&lt;/strong&gt; &lt;em&gt;Write certain URL query strings 2 cookies user&lt;/em&gt; writes the same values as persistent cookies with the &lt;code&gt;_1&lt;/code&gt; suffix, expiring 730 days from now. If a persistent cookie already exists for a parameter, the existing value is retained — preserving first-touch attribution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GTM fires DOM Ready.&lt;/strong&gt; The &lt;code&gt;Extend Cookie Life Trigger&lt;/code&gt; checks the &lt;code&gt;_arrewr&lt;/code&gt; sentinel. If absent, the &lt;em&gt;Extend Cookie Life Tag&lt;/em&gt; runs the Cookie Rewriter against all ten persistent UTM cookies, resetting their expiry to 720 days from now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie Variable variables are evaluated.&lt;/strong&gt; On every subsequent GTM event, the ten Cookie Variable variables read the current values from the browser cookies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared Event Settings deliver values to GA4.&lt;/strong&gt; The six UTM shared event parameters (&lt;code&gt;e_cur_utm_*&lt;/code&gt; and &lt;code&gt;e_1st_utm_*&lt;/code&gt;) are included in every GA4 hit automatically via the Google Tag Shared Event Settings variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days or weeks later, the user returns.&lt;/strong&gt; Their persistent cookies still hold the original first-touch UTM values. On this second visit, the same session cookies are overwritten with the new landing page UTMs (or remain blank if this visit has no UTMs). The persistent cookies are extended again by the Cookie Rewriter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user submits a form.&lt;/strong&gt; Your form handler can read the UTM cookies directly from &lt;code&gt;document.cookie&lt;/code&gt;, or a separate GTM tag can read the GTM Cookie Variable values and push them into the form's hidden fields or a dataLayer event.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Real-World Use Case: Lead Attribution Across Days
&lt;/h2&gt;

&lt;p&gt;Consider a B2B software company running LinkedIn campaigns. A prospect clicks an ad on a Monday, lands on a product page, reads a case study, and leaves without converting. The UTM parameters from that Monday click are now stored in persistent cookies: &lt;code&gt;utm_source_1=linkedin&lt;/code&gt;, &lt;code&gt;utm_medium_1=paid-social&lt;/code&gt;, &lt;code&gt;utm_campaign_1=q2-enterprise-saas&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On Thursday, the prospect types the company URL directly into their browser, reads the pricing page, and submits a contact form. The URL has no UTM parameters. GA4 attributes this session to &lt;em&gt;direct&lt;/em&gt;. But your CRM receives the hidden form fields populated from the persistent cookies — and your sales team knows this lead originated from the LinkedIn Q2 enterprise campaign.&lt;/p&gt;

&lt;p&gt;This is the gap that UTM-to-cookie bridging fills: GA4 session attribution versus real-world multi-touch, multi-day customer journeys where the conversion rarely happens on the same visit as the first paid impression.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Sandboxed Templates Do Not Require New CSP Hashes
&lt;/h2&gt;

&lt;p&gt;GTM's sandboxed Custom Template runtime is granted access to browser APIs through GTM's own internal permission model — not through Content Security Policy. A template that writes a cookie uses GTM's &lt;code&gt;setCookie&lt;/code&gt; sandbox API, which executes within code already authorised by the GTM container's existing CSP allowance (the allowance granted to &lt;code&gt;googletagmanager.com&lt;/code&gt; or your server-side GTM origin).&lt;/p&gt;

&lt;p&gt;In contrast, a Custom HTML tag writes a literal &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; element into the DOM. CSP's &lt;code&gt;script-src&lt;/code&gt; directive applies to every inline script element. Unless you have &lt;code&gt;unsafe-inline&lt;/code&gt; enabled (which defeats much of CSP's purpose) or a matching SHA-256 hash, the browser will block execution. Every change to that script — adding a comment, reformatting a line — changes the hash and requires a CSP header update.&lt;/p&gt;

&lt;p&gt;With sandboxed templates, your CSP never changes when GTM tag logic changes. The entire cookie management implementation in this recipe is CSP-transparent.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON file from the &lt;a href="https://drive.google.com/file/d/1YOIJseTGI_XH5O-HPkHOEcbHK01bQCpm/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;utm-and-url-query-string-2-cookies-recipe.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing tags and triggers.&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;Measurement Stream ID Production&lt;/code&gt; and &lt;code&gt;Measurement Stream ID Development&lt;/code&gt; constant variables to your actual GA4 Measurement IDs.&lt;/li&gt;
&lt;li&gt;Review the enabled query string parameters in both Write tags and toggle on any additional parameters (click IDs, custom fields) that your implementation requires.&lt;/li&gt;
&lt;li&gt;Verify that &lt;code&gt;{{Root Domain}}&lt;/code&gt; resolves correctly for your site — this controls the cookie's domain scope. Check its value in GTM Preview mode on your site.&lt;/li&gt;
&lt;li&gt;In GA4, register &lt;code&gt;e_cur_utm_source&lt;/code&gt;, &lt;code&gt;e_cur_utm_medium&lt;/code&gt;, &lt;code&gt;e_cur_utm_campaign&lt;/code&gt;, &lt;code&gt;e_1st_utm_source&lt;/code&gt;, &lt;code&gt;e_1st_utm_medium&lt;/code&gt;, and &lt;code&gt;e_1st_utm_campaign&lt;/code&gt; as custom event-scoped dimensions under &lt;strong&gt;Admin → Custom definitions → Custom dimensions&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Open GTM Preview mode on a URL with UTM parameters (e.g. &lt;code&gt;?utm_source=test&amp;amp;utm_medium=test&amp;amp;utm_campaign=test&lt;/code&gt;) and confirm the cookie variables populate correctly in the Tag Assistant panel.&lt;/li&gt;
&lt;li&gt;Navigate to a second page with no UTM parameters and confirm the session and persistent cookie values persist across the navigation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Cookie consent:&lt;/strong&gt; Writing persistent cookies that track marketing attribution may require consent under GDPR, CCPA, or equivalent regulations depending on your jurisdiction and how the data is used. Review with your legal team. The recipe writes cookies on the first page view; if you operate a consent-managed site, gate both Write tags behind a consent trigger that only fires after the user has accepted marketing or analytics cookies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Can Do With It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Attach first-touch UTM values to CRM lead records by reading persistent cookies from hidden form fields — connect campaign spend to closed revenue without server-side infrastructure&lt;/li&gt;
&lt;li&gt;Compare &lt;code&gt;e_cur_utm_campaign&lt;/code&gt; (session UTM) against &lt;code&gt;e_1st_utm_campaign&lt;/code&gt; (first-touch UTM) in GA4 Explorations to understand how many conversions involve multi-campaign journeys&lt;/li&gt;
&lt;li&gt;Persist platform click IDs (&lt;code&gt;gclid&lt;/code&gt;, &lt;code&gt;fbclid&lt;/code&gt;, &lt;code&gt;msclkid&lt;/code&gt;) by enabling the relevant parameters — forward them to your CRM for enhanced conversions and offline conversion imports&lt;/li&gt;
&lt;li&gt;Build BigQuery attribution models that join on &lt;code&gt;e_1st_utm_source&lt;/code&gt; to compare first-touch against data-driven attribution without rebuilding your tracking implementation&lt;/li&gt;
&lt;li&gt;Extend the cookie name suffix convention (&lt;code&gt;_1&lt;/code&gt; for first touch, &lt;code&gt;_u&lt;/code&gt; for last touch) to create a full first/last attribution duality in a single container&lt;/li&gt;
&lt;li&gt;Use session UTM cookies to personalise on-site content via GTM variables — show a returning email subscriber a different headline without any server-side personalisation&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The full source — including the container JSON, variable inventory, and the drewspen sandboxed Custom Templates — is available on GitHub. If you enable additional query string parameters or extend the cookie naming convention, open a pull request or issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1YOIJseTGI_XH5O-HPkHOEcbHK01bQCpm/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cookies</category>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googletagmanager</category>
    </item>
    <item>
      <title>GTM Browser Viewport Measurement</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Wed, 24 Jun 2026 02:31:57 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/gtm-browser-viewport-measurement-28p4</link>
      <guid>https://dev.to/drew_spencer_1066/gtm-browser-viewport-measurement-28p4</guid>
      <description>&lt;h1&gt;
  
  
  Browser Viewport Measurement in Google Tag Manager — A Complete Recipe Without Custom JavaScript
&lt;/h1&gt;

&lt;p&gt;Send the user's current browser viewport dimensions as an event-level parameter on every GA4 hit — using only GTM's built-in JavaScript Variable type and a Constant. No Custom HTML tags. No new CSP hashes.&lt;/p&gt;

&lt;p&gt;Published · Tags: GTM, Google Analytics 4, Browser Viewport, Window Size, JavaScript Variable, CSP, GA4, Shared Event Settings&lt;/p&gt;

&lt;p&gt;How big is the browser window when a user converts? Does your checkout funnel drop off more on narrow viewports than wide ones? These questions sound like they should be answerable from GA4's built-in device category dimension — but &lt;em&gt;device category&lt;/em&gt; is derived from the User-Agent string and tells you nothing about how much screen space the browser is actually using. A desktop user with a browser snapped to half-screen is classified as "desktop" even though their experience is closer to a narrow tablet. This recipe measures the actual viewport: the rendered area available to your page at the moment of each event.&lt;/p&gt;

&lt;p&gt;Crucially, viewport size is an &lt;strong&gt;event-level metric&lt;/strong&gt; , not a user or session property. A user can resize their browser, rotate their phone, or dock a floating panel at any point during a session. Capturing it at session start misses every mid-session change. Sending it as a shared event parameter on every GA4 hit means you always know the viewport at the exact moment each interaction happened.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1XATwqQ5j9ptNDKmZ-3EumL0oAvqRnz7j/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Viewport Size Is an Event-Level Metric
&lt;/h2&gt;

&lt;p&gt;It is tempting to record viewport dimensions once — at page load or session start — and treat them as a fixed property for the session. This is wrong for several reasons.&lt;/p&gt;

&lt;p&gt;Users resize browser windows during long sessions. Mobile users rotate their devices between portrait and landscape. Tablet users attach and detach keyboards that alter the usable viewport. Users on multi-monitor setups drag the browser between screens with different resolutions. In all these cases, a single session-start capture would reflect the viewport at the wrong moment.&lt;/p&gt;

&lt;p&gt;By attaching the viewport to every event via the &lt;strong&gt;Shared Event Settings&lt;/strong&gt; variable, you get the actual viewport at the time of every page view, click, form submission, and conversion. This means you can ask GA4: "Of the users who completed checkout, what was their viewport size &lt;em&gt;at the moment they clicked the buy button&lt;/em&gt;?" — not "what was it when they arrived?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for CSP:&lt;/strong&gt; Measuring viewport dimensions might seem to require a Custom HTML tag or inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block that reads &lt;code&gt;window.innerWidth&lt;/code&gt;. That approach would require a new SHA-256 hash in your Content Security Policy every time the script content changed. This recipe avoids all of that: GTM's built-in &lt;strong&gt;JavaScript Variable&lt;/strong&gt; type reads any JavaScript expression from the page context without introducing new CSP requirements. No custom JavaScript. No new CSP hashes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported container (&lt;code&gt;browser-viewport-measurement-recipe.json&lt;/code&gt;) adds one new folder of assets to the existing Analytics infrastructure, and wires the result into the shared event settings so every GA4 hit carries the viewport dimension automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Folders
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Folder&lt;/th&gt;
&lt;th&gt;Contains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;GA4 configuration tag, shared settings variables, environment/stream routing variables, client ID utilities, and session/timestamp infrastructure — carried over from the base container.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser Viewport&lt;/td&gt;
&lt;td&gt;Six JavaScript Variable variables reading the three standard width sources and three height sources, plus the &lt;code&gt;Browser Viewport&lt;/code&gt; Constant that assembles them into a single &lt;code&gt;WIDTHxHEIGHT&lt;/code&gt; string.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Tags
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Analytics Configuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Google Tag (&lt;code&gt;googtag&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Standard GA4 config tag wired to environment-aware Stream IDs via shared configuration and event settings variables. Fires once per load. The shared event settings variable it references includes &lt;code&gt;e_browser_viewport&lt;/code&gt;, so every hit from this container carries the viewport string.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Variables Added
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;JavaScript Expression&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window.innerWidth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Variable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;window.innerWidth&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window.innerHeight&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Variable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;window.innerHeight&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;document.body.clientWidth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Variable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;document.body.clientWidth&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;document.body.clientHeight&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Variable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;document.body.clientHeight&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;document.documentElement.clientWidth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Variable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;document.documentElement.clientWidth&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;document.documentElement.clientHeight&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Variable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;document.documentElement.clientHeight&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Browser Viewport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Constant&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{window.innerWidth}}x{{window.innerHeight}}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Three Viewport Width Sources — and Why We Expose All Three
&lt;/h2&gt;

&lt;p&gt;There is no single universally correct way to read browser viewport width in JavaScript. Three properties exist, each returning subtly different values depending on the browser engine, the presence of scrollbars, zoom level, and whether the page has a CSS viewport meta tag. This recipe exposes all three so you can compare them across your traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;window.innerWidth&lt;/code&gt; / &lt;code&gt;window.innerHeight&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The CSS viewport size — the space available to the page's layout after browser chrome (toolbar, address bar, tabs) is excluded. On most desktop browsers this includes the scrollbar width, so it may be slightly larger than the usable content area. This is the value most commonly recommended for responsive design breakpoint analysis because it matches what CSS &lt;code&gt;@media&lt;/code&gt; queries see.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;document.documentElement.clientWidth&lt;/code&gt; / &lt;code&gt;clientHeight&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The width of the &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; element's content box. Scrollbars are excluded, so this is typically a few pixels narrower than &lt;code&gt;window.innerWidth&lt;/code&gt; on desktop when a vertical scrollbar is visible. On mobile, where scrollbars overlay the content rather than displacing it, the two values converge. This is often the most accurate proxy for "how wide is the content area the user actually sees."&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;document.body.clientWidth&lt;/code&gt; / &lt;code&gt;clientHeight&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The width of the &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; element's content box. This can differ from &lt;code&gt;documentElement.clientWidth&lt;/code&gt; if the body has margins, padding, or an explicit width set in CSS. On pages with a centred fixed-width layout, &lt;code&gt;body.clientWidth&lt;/code&gt; may be narrower than the viewport. Less reliable for breakpoint analysis, but useful as a diagnostic comparison when values diverge unexpectedly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which one to use in GA4 reports?&lt;/strong&gt; The &lt;code&gt;Browser Viewport&lt;/code&gt; Constant uses &lt;code&gt;window.innerWidth&lt;/code&gt; and &lt;code&gt;window.innerHeight&lt;/code&gt; because they most closely match the breakpoints your CSS framework responds to. The other four variables are available in GTM Preview for diagnostic comparison; you can promote any of them to a GA4 parameter if you have a specific reason to prefer a different source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Browser Viewport Constant: Assembling the String
&lt;/h2&gt;

&lt;p&gt;The seven variables above are designed as building blocks. The &lt;code&gt;Browser Viewport&lt;/code&gt; Constant ties them together into a single dimension value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight handlebars"&gt;&lt;code&gt;&lt;span class="k"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;innerWidth&lt;/span&gt;&lt;span class="k"&gt;}}&lt;/span&gt;x&lt;span class="k"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;innerHeight&lt;/span&gt;&lt;span class="k"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces strings like &lt;code&gt;1440x900&lt;/code&gt;, &lt;code&gt;390x844&lt;/code&gt;, or &lt;code&gt;1024x768&lt;/code&gt; — a format familiar from screen resolution reporting and directly usable as a GA4 custom dimension value. The lowercase &lt;code&gt;x&lt;/code&gt; separator is intentional: it avoids confusion with multiplication operators and is the conventional format used by analytics platforms and browser developer tools.&lt;/p&gt;

&lt;p&gt;The Constant type was chosen deliberately over a more complex variable type. A Constant re-evaluates its template expression references at the time each event fires — it does not cache the value at page load. This means that if a user resizes their browser between a page view and a click event, the click event captures the resized viewport, not the original one. This is exactly the event-level behaviour described in the opening section.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Plugs Into Every GA4 Hit: Shared Event Settings
&lt;/h2&gt;

&lt;p&gt;Rather than adding &lt;code&gt;e_browser_viewport&lt;/code&gt; to every individual tag's event parameter table, the recipe wires &lt;code&gt;{{Browser Viewport}}&lt;/code&gt; into the &lt;strong&gt;Google Tag Shared Event Settings&lt;/strong&gt; variable. This variable is referenced by the Google Analytics Configuration tag, which means its parameter table is merged into every GA4 event sent by the container — page views, scroll events, click events, custom events, and conversions alike.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared Event Settings Parameters (full list)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Parameter&lt;/th&gt;
&lt;th&gt;GTM Variable&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;container_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{GTM Container ID}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The GTM container public ID (e.g. &lt;code&gt;GTM-XXXXXX&lt;/code&gt;). Useful for filtering in BigQuery when multiple containers write to the same GA4 property.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;environment_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{GTM Environment Name}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The GTM Environment label (live, staging, development) when GTM Environments are in use.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stream_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Measurement Stream RegEx Lookup}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The GA4 Measurement ID that received this event, after environment routing. Confirms which stream a hit was routed to.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_session_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Browser Session ID RegEx Table}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GA4 session ID extracted from the &lt;code&gt;_ga_XXXXX&lt;/code&gt; cookie. Makes session-level joins in BigQuery straightforward.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_client_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Browser Client ID Append Underscore}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GA4 client ID with trailing underscore to force BigQuery string typing and avoid float precision loss.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_1st_session_ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Timestamp Session Start}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unix timestamp of the start of the current session, from the GA4 session cookie.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_cur_session_ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Timestamp Current}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unix timestamp at the moment the event fired.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_1st_user_ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Timestamp First User}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unix timestamp of the user's first ever session, from the GA4 client ID cookie.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;page_title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Page Title}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;document.title&lt;/code&gt; value at event time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_browser_viewport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Browser Viewport}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The viewport string — e.g. &lt;code&gt;1440x900&lt;/code&gt; — captured at the moment this event fired.&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Event-level, not session-level:&lt;/strong&gt; Because &lt;code&gt;e_browser_viewport&lt;/code&gt; is sent with every event via shared event settings, it reflects the viewport &lt;em&gt;at event time&lt;/em&gt;, not at session start. A session where the user rotates their phone mid-way through will show the correct viewport for each individual event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why JavaScript Variable Type Does Not Require New CSP Hashes
&lt;/h2&gt;

&lt;p&gt;Content Security Policy's &lt;code&gt;script-src&lt;/code&gt; directive controls which JavaScript is permitted to execute. Custom HTML tags in GTM that contain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; blocks with inline code must be covered by a SHA-256 hash of the script content (or an unsafe-inline allowance, which defeats CSP's purpose). Every time the script text changes — even by one character — the hash changes and must be updated in the CSP header.&lt;/p&gt;

&lt;p&gt;GTM's &lt;strong&gt;JavaScript Variable&lt;/strong&gt; type works differently. It evaluates a JavaScript expression inside GTM's own sandboxed runtime, which runs under the existing permission granted to the GTM container snippet. No new &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags are injected into the DOM. The expression &lt;code&gt;window.innerWidth&lt;/code&gt; is read directly from the page's window object at variable evaluation time, inside code that is already covered by the GTM container's own CSP allowance.&lt;/p&gt;

&lt;p&gt;The practical result: you can add, modify, or remove JavaScript Variable variables in GTM as many times as you like, and your CSP never needs updating. This is a significant operational advantage for organisations that maintain strict CSPs and require security team review for any hash change.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON file from the &lt;a href="https://drive.google.com/file/d/1XATwqQ5j9ptNDKmZ-3EumL0oAvqRnz7j/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;browser-viewport-measurement-recipe.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing tags and triggers.&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;Measurement Stream ID Production&lt;/code&gt; and &lt;code&gt;Measurement Stream ID Development&lt;/code&gt; constant variables to your actual GA4 Measurement IDs.&lt;/li&gt;
&lt;li&gt;Review the &lt;code&gt;Measurement Stream RegEx Lookup&lt;/code&gt; variable and adjust hostname patterns to match your non-production environments.&lt;/li&gt;
&lt;li&gt;In GA4, register &lt;code&gt;e_browser_viewport&lt;/code&gt; as a custom event-scoped dimension under &lt;strong&gt;Admin → Custom definitions → Custom dimensions&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Open GTM Preview mode, interact with your site, and verify that the &lt;code&gt;e_browser_viewport&lt;/code&gt; parameter appears on every event in the Tag Assistant panel before publishing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Custom dimension registration:&lt;/strong&gt; GA4 does not automatically expose new event parameters as dimensions in reports or Explorations. You must register &lt;code&gt;e_browser_viewport&lt;/code&gt; as a custom event-scoped dimension in GA4 Admin before it becomes available for filtering, segmenting, or use in Funnel Explorations. Registration takes up to 24 hours to propagate into the GA4 interface, but the data is collected immediately once the container is published.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Can Do With It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Compare conversion rates across viewport size buckets — identify whether narrow viewports underperform wide ones on specific funnel steps, independent of device category&lt;/li&gt;
&lt;li&gt;Detect mid-session viewport changes: a session where early events show &lt;code&gt;1440x900&lt;/code&gt; and later events show &lt;code&gt;720x900&lt;/code&gt; indicates a window resize, not a device switch&lt;/li&gt;
&lt;li&gt;Validate responsive design breakpoints against real user data — see the actual distribution of viewport widths hitting each breakpoint boundary in your traffic&lt;/li&gt;
&lt;li&gt;Correlate viewport size with scroll depth, time on page, or click-through rate to understand how layout affects engagement at different viewport widths&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;e_browser_viewport&lt;/code&gt; as a secondary dimension in GA4 Explorations to understand whether a high-exit page affects wide viewports, narrow viewports, or both equally&lt;/li&gt;
&lt;li&gt;Filter BigQuery exports by &lt;code&gt;e_browser_viewport&lt;/code&gt; to build custom viewport-aware cohort analyses without touching the raw User-Agent string&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Attribution
&lt;/h2&gt;

&lt;p&gt;The three-source approach to reading viewport dimensions — using &lt;code&gt;window.innerWidth&lt;/code&gt;, &lt;code&gt;document.documentElement.clientWidth&lt;/code&gt;, and &lt;code&gt;document.body.clientWidth&lt;/code&gt; in parallel — is based on a solution by &lt;a href="https://www.matthewedgar.net/google-analytics-viewport-or-browser-size" rel="noopener noreferrer"&gt;Matthew Edgar&lt;/a&gt;. The implementation here adapts that approach to GTM's built-in JavaScript Variable type and wires it into the Shared Event Settings pattern so it propagates automatically to all GA4 events without Custom HTML tags or CSP implications.&lt;/p&gt;




&lt;p&gt;The full source — including the container JSON and variable inventory — is available on GitHub. If you adapt the viewport string format or add additional measurement sources, open a pull request or issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1XATwqQ5j9ptNDKmZ-3EumL0oAvqRnz7j/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>browserviewport</category>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googletagmanager</category>
    </item>
    <item>
      <title>GTM / GA Page Count Measurement</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Mon, 22 Jun 2026 16:19:23 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/gtm-ga-page-count-measurement-1pjf</link>
      <guid>https://dev.to/drew_spencer_1066/gtm-ga-page-count-measurement-1pjf</guid>
      <description>&lt;h1&gt;
  
  
  Page View Counter in GA4 via GTM — Session &amp;amp; User-Level Counting Without CSP SHA-256
&lt;/h1&gt;

&lt;p&gt;Count how many pages a visitor has seen — within a session or across multiple visits — using GTM Custom Templates that write cookies and push events to the dataLayer, with no inline JavaScript and no Content Security Policy headaches.&lt;/p&gt;

&lt;p&gt;Published · Tags: GTM, Google Analytics 4, Page View Counter, Cookies, dataLayer, Custom Event Trigger, CSP, SPA, History Change&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This GTM container recipe is designed to work on Google Blogger / Blogspot as well as AEM, Sitecore, WordPress, React.js, and other platforms. A key design decision — using the page hostname instead of a computed root domain — is documented where it matters. The JSON can be downloaded from Google Drive below.&lt;/p&gt;

&lt;p&gt;Knowing how many pages a visitor has viewed — either within the current browser session or accumulated over multiple visits — opens up powerful segmentation and trigger logic in GA4. You can fire different tags on a visitor's first page versus their fifth, suppress introductory banners for returning heavy readers, or use the count as a GA4 custom dimension to analyse depth-of-engagement cohorts.&lt;/p&gt;

&lt;p&gt;The challenge is doing this cleanly inside GTM. The obvious approach — a Custom HTML tag with inline JavaScript — collides with Content Security Policy on sites that require &lt;code&gt;script-src 'nonce-...'&lt;/code&gt; or explicit SHA-256 hashes. Every time you edit the script, the hash changes and you have to update your CSP headers. This recipe solves that by using &lt;strong&gt;GTM Sandboxed JavaScript Custom Templates&lt;/strong&gt; instead. Template code runs inside GTM's sandbox and is &lt;strong&gt;not subject to CSP SHA-256 requirements&lt;/strong&gt; — the container itself is already allowed by the &lt;code&gt;www.googletagmanager.com&lt;/code&gt; script-src entry your site already needs.&lt;/p&gt;

&lt;p&gt;Pageview counts are persisted in &lt;strong&gt;two cookies&lt;/strong&gt; and simultaneously pushed into the &lt;strong&gt;dataLayer as custom events&lt;/strong&gt; , making the counters available both as GTM variables (readable on any subsequent tag) and as custom event triggers (fire a tag on the Nth pageview).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1EPpqakljdgJaiKtfCL-d2xZLSwF_ddB9/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported container (&lt;code&gt;pageview-count-measurement-recipe.json&lt;/code&gt;) is organised into four folders, four tags, two triggers, and a set of variables covering cookie names, cookie values, environment/stream routing, timestamps, and history-change helpers. Three community Custom Templates round out the package: &lt;em&gt;Page View Counter&lt;/em&gt;, &lt;em&gt;Cookie Rewriter&lt;/em&gt;, and three previously-imported utilities (If Else If, Timestamp, Get Root Domain).&lt;/p&gt;

&lt;h3&gt;
  
  
  Folders
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Folder&lt;/th&gt;
&lt;th&gt;Contains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;GA4 configuration tag, shared event-settings variable, environment/stream routing variables, Browser Client ID, Root Domain, session-start and first-user timestamp utilities.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page View Counter&lt;/td&gt;
&lt;td&gt;The two counter tags (&lt;em&gt;User&lt;/em&gt; and &lt;em&gt;Session&lt;/em&gt;), their cookie-name constants, and the cookie-value variables that surface the counts to other tags.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;History Change&lt;/td&gt;
&lt;td&gt;Variables for &lt;code&gt;gtm.oldUrl&lt;/code&gt;, &lt;code&gt;gtm.newUrl&lt;/code&gt;, and a Simple Lookup that resolves whether the new URL differs from the old one — used to fire the counter accurately on SPA navigation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extend Cookie Life&lt;/td&gt;
&lt;td&gt;The &lt;em&gt;Cookie Rewriter&lt;/em&gt; tag and its trigger, session-guard cookie name/value pair, and the Extend Cookie Life Trigger that runs once per session to refresh persistent cookie expiry dates.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Tags
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Fires On&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Analytics Configuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Google Tag (&lt;code&gt;googtag&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;All Pages (built-in)&lt;/td&gt;
&lt;td&gt;Standard GA4 config tag. Uses the &lt;em&gt;Measurement Stream RegEx Lookup&lt;/em&gt; variable for environment-aware stream routing and the &lt;em&gt;Google Tag Shared Event Settings&lt;/em&gt; to append counter values and timestamps to every event.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page View Counter - User&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Template (Page View Counter)&lt;/td&gt;
&lt;td&gt;All Pages + History Change&lt;/td&gt;
&lt;td&gt;Reads the _i_mpvcnt persistent cookie, increments it by 1, writes it back with a 720-day max-age, and pushes a dataLayer event named &lt;code&gt;_i_mpvcnt&lt;/code&gt; with the new count in &lt;code&gt;pageViewCounter&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page View Counter - Session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Template (Page View Counter)&lt;/td&gt;
&lt;td&gt;All Pages + History Change&lt;/td&gt;
&lt;td&gt;Same logic but uses the _i_spvcnt &lt;strong&gt;session cookie&lt;/strong&gt; (no max-age). Resets automatically when the browser session ends. Pushes a dataLayer event named &lt;code&gt;_i_spvcnt&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Extend Cookie Life Tag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Template (Cookie Rewriter)&lt;/td&gt;
&lt;td&gt;DOM Ready (once per session)&lt;/td&gt;
&lt;td&gt;On the first page of each browser session, rewrites the _i_mpvcnt persistent cookie with its current value and a fresh 720-day expiry. Sets a session-guard cookie _arrewr to prevent running again in the same session.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Triggers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Conditions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page View Counter History Change&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;History Change&lt;/td&gt;
&lt;td&gt;History Source &lt;strong&gt;equals&lt;/strong&gt; &lt;code&gt;pushState&lt;/code&gt; &lt;strong&gt;AND&lt;/strong&gt; Updated History URL &lt;strong&gt;equals&lt;/strong&gt; &lt;code&gt;true&lt;/code&gt; &lt;strong&gt;AND&lt;/strong&gt; New History URL &lt;strong&gt;does not match&lt;/strong&gt; `^(undefined&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;Extend Cookie Life Trigger&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;DOM Ready&lt;/td&gt;
&lt;td&gt;Fires when &lt;em&gt;Extend Cookie Life Already Ran Cookie Value&lt;/em&gt; &lt;strong&gt;matches&lt;/strong&gt; `^(undefined&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Page View Counter Template: How It Works
&lt;/h2&gt;

&lt;p&gt;The &lt;em&gt;Page View Counter&lt;/em&gt; Custom Template is a Sandboxed JavaScript tag that uses only GTM's built-in sandboxed APIs — {% raw %}&lt;code&gt;getCookieValues&lt;/code&gt;, &lt;code&gt;setCookie&lt;/code&gt;, and &lt;code&gt;createQueue('dataLayer')&lt;/code&gt;. Because it is a Template (not a Custom HTML tag), it does &lt;strong&gt;not&lt;/strong&gt; inject any inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; element into the page and therefore does &lt;strong&gt;not&lt;/strong&gt; require a CSP SHA-256 hash.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Page View Counter — Sandboxed JS (inside GTM Custom Template)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getCookieValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getCookieValues&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setCookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setCookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;createQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;createQueue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataLayerPush&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dataLayer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pageViewCounterName&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pv_cntr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Build cookie options: persistent cookie uses max-age, session cookie omits it&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookieDuration&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;persistentCookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max-age&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;persistentCookieExpires&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Read current count (default 0 if cookie absent)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCookieValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existingValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookieValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;cookieValue&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existingValue&lt;/span&gt; &lt;span class="o"&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="nx"&gt;existingValue&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentCount&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="c1"&gt;// Persist the incremented count&lt;/span&gt;
&lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newCount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cookieOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Push to dataLayer so the count is available as a custom event trigger&lt;/span&gt;
&lt;span class="c1"&gt;// and as a dataLayer variable in any subsequent tag&lt;/span&gt;
&lt;span class="nf"&gt;dataLayerPush&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// e.g. '_i_mpvcnt' — usable as a GTM custom event trigger&lt;/span&gt;
  &lt;span class="na"&gt;pageViewCounter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newCount&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gtmOnSuccess&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Dual persistence — cookie AND dataLayer event:&lt;/strong&gt; Writing the count to both a cookie and a dataLayer event is intentional. The cookie lets any subsequent tag (or future page) read the accumulated total via a GTM First-Party Cookie variable. The dataLayer push makes the count available as a &lt;em&gt;Custom Event trigger condition&lt;/em&gt; — for example, fire a specific tag only when &lt;code&gt;pageViewCounter&lt;/code&gt; equals 3 (the visitor's third page in this session).&lt;/p&gt;

&lt;h2&gt;
  
  
  Session-Level vs User-Level Counting
&lt;/h2&gt;

&lt;p&gt;The template is instantiated twice, with a single parameter difference that changes its persistence behaviour entirely:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Cookie Name&lt;/th&gt;
&lt;th&gt;Cookie Type&lt;/th&gt;
&lt;th&gt;Expiry&lt;/th&gt;
&lt;th&gt;Measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page View Counter - User&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;_i_mpvcnt&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;720 days from last write&lt;/td&gt;
&lt;td&gt;Total pages viewed across all sessions within 720 days (multi-visit / user-level depth)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page View Counter - Session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;_i_spvcnt&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Browser session end&lt;/td&gt;
&lt;td&gt;Pages viewed in the current browser session only (resets on next visit)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both counts flow into GA4 as event parameters via the &lt;em&gt;Google Tag Shared Event Settings&lt;/em&gt; variable, where they are mapped to &lt;code&gt;e_cur_session_pv&lt;/code&gt; (session counter) and &lt;code&gt;e_cur_multi_pv&lt;/code&gt; (user/persistent counter). Register these as custom dimensions in GA4 to use them in Explorations and Audiences.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using the Counter as a GTM Custom Event Trigger
&lt;/h3&gt;

&lt;p&gt;Because the template pushes &lt;code&gt;{ event: '_i_mpvcnt', pageViewCounter: N }&lt;/code&gt; to the dataLayer, you can create a GTM Custom Event trigger that listens for &lt;code&gt;_i_mpvcnt&lt;/code&gt; and adds a condition on the dataLayer variable &lt;code&gt;pageViewCounter&lt;/code&gt;. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fire a "Welcome back" popup&lt;/strong&gt; — Custom Event: &lt;code&gt;_i_mpvcnt&lt;/code&gt; with condition &lt;code&gt;pageViewCounter equals 5&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show a newsletter prompt on the second page of a session&lt;/strong&gt; — Custom Event: &lt;code&gt;_i_spvcnt&lt;/code&gt; with condition &lt;code&gt;pageViewCounter equals 2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suppress an introductory banner for returning heavy users&lt;/strong&gt; — Custom Event: &lt;code&gt;_i_mpvcnt&lt;/code&gt; with condition &lt;code&gt;pageViewCounter greater than 10&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Extending Cookie Life Without Re-Counting
&lt;/h2&gt;

&lt;p&gt;A persistent cookie with a fixed expiry silently shrinks: a cookie written with a 720-day max-age on day one has only 700 days remaining by day 20. Without intervention, active users lose their accumulated count on a fixed schedule.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;Extend Cookie Life Tag&lt;/em&gt; (using the &lt;em&gt;Cookie Rewriter&lt;/em&gt; Custom Template) solves this by rewriting the _i_mpvcnt cookie with its current value and a fresh 720-day max-age &lt;strong&gt;once per browser session&lt;/strong&gt;. A session-guard cookie (_arrewr) prevents the tag from running more than once per session regardless of how many pages load.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Cookie Rewriter — Sandboxed JS (inside GTM Custom Template)&lt;/span&gt;
&lt;span class="c1"&gt;// Runs once per session to refresh persistent cookie expiry&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SESSION_GUARD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SESSION_GUARD&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_arrewr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;guardValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCookieValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SESSION_GUARD&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;guardValues&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;guardValues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;guardValues&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="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gtmOnSuccess&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Already ran this session — exit immediately&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maxAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;durationDays&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cookieList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookieList&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;cookieName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCookieValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;values&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="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&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="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max-age&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;samesite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lax&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Set session guard so this tag is skipped for the rest of the session&lt;/span&gt;
&lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SESSION_GUARD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rootDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;samesite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lax&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  SPA Support via History Change Trigger
&lt;/h2&gt;

&lt;p&gt;Single-page applications (React, Vue, Angular) update the URL via the History API without triggering a full page reload. The standard GTM All Pages (pageview) trigger does not fire on these navigations. The &lt;em&gt;Page View Counter History Change&lt;/em&gt; trigger fills this gap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It listens for GTM's built-in &lt;code&gt;gtm.historyChange&lt;/code&gt; event.&lt;/li&gt;
&lt;li&gt;It filters to &lt;strong&gt;pushState&lt;/strong&gt; changes only (not &lt;code&gt;replaceState&lt;/code&gt; or back-button hash changes that don't actually navigate to a new page).&lt;/li&gt;
&lt;li&gt;It verifies the new URL is genuinely different from the old URL before firing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both counter tags fire on &lt;strong&gt;All Pages AND this History Change trigger&lt;/strong&gt; , so they accumulate correctly whether the site is a traditional multi-page site or a SPA.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Domain Decision for Google Blogger
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Google Blogger / Blogspot note:&lt;/strong&gt; Most GTM setups write cross-domain cookies to the root domain (e.g. &lt;code&gt;.example.com&lt;/code&gt;) using a "Get Root Domain" template so that cookies are shared across subdomains. On Blogger/Blogspot, this approach produces &lt;code&gt;.blogspot.com&lt;/code&gt; as the root domain — which scopes the cookie to every Blogspot site on the internet, not just yours. Using &lt;code&gt;.blogger.com&lt;/code&gt; would require hard-coding a platform-specific value. This recipe therefore sets &lt;code&gt;{{Root Domain}}&lt;/code&gt; to the &lt;strong&gt;page URL hostname&lt;/strong&gt; (e.g. &lt;code&gt;yourblog.blogspot.com&lt;/code&gt;) rather than the computed root domain. The cookie is scoped to your specific blog. The variable is lowercased and has "strip www" enabled. On non-Blogger platforms (AEM, WordPress, React.js) the same variable continues to work correctly — it just scopes to the full hostname instead of the root domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment-Aware Stream Routing
&lt;/h2&gt;

&lt;p&gt;Like the other recipes in this series, the container automatically routes hits to the correct GA4 property:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measurement Stream RegEx Lookup&lt;/strong&gt; checks &lt;code&gt;{{Page URL}}&lt;/code&gt;. Non-production hostname patterns match &lt;code&gt;{{Measurement Stream ID Development}}&lt;/code&gt; (placeholder: &lt;code&gt;G-ZZZZZZZZ&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment Stream ID&lt;/strong&gt; checks GTM's &lt;code&gt;{{Environment Name}}&lt;/code&gt; built-in: live environments match &lt;code&gt;^liv.*&lt;/code&gt;; pre-production matches &lt;code&gt;^(pre|de|st|ua|qa|te).*&lt;/code&gt;. The default falls through to &lt;code&gt;{{Measurement Stream ID Production}}&lt;/code&gt; (placeholder: &lt;code&gt;G-AAAAAAAA&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Update the two Constant variables (&lt;code&gt;Measurement Stream ID Production&lt;/code&gt; and &lt;code&gt;Measurement Stream ID Development&lt;/code&gt;) with your real GA4 Measurement IDs before publishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared Event Settings and GA4 Custom Dimensions
&lt;/h2&gt;

&lt;p&gt;The &lt;em&gt;Google Tag Shared Event Settings&lt;/em&gt; variable appends the following parameters to every GA4 event fired by this container:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event Parameter&lt;/th&gt;
&lt;th&gt;Source Variable&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_cur_session_pv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Page View Counter - Session - Cookie Value&lt;/td&gt;
&lt;td&gt;Pages viewed in current session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_cur_multi_pv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Page View Counter - User - Cookie Value&lt;/td&gt;
&lt;td&gt;Total pages viewed across sessions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_client_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browser Client ID (with appended underscore)&lt;/td&gt;
&lt;td&gt;GA4 client identifier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_session_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browser Session ID RegEx Table&lt;/td&gt;
&lt;td&gt;GA4 session identifier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_1st_session_ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp Session Start&lt;/td&gt;
&lt;td&gt;First session timestamp (ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_cur_session_ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp Current&lt;/td&gt;
&lt;td&gt;Current timestamp (ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e_1st_user_ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp First User&lt;/td&gt;
&lt;td&gt;First-ever user timestamp (ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;page_title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Page Title (document.title)&lt;/td&gt;
&lt;td&gt;Page title at event time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;container_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GTM Container ID&lt;/td&gt;
&lt;td&gt;GTM container for debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;environment_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GTM Environment Name&lt;/td&gt;
&lt;td&gt;Live / preview environment label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stream_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Measurement Stream RegEx Lookup&lt;/td&gt;
&lt;td&gt;Active GA4 stream ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To use the page-view counters in GA4 Explorations and Audiences, register &lt;code&gt;e_cur_session_pv&lt;/code&gt; and &lt;code&gt;e_cur_multi_pv&lt;/code&gt; as &lt;strong&gt;Custom Dimensions&lt;/strong&gt; (Event scope) in &lt;strong&gt;GA4 → Admin → Custom definitions → Custom dimensions&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Community Custom Templates Used
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template&lt;/th&gt;
&lt;th&gt;Author&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page View Counter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;drewspen (this recipe)&lt;/td&gt;
&lt;td&gt;Increments a cookie counter and pushes a dataLayer event on each page view.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cookie Rewriter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;drewspen (this recipe)&lt;/td&gt;
&lt;td&gt;Refreshes persistent cookie expiry once per session without modifying the value.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;If Else If – Advanced Lookup Table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/sublimetrix/gtm-template-ifelseif" rel="noopener noreferrer"&gt;sublimetrix&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Multi-condition lookup table for timestamp and session-ID extraction.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Timestamp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/luratic/Timestamp" rel="noopener noreferrer"&gt;luratic&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Returns current time in milliseconds since epoch.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Get Root Domain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/mbaersch/get-root-domain" rel="noopener noreferrer"&gt;mbaersch&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Extracts root domain from hostname or URL (used for the &lt;em&gt;Root Domain - Old&lt;/em&gt; variable; the active &lt;code&gt;Root Domain&lt;/code&gt; variable uses the hostname directly for Blogger compatibility).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;No CSP SHA-256 required:&lt;/strong&gt; All counter logic lives in GTM Sandboxed JavaScript Custom Templates. Template code is not injected as an inline script — it runs inside GTM's own sandboxed execution environment, which is already covered by your existing &lt;code&gt;script-src https://www.googletagmanager.com&lt;/code&gt; CSP directive. You never need to compute or update an inline-script hash when the counter logic changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON from the &lt;a href="https://drive.google.com/file/d/1EPpqakljdgJaiKtfCL-d2xZLSwF_ddB9/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt; above.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;pageview-count-measurement-recipe.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing container setup.&lt;/li&gt;
&lt;li&gt;Update the &lt;code&gt;Measurement Stream ID Production&lt;/code&gt; Constant variable to your live GA4 Measurement ID.&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;Measurement Stream ID Development&lt;/code&gt; for your dev/staging GA4 property.&lt;/li&gt;
&lt;li&gt;Verify consent settings: both counter tags are set to require &lt;code&gt;analytics_storage&lt;/code&gt;; the Cookie Rewriter requires both &lt;code&gt;analytics_storage&lt;/code&gt; and &lt;code&gt;functionality_storage&lt;/code&gt;. Adjust to match your CMP setup.&lt;/li&gt;
&lt;li&gt;Open GTM Preview mode and load a page. Confirm the &lt;code&gt;Page View Counter - User&lt;/code&gt; and &lt;code&gt;Page View Counter - Session&lt;/code&gt; tags fire and that the browser cookies _i_mpvcnt and _i_spvcnt are set.&lt;/li&gt;
&lt;li&gt;Navigate to a second page (or trigger a pushState navigation on a SPA). Confirm both counters increment correctly.&lt;/li&gt;
&lt;li&gt;In GA4, create custom dimensions for &lt;code&gt;e_cur_session_pv&lt;/code&gt; and &lt;code&gt;e_cur_multi_pv&lt;/code&gt; (event scope) to surface the counters in Explorations.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Blogger / Blogspot users:&lt;/strong&gt; The &lt;code&gt;Root Domain&lt;/code&gt; variable is already configured to use the page hostname. No changes needed. However, confirm that your GTM container snippet is installed correctly in your Blogger theme — go to &lt;strong&gt;Theme → Edit HTML&lt;/strong&gt; and verify both the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; GTM snippets are present.&lt;/p&gt;




&lt;p&gt;The full source — container JSON and documentation — is also published on GitHub. If you adapt the cookie naming scheme, add additional counters (e.g. a "pages viewed this week" counter with a 7-day cookie), or extend the Extend Cookie Life template to rewrite additional cookies, open a pull request or issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1EPpqakljdgJaiKtfCL-d2xZLSwF_ddB9/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googletagmanager</category>
      <category>gtm</category>
    </item>
    <item>
      <title>GA4 Page Performance - Bring Back Site Speed</title>
      <dc:creator>Kent Spencer</dc:creator>
      <pubDate>Mon, 22 Jun 2026 16:16:12 +0000</pubDate>
      <link>https://dev.to/drew_spencer_1066/ga4-page-performance-bring-back-site-speed-pdc</link>
      <guid>https://dev.to/drew_spencer_1066/ga4-page-performance-bring-back-site-speed-pdc</guid>
      <description>&lt;h1&gt;
  
  
  Page Performance Timing in GA4 via GTM — Bringing Back Site Speed Without Universal Analytics
&lt;/h1&gt;

&lt;p&gt;Send eight browser performance timing metrics to Google Analytics 4 on every sampled page load, using GTM's Window Loaded trigger, a Custom HTML timing script, and a custom event — no additional tools required.&lt;/p&gt;

&lt;p&gt;Published · Tags: GTM, Google Analytics 4, Performance Timing, Navigation Timing API, Site Speed, dataLayer, Custom Metrics, BigQuery&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original concept and code:&lt;/strong&gt; This recipe is a rewrite and GTM container implementation of the performance timing approach originally published by &lt;strong&gt;David Vallejo (&lt;a href="https://twitter.com/thyngster" rel="noopener noreferrer"&gt;@thyngster&lt;/a&gt;)&lt;/strong&gt; at &lt;a href="https://www.thyngster.com/performance-timing-tracking-with-google-analytics-4" rel="noopener noreferrer"&gt;thyngster.com — Performance Timing tracking with Google Analytics 4&lt;/a&gt; (July 2022). David's article remains the authoritative reference for the original technique, the sampling logic, and the GA4 custom metric definitions. This post documents a ready-to-import GTM container that packages his approach into a deployable recipe.&lt;/p&gt;

&lt;p&gt;One of the most frequently missed features after migrating from Universal Analytics to GA4 is the &lt;strong&gt;Site Speed report&lt;/strong&gt;. UA tracked eight browser timing metrics out of the box — page load time, DNS lookup, server response, and more — and surfaced them in a dedicated report. GA4 ships with none of this by default.&lt;/p&gt;

&lt;p&gt;The good news is that the browser's &lt;code&gt;window.performance.timing&lt;/code&gt; API (part of the Navigation Timing Level 1 specification) is still available in every modern browser, and GTM's Window Loaded trigger fires at exactly the right moment to read it. This recipe collects all eight timing metrics, pushes them to the dataLayer, guards against negative or zero values that indicate a timing anomaly, applies the same client-ID-based sampling logic that Universal Analytics used, and sends a clean &lt;code&gt;page_performance&lt;/code&gt; event to GA4 with four parameters that you register as custom millisecond metrics.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1p9WJyzncg2a-YwvX0MZPa80iJw2SD-Z6/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download the GTM Container JSON from Google Drive&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Inside the Container
&lt;/h2&gt;

&lt;p&gt;The exported container (&lt;code&gt;page-performance-measurement-recipe.json&lt;/code&gt;) is organised into two folders, three tags, two triggers, twenty-five variables, and three community Custom Templates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Folders
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Folder&lt;/th&gt;
&lt;th&gt;Contains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;GA4 configuration tag, shared settings variables, environment/stream routing, Browser Client ID, session and timestamp utilities, Root Domain helper.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page Performance&lt;/td&gt;
&lt;td&gt;The Custom HTML timing tag, the GA4 send event tag, the custom event trigger, and the four dataLayer variables that bridge the timing push to the GA4 tag.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Tags
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Fires On&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google Analytics Configuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Google Tag (&lt;code&gt;googtag&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;All Pages (built-in)&lt;/td&gt;
&lt;td&gt;Standard GA4 config tag with environment-aware stream ID routing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page Performance&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom HTML&lt;/td&gt;
&lt;td&gt;Window Loaded&lt;/td&gt;
&lt;td&gt;Reads &lt;code&gt;window.performance.timing&lt;/code&gt;, applies sampling, performs sanity checks, and pushes a &lt;code&gt;performance_timing&lt;/code&gt; custom event to the dataLayer.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page Performance Send Event&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GA4 Event (&lt;code&gt;gaawe&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Page Performance Send Event Trigger (custom event)&lt;/td&gt;
&lt;td&gt;Listens for the &lt;code&gt;performance_timing&lt;/code&gt; dataLayer event and forwards four timing metrics to GA4 as a &lt;code&gt;page_performance&lt;/code&gt; event with millisecond parameters.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Triggers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Conditions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Window Loaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Window Loaded&lt;/td&gt;
&lt;td&gt;No additional filter. Fires after all resources on the page have finished loading — the correct moment to read &lt;code&gt;loadEventStart&lt;/code&gt; from the timing API.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page Performance Send Event Trigger&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom Event&lt;/td&gt;
&lt;td&gt;Event name equals &lt;code&gt;performance_timing&lt;/code&gt; &lt;strong&gt;AND&lt;/strong&gt; all four metrics match &lt;code&gt;^[1-9][0-9].*&lt;/code&gt; — meaning each value must be at least 10 ms. This prevents the GA4 tag from firing when the timing script ran but produced zero or sub-10 ms values, which typically indicate cached or pre-rendered pages where timing data is unreliable.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Timing Script: How It Works
&lt;/h2&gt;

&lt;p&gt;The &lt;em&gt;Page Performance&lt;/em&gt; Custom HTML tag contains a self-executing function that runs at Window Load. Here is the full script with commentary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// Performance Timing GTM Custom HTML Javascript Tag&lt;/span&gt;
&lt;span class="c1"&gt;// Rewrite by Claude.ai of https://www.thyngster.com/performance-timing-tracking-with-google-analytics-4&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;siteSpeedSampleRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 100 = measure every visitor; lower to reduce volume&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;gaCookieName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_ga&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;dataLayerName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dataLayer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// ── Sampling ──────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="c1"&gt;// Replicates Universal Analytics' siteSpeedSampleRate logic.&lt;/span&gt;
  &lt;span class="c1"&gt;// Hashes the GA client ID and checks whether it falls within the&lt;/span&gt;
  &lt;span class="c1"&gt;// sample bucket. Deterministic: the same visitor is always in or&lt;/span&gt;
  &lt;span class="c1"&gt;// always out for a given rate.&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;hashId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;b&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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;268435455&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;266338304&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;siteSpeedSampleRate&lt;/span&gt; &lt;span class="o"&gt;&amp;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;100&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;siteSpeedSampleRate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;siteSpeedSampleRate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;cookieMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;gaCookieName&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                     &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/GA&lt;/span&gt;&lt;span class="se"&gt;\d\.\d\.&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookieMatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;cookieMatch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;hashId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// not in sample — exit&lt;/span&gt;

  &lt;span class="c1"&gt;// ── Timing API ────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;performance&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webkitPerformance&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;pt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pt&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;navigationStart&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loadEventStart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// API unavailable or page not fully loaded&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;navigationStart&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;timingData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;page_load_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loadEventStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// total time to loadEventStart&lt;/span&gt;
    &lt;span class="na"&gt;page_download_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseEnd&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// bytes transfer time&lt;/span&gt;
    &lt;span class="na"&gt;dns_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domainLookupEnd&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domainLookupStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;redirect_response_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetchStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// redirect overhead&lt;/span&gt;
    &lt;span class="na"&gt;server_response_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tcp_connect_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connectEnd&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connectStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dom_interactive_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domInteractive&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// time to DOM ready&lt;/span&gt;
    &lt;span class="na"&gt;content_load_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domContentLoadedEventStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt; &lt;span class="c1"&gt;// time to DOMContentLoaded&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// ── Sanity check: abort if any metric is negative ─────────────────&lt;/span&gt;
  &lt;span class="c1"&gt;// Negative values occur when the timing API is partially reset (e.g.&lt;/span&gt;
  &lt;span class="c1"&gt;// bfcache navigations, some SPAs). Sending negative milliseconds to&lt;/span&gt;
  &lt;span class="c1"&gt;// GA4 would corrupt metric averages.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;timingData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timingData&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// ── Push to dataLayer ─────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;dl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;dataLayerName&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;dl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;performance_timing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timingData&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="p"&gt;}());&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/script&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Window Loaded vs DOM Ready:&lt;/strong&gt; The script fires on GTM's Window Loaded trigger (equivalent to the browser's &lt;code&gt;window.onload&lt;/code&gt; event) because &lt;code&gt;pt.loadEventStart&lt;/code&gt; is only populated after the load event has fired. Using DOM Ready would cause &lt;code&gt;loadEventStart&lt;/code&gt; to read as &lt;code&gt;0&lt;/code&gt;, making the sanity check &lt;code&gt;!pt.loadEventStart&lt;/code&gt; bail out immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sampling: Replicating Universal Analytics' siteSpeedSampleRate
&lt;/h2&gt;

&lt;p&gt;Universal Analytics sampled site speed hits using a deterministic hash of the client ID so that the same visitor was consistently included or excluded — avoiding session-level inconsistency where a user's first page was sampled but their second was not. This recipe replicates that exact logic.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;hashId()&lt;/code&gt; function produces an integer from the client ID string. The result modulo 100 gives a value in the range 0–99. If that value is less than &lt;code&gt;siteSpeedSampleRate&lt;/code&gt;, the visitor is in the sample and the timing data is collected. At a rate of 100 (the default in this container), every visitor is included. At a rate of 10, approximately 10% of visitors are measured.&lt;/p&gt;

&lt;p&gt;The cookie parsing uses a regex compatible with both &lt;code&gt;GA1.2.&lt;/code&gt; and &lt;code&gt;GA4.2.&lt;/code&gt; style client ID prefixes (the original Thyngster script used a &lt;code&gt;GA1.[0-9]\.&lt;/code&gt; pattern; this rewrite generalises to &lt;code&gt;GA\d\.\d\.&lt;/code&gt; to accommodate multi-domain tagging configurations that produce different domain depth prefixes).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adjust the sample rate for high-traffic sites:&lt;/strong&gt; At &lt;code&gt;siteSpeedSampleRate = 100&lt;/code&gt; every page load sends a &lt;code&gt;page_performance&lt;/code&gt; event. On GA4 free properties, which have a 1,000 events-per-session cap before sampling, this adds one event to every session. For sites with very high page-view volume consider setting the rate to 10–50. The trade-off is wider confidence intervals on average timing metrics in GA4 Explorations and BigQuery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dataLayer Push
&lt;/h2&gt;

&lt;p&gt;When sampling and sanity checks pass, the script pushes this object to the dataLayer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;event:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'performance_timing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;timing:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;page_load_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;131&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ms&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;navigationStart&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;loadEventStart&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;page_download_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ms&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;transfer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;response&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;bytes&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;dns_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ms&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;DNS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;resolution&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;redirect_response_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ms&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;redirect&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;overhead&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;server_response_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ms&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;request&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;first&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;byte&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;tcp_connect_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ms&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;establish&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;TCP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;connection&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;dom_interactive_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;63&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ms&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;DOM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;interactive&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;content_load_time:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;63&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ms&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;DOMContentLoaded&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All eight metrics are present in the push, but only four are forwarded to GA4 in this recipe. The four dataLayer variables read from the &lt;code&gt;timing.*&lt;/code&gt; path and are filtered by the send event trigger before any hit leaves the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four dataLayer Variables
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GTM Variable&lt;/th&gt;
&lt;th&gt;dataLayer Path&lt;/th&gt;
&lt;th&gt;GA4 Parameter Name&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page Performance dataLayer page_load_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;timing.page_load_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e_pageload_ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page Performance dataLayer content_load_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;timing.content_load_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e_windowload_ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page Performance dataLayer page_download_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;timing.page_download_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e_initialization_ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Page Performance dataLayer dom_interactive_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;timing.dom_interactive_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e_domready_ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Why only four parameters?&lt;/strong&gt; GA4's free tier allows up to 50 custom event parameters per event, but registering custom metric definitions in the GA4 property UI takes deliberate effort and namespace. The four parameters selected — page load, DOMContentLoaded, DOM interactive, and page download — cover the metrics most directly equivalent to the UA Site Speed report's headline figures. The remaining four (&lt;code&gt;dns_time&lt;/code&gt;, &lt;code&gt;redirect_response_time&lt;/code&gt;, &lt;code&gt;server_response_time&lt;/code&gt;, &lt;code&gt;tcp_connect_time&lt;/code&gt;) are available in the dataLayer push and can be added to additional GTM variables and GA4 parameters without changing the script.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Send Event Trigger: Double-Gating on Data Quality
&lt;/h2&gt;

&lt;p&gt;The &lt;em&gt;Page Performance Send Event Trigger&lt;/em&gt; applies a second layer of data quality filtering beyond the script's own sanity check. In addition to matching the custom event name &lt;code&gt;performance_timing&lt;/code&gt;, it requires that all four forwarded metrics match the regex &lt;code&gt;^[1-9][0-9].*&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The pattern requires the value to start with a non-zero digit followed by at least one more digit — meaning the metric must be ≥ 10 ms.&lt;/li&gt;
&lt;li&gt;Metrics of 0–9 ms are excluded. Single-digit values can arise from cached resources, service worker interceptions, or HTTP/2 push where timing boundaries collapse to near-zero. These readings are real but skew average metrics significantly when mixed with genuine navigation timings, and are excluded here for analytical cleanliness.&lt;/li&gt;
&lt;li&gt;All four parameters must pass simultaneously. If any one of the four key metrics is below 10 ms, the entire event is suppressed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  GA4 Event Parameters Sent
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GA4 Parameter&lt;/th&gt;
&lt;th&gt;Source Variable&lt;/th&gt;
&lt;th&gt;Measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;e_initialization_ms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Page Performance dataLayer page_download_time}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time in ms to transfer the response body bytes (&lt;code&gt;responseEnd − responseStart&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;e_pageload_ms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Page Performance dataLayer page_load_time}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Total time in ms from navigation start to &lt;code&gt;loadEventStart&lt;/code&gt; — the closest equivalent to UA's Page Load Time metric&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;e_domready_ms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Page Performance dataLayer dom_interactive_time}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time in ms from navigation start to the DOM becoming interactive (&lt;code&gt;domInteractive − navigationStart&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;e_windowload_ms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{Page Performance dataLayer content_load_time}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time in ms from navigation start to &lt;code&gt;DOMContentLoaded&lt;/code&gt; firing (&lt;code&gt;domContentLoadedEventStart − navigationStart&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  All Eight Timing Metrics Available in the dataLayer
&lt;/h2&gt;

&lt;p&gt;The script computes and pushes all eight metrics even though only four are currently forwarded to GA4. The full set mirrors the metrics David Vallejo documented in the original Thyngster article:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric Key&lt;/th&gt;
&lt;th&gt;Timing API Calculation&lt;/th&gt;
&lt;th&gt;UA Equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;page_load_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;loadEventStart − navigationStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Page Load Time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;page_download_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;responseEnd − responseStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Page Download Time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dns_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domainLookupEnd − domainLookupStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Domain Lookup Time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redirect_response_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fetchStart − navigationStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Redirection Time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server_response_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;responseStart − requestStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server Response Time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tcp_connect_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;connectEnd − connectStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server Connection Time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dom_interactive_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domInteractive − navigationStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Document Interactive Time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;content_load_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domContentLoadedEventStart − navigationStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Document Content Loaded Time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Registering GA4 Custom Metrics
&lt;/h2&gt;

&lt;p&gt;Sending event parameters to GA4 does not automatically make them available in reports. You must register each parameter as a &lt;strong&gt;custom metric&lt;/strong&gt; in the GA4 property with &lt;strong&gt;event scope&lt;/strong&gt; and &lt;strong&gt;Milliseconds&lt;/strong&gt; as the unit of measurement. This allows GA4 Explorations and the standard reports to compute averages correctly.&lt;/p&gt;

&lt;p&gt;In GA4, go to &lt;strong&gt;Admin → Custom definitions → Custom metrics&lt;/strong&gt; and create:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric Name (suggested)&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;Event Parameter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Page Load Time&lt;/td&gt;
&lt;td&gt;Event&lt;/td&gt;
&lt;td&gt;Milliseconds&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e_pageload_ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOM Ready Time&lt;/td&gt;
&lt;td&gt;Event&lt;/td&gt;
&lt;td&gt;Milliseconds&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e_domready_ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Window Load Time&lt;/td&gt;
&lt;td&gt;Event&lt;/td&gt;
&lt;td&gt;Milliseconds&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e_windowload_ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Initialization Time&lt;/td&gt;
&lt;td&gt;Event&lt;/td&gt;
&lt;td&gt;Milliseconds&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e_initialization_ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you choose to add the remaining four timing metrics from the dataLayer push, add additional GTM variables for &lt;code&gt;timing.dns_time&lt;/code&gt;, &lt;code&gt;timing.redirect_response_time&lt;/code&gt;, &lt;code&gt;timing.server_response_time&lt;/code&gt;, and &lt;code&gt;timing.tcp_connect_time&lt;/code&gt;, wire them into the GA4 event tag, and register them as custom metrics with millisecond units in GA4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Querying the Data in BigQuery
&lt;/h2&gt;

&lt;p&gt;Once GA4 is linked to BigQuery, performance data becomes available for flexible querying. As David Vallejo showed in the original article, you can identify slow pages directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Pages where page load exceeded 1 second&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;,&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;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string_value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;UNNEST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'page_location'&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;page&lt;/span&gt;&lt;span class="p"&gt;,&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;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int_value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;UNNEST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'e_pageload_ms'&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;page_load_ms&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`your_project.your_dataset.events_*`&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'page_performance'&lt;/span&gt;
  &lt;span class="k"&gt;AND&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;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int_value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;UNNEST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'e_pageload_ms'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&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;page_load_ms&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;

&lt;span class="c1"&gt;-- Average timing metrics by page path&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string_value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;UNNEST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'page_location'&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;page&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="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&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;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int_value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;UNNEST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'e_pageload_ms'&lt;/span&gt;&lt;span class="p"&gt;)&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;avg_page_load_ms&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="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&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;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int_value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;UNNEST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'e_domready_ms'&lt;/span&gt;&lt;span class="p"&gt;)&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;avg_dom_ready_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;samples&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`your_project.your_dataset.events_*`&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'page_performance'&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;page&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;avg_page_load_ms&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Environment-Aware Stream Routing
&lt;/h2&gt;

&lt;p&gt;Like the other recipes in this series, the container routes traffic to development or production GA4 properties automatically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measurement Stream RegEx Lookup&lt;/strong&gt; checks &lt;code&gt;{{Page URL}}&lt;/code&gt; against non-production hostname patterns (&lt;code&gt;dev|uat|qa|orig|stg|stage|staging|int|local|admin|aem&lt;/code&gt;, plus the GTM Preview appspot.com domain). Matches route to &lt;code&gt;{{Measurement Stream ID Development}}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment Stream ID&lt;/strong&gt; checks the GTM &lt;code&gt;{{Environment Name}}&lt;/code&gt; built-in. Live environments match &lt;code&gt;^liv.*&lt;/code&gt;; pre-production matches &lt;code&gt;^(pre|de|st|ua|qa|te).*&lt;/code&gt;. The default is &lt;code&gt;{{Measurement Stream ID Production}}&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Update the &lt;code&gt;Measurement Stream ID Production&lt;/code&gt; (placeholder: &lt;code&gt;G-AAAAAAAA&lt;/code&gt;) and &lt;code&gt;Measurement Stream ID Development&lt;/code&gt; (placeholder: &lt;code&gt;G-ZZZZZZZZ&lt;/code&gt;) Constant variables before publishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Import
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the JSON from the &lt;a href="https://drive.google.com/file/d/1p9WJyzncg2a-YwvX0MZPa80iJw2SD-Z6/view?usp=drivesdk" rel="noopener noreferrer"&gt;Google Drive link&lt;/a&gt; above.&lt;/li&gt;
&lt;li&gt;In GTM, go to &lt;strong&gt;Admin → Import Container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;page-performance-measurement-recipe.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Merge&lt;/strong&gt; (not Overwrite) to preserve your existing container.&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;Measurement Stream ID Production&lt;/code&gt; and &lt;code&gt;Measurement Stream ID Development&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Adjust &lt;code&gt;siteSpeedSampleRate&lt;/code&gt; in the &lt;em&gt;Page Performance&lt;/em&gt; Custom HTML tag if needed (line 3 of the script; range 1–100).&lt;/li&gt;
&lt;li&gt;In GA4, create four custom metrics (event scope, Milliseconds unit) for &lt;code&gt;e_pageload_ms&lt;/code&gt;, &lt;code&gt;e_domready_ms&lt;/code&gt;, &lt;code&gt;e_windowload_ms&lt;/code&gt;, and &lt;code&gt;e_initialization_ms&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Open GTM Preview mode, load any page, and confirm the &lt;code&gt;performance_timing&lt;/code&gt; custom event appears in the Tag Assistant panel. Check that the four parameter values are positive integers above 10.&lt;/li&gt;
&lt;li&gt;Confirm the &lt;em&gt;Page Performance Send Event&lt;/em&gt; tag fires on the same event in Preview.&lt;/li&gt;
&lt;li&gt;Wait 24–48 hours for GA4 to begin populating the custom metrics in Explorations.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Navigation Timing Level 1 deprecation:&lt;/strong&gt; The &lt;code&gt;window.performance.timing&lt;/code&gt; API (Navigation Timing Level 1) is deprecated in favour of the Navigation Timing Level 2 &lt;code&gt;PerformanceNavigationTiming&lt;/code&gt; interface accessible via &lt;code&gt;performance.getEntriesByType('navigation')[0]&lt;/code&gt;. Level 1 remains supported in all major browsers as of mid-2025, but a future version of this recipe will migrate to Level 2 for forward compatibility. The core timing calculations and metric names will not change.&lt;/p&gt;




&lt;p&gt;The full source — container JSON, script commentary, and variable inventory — is available on GitHub. Credit and gratitude to &lt;a href="https://www.thyngster.com/performance-timing-tracking-with-google-analytics-4" rel="noopener noreferrer"&gt;David Vallejo (@thyngster)&lt;/a&gt; for the original technique and for sharing it openly. If you extend the metric set, add Level 2 API support, or adapt the sampling logic, open a pull request or issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1p9WJyzncg2a-YwvX0MZPa80iJw2SD-Z6/view?usp=drivesdk" rel="noopener noreferrer"&gt;⬇ Download GTM Container JSON&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ga4</category>
      <category>googleanalytics</category>
      <category>googletagmanager</category>
      <category>gtm</category>
    </item>
  </channel>
</rss>
