Machina

Chapter 8·Part III · Implementation: Correctness and Architecture

Event Taxonomy and Custom Dimensions

8.1 The taxonomy is a contract, not a convenience

An event taxonomy is the interface between two systems that never meet at runtime: the application code that emits events and the reporting surface that consumes them. The emitting side (gtag in the browser, the Measurement Protocol in the contact and careers API routes) writes a stream of named events, each carrying a bag of named parameters. The consuming side (Explorations, funnels, the Reports collection, and any downstream BigQuery table) can only read what the emitter agreed in advance to name and register. Nothing negotiates the two at request time. If the emitter sends a parameter the reporting side never registered, GA4 accepts the event, stores the parameter on the raw event, and shows it nowhere in the standard UI. If the reporting side registers a dimension whose parameter name the emitter never sends, the dimension exists and stays permanently empty. Both failures are silent. Neither raises an error, a warning, or a validation message.

That silence is the reason taxonomy governance earns a dedicated chapter rather than a paragraph. Chapter 2 (The GA4 Data Model as a Working Mental Model) established the mechanics: events carry parameters, parameters become reportable only after registration as custom dimensions, and scope (event, user, item) governs how GA4 stores and joins a parameter across the session and user graph. This chapter turns that model into governance for the {COMPANY} property (GA4 534525683, measurement ID G-WH410Z73V1): the naming conventions that keep client and server in agreement, the register-before-send discipline and the exact-match rule that determine whether a dimension ever holds data, the scope decision and the specific way a wrong scope corrupted session_source, the personally-identifiable-information and cardinality rules that make error_message a safe dimension and would have made a raw error string a liability, and the rebuild of page_view from a doubled, untagged mess into a single tagged source of truth. Chapter 13 (Data Quality and Governance) covers the ongoing enforcement mechanism, the committed MEASUREMENT_PLAN.md; this chapter covers the design the plan encodes.

8.2 Naming conventions and cross-surface parity

The property's event names already followed a defensible pattern before the audit: lowercase snake_case, a verb-plus-noun shape for user actions (contact_form_submit, quote_form_submit, career_application_submit, call_button_click, cta_click), and noun-shaped parameter names (form_source, cta_location, service_slug). This is worth stating as policy rather than accident, because GA4 imposes hard limits that a loose naming habit eventually collides with: event names cap at 40 characters, parameter names at 40 characters, parameter values at 100 characters, and a property may register at most 50 event-scoped custom dimensions, 25 user-scoped, and 10 item-scoped. GA4 also reserves the ga_, google_, and firebase_ prefixes and a list of reserved event names; a name that trips a reserved prefix is dropped without notice. Short, consistent, prefix-clean names keep the property well inside those ceilings and keep every event legible to a reader who has never seen the code.

The convention that the audit found broken was not spelling but parity across surfaces. A single logical action must carry one name wherever it is emitted. The careers form violated this: the client fired career_application_submit while the careers API route posted a Measurement Protocol event named career_application. Two names for one act produce two events in GA4, neither of which is the other's fallback, and the server variant sat as an orphan that no key event or report referenced. Chapter 6 (The Server-Side Measurement Protocol) documents the resolution (the server event was removed rather than renamed, because the low-volume client key event already covers the form). The taxonomy lesson generalizes past that one fix: the client and the server are two emitters writing into the same namespace, and a name is only as useful as its consistency across both. The remediation registered this as a rule in the measurement plan. Every event has exactly one canonical name and one canonical parameter set, and any surface that emits it (gtag, Measurement Protocol, a future server-to-server call) uses that name verbatim.

8.3 Register-before-send and the exact-match rule

Two properties of GA4 custom dimensions govern whether a parameter ever becomes reportable, and both punish the practitioner who learns them late.

The first is that registration is not retroactive. When you register a custom dimension, GA4 begins associating the named parameter with that dimension from the registration moment forward. Events collected before registration keep the parameter on the raw event row, but the standard reporting surface cannot expose it for the pre-registration window. There is no backfill. This is the register-before-send rule stated as consequence: register the dimension at or before the first event that carries the parameter, because any lag between first-send and registration is a permanent reporting gap for that interval. In the {COMPANY} build the three new dimensions (service_type, cta_label, error_message) were registered as part of the same Phase 1 change set that shipped the code emitting them, so the gap was bounded to deployment ordering rather than left open for weeks.

