Product analytics
Recording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.
The problem: numbers that quietly lie
Picture a shop that sells running shoes. On Monday the product team opens its dashboard and sees that checkout conversion fell from 3.1% to 2.2% over the weekend. A release went out on Friday. The team rolls it back, holds an incident call, and spends two days looking for a bug in the payment form.
There was no bug in the payment form. The Friday release of the mobile web checkout renamed one event from Order Completed to order_completed, because a new engineer followed a different style. The dashboard counted only the old name, so mobile web orders, about 30% of the total, vanished from the chart.
Orders were fine; the count of orders was broken. This is the everyday failure of product analytics: recording what users do in your app as events, then counting and combining those events to answer product questions. The data rarely fails loudly. It drifts, double counts, and loses a share of users, yet still draws a neat chart.
What product analytics is for
Product analytics answers questions about behaviour. Logs and error tracking say what broke, and real user monitoring says how fast it felt; analytics says what people did.
- Funnels. Of the people who viewed a product, how many added it to the cart, started checkout, and paid? Where do they drop off?
- Activation and retention. Did a new user reach the moment where the product clicked for them, and did they come back?
- Feature adoption. Who uses the new saved-searches feature, and do they buy more?
- Experiments. Did variant B of the checkout convert better than A? An A/B test is only as good as the events it counts.
Each answer is built from a stream of events: small records like Product Viewed or Order Completed, each with a few details. The frontend sends most of them, because that is where users click, scroll, and type.
Five ways the numbers go wrong
Sending an event is one line of code. Sending events you can trust is a design problem. Almost every analytics failure falls into one of five groups.
| Failure | What it looks like | Chapter that fixes it |
|---|---|---|
| Bad names and shapes | signup, sign_up, and SignUp all exist; price is a string in one place and cents in another | Event design, tracking plans |
| Lost events | Events fired as the tab closes never arrive; offline users vanish | Delivery |
| Missing people | Ad blockers and declined consent hide a share of users, not at random | Consent, ad blockers |
| Extra events | Retries and double renders count one order twice; bots inflate page views | Data quality |
| Wrong questions | A chart of clicks that nobody acts on | Measuring what matters |
Order Completed to order_completed on mobile web, about 30% of orders, and the dashboard counted only the old name.The frontend engineer owns most of this: the names, the calls, when the queue flushes, the consent wiring, and often the tracking plan. In an interview, a sentence like "events follow a typed tracking plan checked in CI, and they are queued, batched, and flushed with sendBeacon after consent" shows you have shipped this for real.
The mental model: a diary with a strict editor
Think of analytics as a diary your app keeps about each visit. Every line is one fact in the past tense: "viewed the trail shoe at 10:02", "added it to the cart at 10:03". The diary is only useful if every writer uses the same words, nobody tears out pages or writes a line twice, and nobody writes without the visitor's permission.
The tracking plan is the strict editor: the list of allowed lines and what each must contain. The rest of the system carries the pages from the browser to the analysts without losing or copying them.
The pipeline, layer by layer
Every analytics setup, whether built in-house or bought from Amplitude, Mixpanel, PostHog, Segment, or Google Analytics, has the same layers. Vendors differ in which layers they run for you.
- Instrumentation. Your code calls
track("Order Completed", { orderId, revenue })when something happens. Only this layer knows what the user did. - The client SDK. A small library that adds context (page, device, ids, time), checks consent, queues the event, and sends batches.
- The collector. An HTTP endpoint that receives batches, answers fast (usually
200or204with an empty body), and writes the raw events to a durable log or queue. - Processing. Jobs that validate events against the plan, filter bots, remove duplicates, join anonymous visits to known users, and add data such as country from the IP address.
- Storage. A warehouse (BigQuery, Snowflake, ClickHouse) or the vendor's own event store.
- Analysis. Funnel, retention, and experiment tools, plus SQL and dashboards.
Errors compound down the pipe. A wrong name at layer 1 becomes a wrong row at layer 5 and a wrong chart at layer 6. Fixing it later means rewriting history in the warehouse, which is slow and often skipped, so the cheapest place to get analytics right is the track call itself.
Two sources of events
Events come from two places, and a good design uses both. Client-side events are sent from the browser: a page was viewed, a filter was opened, a video played. Only the browser sees these.
Server-side events are sent from your backend: an order was paid, a subscription renewed, a refund was issued. The backend is the source of truth for these.
The rule of thumb: track an event where the fact becomes true. A click becomes true in the browser; a payment becomes true when the payment provider confirms it, on the server. Send Order Completed from the thank-you page and you miss users who close the tab during the redirect, and count twice those who reload it.
| Question | Client-side event | Server-side event |
|---|---|---|
| Sees | Clicks, views, scrolls, time on page, UI state | Payments, sign-ups, renewals, anything stored in your database |
| Lost to | Ad blockers, declined consent, closed tabs, flaky networks | Almost nothing, if sent from a reliable job with retries |
| Knows | Page URL, referrer, screen size, the anonymous id | The user id, the true amount, the order state |
| Best for | Behaviour before the important moment | The important moment itself |
Think first
A team tracks Subscription Started from the browser when the Stripe checkout redirect lands on /welcome. What will be wrong with this number, and where should the event come from?
It will be too low, because some users close the tab or lose signal during the redirect, and ad blockers or declined consent drop the event for others. It can also be too high, because users who reload /welcome or open it in two tabs fire it twice.
Send it from the server, in the handler for the payment provider's webhook that confirms the subscription. Keep a client-side Checkout Submitted event for the behaviour before payment, and join the two with the user id.
Designing events: names, properties, context, identity
An event answers four questions: what happened, to what, in which situation, and to whom. Good event design gives each question a fixed home, so that anyone can read an event a year later without asking its author.
Names: object, then action, in the past tense
The most widely used convention is object-action: the thing first, then what happened to it, in the past tense. Product Viewed, Cart Item Added, Checkout Started, Order Completed, Search Performed. Segment's published spec uses this style, and most teams copy it.
- Past tense, because an event is a fact that already happened:
Order Completed, notComplete Order. - Object first, so a sorted list groups by object: every
Cart ...event sits together, and a missingCart Item Removedstands out. - Business words, not UI words.
Cart Item Added, notBlue Button Clicked. The button will move; adding to a cart will still matter. - One casing rule, enforced. Title Case (
Order Completed) or snake_case (order_completed) are both fine. Tools treat names as exact strings, so mixing them is what broke the dashboard in chapter 1.
The most common naming mistake is putting a value in the name: Clicked Plan Pro, Clicked Plan Team, Clicked Plan Free. Now a question like "which plans do people click" needs three events glued together, and a new plan needs a new event. Use one event, Plan Selected, with a property plan: "pro".
Properties and context
Properties describe this event: the product id, the price, the plan, the step number. The author of the track call chooses them. Context describes the situation every event shares: page URL, referrer, browser, screen size, app version, locale, campaign. The SDK adds context automatically, so authors never type it.
Keeping the two apart means authors write less and make fewer mistakes, context stays identical so you can slice any event by app.version or page.path, and the plan lists only what makes each event special, not the 20 fields every event carries.
{
"event": "Cart Item Added",
"properties": {
"product_id": "shoe-trail-42",
"price_cents": 12900,
"currency": "EUR",
"quantity": 1,
"source": "product_page"
},
"context": {
"page": { "path": "/p/shoe-trail-42", "referrer": "https://www.google.com/" },
"app": { "version": "2026.10.3" },
"locale": "de-DE",
"consent": { "analytics": true, "ads": false }
},
"anonymous_id": "a7f3c1e0-5b2d-4e8a-9c61-0d2f7b9e4a13",
"user_id": null,
"message_id": "e1c9b8d2-77aa-4f0e-a3b1-6c5d2e9f0a88",
"timestamp": "2026-10-19T10:03:12.480Z"
}
event and properties.Note the small choices. Money is an integer in the smallest unit (price_cents: 12900) with a separate currency, so nobody sums euros with yen or loses a cent to floating point. Ids are stable strings, not display names, and source uses a short fixed list of values (an enum), so it can be grouped.
message_id is a fresh random id for this one event. Chapter 5 shows why it is the key to removing duplicates.
Identity: anonymous id, user id, and stitching
Most visitors are not logged in when they arrive. The SDK gives each browser an anonymous id: a random UUID stored in a first-party cookie or localStorage the first time it runs (after consent, where consent is required). Every event carries it. When the user signs up or logs in, your code calls identify(userId), and from then on events carry both ids.
Identity stitching (also called identity resolution) is the processing step that links the anonymous id to the user id, so the shoe viewed before sign-up counts toward the same person who bought it after. One person can have several anonymous ids (a phone, a laptop, a cleared cookie), and each login links another one to the same user id.
identify call adds an edge. The tablet never logged in, so its visits stay unlinked: cross-device numbers are always estimates.- Use your own stable user id, such as the database key or a hash of it, never the email address. Emails change and are personal data.
- Call
identifyon every login, not only on sign-up, so a second device gets linked. - Reset on logout. Call
reset()so the next person on a shared computer gets a fresh anonymous id. - Expect stitching to be partial. A user who never logs in on their phone stays a stranger there, so cross-device numbers are estimates.
Check your understanding · Exercise 1
Which of these event designs follow the conventions in this chapter? Select all that apply.
Tracking plans and schemas checked in CI
A tracking plan is the single list of every event your product sends: its name, why it exists, when exactly it fires, its properties with their types and allowed values, who owns it, and which platforms send it. It is the contract between the engineers who send events and the analysts who read them.
Most teams start with a spreadsheet. Nothing checks the code against it, so code and plan drift apart within months. The fix is to make the plan machine-readable and let tools enforce it.
The plan as a schema
Write each event as a JSON Schema (or a similar typed definition) in a file that lives in the repository and is reviewed like code. A schema says which properties are required, their types, their allowed values, and whether extra properties are allowed.
{
"event": "Cart Item Added",
"description": "User added a product to the cart from any surface.",
"fires_when": "The add-to-cart request succeeded, not on click.",
"owner": "team-checkout",
"properties": {
"type": "object",
"required": ["product_id", "price_cents", "currency", "quantity", "source"],
"additionalProperties": false,
"properties": {
"product_id": { "type": "string" },
"price_cents": { "type": "integer", "minimum": 0 },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
"quantity": { "type": "integer", "minimum": 1 },
"source": { "enum": ["product_page", "quick_view", "recommendations"] }
}
}
}
additionalProperties: false turns typos into errors.The fires_when line is easy to skip and matters most. "On click" and "after the server confirmed" give different counts whenever requests fail. Write down which one, and keep it the same across web, iOS, and Android.
Three places to check events
- At compile time. Generate a typed function per event from the schema, such as
trackCartItemAdded(props). A wrong property name or type failstscbefore the code is committed. - In CI. A check compares the plan in the pull request with the main branch. Adding an optional property passes. Renaming an event, removing a property, or changing a type is breaking: the check fails until the data team approves, so dashboards get updated in the same change.
- At ingestion. The collector or processing job validates each event against the plan. Failures go to a quarantine table with the reason, not silently dropped, and an alert fires when an event's failure rate jumps.
// generated/analytics.ts: do not edit, run `npm run analytics:codegen`
export type CartItemAdded = {
product_id: string;
price_cents: number;
currency: string;
quantity: number;
source: "product_page" | "quick_view" | "recommendations";
};
export function trackCartItemAdded(props: CartItemAdded) {
analytics.track("Cart Item Added", props);
}
// In a component: a typo or a missing field is a type error.
trackCartItemAdded({
product_id: product.id,
price_cents: product.priceCents,
currency: "EUR",
quantity: 1,
source: "product_page",
});
Decision Where should the tracking plan live?
What we pick: Move to schemas in the repository as soon as more than one team sends events, and add a vendor tool when non-engineers need to own the plan. A spreadsheet is fine for a prototype, not for a product people make money decisions with.
Check your understanding · Exercise 2
Which changes to an existing event should the CI check treat as breaking and send for data-team approval? Select all that apply.
Delivery: batching, offline queues, and the closing tab
A busy session can produce 100 to 300 events: page views, clicks, scroll depth, product card impressions. One request per event would wake the mobile radio again and again and compete with the requests that load the page, so the SDK queues events and sends them in batches.
The delivery design is the one the frontend logging lesson builds for log records, with a different priority. A lost debug log is a shame; a lost Checkout Started event changes a conversion rate someone will act on. So analytics delivery leans harder on persistence: events survive a closed tab, a crash, and an offline hour.
When the queue flushes
- On size: when the queue holds, say, 20 events or about 16 KB, send them now.
- On a timer: every 5 to 10 seconds, send whatever is waiting, so a quiet page still reports.
- On hide: when
document.visibilityStatebecomeshidden(the user switched tabs, minimised, or is closing the page), send what is left withnavigator.sendBeaconorfetchwithkeepalive: true. Both let the request outlive the page. - On reconnect: when the
onlineevent fires, or on the next page load, send what was stored while offline.
Do not rely on unload or beforeunload for the last flush. They do not fire reliably on mobile, where the system often freezes or kills a background tab without warning, and an unload listener can keep the page out of the back/forward cache. visibilitychange to hidden is the last event you can count on.
Keep the hide-time batch small. The Fetch standard gives all keepalive requests in flight on a page a shared budget of 64 KiB of body; Chrome enforces it, and other browsers count slightly differently. Over the budget, sendBeacon returns false and fetch rejects, so the SDK should check the result and keep those events stored for the next visit.
2xx.A queue that survives
An in-memory array dies with the tab, so an analytics SDK mirrors its queue to storage. localStorage is simple and synchronous and fine for a few hundred small events; IndexedDB holds more and does not block the main thread. On the next page load the SDK sends the stored events first.
The order of operations matters most. Write the event to storage when it is tracked, and remove it only after the collector answers 2xx. If the tab dies between sending and the answer, the event is sent again next time. That is at-least-once delivery: nothing is lost, but some events arrive twice.
The message_id created when the event was tracked lets processing drop the copy. It is the same idempotency-key idea the background sync lesson uses for writes.
function track(event: string, properties: Record<string, unknown>) {
if (!consent.analytics) return;
const e = { event, properties, context: getContext(), message_id: crypto.randomUUID(),
timestamp: new Date().toISOString(), anonymous_id, user_id };
queue.push(e);
storage.save(queue); // survives a crash or a closed tab
if (queue.length >= 20) flush();
}
async function flush(hiding = false) {
const batch = queue.slice(0, 20);
if (batch.length === 0) return;
const body = JSON.stringify({ batch, sent_at: new Date().toISOString() });
try {
// keepalive only for the last flush on hide: it shares a 64 KiB budget
const res = await fetch("/collect", { method: "POST", body, keepalive: hiding });
if (!res.ok) throw new Error(String(res.status));
queue = queue.filter((e) => !batch.includes(e));
storage.save(queue); // delete only after a 2xx
} catch {
scheduleRetry(); // exponential backoff with jitter
}
}
- Cap the queue. A user offline for a week should not fill storage. Keep, say, the newest 1,000 events or 7 days, and count what you drop.
- Retry with backoff. On
5xxor a network error, wait 1 s, 2 s, 4 s, up to a minute, with random jitter so a recovering collector is not hit by every client at once. On4xxother than429, drop the batch: a resend will fail the same way. - Stay off the critical path. Load the SDK after the page is interactive, track with a cheap synchronous push, and serialise in an idle callback or a timer.
- Multiple tabs. Tabs share one
localStoragequeue. Give each tab its own key, or let one tab flush under a lock (the Web Locks API) so stored events are not sent twice.
A timer flushes every 10 s and retries failed batches from memory. Whatever is still queued when the tab closes dies with it. Result: 10 lost, 0 counted twice, 0 late.
For true background delivery after the page is gone, a service worker with Background Sync can send stored events when the connection returns, in Chromium-based browsers. Treat it as a bonus: other browsers fall back to sending on the next visit.
Check your understanding · Exercise 3
A user adds a shoe to the cart on a train, loses signal, and closes the tab. Which designs still count the Cart Item Added event eventually, exactly once? Pick one.
Consent and privacy
Analytics is about people, so privacy law applies to it. This chapter is engineering guidance, not legal advice: the rules differ by country, change over time, and depend on what you collect. Your legal or privacy team sets the policy; your job is a system that follows it reliably.
The rules in brief
- ePrivacy (the EU "cookie law"). Storing or reading anything on the user's device, such as a cookie or a
localStorageid, needs consent unless it is strictly necessary for the service the user asked for; analytics ids generally are not. France's CNIL exempts narrow audience measurement only under strict conditions (anonymous statistics for the site alone, no sharing or cross-site tracking, limited cookie and data lifetimes), and the UK recently relaxed its rules for some analytics. Do not assume an exemption applies to you. - GDPR (and the UK's version). Event data tied to an id is personal data. You need a lawful basis, must collect and keep only what you need, and must be able to delete a person's data on request.
- US state laws such as California's CCPA and CPRA work mostly as opt-out: users can refuse the sale or sharing of their data, and businesses are expected to honour the Global Privacy Control signal (
Sec-GPC: 1header,navigator.globalPrivacyControlin script).
The usual consent-first policy for an EU audience: before the user answers the banner, no analytics cookie, no stored anonymous id, and no non-essential event leaves the browser. After a "yes" to analytics, track normally. After a "no", track nothing that needs consent.
Consent as a state in your app
A consent management platform (CMP) is the banner plus the record of what each user chose, per purpose: analytics, advertising, personalisation. Your SDK must read that choice and react when it changes, so model it as a small state machine, not a boolean.
- Unknown. The user has not answered. Do not load ad tags or write the anonymous id. Some teams hold events in memory and send them only after a yes; whether even that is allowed is for your privacy team, so make it a setting.
- Granted. Create the anonymous id, send held and new events, and include the consent state in
contextso processing can check it. - Denied. Drop held events, store nothing except the choice itself (which is strictly necessary), and keep the SDK idle.
- Withdrawn. The user said yes, then no. Stop sending, delete the stored queue and the anonymous id, and tell the backend so it stops linking new data.
type Consent = "unknown" | "granted" | "denied";
let analyticsConsent: Consent = cmp.get("analytics") ?? "unknown";
const held: AnalyticsEvent[] = [];
cmp.onChange("analytics", (next: Consent) => {
if (next === "granted") {
anonymousId = storage.getOrCreateId();
held.splice(0).forEach(enqueue); // send what waited
} else if (next === "denied") {
held.length = 0;
storage.clearQueueAndId(); // withdrawn: forget everything
}
analyticsConsent = next;
});
function track(e: AnalyticsEvent) {
if (analyticsConsent === "granted") enqueue(e);
else if (analyticsConsent === "unknown" && settings.holdBeforeConsent) held.push(e);
}
Google Consent Mode
If you use Google Analytics or Google Ads tags, Consent Mode is Google's API for passing the user's choice to those tags. You set defaults before the tags load, typically "denied" for analytics_storage, ad_storage, ad_user_data, and ad_personalization for EEA visitors, and call an update when the user answers. Since March 2024, Google has required the last two signals (Consent Mode version 2, or an equivalent CMP framework) for EEA traffic to keep ad personalization and remarketing.
In the "advanced" setup, denied tags still send cookieless pings that Google uses to model the missing conversions; the "basic" setup loads no tags until the user agrees. Some privacy experts argue the pings themselves need consent, so pick with your privacy team, and remember that modelled numbers are estimates, not counts.
Privacy by design in the pipeline
- Minimise. Send what a question needs.
query_lengthinstead of the query;/orders/:idinstead of/orders/8812?email=.... - Scrub at the edge. Strip emails, phone numbers, and tokens from URLs and properties in the SDK and again in the collector, as the logging lesson describes.
- Truncate IPs. Use the IP address for a country lookup, then drop it or zero its last part before storage.
- Retain on purpose. Raw events for, say, 13 to 25 months, aggregates longer. Write the period down and automate deletion.
- Support deletion. A "delete my data" request must reach every store of the user's events, keyed by user id and every linked anonymous id.
Check your understanding · Exercise 4
An EU visitor lands on the shop and has not answered the banner yet. With a typical consent-first policy, which actions are fine? Select all that apply.
Ad blockers, first-party proxies, and server-side tracking
Even with consent, a share of events never arrives. Extensions such as uBlock Origin and AdGuard, and browsers with built-in protection such as Brave, block requests that match public filter lists, which name the domains and paths of well-known analytics vendors. Firefox and Safari also limit known trackers and third-party cookies by default.
How many users block depends heavily on the audience. Measurements published by the analytics vendor Plausible found under 10% of visitors blocking Google Analytics on food and lifestyle sites, over 25% on tech sites, and close to 60% for one post read mostly by developers. Measure your own number: compare server-side order counts with client-side Order Completed counts for the same day.
First-party proxies
A first-party proxy sends events to your own domain, for example https://shop.example/a/collect, and your server or CDN forwards them to the vendor. Filter lists that match vendor domains no longer match, and most vendors document this setup.
There is an ethical line here. A proxy that makes your own first-party measurement reliable, after consent, with minimal data, is widely seen as reasonable. A proxy whose purpose is to sneak advertising trackers past a user who installed a blocker to avoid exactly that is evasion, and it may break consent rules as well.
Filter-list maintainers also add popular proxy paths over time, so evasion is a race you keep running. And proxying never replaces consent: a proxied event needs the same permission as a direct one.
Server-side tracking
Server-side tracking sends events from your backend, not the browser. It comes in two flavours: business events sent where they happen (the payment webhook emits Order Completed), and a server-side tag container (such as Google Tag Manager server-side) that receives one stream from the browser and fans it out to several vendors, so the page loads one small script instead of five.
Decision Where should events be sent from?
What we pick: Use a hybrid. Send behaviour from the client, through a first-party endpoint if you control the pipeline, and every money or account event from the server. Join them with the anonymous id and user id, and compare the two counts every day.
To join a server event to the browser journey, the server needs the browser's ids. Send the anonymous id with the checkout request, store it on the order, and include it when the webhook emits Order Completed; otherwise the funnel's last step cannot be linked to its first steps.
Check your understanding · Exercise 5
Client-side Order Completed shows 820 orders yesterday; the orders table shows 1,000. What is a sound response? Select all that apply.
Data quality: duplicates, bots, clocks, and drift
Missing events make numbers too small. The other half of data quality is events that should not be there, or are there with the wrong time or shape. Each problem below has a cause in the frontend, a fix in processing, and a check that tells you when it comes back.
Duplicates
Duplicates come from three places: retries after a lost response (the at-least-once queue from chapter 5), code that fires twice (a remount, a double click), and users who reload a confirmation page.
- Retry copies carry the same
message_id. Processing keeps the first event per id within a window such as 24 hours; most vendors dedupe on a message or insert id, but check yours. - Code that fires twice creates two different ids, so id dedupe cannot help. Fire events from the action that caused them (the click handler, the successful response), not from a render or a mount effect.
- Reloads of a success page are fixed by moving the event to the server, or by deduping on a business key: one
Order Completedperorder_id.
Bots
Crawlers, uptime checks, scrapers, and your own end-to-end tests run JavaScript and send events. Left in, they inflate page views and wreck conversion rates. Filter in layers: known bot user agents and data-centre IP ranges at the collector, impossible behaviour in processing (hundreds of page views a minute), and an is_test flag that test runs set in context.
Clock skew
The event timestamp comes from the device clock, and device clocks are wrong: minutes off, or years off on a phone with a dead battery. A funnel that requires steps in order then drops users. The standard fix keeps three times: timestamp (device clock, when it happened), sent_at (device clock, when the batch left), and received_at (server clock, when it arrived).
Since the device's error is the same in both device times, it cancels out: the corrected time is received_at - (sent_at - timestamp). Example: the phone says the event happened at 10:00:00 and the batch left at 10:00:30, and the server received it at 10:07:31.
The event was 30 seconds old when sent, so it really happened at about 10:07:01. The phone's clock was about 7 minutes slow.
received_at - (sent_at - timestamp).Schema drift
Schema drift is the slow change of what events contain without the plan changing: a property starts arriving empty, a new enum value appears, a number becomes a string after a refactor. Validation catches type errors, but not a valid property that is suddenly null in 40% of events, so watch the shape too.
Monitoring the data like a service
| Check | How | What it catches |
|---|---|---|
| Volume per event | Daily count compared with the same weekday last week; alert on a 30% change | Renamed events, broken releases, a new blocker rule |
| Validation failures | Share of events in quarantine, per event and app version | Wrong types, missing required properties |
| Null and enum share | Share of null per property; new enum values | Silent drift that passes validation |
| Client against server | Client Order Completed count divided by orders in the database | Loss from blockers, consent, closed tabs |
| Duplicate rate | Events dropped by message_id dedupe as a share of all events | Retry storms, flaky collector |
| Clock correction | Share of events with a correction over 1 hour | Broken device clocks, replay bugs |
Check your understanding · Exercise 6
An event arrives with timestamp 09:00:00, sent_at 09:00:10, and the server's received_at is 08:55:12. Which statements are true? Select all that apply.
Measuring what matters: funnels and activation
A perfect pipeline that counts the wrong things is still useless. The common failure is tracking everything (every click, every hover) and answering nothing. Start from the questions, then choose the few events that answer them. A good product often needs 20 to 50 well-designed events, not 500.
Funnels
A funnel is an ordered list of steps and the share of people who reach each one. For a shop: Product Viewed, Cart Item Added, Checkout Started, Payment Info Entered, Order Completed. A funnel tool counts the people who did step 1, then how many of them did step 2 within a window such as 7 days, and so on.
- Count people, not events. A user who views 40 products is one person at step 1. This is why identity matters: if stitching fails at login, one buyer looks like two people, one who dropped off and one who appeared from nowhere at payment.
- Order and window. Steps must happen in order within the window. Clock skew can reorder steps; corrected timestamps fix it.
- Same rules for every step. If step 4 fires on click and step 5 on server success, the drop between them includes payment failures. Decide that on purpose and write it in the plan.
- Holes bias the funnel. If the last step loses more than the others, for example
Order Completedfired on a thank-you page that some shoppers never reach, the last step looks worse than reality and the team chases a payment problem that does not exist.
Activation and the few metrics that matter
Activation is the moment a new user first gets real value: for a project tool, creating a project and inviting a teammate. Teams find it by comparing the early actions of users who stayed with those who left, then define one event or a combination as the activation event. Onboarding work is judged by the share of new users who activate within, say, 7 days.
Around activation sit a few other metrics: retention (the share of a sign-up week still active in week 4 or week 12), feature adoption, and one company-level metric. Each needs only a handful of reliable events. The A/B testing lesson reuses exactly these events as success metrics, another reason to get them right once.
Lab: instrument a checkout funnel
Now design the checkout events yourself: a naming style, what goes into Order Completed, where it is sent from, and how to handle consent and delivery. The lab follows 10,000 shoppers through five steps and shows the funnel you would see next to the true one, with the data quality issues your choices raise.
- Mobile web sends
payment_info_entered, everyone elsePayment Info Entered: step 4 undercounts by about 35%, and the funnel shows a fake cliff. - Events and the anonymous id leave the browser before consent: likely non-compliant for EU visitors. Ask your privacy team before shipping this.
Order Completedfires on the thank-you page: blockers, declined consent, and tabs closed during the redirect hide real orders.- Without
order_id, reloads of the thank-you page count about 5% of orders twice and nothing can tell the copies apart. - Fire and forget: about 4% of events vanish on flaky networks and closed tabs.
Think first
In the lab, set everything to the clean choices, then switch only Order Completed between the thank-you page and the payment webhook. Why does the server version make the order count exact but the conversion rate worse?
With every step on the client, blockers and consent remove the same share from every step (85% unblocked times 70% consenting leaves about 59.5%), so the losses cancel in the rate. You see about 9.4% against a true 10%, even though every count is far too low; the small gap is the shoppers lost during the thank-you redirect.
Moving only the last step to the server makes the order count exact (1,000), but the first step still counts only unblocked, consenting shoppers (5,950). Dividing a full count by a partial one gives 1,000 / 5,950, about 16.8%, far above the truth. Compare like with like: read client funnels as a consistent sample, and take money totals from the server.
Check your understanding · Exercise 7
A funnel shows 40% of users drop between Checkout Started and Payment Info Entered after a release. Before blaming the payment form, what should you check? Select all that apply.
Product analytics in a system design interview
Analytics comes up in two ways. Sometimes it is the question: "design the analytics SDK for our web app". More often it is a requirement inside a bigger design, such as "product wants to measure the new onboarding", and the interviewer wants to see measurement treated as part of the system.
What to draw
- The client: generated typed
trackfunctions, a consent gate, a persistent queue, and flushes on size, timer, hide, and reconnect. - A first-party collector that answers fast and writes to a durable log.
- Processing: validation with a quarantine, dedupe by
message_id, bot filtering, clock correction, identity stitching. - Server-side events for money and account state, joined by the anonymous id and user id.
- The warehouse and tools on top, plus data-quality monitors with alerts.
message_id made when the event was tracked lets the pipeline drop the copy, and the money event comes from the backend.Then walk one event through the drawing, including the failure case: the answer is lost, the browser resends, and the pipeline drops the copy by message_id.
How deep to go by level
| Level | What a strong answer covers |
|---|---|
| Junior | Clear object-action names in the past tense; properties vs context; batching instead of one request per event; not tracking before consent. |
| Mid | A tracking plan with typed functions; flush on visibilitychange with sendBeacon or keepalive; anonymous id, identify, and reset; dedupe by event id. |
| Senior | Persistent at-least-once queue with storage caps and backoff; server-side money events; ad blockers and first-party proxies with the ethics; clock correction; CI checks for breaking changes; consent as a state machine. |
| Staff | Data quality as an owned service with monitors and on-call; consent bias and blocked share in how results are read; deletion requests across systems; vendor choice and cost; how experiments, feature flags, and analytics share one event model. |
Sentences that show experience
- "I'd track where the fact becomes true: clicks in the browser, payments from the webhook."
- "Events are at-least-once with a client-made
message_id, so the pipeline dedupes." - "The plan is code: schemas in the repo, generated types, and a CI check that blocks renames without a migration."
- "We gate on consent per purpose and treat a withdrawal as delete-and-stop, not just stop."
- "When a metric drops, I check instrumentation volume before the product."
Summary
- Name events as object-action in the past tense, with one casing style; put values in properties, not names, and let the SDK add shared context.
- Keep the tracking plan as code: schemas in the repo, generated typed functions, a CI check for breaking changes, and validation with a quarantine at ingestion.
- Give every browser an anonymous id, call
identifyon every login andreseton logout, and expect stitching across devices to be partial. - Deliver at least once: store the queue, flush on size, timer, and
visibilitychangewithsendBeaconorkeepalive, delete after a2xx, and dedupe bymessage_id. - Treat consent as a per-purpose state machine; no stored id or non-essential event before consent, and withdrawal means delete and stop. Get legal answers from your privacy team.
- Track where the fact becomes true: behaviour from the client, money and account events from the server, joined by ids.
- Monitor the data like a service: volume, validation failures, nulls, duplicates, clock corrections, and the client-to-server gap.
- Start from the questions: a few reliable events for funnels, activation, and retention beat hundreds of clicks nobody reads.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Design the event tracking for an e-commerce checkout. What events, and where are they sent from?
I'd start from the question: what share of shoppers who view a product end up paying, and where do they drop off. That gives five object-action events in the past tense: Product Viewed, Cart Item Added, Checkout Started, Payment Info Entered, Order Completed, with ids, money as integer cents plus currency, and fixed enums for things like source.
The first four are client-side because only the browser sees them. Order Completed comes from the server, from the payment confirmation webhook, keyed by order_id, because blockers, declined consent, and closed tabs would hide a share of it on the client and reloads would double it. The checkout request carries the anonymous id so the server event joins the browser journey.
All events are defined in a tracking plan with generated types and checked in CI and at ingestion.
Question 2
How do you make sure analytics events are not lost when the user closes the tab or goes offline?
Queue events and mirror the queue to localStorage or IndexedDB when they are tracked. Flush on size, on a timer, and on visibilitychange to hidden with sendBeacon or fetch with keepalive, keeping that batch under the shared 64 KiB budget. Delete events from storage only after a 2xx, and resend the rest on the next load or on online.
That is at-least-once delivery, so every event carries a message_id created when it was tracked, and processing dedupes on it. I'd cap the stored queue, retry with backoff and jitter, and record both timestamp and sent_at so replayed events land on the right day.
Question 3
How does consent change your analytics design?
Consent becomes a state in the SDK, per purpose: unknown, granted, denied, and withdrawn. Until it is granted for analytics, I store no anonymous id and send no non-essential events; whether to hold events in memory meanwhile is a policy setting for the privacy team. Granted creates the id and flushes; denied drops; withdrawn stops, deletes the stored queue and id, and tells the backend.
If Google tags are present I'd wire the CMP to Consent Mode with denied defaults for EEA visitors. I'd also honour Global Privacy Control, minimise properties, scrub PII, truncate IPs, set retention, and make deletion by user id work. And I'd remind the team that consenting users are a biased sample.
Question 4
The conversion rate dropped 25% overnight after a release. Walk me through it.
Check the instrumentation before the product. Compare per-event volume with last week: did an event in the funnel get renamed, stop firing, or start failing validation? Did identify break so people split in two? Did a consent banner or proxy change ship?
Then compare client counts with the source of truth, such as orders in the database. If orders are flat, it is a measurement bug: fix it, annotate the dashboard, and add a volume alert for that event. If orders really dropped, it is a product bug, and the funnel step with the change tells you where to look.
Question 5
Should we proxy analytics through our own domain to get around ad blockers?
For first-party measurement that the user has consented to, a proxy is reasonable: it improves completeness and gives one place to scrub data and truncate IPs. It does not replace consent; a proxied event needs the same permission as a direct one.
Using a proxy to sneak advertising trackers past users who chose to block them is evasion, risks consent violations, and is a race against filter lists. For numbers that must be complete, such as revenue, the better answer is server-side events, not a cleverer proxy.
Practice it
These design problems lean on product analytics.
- Design a cookie consent managerEasy
- Design a command paletteMedium
- Design a multi-step form wizardMedium
- Design a podcast audio playerMedium
- Design an Instagram-style stories viewerMedium
- Design a search results pageMedium
- Design a product tourMedium
- Design a client-side analytics SDKMedium
- Design a feature flag SDKMedium
- Design a frontend logging and error SDKMedium
- Design a video playerHard
- Design a checkout flowHard
- Design an A/B testing system for the frontendHard
- Design a TikTok-style vertical video feedHard
- Design YouTubeHard
References
- Segment: Naming conventions for clean data The object-action convention and why consistency matters.
- Segment Spec: the track call Events, properties, context, and timestamps in a widely copied format.
- MDN: Navigator.sendBeacon() Sending data that outlives the page.
- MDN: Document visibilitychange event The last reliable moment to flush the queue.
- Google: Consent mode overview Consent signals, basic and advanced setups.
- Google Ads Help: EU user consent policy and consent mode Why
ad_user_dataandad_personalizationare needed for EEA traffic. - CNIL: audience measurement tools exempt from consent (French) The conditions for measuring audience without consent in France.
- Plausible: how many visitors block Google Analytics A vendor's measurement of blocked analytics, by audience and device.
- Global Privacy Control The browser signal for opting out of sale and sharing.
- JSON Schema The schema format used for tracking plans in this lesson.
Read next
- A/B testingShowing different versions of a page to randomly split groups of users and measuring which works better: stable bucketing, no flicker, honest statistics, and guardrails that catch harm.
- Feature flagsSwitches in your code that turn features on or off for chosen users without a deploy: for gradual releases, experiments, emergency kill switches, and plan-based access, if you avoid flicker, leaked code, and a pile of dead flags.
- Logging in the browserSending small, structured records of what happened in each user's browser to your own servers, in batches that survive the tab closing, sampled and scrubbed so they stay cheap and safe: the only way to see bugs that never happen on your machine.
- Real-user monitoring (RUM)Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.
- Background sync and queued writesSaving the user's writes in a durable queue when the network is gone, then sending each one exactly once when it comes back, even if the tab was closed: the write half of every offline-capable app.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.