<?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: hsynkvlc</title>
    <description>The latest articles on DEV Community by hsynkvlc (@hsynkvlc).</description>
    <link>https://dev.to/hsynkvlc</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3815665%2F4213d13b-9f0d-4781-84b5-6e10aa19462b.jpeg</url>
      <title>DEV Community: hsynkvlc</title>
      <link>https://dev.to/hsynkvlc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hsynkvlc"/>
    <language>en</language>
    <item>
      <title>How I Built a GTM Debugger Chrome Extension (And What I Learned the Hard Way)</title>
      <dc:creator>hsynkvlc</dc:creator>
      <pubDate>Tue, 10 Mar 2026 00:26:50 +0000</pubDate>
      <link>https://dev.to/hsynkvlc/how-i-built-a-gtm-debugger-chrome-extension-and-what-i-learned-the-hard-way-26ni</link>
      <guid>https://dev.to/hsynkvlc/how-i-built-a-gtm-debugger-chrome-extension-and-what-i-learned-the-hard-way-26ni</guid>
      <description>&lt;p&gt;Every GTM developer has been there. You fire a tag, open the preview mode, and... nothing. The tag didn't fire. Or maybe it did fire, but with the wrong dataLayer values. You refresh. You check the trigger. You stare at the screen. You start questioning your life choices.&lt;/p&gt;

&lt;p&gt;I've been doing Google Tag Manager implementations professionally for years, and this frustration never went away. GTM's built-in debugger is powerful, but it's slow to load, tied to a separate window, and shows you a lot of noise when you just want to check &lt;em&gt;one thing&lt;/em&gt;: did my dataLayer event push correctly?&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://tagmaster.dev" rel="noopener noreferrer"&gt;Tag Master&lt;/a&gt; — a Chrome extension for GTM and GA4 debugging. Here's how it went, what I built, and what I wish I'd known before starting.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem I Was Actually Solving
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, I spent time articulating exactly what the pain was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;dataLayer inspection is painful&lt;/strong&gt; — you either &lt;code&gt;console.log(dataLayer)&lt;/code&gt; manually or dig through GTM Preview's cluttered UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network request decoding is tedious&lt;/strong&gt; — figuring out what a GA4 &lt;code&gt;/g/collect&lt;/code&gt; request actually contains means manually parsing query parameters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GA4 event validation&lt;/strong&gt; — confirming that GA4 ecommerce events have the right required parameters (&lt;code&gt;transaction_id&lt;/code&gt;, &lt;code&gt;currency&lt;/code&gt;, &lt;code&gt;value&lt;/code&gt;, &lt;code&gt;items&lt;/code&gt;) before they hit the property takes way too long&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The existing tools (GTM's own Preview, GA4 DebugView, browser console) each solve &lt;em&gt;one&lt;/em&gt; of these problems, but you end up context-switching between three different places.&lt;/p&gt;

&lt;p&gt;My goal: a single side panel, always visible alongside the page, that shows all of this in real time.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Tag Master is a Chrome Extension using &lt;strong&gt;Manifest V3&lt;/strong&gt; (more on this pain point later). The core architecture uses a dual content script model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Page Script (MAIN world) ↔ Content Script (ISOLATED world) ↔ Background Service Worker ↔ Side Panel UI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Page Script&lt;/strong&gt; runs in the page's own JavaScript context (the &lt;code&gt;MAIN&lt;/code&gt; world). It intercepts &lt;code&gt;dataLayer.push()&lt;/code&gt; calls, detects GTM containers, and reads &lt;code&gt;window.google_tag_manager&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content Script&lt;/strong&gt; runs in Chrome's isolated world. It acts as a bridge — it can't access &lt;code&gt;window.dataLayer&lt;/code&gt; directly, but it &lt;em&gt;can&lt;/em&gt; communicate with both the page script (via &lt;code&gt;window.postMessage&lt;/code&gt;) and the extension (via &lt;code&gt;chrome.runtime.sendMessage&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background Service Worker&lt;/strong&gt; captures network requests via &lt;code&gt;chrome.webRequest&lt;/code&gt;, manages IndexedDB storage, and routes messages between all components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Side Panel&lt;/strong&gt; is the main UI — a panel that opens alongside the page so you can debug without losing screen space.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why Two Content Scripts?
&lt;/h3&gt;