The second property is exact-match on the parameter name. A GA4 custom dimension has two distinct fields that beginners conflate: a human-facing display name and a machine-facing parameter name. The display name is free text and drives only what a reader sees in a report menu. The parameter name is the join key: GA4 populates the dimension only from events whose parameter key equals that string, byte for byte. A parameter name of service_type will never pick up a parameter the code sends as serviceType or service, and the mismatch produces a dimension that is syntactically valid, visible in the UI, and eternally empty. Because the failure looks identical to "this parameter has no traffic yet," it can survive months of no one noticing.

The cta_click event makes the distinction concrete. The client emits cta_click with parameters label and cta_location. The registered dimension carries the display name cta_label, which reads well in a report, but its parameter name must be set to label (the string the code actually sends) for it to populate. Display name cta_label, parameter name label: the two fields do different jobs, and only the parameter name has to match the wire format. A practitioner who registers the parameter name as cta_label to match the pretty display name breaks the join and gets nothing. The rule to internalize is that the display name is documentation and the parameter name is a foreign key; you tune the display name for humans and you copy the parameter name from the emitting code with zero edits.

8.4 Scope: event, user, and the session_source pitfall

GA4 offers three scopes for a custom dimension, and the scope decision is not cosmetic; it changes how GA4 stores the value and what it can join to.

Event scope attaches the parameter to the single event that carried it. This is the correct default and the scope for nearly every dimension in this property: page_type, service_slug, location_slug, pest_slug, job_slug, form_source, cta_location, cta_label, service_type, and error_message all describe the specific event on which they arrive and nothing beyond it. When you want to answer "which service page produced this form start," event scope is what makes the parameter available on that event's row.

User scope attaches the value to the user and carries it forward, with last-write-wins semantics: GA4 stores the most recent value written for that user and overwrites it on every subsequent write. User scope is right for genuinely user-level attributes that should persist and update (a membership tier, a lifetime customer flag). It is wrong for anything that varies within a user's history and needs to be read at the grain it was written.

The legacy property held session_source as a user-scoped dimension, and this is the scope pitfall in the flesh. A source is a per-session fact: a user can arrive from Google organic on Monday and from a paid click on Thursday, and the analytically useful statement is that each session had its own source. Stored at user scope with last-write-wins, session_source collapses to whichever value was written most recently and overwrites the earlier one, so the dimension cannot describe any individual session and silently mixes acquisition across a user's history. The value it reports is not the source of the session you are looking at; it is the source of that user's latest touch, whenever that happened. Worse, the dimension is redundant: GA4 already computes session source and medium as built-in dimensions with correct session-scoped semantics, so the custom session_source added a wrong answer next to a right one. Chapter 9 (Identity, Attribution, and Value) treats the attribution consequences; the taxonomy verdict is narrower. A dimension that names a session-level fact must not be stored at user scope, and before minting a custom dimension you check whether GA4 already provides the fact at the correct scope, because a redundant custom dimension is not free: it consumes one of the property's 25 user-scoped slots and invites analysts to trust the wrong column.

Item scope is the third option, and it applies only to ecommerce items delivered in the items[] array. This property has no ecommerce and no correct use for item scope, which is exactly why the broken view_item event (flat item_category, item_id, item_name parameters instead of the required items[] array, so item reports never populated) produced nothing: item-scoped reporting reads the array, and flat parameters at event scope never reach it. Chapters 7 (Conversion Architecture) and 14 (Anti-Pattern Catalog) cover that defect and the decision to leave the dead event in place as harmless weight. The taxonomy point is that scope is a claim about what a value describes (an event, a person, or a line item), and a value delivered in the wrong shape for its intended scope is invisible no matter how correctly it is named.

8.5 PII and cardinality discipline: error_message as the worked example

Two rules constrain what a parameter is allowed to carry, and both are absolute for different reasons.

The PII rule is a policy and legal boundary. Google's terms forbid sending personally identifiable information (email, phone number, full name, precise street address) into GA4 as event data, and a violation can put the property out of compliance regardless of intent. The cardinality rule is a mechanical one. GA4 reports cap the number of distinct dimension values they will render per day (on the order of 500 rows for a standard report table), and once a dimension's distinct daily values exceed that ceiling, GA4 buckets the overflow into a single (other) row. A high-cardinality dimension does not fail loudly; it dissolves into (other) and takes its detail with it. A parameter that carries a unique-per-event value (a raw timestamp, a UUID, a freeform string) is the canonical cardinality bomb: every event is its own row until the ceiling collapses them all.

error_message is the dimension the remediation registered, and it is safe on both counts for one reason: the code emits a bounded vocabulary of codes, never the underlying error text. The form_error event fires error_message values drawn from a short enumerated set (server_error, network_error, and their siblings), each a category the application controls. Because the set is small and fixed, cardinality stays in the single or low double digits and every value keeps its own addressable row; because the codes are engineered classifications rather than echoed input, no user's email or message body can ride along inside the string. This is the discipline stated as a design constraint: an error_message dimension is registrable precisely because the emitter guarantees a bounded, PII-free codomain.

