DEV Community

Cover image for How I Built a GTM Debugger Chrome Extension (And What I Learned the Hard Way)
hsynkvlc
hsynkvlc

Posted on

How I Built a GTM Debugger Chrome Extension (And What I Learned the Hard Way)

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.

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 one thing: did my dataLayer event push correctly?

So I built Tag Master — 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.


The Problem I Was Actually Solving

Before writing a single line of code, I spent time articulating exactly what the pain was:

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

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

My goal: a single side panel, always visible alongside the page, that shows all of this in real time.


The Architecture

Tag Master is a Chrome Extension using Manifest V3 (more on this pain point later). The core architecture uses a dual content script model:

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

Why Two Content Scripts?

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 window.dataLayer from a content script. Period.

MV3 introduced a clean solution: you can declare a content script with "world": "MAIN" in your manifest, which runs it in the page's own context:

{
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content/content-script.js"],
      "run_at": "document_start",
      "all_frames": true
    },
    {
      "matches": ["<all_urls>"],
      "js": ["content/page-script.js"],
      "run_at": "document_start",
      "world": "MAIN",
      "all_frames": true
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

No need to manually inject <script> tags into the page. The "world": "MAIN" 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.

Intercepting dataLayer

The page script intercepts dataLayer.push() by wrapping the original method:

// page-script.js — runs in MAIN world, has direct access to window.dataLayer
window.dataLayer = window.dataLayer || [];

const originalPush = window.dataLayer.push.bind(window.dataLayer);

window.dataLayer.push = function(...args) {
  args.forEach((arg) => {
    // Forward to content script via postMessage
    window.postMessage({
      source: 'tag-master-extension',
      type: 'DATALAYER_EVENT',
      payload: {
        eventName: arg?.event || 'unknown',
        data: JSON.parse(JSON.stringify(arg)),
        timestamp: Date.now(),
        pageUrl: window.location.href
      }
    }, '*');
  });

  // Call the original push so GTM still works
  return originalPush(...args);
};
Enter fullscreen mode Exit fullscreen mode

The content script listens for these messages and forwards them to the background:

// content-script.js — ISOLATED world, bridge between page and extension
window.addEventListener('message', (event) => {
  if (event.source !== window) return;
  if (event.data?.source !== 'tag-master-extension') return;

  chrome.runtime.sendMessage({
    type: 'DATALAYER_PUSH',
    data: event.data.payload
  });
});
Enter fullscreen mode Exit fullscreen mode

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 '[Circular]'.


The Manifest V3 Problem

This is where things got painful.

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

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

Some specific things I had to deal with:

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

Building the Side Panel

I chose a Side Panel 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.

// In the background service worker
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
Enter fullscreen mode Exit fullscreen mode

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.

The main panels:

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

Network Request Capture

For network requests, I used chrome.webRequest 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):

// background/service-worker.js
chrome.webRequest.onBeforeRequest.addListener(
  (details) => {
    if (isGoogleRequest(details.url)) {
      captureNetworkRequest(details);
    }
  },
  { urls: ['<all_urls>'] },
  ['requestBody']
);
Enter fullscreen mode Exit fullscreen mode

The isGoogleRequest() 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.

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

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


GA4 Event Validation

One of the most useful features turned out to be automatic GA4 schema validation. Every ecommerce event gets checked against Google's recommended schema:

// Does a 'purchase' event have transaction_id, value, and currency?
// Are items present and non-empty?
// Is value actually a number, not a string?
// Is currency a valid 3-letter ISO code?
Enter fullscreen mode Exit fullscreen mode

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

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


What I'd Do Differently

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

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

3. The message chain is fragile.
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.

4. Timing matters more than you think.
If your content script loads after GTM, you'll miss the initial dataLayer state. Use document_start run timing and handle the case where window.dataLayer 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.

5. Per-tab state management is non-trivial.
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.


Current State & What's Next

Tag Master is live on the Chrome Web Store. Current features:

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

What's coming:

  • GTM container diff (compare what's published vs. what's in preview)
  • Deeper Consent Mode V2 diagnostics
  • Custom dataLayer event templates library

Try It

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.

Tag Master on Chrome Web Store

Built with vanilla JS. No frameworks, no build step. Just a manifest.json, some scripts, and a lot of chrome.runtime.sendMessage().

Top comments (0)