Most analytics problems are not reporting problems. They are implementation problems that nobody noticed for six months.
You open GA4, the numbers look plausible, and you make a decision. Then someone reconciles revenue against the orders table and it is off by 18%. Now every number in the property is suspect, including the ones that were fine.
Almost always, the root cause is the same: nobody designed the dataLayer. Tags were added one at a time, by different people, under deadline, and the "data layer" became whatever each tag happened to need that week.
Here is how we build it instead.
Why most GA4 data can't be trusted
Three failure modes account for the majority of bad analytics data:
Events were named by whoever wrote them. addToCart, add_cart, AddToCart and cart_add all end up in the same property. GA4 treats them as four different events, so no report ever shows the real total.
Values are read from the DOM. Scraping a price out of a rendered element works until a designer adds a currency symbol, a discount badge, or a thousands separator. Then revenue silently becomes NaN or 1 instead of 1,299.
Events fire on intent instead of outcome. A "purchase" that fires when the user clicks Pay counts abandoned and failed payments as revenue. This one is extremely common and it inflates your most important number.
None of these are fixed by changing a setting in GA4. They are fixed upstream, in the dataLayer.
What a tracking plan actually is
A tracking plan is a document, agreed before implementation, that answers: what do we measure, what is it called, and what does it carry?
For each event it records the name, when it fires, every parameter with its type, and which business question it answers. That last column matters more than it sounds — it is what stops you shipping forty events nobody ever opens a report for.
The plan is the contract. Developers implement it, the analyst validates against it, and when someone asks "why don't we track X", the answer is a documented decision rather than an archaeology exercise.
Write it before you touch GTM. It takes a day and saves months.
Designing the dataLayer: page context, user, ecommerce
We think of the dataLayer in three layers.
Page context — pushed on every route change. What kind of page is this?
dataLayer.push({
event: "page_meta",
page_type: "pdp", // home | plp | pdp | cart | checkout | account
page_name: "/products/acme-kit",
locale: "en",
});
page_type is the single highest-value custom dimension you will ever add. It lets you segment every metric by template rather than by URL pattern, which is what makes cross-site comparison possible.
User context — who is this, in non-identifying terms?
dataLayer.push({ user: { user_id: "8f3c...", logged_in: true } });
Send an opaque internal id. Never push email addresses, names or phone numbers into the dataLayer — it is readable by anything running on the page, and it will end up in places you cannot retract it from.
Ecommerce — the GA4 commerce schema, which we cover below.
Naming conventions that survive a redesign
Pick the rules once and write them down:
snake_case everywhere, for events and parameters. GA4's own recommended events use it; matching them means you get the built-in reports for free.
Use GA4's recommended event names wherever one exists (
view_item,add_to_cart,begin_checkout,purchase,sign_up,login,generate_lead). Do not inventproduct_viewed— you lose the standard reporting.Name by what happened, not by where.
select_itemis good.homepage_carousel_clickis not: it breaks the moment the carousel moves to another page.Parameters describe the event; dimensions describe the context. Keep
page_typein the shared context, not repeated inside every event payload by hand.
The nested ecommerce object (and the ecommerce:null reset)
This is where GTM implementations most often go wrong. GTM reads ecommerce data as a nested object, not the flat gtag.js shape:
dataLayer.push({ ecommerce: null }); // clear the previous payload
dataLayer.push({
event: "add_to_cart",
ecommerce: {
currency: "USD",
value: 79.00,
items: [{
item_id: "acme-kit",
item_name: "Acme Starter Kit",
item_brand: "CreativeCape",
item_category: "template",
item_variant: "extended",
price: 79.00,
quantity: 1,
}],
},
});
The { ecommerce: null } push is not optional. The dataLayer is a persistent array and GTM merges pushes. Without the reset, items from a previous event leak into the next one — you get an add_to_cart carrying the three products from the earlier view_item_list, and your item-level reports quietly become fiction.
Put both pushes in one helper so nobody can forget the reset:
function pushEcom(event, ecommerce) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({ event, ecommerce });
}
Every ecommerce event in the app should go through that function. One place to fix, one place to audit.
Wiring GTM: variables, triggers, tags
With a consistent dataLayer, the GTM container becomes small:
Variables — a Data Layer Variable per key you need (
ecommerce,page_type,user.user_id,method). The "Data Layer Variable Name" must be the raw key (ecommerce), not the variable's display label. This trips up almost everyone once.One ecommerce trigger — a Custom Event trigger matching a regex:
^(view_item_list|select_item|view_item|add_to_cart|remove_from_cart|view_cart|begin_checkout|add_payment_info|purchase|refund)$One ecommerce tag — a GA4 Event tag with Event Name set to
{{Event}}and Send Ecommerce data = Data Layer. That single tag covers the entire funnel.
The instinct is to build one tag per event. Resist it. Ten tags means ten places to update when the measurement ID changes and ten chances for one to drift out of sync.
QA in DebugView
Implementation is not done when the tag fires. It is done when the right data arrives.
Work through the funnel in GTM Preview with GA4 DebugView open beside it, and check:
Each event fires exactly once per user action. Double-fires usually mean a React effect without a guard, or a tag on both a click and a route-change trigger.
Item arrays contain the right items, not leftovers from the previous event.
valueis a number, not a string, and matches what the server will charge.purchasefires only after the server confirms the payment.Nothing fires with consent denied if your tags require
analytics_storage.
Then reconcile: pull a day of GA4 purchases against the orders table. If they do not match within a percent or two, find out why before you build a single report on top.
Documentation and handover
The last step is the one that gets skipped. Write down the container structure, the dataLayer spec, and how to add a new event. Without it, the implementation degrades the moment the person who built it moves on — which is precisely how the mess you just cleaned up came to exist.
Want this done properly on your site? → Analytics Implementation