How the measurement actually got built
The case study says what I built. This says how, in order, with the screens — because "I built an analytics practice" is a claim, and a measurement plan with a tag behind it is evidence.
CambiumPak is not a real company. It is an invented maker of recycled and compostable B2B packaging supplies, built so I can show this process end to end. Every product, figure, document, certification mark, stakeholder and screen on this page is fabricated. No mark named here belongs to a real standards body, and no number here is any employer's number.
The real version of this work was done under NDA for a lighting manufacturer, so I cannot publish its data, its screens or its code. What is real is the sequence — the order of the steps, the reasoning at each one, and the artifacts each step produces. That is the transferable part, and it is the same shape I would build on any site.
Every reconstructed interface below is a hand-built HTML recreation of the layout of a working analytics tool, not a screenshot of one. It carries invented data.
The company has a real site. CambiumPak is published at cambiumpak.com — eleven product families, seventeen SKUs and forty-four downloadable documents, every one of them written for this. That site is the thing the measurement below was built against, so the events, the SKU codes and the four document types on this page all resolve to something you can go and click.
the nine steps
The conversation, which is where measurement plans actually start
before any tool is opened · the output is a list of questions, not a list of events
CambiumPak was six weeks from relaunching its site. Eleven product families, seventeen SKUs, forty-four downloadable documents, a distributor portal, and five interactive tools that had never existed before — a box configurator, a material comparison tool, a right-size calculator licensed from a third party, a recycled-content credit finder, and the portal itself. The comparison tool was billed monthly.
I did not start by opening a tag manager. I started by sitting down with Dana Whitfield, the marketing director, and asking her what she wished she knew and currently did not. Everything downstream is an answer to something she said in that hour.
I can see sessions are up. I can see the top pages. I genuinely cannot tell you what to do with either of those.
Give me the question you get asked that you can't answer.
Which documents matter. We publish four sheets per product — spec, comparison, features and benefits, and the instructions. That's forty-four PDFs. Right now downloads come through as one number, so "downloads are up" is the entire insight. Up on what? For which product? Nobody can tell me.
And the portal. It looks busy. I don't know whether that's distributors, our own reps checking stock, or the fulfilment house. Those are three completely different businesses and I'm reporting them as one line.
Then the real one: where do people stop? Somebody lands on a family page and somebody else requests a sample, and I have no idea what happens in between or where we lose them.
And the five new tools?
Eight months of work between them, and on launch day I will have no way of knowing whether a single person used any of them. One of them we pay for every month and I'd have to renew it on a feeling.
Four frustrations. I wrote them down as questions rather than as features, because a question survives a tooling change and a feature request does not:
the thing I would say to a hiring manager about this step
Nobody asks for an event. They describe a frustration. The skill is not tagging — the skill is hearing "the portal looks busy" and recognising it as a segmentation problem with a one-line fix, before anyone has scoped a project around it.
What the default install could and could not answer
an honest audit before proposing work · half of what people want is already there and free
Before proposing anything I checked what a standard analytics install gives you for nothing, because proposing work that the platform already does is the fastest way to lose credibility in the room. Enhanced measurement was already collecting page views, scrolls, outbound clicks, site search and file downloads. That last one is the interesting case, and it is the one that shows exactly where a default install stops being useful.
Here is the file-download report as it actually arrived, before I built anything. It is not empty. It is worse than empty — it is plausible.
file_download
Last 28 days · 4,610 events · dimension: File name
| File name | Event count |
|---|---|
| CambiumPak-CP-MLR-spec-sheet.pdf | 412 |
| CambiumPak-CP-CTN-spec-sheet.pdf | 338 |
| CambiumPak-CP-MLR-assembly-instructions.pdf | 264 |
| CambiumPak-CP-TP-sealing-instructions.pdf | 209 |
| CambiumPak-CP-PPE-spec-sheet.pdf | 205 |
| CambiumPak-CP-CTN-assembly-instructions.pdf | 190 |
| …38 more rows | 2,992 |
Showing 6 of 44 rows.
reconstruction · the default file-download report · invented company, fabricated figures. forty-four rows of filenames. technically itemised, analytically useless.
Everything you would want to ask of that table, it cannot answer. You cannot total spec sheets against instructions, because document type only exists inside a filename. You cannot roll up to a product family, because family only exists as three letters in the middle of a string. And the whole thing breaks silently the day someone renames a file — the row simply stops appearing and no error is ever raised.
free, already working, leave it alone
- Page views, sessions, users, channels
- Scroll depth and outbound clicks
- Site search terms
- That a PDF was downloaded
needs building, and only ever by hand
- Which document, as a type rather than a filename
- Which product and family it belonged to
- Who logged into the portal, by customer type
- Whether each of the five tools was used, and whether use succeeded
- Every step between landing and a sample request
That right-hand column is the project. It is also, precisely, Dana's four questions — which is how I knew the scope was right rather than invented.
The measurement plan, written before a single tag existed
one document · signed off by marketing and IT before build · it is an intake form, not documentation
This is the artifact I care most about and the one most analytics work skips. It is a single document with one row per event, and the leftmost column is the business question. If a row cannot name the decision it informs, it does not get built — which is how you end up with a property that answers questions instead of one that collects everything and answers nothing.
It also names the parameters up front, and that matters more than it looks: a parameter you did not think of on day one is not recoverable on day ninety. You cannot backfill it.
| Business question | Event | Key parameters | Registered as | Pri |
|---|---|---|---|---|
| Which documents, for which products, drive demand? | document_download | doc_type · product_sku · product_family · source_page | 4 dimensions | P1 |
| Who is actually logging into the portal? | portal_login | customer_type · account_tier · first_login | 3 dimensions | P1 |
| Does the configurator get used, and does it succeed? | configurator_start configurator_complete |
box_style · material · recycled_pct (banded) · outcome | 4 dimensions | P1 |
| Is the paid comparison tool worth renewing? | material_compare | materials_compared · compare_count | 2 dimensions | P1 |
| The conversion everything else predicts. | sample_request | product_context · form_type | 2 dimensions | P1 |
| Do buyers care about recycled-content credits, or do we only think so? | credit_finder_search | credit_scheme · region | 2 dimensions | P2 |
| Is anyone reaching the calculator we license? | rightsize_launch | source_page | 1 dimension | P2 · limited |
P2 · limited — the right-size calculator is a third-party embed. Launches are measurable from our side; what happens inside it is not. Recorded as a known boundary rather than a gap to be closed later.
reconstruction · the measurement plan · the leftmost column is the entry requirement. an event with an empty question column does not get built.
the judgment call in this table
That last row is a decision not to build something. The calculator is a vendor component that regenerates its element IDs on every render — DOM-based tracking against it would appear to work perfectly and then break silently on the vendor's next release, at which point the report keeps showing a number and the number is wrong. Silent wrong data is worse than no data. I shipped a launch count, wrote the boundary into the plan, and said so out loud. Declining to build fragile tracking is a harder call than building it, and nobody thanks you for it until eighteen months later.
The dataLayer contract, agreed with the web vendor
the step people skip · if the push is ad hoc, every later step inherits the mess
Some of this could have been scraped off the DOM. Most of it should not be. Anything read out of markup is a promise the front end never made, and the front end will break it the first time someone reorders a template. So before the vendor wrote a line, I gave them the exact shape of what to push, and it went into the build as a requirement rather than a favour.
// Fired when a document is downloaded, from anywhere on the site. // Values come from the product record, never from the filename. window.dataLayer.push({ event: 'document_download', doc_type: 'spec', // spec | comparison | features | instructions product_sku: 'CP-MLR-1208', product_family: 'mailer', // so roll-ups need no lookup table source_page: 'family' // family | compare | portal | search }); // Fired once, after authentication resolves. Never on page load. window.dataLayer.push({ event: 'portal_login', customer_type: 'distributor_rep', // internal | distributor_rep | fulfilment | brand account_tier: 'tier_2', first_login: false // true once, ever — activation, not traffic });
why product_family is in there
- Without it, every roll-up needs a join against a SKU list somebody has to maintain.
- Nobody maintains it.
- Within a year the reporting quietly stops matching the catalogue and no one notices which one is wrong.
- One extra key at push time costs nothing and removes that failure mode permanently.
why recycled_pct is banded before it is sent
- The brand system states recycled content as a band — 0–39 / 40–69 / 70–100 — never as an implied precision.
- Sending the raw figure would let a report make a claim the certification does not support.
- Banding at the push is the only place it can be enforced for everyone downstream.
- Governance you have to remember is governance that fails. Put it in the contract.
Building the tags, by hand, one at a time
no auto-events, no plugins · every tag specified, built, then tested before the next one starts
Eleven tags, nine variables, seven triggers. Three are worth showing because each solves a different class of problem: mapping a messy input to a clean one, passing structured parameters through, and reading something the page already knows but never told anyone.
First, the one that fixes Dana's forty-four rows. A lookup table maps a filename pattern to a document type. This is the fallback path — the dataLayer contract from step 04 is the primary source — but legacy links, emailed PDFs and links inside the portal do not always go through it, and a fallback that produces a clean value beats a fallback that produces a filename.
Pattern → output
| Pattern | Output |
|---|---|
| -spec-sheet\.pdf$ | spec |
| -comparison-sheet\.pdf$ | comparison |
| -features-and-benefits\.pdf$ | features |
| -(assembly|use|handling|fitting|wrapping|filling|sealing)-instructions\.pdf$ | instructions |
| -document-map\.pdf$ | internal |
Ignore case: off · Full matches only: off
reconstruction · the lookup that collapses 44 filename rows into 4 reportable types.
Two details in that table are the whole difference between a variable that works and one that rots. The fourth row alternates seven verbs, because the fourth document in each product pack is not always called the same thing — four families assemble, the rest use, handle, fit, wrap, fill or seal, because that is what the product actually asks of the packer. A pattern matching only "assembly" would have silently dropped seven families out of the report.
And the default is unclassified, not empty. An unclassified row is visible in a report and someone investigates it. An empty value looks like nothing happened, and nobody investigates nothing.
Second, the tag itself, passing the parameters through as named fields rather than jamming them into the event name:
Event parameters
| Parameter | Value |
|---|---|
| doc_type | {{dlv — doc_type}} || {{var — doc type from filename}} |
| product_sku | {{dlv — product_sku}} |
| product_family | {{dlv — product_family}} |
| source_page | {{dlv — source_page}} |
Triggering
Blocking trigger: none. Deduplication handled by the fallback trigger excluding pages where the dataLayer push is present.
reconstruction · the event tag · one event name, four parameters. never download_spec_sheet_pdf.
The naming rule underneath this is worth stating because it is the single most common way an analytics property becomes unusable: events are things a person did, parameters are the details. The moment you write download_spec_sheet_pdf you have put a parameter into the event name, and you will never be able to total downloads again without a regular expression and an apology.
Third, the one that turned a cross-vendor project into six lines. Portal login segmentation was scoped as backend work with an external development partner — expose the user role through an API, wire it in, timeline in months. Before agreeing to that, I opened the post-login page and read the markup. The role was already there, on the body element, put there by the theme so it could style the navigation.
function() {
// The portal theme already writes the role onto <body> to style the nav.
// Read it rather than commissioning an API to tell us what the page knows.
var m = document.body.className.match(/\brole-([a-z_]+)\b/);
if (!m) return 'unresolved'; // never '' — an empty value hides itself
var map = {
staff: 'internal', rep: 'distributor_rep',
threepl: 'fulfilment', brand: 'brand'
};
return map[m[1]] || 'unmapped_' + m[1]; // a new role shows up named, not as noise
}
reconstruction · six lines, no backend ticket, no vendor dependency.
The unmapped_ prefix is deliberate. When someone adds a role in eighteen months — and they will, without telling marketing — it appears in the report as unmapped_auditor rather than being quietly folded into a bucket where it corrupts a segment nobody thinks to re-check.
the pattern behind all three
Every one of these fails loudly rather than quietly. unclassified, unresolved, unmapped_*. Analytics does not usually break with an error — it breaks by continuing to produce a number that has stopped being true. Designing the failure states is most of the engineering.
Verifying before publish, and keeping the receipt
nothing is published on the strength of "the tag went green" · fired ≠ arrived ≠ correct
A tag firing tells you a tag fired. It does not tell you the parameters resolved, and it certainly does not tell you the platform accepted them. Those are three separate claims and I check them separately. Preview mode first — trigger the interaction on staging, then read the resolved values, not the tag list.
Event stream
Variables set on document_download — the values as the tag will actually send them.
| Variable | Resolved value |
|---|---|
| dlv — doc_type | spec |
| dlv — product_sku | CP-MLR-1208 |
| dlv — product_family | mailer |
| dlv — source_page | family |
| var — doc type from filename | spec fallback, unused |
| Click URL | /assets/docs/CambiumPak-CP-MLR-spec-sheet.pdf |
Tags fired on this event
| GA4 — document_download | succeeded |
reconstruction · preview mode, variables tab. i read this tab, not the tags tab — a tag can fire perfectly and send an empty string.
Note what the fallback row proves: the lookup table resolved to the same value the dataLayer supplied. Agreement between the primary path and the fallback is a much stronger signal than either one working alone, and it takes no extra effort to check while you are already looking at the pane.
Then Realtime, because preview proves the browser sent something and Realtime proves the platform received and parsed it. Between those two claims sits every consent tool, ad blocker, and dropped parameter I have ever had to debug.
Event count by event name
| document_download | 1 |
| page_view | 3 |
| session_start | 1 |
Parameter: doc_type
| spec | 1 |
Parameter: product_family
| mailer | 1 |
QA pass — 30 July
| Tag fires | pass |
| Params resolve | pass |
| Event received | pass |
| Params received | pass |
| No duplicate fire | pass |
| Fires on all 11 families | pass |
| Screenshot filed | done |
reconstruction · realtime confirming the parameters arrived, alongside the pass list run against every tag before the container version was published.
why the screenshot row exists
Every verified tag was screenshotted and filed before publish. Not for me — for the conversation eleven weeks later that starts "was this tracking live at launch?" On a relaunch you are one party among several, and the difference between "I'm fairly sure we tested that" and a dated screenshot sent to the web vendor at the time is the difference between a discussion and an argument. It costs four seconds per tag.
Registering the dimensions, which is not optional and is not retroactive
the quiet step that decides whether any of the previous six were worth doing
A parameter that is collected but not registered as a custom dimension is invisible in reporting. The data is arriving, it is in the payload, and it does not appear in a single report or exploration. People discover this months later, register the dimension, and find it populates from that day forward and not one day earlier. Registration is not retroactive. There is no backfill, no support ticket, no recovery.
So this happened on the same day the tags were built, not after the first month of data.
| Dimension name | Scope | Parameter | Created | Status |
|---|---|---|---|---|
| Document type | Event | doc_type | 30 Jul | collecting |
| Product SKU | Event | product_sku | 30 Jul | collecting |
| Product family | Event | product_family | 30 Jul | collecting |
| Source page | Event | source_page | 30 Jul | collecting |
| Customer type | User | customer_type | 30 Jul | collecting |
| Account tier | User | account_tier | 30 Jul | collecting |
| Configurator outcome | Event | outcome | 30 Jul | collecting |
| Box style | Event | box_style | 30 Jul | collecting |
| …6 more | Event | — | 30 Jul | collecting |
reconstruction · custom definitions, registered the same day the tags were built. customer_type is user-scoped, not event-scoped — see below.
The highlighted row is a real decision, not a checkbox. customer_type is registered at user scope rather than event scope. Event scope would tell you the customer type of the login event; user scope attaches it to everything that person subsequently does in the session. That is the difference between "1,240 logins, 58% distributor reps" and being able to ask which documents distributor reps download — which is the question Dana was actually circling when she said the portal looked busy. One radio button, and it determines whether half the interesting questions are answerable at all.
The Explore that answered her question, and one it answered by accident
free-form exploration · rows, columns, one metric, and reading the totals carefully
Once the dimensions were populating I built the report Dana actually asked for: which documents, for which products. A free-form exploration — product family down the side, document type across the top, sessions as the metric. Deliberately sessions rather than event count, for a reason that turns out to matter enormously.
Variables
Product family Document type Product SKU Source page Customer typeMetrics
Sessions Event count Total usersTab settings
Technique
Free formRows
Product familyColumns
Document typeValues
SessionsFilters
event = document_download| Product family | Spec | Comp. | Feat. | Instr. | Sessions |
|---|---|---|---|---|---|
| Corrugated mailer | 412 | 96 | 118 | 264 | 561 |
| Shipping carton, RSC | 338 | 71 | 84 | 190 | 447 |
| Water-activated tape | 174 | 38 | 47 | 209 | 341 |
| Paper-padded envelope | 205 | 44 | 61 | 152 | 318 |
| Moulded pulp tray, 6 cav. | 121 | 52 | 39 | 88 | 214 |
| …6 more families | 402 | 119 | 137 | 278 | — |
| Totals | 1,652 | 420 | 486 | 1,181 | 1,742 |
Sessions is de-duplicated. A session appearing in two columns is counted once in the Totals column.
reconstruction · the free-form exploration · invented figures. the answer to her question is the body of the table. the more interesting finding is in the last column.
Her question, answered. Spec sheets and instructions dominate. The comparison sheet is the least-downloaded document in every single family, without exception — and the comparison sheet is the one whose entire job is to help somebody decide whether this is the right format. The document built to win the specification decision was the one nobody was finding. That is a navigation problem wearing a content problem's clothes, and it went straight onto the website roadmap.
Then the last column. Because the metric is sessions and sessions de-duplicate, the row cells and the row total say two different things — and the gap between them is a finding nobody asked for.
The cells sum to 3,739. The de-duplicated session total is 1,742. So the average session that downloaded anything took 2.15 of the four sheets — and since no session can take more than four, at least 38% of them provably took more than one. The true figure is certainly higher; 38% is only the floor the arithmetic guarantees, and I quoted the floor rather than the estimate because the floor is the part I can defend.
That is not a documentation preference. It is the four-sheet structure working exactly as designed and being, for the buyer, exactly as painful as it looks: the board grade is on the spec sheet, the warranty term is on the features sheet, the make-up time is on the instructions, and the recycled band is on the comparison sheet. Nobody set out to make a buyer open four PDFs. It is what happens when four documents each have one owner and nobody measures the seam between them.
honest limitation, and it belongs on the page
I also used free-form to answer "where do people stop?" — event names down the rows in buying order, sessions as the metric, and read the drop between steps. That is a step comparison, not a funnel. It tells you how many sessions reached each step; it does not prove the same sessions moved between them in that order, and it will not show you someone who arrived at step three from a search result without ever seeing step one. It was enough to point at the largest gap, which was the question being asked. It would not be enough to claim a conversion rate, and I did not claim one.
Handing it over as something she opens herself
the last five percent · a report nobody opens has not changed a decision
An exploration is a thing an analyst opens. Dana is not an analyst, and a report that requires me to run it is a report that stops existing the week I am busy. So the last step is the handover: the same queries, standing, behind a login, with a date range and an export — a dashboard she opens on a Monday without asking anyone.
That build is its own story — the API auth, the caching, the query batching — and it is written up separately, with a working version you can click through.
The CambiumPak executive dashboard
The dashboard these events feed, built on the same invented company. Period filters, stat tiles, the tool sparklines, and the finding that killed a subscription.
The measurement model underneath it
The full event-to-question table, the parameter schemas, and the five governance rules written before the first tag.
what the nine steps produced
- Four questions in, four questions answerable out. Every event traces to a line Dana said in step 01, and nothing was built that could not name the decision it informed.
- Forty-four filename rows became four document types that roll up by family without a lookup table anyone has to maintain.
- A portal login became a segment — six lines of JavaScript instead of a cross-vendor backend project, registered at user scope so it colours everything else that person does.
- Five new tools went from no measurable signal to individually reportable on day one of the relaunch, including the one billed monthly, whose renewal now rests on usage rather than on a feeling.
- One finding nobody asked for — the average downloading session needed 2.15 of the four sheets — which is now the business case for a website change, and came out of choosing sessions over event count.
the part I would want asked about in an interview
Step 01 and step 07. The conversation, because that is where the scope is set and everything downstream is either an answer to it or waste. And the dimension registration, because it is a five-minute admin screen that silently determines whether the previous six steps produced anything usable at all — and it is the mistake I have most often been called in to find, months after it stopped being fixable.
The real version of this work was done for my employer, under NDA, on a lighting manufacturer's site. Its data, screens and code stay private — so this page rebuilds the process on an invented company instead, with fabricated figures throughout. I am glad to walk through the real event schema, the QA process and the architecture in a conversation.
open to marketing operations, analytics and martech roles
Let's talk.
Remote, or Albuquerque and hybrid. If you want the architecture behind any of the work here, ask me and I will walk you through it.