The Chrome DevTools Console for dataLayer Debugging

Published 

7 min read

ShareLinkedInX
Title card reading Chrome DevTools Part 2: Console: Read, filter and live-watch the dataLayer, beside a screenshot of the Chrome DevTools Console

The Network panel tells you what left the browser. The Console tells you why — it is where you read what the site told Google Tag Manager, before GTM turned any of it into a hit.

Almost every tracking bug starts on one side of that line. Either the site pushed the wrong thing to the dataLayer (a developer problem), or it pushed the right thing and the container misread it (a GTM problem). Five minutes in the Console tells you which side you are on, and therefore whose ticket it is.

This guide covers the commands, settings, and helpers that matter for analytics QA, with real use cases at the end. The examples run against the Google Merchandise Store in Chrome 156.

Part of the Chrome DevTools for tracking series. Overview · Elements · Console · Sources · Network · Recorder

How to open it#

  • Press F12 (Mac: Cmd+Option+I) and click the Console tab.
  • Or press Ctrl+Shift+J (Mac: Cmd+Option+J) to open DevTools straight to the Console.
  • From any other panel, press Esc to open the Console as a drawer underneath — the best layout for tracking work, because you can watch the Network or Elements panel and type commands at the same time.
Ctrl+Shift+J opens the Console, the log is cleared, a console.table command that filters the dataLayer to events is typed and run, and a table of dataLayer events appears below it

Open the Console, paste one line, and get every dataLayer event the page has pushed, in order.

The first time you paste into the Console in a fresh profile, Chrome blocks it and asks you to type allow pasting. That is a self-XSS guard, not an error: type it once and pasting works for that profile.

Read the dataLayer#

Typing dataLayer and pressing Enter lists every object pushed to it, in order. On a real site that is dozens of entries, most of them GTM's own (gtm.js, gtm.dom, gtm.load) and consent defaults. Filter to events only and print them as a table:

1console.table(dataLayer.filter((e) => e.event).map((e) => ({ event: e.event })))

The order is the first thing to check. Consent defaults should appear before gtm.js; if they appear after, tags can fire before consent state is known. Your page-level data (page type, user status) should be pushed before the event that tags rely on — not after it.

To see the full contents of one push, expand it in the plain dataLayer output, or pull it out by event name:

1dataLayer.filter((e) => e.event === 'purchase')

Note that GA4 ecommerce pushes are often preceded by { ecommerce: null } to clear the previous object. That push has no event key, so the filtered table hides it — check the unfiltered array when ecommerce values look stale. The ecommerce dataLayer spec shows the shape each event should have.

Ask GTM for its merged state#

The dataLayer array is a history. GTM's data model is the merged result of every push, and that is what your Data Layer Variables actually read:

1google_tag_manager['GTM-XXXXXXX'].dataLayer.get('ecommerce')

The difference matters. If the site pushes { page_type: 'product' } on load and { page_type: undefined } later, the array shows both, but get('page_type') returns what a tag firing now would see. Nested keys work with dot notation, such as dataLayer.get('ecommerce.items').

Typing google_tag_manager alone lists every container and GA4 destination loaded on the page — a quick way to spot a legacy container nobody removed. Filter its keys to see just the IDs:

1Object.keys(google_tag_manager).filter((k) => /^(GTM|G)-/.test(k))

On the Merchandise Store this returns two GTM containers and seven GA4 measurement IDs. Seven is not necessarily wrong, but each one should be on a list somebody owns.

Watch pushes as they happen#

The array tells you what has happened; for interactions you want to see pushes the moment they occur. Paste this once and every subsequent push prints with a label:

1const originalPush = window.dataLayer.push
2window.dataLayer.push = function (...args) {
3 console.log('[dataLayer]', ...args)
4 return originalPush.apply(this, args)
5}

It wraps GTM's own push and passes everything through unchanged, so tags still fire. It lasts until the next page load. If you use it often, save it as a snippet in the Sources panel (Sources: snippets) so you can run it on any page with Ctrl+Enter.

Live expressions are the no-code alternative. Click the eye icon (Create live expression) at the top of the Console and enter:

1dataLayer.at(-1)?.event

The value updates in real time as you browse, pinned above the log — no scrolling, no clutter. It is ideal for checking that a click pushes the event you expect.

Console settings that keep evidence#