&lt;p&gt;Chrome extensions run content scripts in an "isolated world" — they share the DOM with the page but have a completely separate JavaScript environment. This means you can't access &lt;code&gt;window.dataLayer&lt;/code&gt; from a content script. Period.&lt;/p&gt;

&lt;p&gt;MV3 introduced a clean solution: you can declare a content script with &lt;code&gt;"world": "MAIN"&lt;/code&gt; in your manifest, which runs it in the page's own context:&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="nl"&gt;"content_scripts"&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"matches"&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="s2"&gt;"&amp;lt;all_urls&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"js"&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="s2"&gt;"content/content-script.js"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"run_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document_start"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"all_frames"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;span class="nl"&gt;"matches"&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="s2"&gt;"&amp;lt;all_urls&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"js"&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="s2"&gt;"content/page-script.js"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"run_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document_start"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"world"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MAIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"all_frames"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;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;No need to manually inject &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags into the page. The &lt;code&gt;"world": "MAIN"&lt;/code&gt; declaration tells Chrome to run that script in the page's context natively. This is cleaner and avoids CSP issues that come with dynamic script injection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Intercepting dataLayer
&lt;/h3&gt;

&lt;p&gt;The page script intercepts &lt;code&gt;dataLayer.push()&lt;/code&gt; by wrapping the original method:&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-script.js — runs in MAIN world, has direct access to window.dataLayer&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="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;dataLayer&lt;/span&gt; &lt;span class="o"&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;originalPush&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;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;push&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&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="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="nx"&gt;push&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;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;args&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="nx"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Forward to content script via postMessage&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tag-master-extension&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&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;DATALAYER_EVENT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&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="nx"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;pageUrl&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;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&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;*&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="c1"&gt;// Call the original push so GTM still works&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;originalPush&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The content script listens for these messages and forwards them to the background:&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;// content-script.js — ISOLATED world, bridge between page and extension&lt;/span&gt;
&lt;span class="nb"&gt;window&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&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;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&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="k"&gt;return&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;event&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;source&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tag-master-extension&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&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;DATALAYER_PUSH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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;payload&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;One subtlety: the content script needs a safe JSON serializer because dataLayer objects can contain circular references. I had to handle that with a custom replacer that detects cycles and replaces them with &lt;code&gt;'[Circular]'&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Manifest V3 Problem
&lt;/h2&gt;

&lt;p&gt;This is where things got painful.&lt;/p&gt;

&lt;p&gt;MV3 has a strict rule: all extension code must be bundled locally. No dynamic imports from remote URLs, no &lt;code&gt;eval()&lt;/code&gt;, no remote script tags. I had to be careful that nothing in my code could be interpreted as "remotely hosted code."&lt;/p&gt;

&lt;p&gt;The Chrome Web Store rejection message is... not very detailed. You get a policy violation code and a vague description. Lesson: &lt;strong&gt;read the MV3 migration guide thoroughly before you start&lt;/strong&gt;, not after your first rejection.&lt;/p&gt;

&lt;p&gt;Some specific things I had to deal with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;eval()&lt;/code&gt; or &lt;code&gt;new Function()&lt;/code&gt;&lt;/strong&gt; — I originally had a "live test" feature that executed code in the page context. Had to disable it entirely for Web Store compliance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service workers don't persist&lt;/strong&gt; — unlike MV2's background pages, service workers can be terminated at any time. All state needs to go to &lt;code&gt;chrome.storage&lt;/code&gt; or IndexedDB. I implemented a keep-alive ping every 20 seconds and store everything important to IndexedDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static domain references get flagged&lt;/strong&gt; — even having Google domain strings in constants triggered review flags. I ended up constructing some URLs dynamically to avoid static analysis false positives.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Building the Side Panel
&lt;/h2&gt;

