
On this page
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.
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
Escto 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.

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.push2window.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 ifdataLayerlooks 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:
$0is the element currently selected in the Elements panel.$0.closest('[data-track]')shows what a trigger matching on adata-trackancestor would see.$$('a[href^="tel:"]')returns every matching element — the Console version of testing a selector.$$('a[href^="tel:"]').lengthgives 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 wholedataLayerto 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?
- On the confirmation page, run
dataLayer.filter((e) => e.event === 'purchase')and expand the result. - If
ecommerce.valueis0or missing there, the site pushed it that way — a developer ticket, withcopy(dataLayer)attached. - 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.
- Paste the push logger (or add the live expression), then click Add to cart.
- No
[dataLayer]line means the site stopped pushing — the new release dropped the call. - A line with a different event name (
addToCartinstead ofadd_to_cart) means a renamed event, and your Custom Event trigger no longer matches.
Hits fire before consent is known#
The consent mode report shows hits with no consent state.
- Reload with Don't clear log on page reload on, then run the
console.tablecommand above. - Read the order. The
consentdefault should come beforegtm.js. Ifgtm.jsis 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.
- Run
Object.keys(google_tag_manager).filter((k) => /^(GTM|G)-/.test(k)). - A second
GTM-ID or an unexpectedG-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.
- Switch the context selector from "top" to the widget's frame and type
dataLayer. - If it exists inside the frame, the conversion is pushed there and never reaches your container — you need a
postMessagebridge to the parent page. If it isundefined, 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.
Keep exploring
Put this into practice
Built for technical marketers who run audits and ship clean tracking.

Chrome DevTools for Tracking: Every Feature Worth Knowing
A panel-by-panel guide to the Chrome DevTools features that matter for analytics QA — Elements, Console, Sources, Network, Application, device emulation, the Recorder, and more — with what each one tells you about your tags.

Chrome DevTools Elements Panel for GTM Triggers
Use the Chrome DevTools Elements panel to test GTM CSS selectors, find why click triggers don't fire, and pick stable attributes — with real QA use cases.

Chrome DevTools Sources: Find Every Tag on a Page
Inventory every tag, measurement ID and GTM container on a page with the Chrome DevTools Sources panel, then debug tracking code with logpoints and breakpoints.