Open the Console settings with the gear icon. A few settings stop you losing evidence or chasing the wrong frame:

  • Don't clear log on page reload / navigation. Keeps messages across page loads. Without it, everything logged during a form submit disappears as the thank-you page loads — usually exactly the moment you needed.
  • Log XMLHttpRequests. Prints a line for every fetch and XHR. It is a lightweight way to see a tracking request go out alongside your [dataLayer] lines, in one timeline.
  • The JavaScript context selector. The dropdown at the top of the Console (usually reading "top") switches between the main page and each iframe. Embedded checkouts, booking widgets, and video players run in iframes with their own dataLayer — or none — so if dataLayer looks wrong, check which frame you are typing into.
  • Filter the noise. The filter box accepts text and regex, and the log-level dropdown hides warnings from unrelated scripts. Typing [dataLayer] in the filter shows only your logger's lines. Right-click a noisy script's message → Hide messages from… to mute it.

Command Line API helpers#

These work only in the Console, and several are made for this job:

  • $0 is the element currently selected in the Elements panel. $0.closest('[data-track]') shows what a trigger matching on a data-track ancestor would see.
  • $$('a[href^="tel:"]') returns every matching element — the Console version of testing a selector. $$('a[href^="tel:"]').length gives you the count.
  • monitorEvents(window, 'click') logs every click event and its target. unmonitorEvents(window) stops it.
  • getEventListeners(document) lists listeners programmatically, including GTM's own click and submit listeners.
  • copy(dataLayer) copies the whole dataLayer to your clipboard as JSON, ready to paste into a ticket.

Use cases#

The purchase tag sends revenue as zero#

The GA4 purchase hit arrives, but value is 0. Is the site wrong, or the variable?

  1. On the confirmation page, run dataLayer.filter((e) => e.event === 'purchase') and expand the result.
  2. If ecommerce.value is 0 or missing there, the site pushed it that way — a developer ticket, with copy(dataLayer) attached.
  3. If the push is correct, run google_tag_manager['GTM-XXXXXXX'].dataLayer.get('ecommerce.value'). A correct value here means the GTM variable is misconfigured — wrong key path, or a Data Layer Variable set to version 1.

A click event fires on desktop but not after a framework update#

The add_to_cart push used to happen on click; since the last release, the GA4 event is missing.

  1. Paste the push logger (or add the live expression), then click Add to cart.
  2. No [dataLayer] line means the site stopped pushing — the new release dropped the call.
  3. A line with a different event name (addToCart instead of add_to_cart) means a renamed event, and your Custom Event trigger no longer matches.

The consent mode report shows hits with no consent state.

  1. Reload with Don't clear log on page reload on, then run the console.table command above.
  2. Read the order. The consent default should come before gtm.js. If gtm.js is first, the consent default is being set too late — usually by a CMP tag that waits for its own script to load.

A legacy container is double-counting page views#

Two page_view hits per page load, but only one GA4 tag in your container.

  1. Run Object.keys(google_tag_manager).filter((k) => /^(GTM|G)-/.test(k)).
  2. A second GTM- ID or an unexpected G- ID is the likely culprit. Search for it in the Sources panel to find where it is loaded, and confirm the two hits in the Network panel.

The booking widget's conversion never reaches GTM#

The widget runs in an iframe from a third-party domain.

  1. Switch the context selector from "top" to the widget's frame and type dataLayer.
  2. If it exists inside the frame, the conversion is pushed there and never reaches your container — you need a postMessage bridge to the parent page. If it is undefined, the widget does not expose events at all, and the vendor's own callback or a server-side integration is the only option.

A developer needs proof#

You found the bug; now the ticket has to be undeniable. Run copy(dataLayer) on the broken page and paste the JSON into the ticket, alongside the expected push from your measurement plan. The developer sees the actual and expected objects side by side, with no screenshot to squint at.

Where the Console stops#

The Console shows intent: what the site pushed and what GTM's data model holds. It cannot tell you whether a GA4 hit actually left the browser, with which parameters, or whether it was blocked. For that, filter the Network panel to collect?v=2 and read the payload — the Network guide and the payload reference cover it.

Next in the series: Sources — find every tag on the page. Or go back to the series overview.

How Tagfire helps#

Everything in this guide runs only while someone has the Console open. Tagfire's Tag Debugger shows every dataLayer push and the GA4 hit it produced side by side as you browse — event name, parameters, and consent state decoded — so you see both sides of the line without pasting a logger on every page. For the full live-debugging workflow, see live tag debugging for GA4 and GTM.

→Related reading