&lt;p&gt;I chose a &lt;strong&gt;Side Panel&lt;/strong&gt; instead of a DevTools panel for a key reason: it stays open alongside the page without requiring DevTools to be open. For a debugging tool that marketers (not just developers) use, this was important.&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;// In the background service worker&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sidePanel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPanelBehavior&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;openPanelOnActionClick&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;p&gt;The panel itself is vanilla JS — no React, no Vue, no build step. Just DOM manipulation and CSS. This keeps the extension lightweight and avoids adding framework overhead to every page load.&lt;/p&gt;

&lt;p&gt;The main panels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GTM&lt;/strong&gt; — Paste a GTM snippet to inject it into any page, detect active containers, open Tag Assistant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DataLayer Monitor&lt;/strong&gt; — Live feed of every &lt;code&gt;dataLayer.push()&lt;/code&gt; with collapsible JSON, page navigation sidebar, quick filter chips for ecommerce/GA4/user events, and GA4 schema validation that scores each event&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network Inspector&lt;/strong&gt; — Captures GA4, Universal Analytics, Google Ads, Floodlight, and DoubleClick requests with human-readable parameter names and server-side hit detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit&lt;/strong&gt; — One-click scan that checks GTM presence, GA4 hits, Consent Mode V2 signals, Conversion Linker status, and tracking performance impact&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookies&lt;/strong&gt; — Lists all Google tracking cookies with one-click deletion and a test GCLID generator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSP Check&lt;/strong&gt; — Verifies Content Security Policy compatibility with Google tags&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tools&lt;/strong&gt; — Element picker that generates CSS selectors and GTM variable code, plus dataLayer push presets&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Network Request Capture
&lt;/h2&gt;

&lt;p&gt;For network requests, I used &lt;code&gt;chrome.webRequest&lt;/code&gt; in the background service worker — not page-level fetch/XHR interception. This is cleaner and catches requests regardless of how they're sent (fetch, XHR, sendBeacon, image pixels):&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;// background/service-worker.js&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onBeforeRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nf"&gt;isGoogleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;captureNetworkRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;details&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;urls&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;&amp;lt;all_urls&amp;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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;requestBody&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;isGoogleRequest()&lt;/code&gt; function checks against a list of Google marketing domains (analytics, ads, doubleclick, etc.). Each captured request is identified by type — GA4, Universal Analytics, Google Ads Conversion, Remarketing, Floodlight — and stored per-tab.&lt;/p&gt;

&lt;p&gt;For GA4 specifically, I parse the query parameters and map them to human-readable names. The GA4 measurement protocol uses cryptic parameter names (&lt;code&gt;en&lt;/code&gt; = event name, &lt;code&gt;ep.&lt;/code&gt; = event parameter prefix, &lt;code&gt;tid&lt;/code&gt; = measurement ID), so decoding them makes debugging dramatically faster.&lt;/p&gt;

&lt;p&gt;I also detect &lt;strong&gt;server-side tagging&lt;/strong&gt; by checking if GA4-formatted requests are going to non-Google domains (custom server-side endpoints), and validate &lt;strong&gt;Enhanced Conversions&lt;/strong&gt; by checking if user data parameters contain properly SHA-256 hashed values.&lt;/p&gt;




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

&lt;p&gt;One of the most useful features turned out to be automatic GA4 schema validation. Every ecommerce event gets checked against Google's recommended schema:&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;// Does a 'purchase' event have transaction_id, value, and currency?&lt;/span&gt;
&lt;span class="c1"&gt;// Are items present and non-empty?&lt;/span&gt;
&lt;span class="c1"&gt;// Is value actually a number, not a string?&lt;/span&gt;
&lt;span class="c1"&gt;// Is currency a valid 3-letter ISO code?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each event gets a validation score (0-100) with specific error and warning messages. This catches issues like &lt;code&gt;"value": "29.99"&lt;/code&gt; (string instead of number) or a &lt;code&gt;purchase&lt;/code&gt; event missing &lt;code&gt;transaction_id&lt;/code&gt; — things that won't cause errors in GA4 but will silently break your reporting.&lt;/p&gt;

