How to Build a World-Class GA4 Setup, Part 1: Foundations & the Measurement Plan
A world-class GA4 setup is not a maximal feature set. It is a correct, closed revenue loop plus an honest account of its own limits. This first article builds the mental model — GA4 as one event with parameters — and the plan: a maturity model that grades correctness over feature count, and a disciplined event taxonomy and dimension registry.
Francisco Contreras · Founder, Machina
13 min read

Key takeaways
- World-class measurement is a correct, closed revenue loop plus an honest account of its limits — not a feature checklist. The audited property carried eight custom dimensions, six key events, and a server-side event, yet its one revenue signal recorded zero rows because the server posted to the wrong host.
- GA4 records exactly one kind of thing: an event with parameters. Name the verb (page_view), parameterize the nouns (page_type, service_slug). Meaning lives in parameters, not in event names.
- Grade a property with a gated maturity model, not an additive one. Correctness (Level 1) is a gate every higher level depends on — the case property looked Level 2 on its surface and graded Level 0 underneath.
- A custom dimension's scope is a persistence-and-overwrite decision. A user-scoped session_source keeps only the last write, silently overwriting each visit's source and misattributing a user's whole history.
- A dimension is only reportable if you register it before you send it and the parameter name matches the wire format byte for byte — the display name is documentation, the parameter name is the join key.
Why can a green GA4 dashboard hide a broken business?
Open the Google Analytics interface for a well-run property and everything can look correct while the one number that pays the bills records nothing. The property at the center of this series passed every casual inspection: a live web stream, eight custom dimensions with sensible names, six key events including contact_form_submit, Enhanced Measurement fully enabled, and a tidy thirty-day baseline — 2,328 events, 401 active users, 527 sessions across a believable channel mix led by direct and organic search. Nothing on that screen blinks red. An agency handoff or a new analyst's first afternoon would each have concluded the property worked.
The business behind it, which we anonymize here as Cypress, is a pest-control operator serving four Central California counties — Monterey, Santa Cruz, San Benito, and Santa Clara. It sells nothing online. Revenue arrives as phone calls and web-form leads that become booked jobs offline, days later. To survive ad-blockers and privacy browsers, its site fired a server-side generate_lead event from the backend on every captured lead — exactly the resilient technique a careful practitioner reaches for when client-side collection is being eroded.
That event posted to https://www.google.com/mp/collect. The documented Measurement Protocol endpoint is https://www.google-analytics.com/mp/collect. The two hosts differ by one subdomain and the difference is total: the wrong host returns HTTP 404 and discards the payload; the correct host returns HTTP 204 and ingests it. Replaying the identical payload against both hosts with curl confirms it live — 404 against one, 204 against the other. For the property's entire lifetime, the one event engineered to be the resilient record of revenue recorded zero rows.
404
HTTP status the wrong Measurement Protocol host (www.google.com/mp/collect) returned on every server-side lead — the payload was discarded. The documented host, www.google-analytics.com/mp/collect, returns 204 and ingests it.
Live curl replay against both hosts; Google Measurement Protocol (GA4) reference.
The failure was self-concealing. Because generate_lead never arrived, no data appeared under that name, and because no data appeared, no one had marked it a key event — its absence read as restraint rather than a fault. This is the gap the whole series works to close, and it is worth stating precisely because the industry's vocabulary blurs it: recording is not measuring. Recording is the accumulation of events in a store. Measuring is the production of a quantity that changes a decision — whether a paid click became a booked job, what a lead from Salinas is worth relative to one from Santa Cruz, which service line to staff before termite-swarm season. A property can record with great fidelity and measure nothing.
What does a world-class GA4 setup actually mean?
World-class measurement is a correct, closed revenue loop plus an honest account of its own limits. It is not a maximal feature set, and that distinction organizes everything that follows. By a feature checklist the case property was well appointed — eight dimensions, six key events, full Enhanced Measurement, a server-side event. By the only test that matters, whether a booked job can be traced back to the spend that produced it, it was inert. Adding a ninth dimension or a warehouse export before fixing the host would have added surface without adding truth.
The shape of a closed loop
A correct loop has a definite shape. A visitor's click carries an identity — the client_id in the _ga cookie, plus the paid-click identifiers gclid, wbraid, and gbraid. A lead persists that identity and its acquisition context to the property's own database. A backend event conveys the lead to GA4 reliably, over a channel ad-blockers cannot suppress, with the session and engagement fields that let attribution work. The office later records the job's stage and booked value against the same lead. Realized revenue then flows back to the acquisition channel, so spend and outcome meet. Break any link and the loop opens — the 404 host broke the conveyance link and returned the property to recording without measuring.
Correctness before architecture before activation before optimization
The thesis implies a strict order of work, and the order is load-bearing. A single event arriving at the correct host with a real client_id and session_id is worth more than a warehouse full of events that never fired or fired to a 404. Correctness first. Then architecture — a taxonomy that carries meaning and a conversion design that names the outcomes. Then activation — the offline-revenue loop, the warehouse, and the governance to keep it true. Optimization last. Architecture built on an incorrect signal encodes the incorrectness permanently, and activation before architecture builds dashboards on sand.
The honest account of limits
The second half of the thesis is discipline, not decoration. At roughly 46 paid clicks and about 500 sessions a month, Cypress sits below the volume where Google's automation stabilizes: data-driven attribution historically wants on the order of 400 conversions per 28 days, and value-based Smart Bidding wants roughly 15 to 30 conversions a month before it engages. A world-class setup says so plainly rather than implying an optimization it cannot deliver. That constraint is clarifying: it forces you to build the loop for human decision-making first and algorithmic optimization second, because a small property must be correct by construction rather than smoothed over by scale.
~46
Paid clicks per month at the case property — below the ~400 conversions/28 days data-driven attribution needs and the 15–30 conversions/month value-based bidding wants. At low volume, every dropped signal is visible and expensive.
Case field measurement; Google Analytics Help, About attribution and attribution modeling.
How does GA4 actually model your data?
Every downstream decision rests on six primitives: an event, a parameter, a scope, an identity, a session, and a transport. Hold them in the right relation and you can read a property's configuration and predict how it behaves under load and under ad-blocking. Start with the core: GA4 records exactly one kind of thing — an event. There is no separate pageview, transaction, or timing type. A pageview is an event named page_view. A form submission is contact_form_submit. Something happened (the name), and here is what characterized it (the parameters).
The first mental shift is that meaning lives in parameters, not in event names. When the case site serves its homepage it sends page_view with page_type: home; a service page sends page_view with page_type: service and service_slug: termite-control; a location page sends page_view with page_type: location and location_slug: salinas. The event name is stable across all three; the parameters carry the detail. The temptation, especially coming from a system with rigid hit types, is to encode detail into the name — page_view_service, page_view_location — which produces a sprawling event list that resists aggregation. You could no longer ask "how many pageviews did the site serve" without summing across a dozen names. Name the verb, parameterize the nouns.
| Primitive | What it is | The trap to avoid |
|---|---|---|
| Event | A named thing that happened; GA4's only record type | Encoding detail into the name instead of the parameters |
| Parameter | A key-value attached to one event occurrence | Sending it but never registering it as a custom dimension, so it reports nowhere |
| Scope | Whether a value describes an event, a user, or an item | Choosing the word that reads well over the value's true lifetime (user-scoped session_source) |
| Identity | client_id names a browser (in _ga); user_id names a person | Faking a client_id when the _ga cookie is absent, minting phantom users |
| Session | session_id anchors a visit (in the _ga_<container> cookie) | Omitting it server-side, so events default to (direct)/(none) |
| Transport | gtag.js in the browser vs the server Measurement Protocol | Assuming the server transport supplies identity — it sends only what you put in the body |
Distilled from the GA4 data model as applied to the case property; field references are Google's Measurement Protocol (GA4) reference and Sending Measurement Protocol events.
Two of those primitives carry the case property's sharpest lessons. Key events are the first. GA4 flags certain event names as key events so it counts them, foregrounds them in conversion reports, and — once a Google Ads link exists — lets Ads bid on them. Flagging is a reporting-and-activation decision layered on top of collection, not a change to collection: the event fires and is stored whether or not it is flagged. That is why the property's purchase key event sat inert — a GA4 default on a site with no cart, which the Admin API even refuses to delete — while the most important lead signal, the server-side generate_lead, was never flagged at all. There was no data to flag. Collection first, then flagging; keep the order straight.
Identity is the second. The client_id identifies a browser, not a person; GA4's tag mints it on first visit and stores it in the _ga cookie, and the server pipeline reads that cookie to stitch a later lead back to the browser the tag saw. Measured directly from the database, of 29 leads, 20 carried a client_id and 9 did not — a 69% capture rate, with the missing 31% attributable to ad-block and privacy-browser loss rather than timing. The correct response is to skip an untrackable lead, not to invent an identity for it. A synthetic client_id mints a phantom user on every such lead and inflates the user count with people who never existed.
69%
Share of the case property's leads that carried a first-party client_id and could be attributed (20 of 29). The missing 31% were ad-block and privacy-browser loss — skipped honestly, never faked.
Capture rate measured directly from the property's PostgreSQL database.
Two transports carry every event, and each does what the other cannot. The browser tag (gtag.js) sees the cookies, the URL, and the screen, and stamps client_id, session_id, and engagement automatically onto every event — but ad-blockers, privacy browsers, and consent refusals suppress it, and there is no fallback for an event the browser never runs. The Measurement Protocol is an HTTP transport your server posts to; it cannot be blocked, but it invents nothing — it sends only what you put in the body, does not validate that the data is sane, and will not attach a client_id or session_id for you. Fix the pairing in your head: the browser supplies identity but can be blocked; the server cannot be blocked but supplies only what you send.
How do you grade a GA4 setup honestly?
Most analytics maturity frameworks are additive: install the tag, add dimensions, mark conversions, connect a server stream, export to a warehouse, and each addition raises your grade one rung. The case property falsifies that premise cleanly. It exhibited every surface marker of an advanced setup, and its server-side conversion recorded nothing. A model that cannot distinguish a working server conversion from a broken one is not measuring maturity; it is counting features.
The remedy is to make the model gated rather than additive: a property's grade is the highest level for which it passes that level's criteria and every criterion below it. Correctness stops being one feature among many and becomes a gate everything above it depends on. Structure over unreliable data is decoration; conversion counting over mis-attributed events is fiction; optimization over an unjoined loop is guesswork.
| Level | Name | The question it answers | Pass condition |
|---|---|---|---|
| 0 | Collecting | Is any data arriving? | A tag is installed and firing; events reach the property; active users and sessions register in Realtime. |
| 1 | Correct | Does every event we claim to collect actually arrive, on the right stream, attributed to the right session and source? | Every declared event verified (a 2xx from the endpoint, visible in DebugView or Realtime); payloads validate; client_id and session_id populated so attribution is not (direct)/(none). |
| 2 | Structured | Can we slice the data by the entities the business cares about? | A documented, consistent taxonomy; custom dimensions of correct scope receiving values; reports sliceable by service, county, and form source. |
| 3 | Converting | Which events represent value, counted once, valued, and surviving the loss channels? | Key events reflect real conversions; each is deduplicated and assigned a defensible value; a server-side path survives ad-block. |
| 4 | Closed-Loop | What did the conversion become offline, and what did it cost to book? | Durable identifiers captured (client_id, gclid, wbraid, gbraid, a lead_id); offline stage and booked value recorded and joined. |
| 5 | Optimized | Is the joined data changing automated decisions? | Closed-loop data feeds attribution modeling and value-based bidding with enough volume to stabilize. |
Maturity model from the case dissertation; Level 1 and Level 5 pass conditions reference Google's Measurement Protocol validation guide and its attribution-modeling documentation.
Graded against this ladder, the case property passed Level 0 without argument — data flowed and reports populated. It failed Level 1 on more than one count: the server conversion posted to a 404, the client_id fell back to a synthetic server.<timestamp> that minted a fresh phantom user on every untracked lead, and the payload omitted session_id, so any event that did arrive attributed to (direct)/(none) rather than the campaign that earned it. Read against Level 2 it looked like a pass — eight dimensions, typed and slugged pages, six key events — but Level 2 sits above Level 1, and Level 1 failed. The property was simultaneously Level 2 on its surface and Level 0 underneath, a live pipe carrying partly untrustworthy water in the costume of an advanced setup.
A quieter default compounded the ceiling. Event-data retention sat at the two-month floor, which caps exactly the seasonal, dimension-scoped analysis a four-county business depends on — Explorations and any report using event- or user-scoped custom dimensions honor that window. A large single-market retailer might never notice a two-month setting; a seasonal, multi-county service business is defined by what it destroys. Raising it to fourteen months was among the first zero-risk fixes, made through the Admin API before anything new was built.
How should you name events and register custom dimensions?
An event taxonomy is a contract between two systems that never meet at runtime: the code that emits events and the reporting surface that consumes them. If the emitter sends a parameter the reporting side never registered, GA4 accepts the event, stores the parameter on the raw row, and shows it nowhere in the standard UI. If the reporting side registers a dimension whose parameter the emitter never sends, the dimension exists and stays permanently empty. Both failures are silent — no error, no warning, no validation message. That silence is why taxonomy governance earns a dedicated discipline rather than a footnote.
Naming and cross-surface parity
The case names already followed a defensible pattern — lowercase snake_case, a verb-plus-noun shape for actions (contact_form_submit, call_button_click), and noun-shaped parameters (form_source, service_slug). That is policy, not accident, because GA4 imposes hard ceilings a loose habit eventually collides with: event and parameter names cap at 40 characters, values at 100, and a property may register at most 50 event-scoped custom dimensions, 25 user-scoped, and 10 item-scoped, with the ga_, google_, and firebase_ prefixes reserved. The convention the audit found broken was not spelling but parity: a single logical action must carry one name wherever it is emitted. The careers form fired career_application_submit from the browser but posted career_application from the server — two names for one act, neither a fallback for the other, the server variant an orphan no report referenced.
Register before you send, and match the name exactly
Two properties of custom dimensions decide whether a parameter ever becomes reportable, and both punish the practitioner who learns them late. First, registration is not retroactive: GA4 begins associating a parameter with its dimension only from the registration moment forward, with no backfill, so any lag between first-send and registration is a permanent reporting gap for that interval. Register the dimension at or before the first event that carries the parameter. Second, a dimension has two fields beginners conflate — a human-facing display name and a machine-facing parameter name — and only the parameter name is the join key. It must equal the string the code sends, byte for byte.
The cta_click event makes it concrete. The code emits parameters label and cta_location. The registered dimension reads well as cta_label in a report menu, but its parameter name must be set to label — the string the code actually sends — for it to populate. Register the parameter name as cta_label to match the pretty display name and the join breaks: the dimension is syntactically valid, visible in the UI, and eternally empty, and the failure looks identical to "this parameter has no traffic yet," so it can survive months unnoticed. The display name is documentation; the parameter name is a foreign key you copy from the emitting code with zero edits.
What is dimension scope, and how does it silently corrupt data?
Scope answers one question: to what does this value belong, and how long does it persist? GA4 offers three. Event scope attaches the value to the single event that carried it and describes nothing beyond it — the correct default for almost everything descriptive (page_type, service_slug, form_source, cta_location). User scope attaches the value to the person and carries it forward with last-write-wins semantics: GA4 keeps only the most recent value and overwrites it on every subsequent write. Item scope attaches to line items inside an e-commerce items[] array and functions only there.
The case property held session_source at user scope, and that is the pitfall in the flesh. A source is a per-visit fact — a person arrives from google/organic on Monday and returns via (direct) on Thursday. Under user scope with last-write-wins, Thursday's direct visit overwrites Monday's organic value, and every historical event for that user, Monday's included, now reports session_source as direct. The dimension name promises a session attribute; the scope delivers a last-write-wins user attribute, and the scope wins. No error appears and rows populate, so an analyst segmenting by it sees plausible numbers that understate the channels that open relationships and overstate the ones that close them — corrupting the exact seasonal channel analysis the dimension was built to serve. Worse, it is redundant: GA4 already computes session source at the correct scope. Choosing a scope is choosing a persistence and overwrite behavior; pick it from the value's true lifetime, not the word that reads well in the name.
PII and cardinality — bound the value at the emit site
Two rules constrain what a parameter may carry, and both are absolute. The PII rule is a policy boundary: Google's terms forbid sending personally identifiable information — email, phone, full name, precise address — into GA4 as event data. The cardinality rule is mechanical: a standard report renders on the order of 500 distinct dimension values per day, and once a dimension exceeds that ceiling GA4 folds the overflow into a single (other) row that takes its detail with it. A parameter carrying a unique-per-event value — a raw timestamp, a UUID, a freeform error string — is the canonical cardinality bomb: every event is its own row until the ceiling collapses them all.
The remediation's error_message dimension is safe on both counts for one reason: the code emits a bounded vocabulary of codes (server_error, network_error, and their siblings), never the raw error text. Because the set is small and fixed, cardinality stays in the single or low double digits and every value keeps its own row; because the codes are engineered classifications rather than echoed input, no user's email or message body can ride along. Had the code sent the raw error the way naive logging does, the same dimension name would have become a double liability — a PII channel and a cardinality explosion at once. Enforce the guarantee at the emit site, mapping exceptions to codes before they reach gtag or the Measurement Protocol, because GA4 offers no filter that can retroactively unsee a value it already stored.
What goes in the measurement plan?
The design in the last three sections is only as durable as the discipline that keeps code and registration in step. On the case property that discipline lives in a committed MEASUREMENT_PLAN.md — a reviewable contract a code change can be checked against, rather than tribal knowledge. Three rules carry the load, each mapping to a failure the property exhibited before remediation: register before you send (a dimension registered late loses its window with no backfill), match the parameter name exactly (only the wire name joins), and bound the codomain at the emit site (PII and cardinality are properties of the value the code produces, not of any filter applied afterward).
The plan's centerpiece is the dimension registry — for every dimension, the wire-level parameter name, the display name, the scope, the emitting event, and the bound on its value set, because those four facts are what a future maintainer needs to reason about the join, the storage semantics, and the cardinality budget. The case property carries eleven dimensions after remediation: the eight it inherited plus three (service_type, cta_label, error_message) registered in the same change set that shipped the code emitting them, so the reporting gap was bounded to deployment ordering rather than left open for weeks.
| Parameter name (wire) | Display name | Scope | Emitting event(s) | Value bound |
|---|---|---|---|---|
| page_type | page_type | event | page_view | Bounded (home, service, location, pest, job) |
| service_slug | service_slug | event | page_view (service pages) | Bounded (~18 service slugs) |
| location_slug | location_slug | event | page_view (location pages) | Bounded (~43 location slugs) |
| form_source | form_source | event | contact_form_submit, quote_form_submit, form_start, form_error | Bounded (form identifiers) |
| label | cta_label | event | cta_click | Bounded (curated CTA labels) — display name and join key differ |
| error_message | error_message | event | form_error | Bounded codes (server_error, network_error, ...) |
| session_source | session_source | user | (legacy) | Mis-scoped; redundant with GA4's built-in session source |
Registered dimension set for the case property, read from the GA4 Admin API custom-dimension configuration after remediation.
A registry written this way names its known-wrong entries rather than hiding them — the session_source row stays in the table as a documented liability, not a silent one. That is the plan half of "understand it and plan it." The next article in this series, Correct Collection, turns the plan into a working signal: the audit that finds the 404, the server-side Measurement Protocol rebuild, and a conversion architecture that survives ad-block. If you would rather hand off the diagnosis, that shape of work — a correctness-first GA4 audit tied to real revenue — is where our paid advertising and SEO engagements start, because a channel you cannot measure is a channel you cannot improve.
FAQ
Frequently asked questions
What makes a GA4 setup "world-class"?
A correct, closed revenue loop plus an honest account of its limits — not a maximal feature set. The loop runs from a click that carries identity, to a lead that persists it, to a server event that conveys it reliably, to an offline booked value joined back to the acquisition channel. The audited property had eight custom dimensions, six key events, and a server-side event, yet measured nothing, because its server event posted to a 404 host and the loop never closed.
What is the difference between recording and measuring in GA4?
Recording is the accumulation of events, sessions, and users in a store. Measuring is the production of a quantity that changes a decision — whether a paid click became a booked job, what a lead is worth. The case property recorded 24 to 113 events a day faithfully and measured nothing that answered the owner's question, because its one revenue signal never reached GA4. A green dashboard is a hypothesis about correctness, not a proof of it.
How does GA4 model events and parameters?
GA4 records exactly one kind of thing: an event with a name and a bag of parameters. There is no separate pageview, transaction, or timing type — a pageview is an event named page_view. Meaning lives in the parameters, not the name: one stable page_view carrying page_type and a slug (service_slug, location_slug) beats minting page_view_service and page_view_location, which fragments your event list and resists aggregation. Name the verb, parameterize the nouns.
What is a measurement maturity model, and why should it be gated?
It grades a property against six strictly ordered levels — Collecting, Correct, Structured, Converting, Closed-Loop, Optimized — where a property's grade is the highest level it passes along with every level below it. Gating matters because correctness (Level 1) is the load-bearing gate: structure, conversion counting, and optimization all consume the event stream as input. The case property looked Level 2 on its surface but graded Level 0, because its flagship conversion recorded nothing.
What is custom dimension scope, and how can it corrupt data?
Scope decides what a value describes and how long it persists: event scope attaches to one event, user scope attaches to the person with last-write-wins, and item scope attaches to e-commerce line items. The case property's session_source was user-scoped, so each new visit overwrote the last, retroactively recoloring a user's entire history with their latest source. It reports plausible numbers that misattribute earlier behavior — pick scope from the value's true lifetime, not the word that reads well in the name.
Why is my GA4 custom dimension empty even though I am sending the parameter?
Two common causes, both silent. Registration is not retroactive, so a dimension registered after the parameter started firing has no data for the pre-registration window and no backfill. And the parameter name is a byte-for-byte join key: if the code sends label but the dimension's parameter name is set to cta_label to match the display name, the join never happens and the dimension stays valid, visible, and eternally empty. The display name is for humans; the parameter name must copy the wire format exactly.
Sources
- Google Analytics — Measurement Protocol (GA4) reference: the collection endpoint on the google-analytics.com host and the required client_id and events array
- Google Analytics — Sending Measurement Protocol events: client_id, session_id, and engagement_time_msec, the fields that decide whether a server event joins its session
- Google Analytics — Validating Measurement Protocol events: the /debug/mp/collect endpoint and its validationMessages array
- Google Analytics Help — Data retention: the two-to-fourteen-month event-data window and which reports it governs
- Google Analytics Help — About attribution and attribution modeling: the models available and the data-volume conditions under which data-driven attribution operates
- Google Analytics — Admin API v1 (guide): the configuration surface for custom dimensions, key events, data-retention settings, and Enhanced Measurement
- Google Analytics — Admin API v1 REST reference: customDimensions.create, keyEvents, and dataRetentionSettings.patch
- Google Analytics — Data API v1 (guide): the reporting surface used to read event counts, dimensions, and metrics during the audit
- Google Analytics Help — BigQuery Export schema: the events_ table layout and the nested event_params and user_properties records
- Google Analytics Help — Set up BigQuery Export: the permission model and daily batch export used to lift the retention ceiling
Keep reading
Related guides
Explore related work
Next steps
Let's talk about your project
Tell us what you're working on. We'll tell you if we can help — and exactly what we'd do.