The counterfactual sharpens the rule. Had the code sent the raw error the way naive logging does (Invalid email: jane@{company-domain}, or a stack trace, or the user's typed message truncated into the reason string), the same dimension name would have become a double liability: a PII channel smuggling user contact data into analytics against Google's terms, and a cardinality explosion where near-every error is a unique string that GA4 folds into (other), yielding a dimension that is both non-compliant and useless. Chapter 10 (Privacy and Consent by Design) develops the PII posture; the taxonomy rule that this example anchors is prior to both concerns and simple to enforce. Register a parameter as a dimension only when the emitting code guarantees the value is bounded and free of PII, and enforce that guarantee at the emit site (map exceptions to codes before they reach gtag or the Measurement Protocol), never at the reporting site, because GA4 offers no filter that can retroactively unsee a value it already stored.

8.6 The page_view rebuild as a taxonomy problem

The most instructive fix in the taxonomy layer was not a new dimension but the reconstruction of the oldest, most familiar event. page_view looks like a solved problem on every site, and on a Next.js 15 App Router application with roughly 1,200 statically generated routes it was quietly producing doubled, half-tagged data. Treating page_view as a taxonomy object (one event, a defined parameter set, exactly one emitter) rather than a platform default was what fixed it.

8.6.1 The defect: three uncoordinated producers of one event

Three mechanisms were all trying to author page_view, and they disagreed. First, the gtag config call fired an initial page_view on load by default (send_page_view defaults to true). Second, Enhanced Measurement's page-changes option (pageChangesEnabled) listened to the browser History API and fired an additional page_view on every client-side route change, which an App Router SPA performs constantly as the user navigates without full reloads. Third, a per-page tracker component issued its own trackPageView call, duplicating the intended tagged view. The arithmetic result was inflated page_view counts. The analytical result was worse: the automatic views from Enhanced Measurement carried none of the property's custom parameters (page_type, service_slug, location_slug, pest_slug, job_slug), so a large share of page_view traffic arrived untagged and could not be sliced by the very dimensions the business needs to read page performance by service and county. A doubled count is a nuisance; an untagged majority is a reporting hole.

8.6.2 send_page_view:false and one component that owns the event

The rebuild established a single author. The gtag config now sets send_page_view:false, which suppresses the automatic initial view and cedes the event entirely to application code. One client component, RouteAnalytics, mounts once and fires exactly one page_view per route, and the duplicate trackPageView call in the per-page tracker was deleted so no second emitter survives. The principle is ownership: an event with more than one uncoordinated producer has no source of truth, and the cure is to name a single owner and silence the rest.

// gtag config: application owns page_view
gtag('config', 'G-WH410Z73V1', {
  send_page_view: false,
});

8.6.3 Deriving page_type and slug from usePathname

RouteAnalytics derives the taxonomy from the route itself. It reads the current path with Next.js's usePathname hook, classifies it into a page_type, extracts the relevant slug, and emits one tagged page_view.

'use client';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';

export function RouteAnalytics() {
  const pathname = usePathname();
  useEffect(() => {
    const params = derivePageParams(pathname);
    // e.g. { page_type: 'service', service_slug: 'termite-control' }
    window.gtag?.('event', 'page_view', params);
  }, [pathname]);
  return null;
}

Because the pathname is the input, the tagging is deterministic and total: every route yields a page_type and, where applicable, exactly one slug parameter (service_slug, location_slug, pest_slug, or job_slug). There is no hand-tagging per page to drift out of sync, and a new route inherits correct tagging the moment its path matches the classifier. The useEffect dependency on pathname is what makes the component fire once per client navigation, replacing the untagged Enhanced Measurement view with a tagged one.

The GA loader's Next.js Script strategy changed from lazyOnload to afterInteractive. This is not a page_view change on its face, but it belongs to the same rebuild because it governs when gtag runs and therefore when GA4 writes the _ga client-id cookie. Under lazyOnload the browser defers the tag until after the load event and idle time, which on a fast visitor can leave gtag not yet executed at the moment they submit a form. Under afterInteractive the tag loads as soon as the page becomes interactive, so gtag runs sooner and sets _ga earlier in the visit. The downstream payoff is measured in Chapter 6: the server generate_lead needs a real ga_client_id read from _ga, and an earlier cookie write raises the odds that the cookie exists before a quick visitor completes the contact or quote form. Earlier tag execution is also earlier and more reliable page_view, so the strategy change tightens both the identity path and the page_view path at once.

8.6.5 The Enhanced Measurement collision and the single source of truth

With application code now the sole author of page_view and of the form events, Enhanced Measurement's automatic firing became a source of duplication rather than coverage. Two options were switched off. pageChangesEnabled was disabled because its History-API-driven page_view double-fired against the RouteAnalytics view on every client navigation and carried no custom parameters. formInteractionsEnabled was disabled because it auto-fires GA4's own form_start and form_submit events on any form interaction, which duplicated the property's custom form_start and the custom submit events (contact_form_submit, quote_form_submit). The verify pass of the adversarial audit surfaced this second collision specifically: teams enable form interactions expecting free coverage and instead get a shadow set of GA4-authored form events sitting beside the custom ones, inflating counts and splitting each funnel across two event names. Disabling both options restored the invariant the taxonomy depends on: for page_view and for every form event, exactly one emitter authors the event, so a count is a count and a funnel reads a single coherent event stream.

8.6.6 Verification

The centralization was verified in headless Chrome by reading dataLayer directly rather than trusting the code by inspection. Each route pushed one and only one tagged view: the homepage pushed {page_type: home}, /services/termite-control pushed {page_type: service, service_slug: termite-control}, and /locations/salinas pushed {page_type: location, location_slug: salinas}, with a single push per route and no untagged duplicate alongside it. Reading the dataLayer, a practitioner confirms both halves of the fix at once: the parameters are present (tagging works) and the event fires once (deduplication works).

8.7 The registered dimension set for the case

The property carries eleven custom dimensions after remediation: the eight the audit inherited plus the three (service_type, cta_label, error_message) that Phase 1 registered alongside the code that emits them. The table records each dimension's wire-level parameter name, its scope, its emitting event or events, 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.

Parameter name (wire)Display nameScopeEmitting event(s)Value bound
page_typepage_typeeventpage_viewBounded (home, service, location, pest, job, and the fixed set of top-level types)
service_slugservice_slugeventpage_view (service pages)Bounded (~18 service slugs)
location_sluglocation_slugeventpage_view (location pages)Bounded (~43 location slugs)
pest_slugpest_slugeventpage_view (pest pages)Bounded (~15 pest slugs)
job_slugjob_slugeventpage_view (job pages), career_application_submitBounded (open roles)
form_sourceform_sourceeventcontact_form_submit, quote_form_submit, form_start, form_errorBounded (form identifiers)
cta_locationcta_locationeventcall_button_click, cta_clickBounded (placement identifiers)
labelcta_labeleventcta_clickBounded (curated CTA labels)
service_typeservice_typeeventgenerate_lead (server, Measurement Protocol)Bounded (service categories)
error_messageerror_messageeventform_errorBounded codes (server_error, network_error, ...)
(legacy) session_sourcesession_sourceuser(legacy user-scope dimension)Redundant with built-in session source; mis-scoped (see 8.4)

Two rows in that table repay a second look. The label row shows the display-name-versus-parameter-name split from Section 8.3: the reportable name is cta_label, the join key is label, and only the latter must match the code. The session_source row is the surviving example of the scope pitfall from Section 8.4, kept in the table because a governed taxonomy names its known-wrong entries rather than hiding them; Chapter 9 carries the attribution-side decision on it. The service_type dimension also connects outward: its bounded categories are the same keys the per-service lead-value map uses (termite, fumigation, commercial, bed-bug, rodent, general, pest, ant, wasp-bee, other), so the dimension that segments leads by service and the value that prices them share one vocabulary, which Chapters 6 and 7 develop.

8.8 Governance forward

The design in this chapter is only as durable as the discipline that keeps code and registration in step. Three rules carry the load, and each maps to a failure this property exhibited before remediation. Register before you send, because a dimension registered late loses its pre-registration window with no backfill. Match the parameter name exactly, because a display name tuned for humans and a parameter name copied from the emitter are different fields and only the second one joins. Bound the codomain at the emit site, because PII and cardinality are properties of the value the code produces, not of any filter the reporting side can apply after the fact. The MEASUREMENT_PLAN.md committed to the repository is where these rules stop being tribal knowledge and become a reviewable contract that a code change can be checked against; Chapter 13 (Data Quality and Governance) treats that document and the review process as the standing enforcement mechanism, and Chapter 14 (Anti-Pattern Catalog) collects the specific taxonomy failures (the career_application orphan, the flat-parameter view_item, the user-scoped session_source, the doubled page_view) as a reusable list of what to grep for on the next property.