&lt;p&gt;The extension also runs real-time alert rules. If a &lt;code&gt;purchase&lt;/code&gt; fires without a &lt;code&gt;transaction_id&lt;/code&gt;, or an ecommerce event has an empty &lt;code&gt;items&lt;/code&gt; array, you get an immediate notification — no need to wait until the data shows up (or doesn't) in your GA4 reports.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Test the Chrome Web Store submission process early.&lt;/strong&gt;&lt;br&gt;
Don't wait until your extension is "done." Submit a minimal version early to catch policy issues before you've invested weeks of work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. MV3 is strict — embrace it from day one.&lt;/strong&gt;&lt;br&gt;
Don't plan to "migrate later." Build with the constraints from the start: no remote code, no &lt;code&gt;eval()&lt;/code&gt;, service workers instead of background pages (which means no persistent state — everything needs to go to &lt;code&gt;chrome.storage&lt;/code&gt; or IndexedDB).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The message chain is fragile.&lt;/strong&gt;&lt;br&gt;
The connection between page script → content script → background → side panel goes through four components. Any break in that chain means your UI stops receiving events. Build reconnection logic and timeout fallbacks from the start. I use different timeout durations depending on the operation: 8 seconds for general commands, 15 seconds for tech detection, 30 seconds for interactive element selection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Timing matters more than you think.&lt;/strong&gt;&lt;br&gt;
If your content script loads after GTM, you'll miss the initial dataLayer state. Use &lt;code&gt;document_start&lt;/code&gt; run timing and handle the case where &lt;code&gt;window.dataLayer&lt;/code&gt; doesn't exist yet. I also periodically re-check for dataLayer name changes, because some GTM setups use custom dataLayer names that only become visible after the container loads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Per-tab state management is non-trivial.&lt;/strong&gt;&lt;br&gt;
Every piece of state — events, network requests, session info — needs to be tracked per browser tab. When a user switches tabs, your UI needs to swap all displayed data. When a tab is closed, you need to clean up. I cap storage at 300 events and 300 network requests per tab with FIFO eviction to prevent memory issues.&lt;/p&gt;




&lt;h2&gt;
  
  
  Current State &amp;amp; What's Next
&lt;/h2&gt;

&lt;p&gt;Tag Master is live on the Chrome Web Store. Current features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real-time dataLayer monitoring with collapsible JSON and page navigation&lt;/li&gt;
&lt;li&gt;GA4 event schema validation with scoring and real-time alerts&lt;/li&gt;
&lt;li&gt;Network request capture for GA4, Google Ads, Floodlight, and more&lt;/li&gt;
&lt;li&gt;GTM snippet injection with persistence across page reloads&lt;/li&gt;
&lt;li&gt;One-click compliance audit (Consent Mode V2, Conversion Linker, performance)&lt;/li&gt;
&lt;li&gt;Cookie management with test GCLID generation&lt;/li&gt;
&lt;li&gt;Element picker with GTM variable code generation and trigger suggestions&lt;/li&gt;
&lt;li&gt;Export as JSON, CSV, or HAR format&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's coming:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GTM container diff (compare what's published vs. what's in preview)&lt;/li&gt;
&lt;li&gt;Deeper Consent Mode V2 diagnostics&lt;/li&gt;
&lt;li&gt;Custom dataLayer event templates library&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;If you do GTM or GA4 work, I'd genuinely appreciate you trying it out and sending feedback. The hardest part of building developer tools is getting real-world usage — every bug report and feature request makes it better.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tagmaster.dev" rel="noopener noreferrer"&gt;Tag Master on Chrome Web Store&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Built with vanilla JS. No frameworks, no build step. Just a manifest.json, some scripts, and a lot of &lt;code&gt;chrome.runtime.sendMessage()&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>googletagmanager</category>
      <category>chromeextension</category>
      <category>webanalytics</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
