cf.completefrontend
My progress
FUNDAMENTALS · MODULE 15

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.

FailureWhat it looks likeChapter that fixes it
Bad names and shapessignup, sign_up, and SignUp all exist; price is a string in one place and cents in anotherEvent design, tracking plans
Lost eventsEvents fired as the tab closes never arrive; offline users vanishDelivery
Missing peopleAd blockers and declined consent hide a share of users, not at randomConsent, ad blockers
Extra eventsRetries and double renders count one order twice; bots inflate page viewsData quality
Wrong questionsA chart of clicks that nobody acts onMeasuring what matters
A dashboard of checkout conversion for seven days: Tuesday 3.1%, Wednesday 3.0%, Thursday 3.2%, Friday 3.1%, then after a Friday release Saturday 2.2%, Sunday 2.1%, Monday 2.2%. A note says the release renamed the event on mobile web and the orders table stayed flat.Checkout conversion3.1%Tue3%Wed3.2%Thu3.1%Fri2.2%Sat2.1%Sun2.2%MonreleaseFri release:renamed onmobile weborders: flat
The chart dropped by about 30% overnight, but the number of orders did not. The release renamed 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.

  1. Instrumentation. Your code calls track("Order Completed", { orderId, revenue }) when something happens. Only this layer knows what the user did.
  2. The client SDK. A small library that adds context (page, device, ids, time), checks consent, queues the event, and sends batches.
  3. The collector. An HTTP endpoint that receives batches, answers fast (usually 200 or 204 with an empty body), and writes the raw events to a durable log or queue.
  4. 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.
  5. Storage. A warehouse (BigQuery, Snowflake, ClickHouse) or the vendor's own event store.
  6. Analysis. Funnel, retention, and experiment tools, plus SQL and dashboards.
A stack of six layers. In the browser: instrumentation, where the names come from the tracking plan, and the client SDK, which handles consent, the queue, and batching. On the servers: the collector, which answers 204 fast and writes a durable log; processing, which validates, dedupes, and stitches identity; storage in warehouse tables; and analysis, with funnels and retention.BrowserServersInstrumentationnames, from the planClient SDKconsent, queue, batchCollectorfast 204, durable logProcessingvalidate, dedupe, stitchStoragewarehouse tablesAnalysisfunnels, retention
Events flow down. A mistake made at the top is copied into every layer below it, so the cheapest fixes are in the two browser layers.

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.

QuestionClient-side eventServer-side event
SeesClicks, views, scrolls, time on page, UI statePayments, sign-ups, renewals, anything stored in your database
Lost toAd blockers, declined consent, closed tabs, flaky networksAlmost nothing, if sent from a reliable job with retries
KnowsPage URL, referrer, screen size, the anonymous idThe user id, the true amount, the order state
Best forBehaviour before the important momentThe 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?

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, not Complete Order.
  • Object first, so a sorted list groups by object: every Cart ... event sits together, and a missing Cart Item Removed stands out.
  • Business words, not UI words. Cart Item Added, not Blue 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.

json
{
  "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"
}
One event as it leaves the browser. The SDK fills everything except 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.

A graph with one user node, user 8812, in the middle. Three anonymous id nodes connect to it: the laptop id joined at sign-up, the phone id joined at a later login, and an id after cleared cookies joined at another login. A fourth anonymous id, a tablet that never logged in, has no link and stays a separate stranger.user 8812anon Aanon Banon Canon Dsign-uploginloginlaptopphonecleared cookiestablet, never logged in
Every 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 identify on 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.

json
{
  "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"] }
    }
  }
}
One entry of the tracking plan. 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

  1. At compile time. Generate a typed function per event from the schema, such as trackCartItemAdded(props). A wrong property name or type fails tsc before the code is committed.
  2. 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.
  3. 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.
A flowchart. A track call goes to a TypeScript check, where a type error fails the build, then to a CI check that diffs the tracking plan. If the change is breaking, the data team must approve before release; if not, it goes straight to release. After release, every event is checked again at ingestion: valid events go to the warehouse, invalid ones to a quarantine table with an alert.track calltsc typesCI plan difftype error: build failsbreaking?data team OKyesreleasenoapprovedingest checkquarantineinvalidvalid: to warehouse
Three checks, three moments: the compiler catches typos while you type, CI catches breaking changes in review, and ingestion catches what old app versions and other platforms still send.
ts
// 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",
});
Generated from the plan: the compiler now knows every event and property.

Decision Where should the tracking plan live?

A shared spreadsheet

One row per event and property, edited by product managers and analysts.

Pros

  • Zero setup; anyone can edit it.
  • Fine for a first pass with five to ten events.

Cons

  • Nothing checks the code against it, so it drifts within months.

Schemas in the repository

JSON Schema files next to the code, with a codegen script and a CI check; the collector loads the same files.

Pros

  • Reviewed and versioned like code; breaking changes show in pull requests.
  • Type errors catch mistakes before release.

Cons

  • You build and maintain the codegen, the check, and the validation.
  • Harder for non-engineers to propose changes.

A tracking-plan product

Tools such as Avo, Segment Protocols, Amplitude Data, or Mixpanel Lexicon hold the plan in a web app; typed code and blocking of bad events vary by vendor and tier.

Pros

  • Friendly for product managers, with review flows and validation dashboards built in.

Cons

  • Costs money and ties the plan to a vendor.
  • Only checks what is in it.

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.visibilityState becomes hidden (the user switched tabs, minimised, or is closing the page), send what is left with navigator.sendBeacon or fetch with keepalive: true. Both let the request outlive the page.
  • On reconnect: when the online event 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.

Six queued events, view, view, add, view, start, pay, held in memory and mirrored in localStorage. The first two are in flight to the collector as a batch. A note lists the flush triggers: 20 events or 16 KB, every 5 seconds, tab hidden, and back online. Events are deleted from storage only after a 2xx answer.queue in memoryviewviewaddviewstartpayCollectorbatchmirror in localStorageviewviewaddviewstartpayin flightflush when:20 events or 16 KBevery 5 stab hidden, onlinedelete from storage only after a 2xx; resend the rest on the next load
Every event is written to storage when it is tracked. A batch leaves from the front of the queue, and its events leave storage only when the collector answers 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.

ts
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
  }
}
The core of a persistent analytics queue: store first, delete only after success.
  • 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 5xx or 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. On 4xx other than 429, 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 localStorage queue. 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.
30 events over 60 seconds with "Batch in memory" and "Offline from 45 s to close". 20 delivered once, 0 counted twice, 0 delivered on the next visit, 10 lost. 5 requests.0 s30 s60 s: tab closesmeasured 20 of 30 true events, 5 requests
Each cell is one event, in time order; the tab closes at 60 s. Green arrived once, hatched was counted twice, warm arrives on the next visit, red never arrives. The hatched band marks the time offline.

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.

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.

Two panels. Before: the browser sends events straight to the analytics vendor's domain, and a filter list blocks the request, so a share of events is lost. After: the browser sends to a path on the shop's own domain, which forwards to the vendor; the same consent rules still apply.before: straight to vendorBrowserblockedvendor domaina share of events lostafter: first-party pathBrowsershop.example/avendorsame consent rules apply
Filter lists match known vendor hosts and paths. A first-party path makes honest measurement more complete, but it never replaces consent.

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?

Client-side, straight to the vendor

Load the vendor's SDK; it sends to the vendor's domain.

Pros

  • Fastest to set up, with full browser context and autocapture.

Cons

  • Lost to ad blockers and closed tabs.
  • Every vendor adds script weight and main-thread work.

Client-side through a first-party proxy

Same SDK, pointed at a path on your domain that forwards to the vendor.

Pros

  • Fewer blocked events for honest first-party measurement.
  • One place to scrub data and truncate IPs.

Cons

  • You run and pay for the forwarding.
  • Easy to misuse for evasion; still needs consent.

Server-side for business events

Backend emits money and account events from the code that changes the database.

Pros

  • Complete and accurate: no blockers, no closed tabs, trusted amounts.

Cons

  • Blind to UI behaviour.
  • Needs the browser's anonymous id to join with client events.

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 Completed per order_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.

Two time axes. On the device clock, the event happens at 10:00:00 and the batch is sent at 10:00:30, 30 seconds later. On the server clock, the batch is received at 10:07:31. Copying the same 30 second gap back from the received time gives a corrected event time of 10:07:01. The device clock was 7 minutes slow.device10:00:0010:00:30timestampsent_at30 sdevice clock7 min slowbatch travelsserver30 scorrectedreceived_at10:07:0110:07:3110:07:31 - (10:00:30 - 10:00:00) = 10:07:01
Both device times share the same error, so their difference is true. Corrected time is 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

CheckHowWhat it catches
Volume per eventDaily count compared with the same weekday last week; alert on a 30% changeRenamed events, broken releases, a new blocker rule
Validation failuresShare of events in quarantine, per event and app versionWrong types, missing required properties
Null and enum shareShare of null per property; new enum valuesSilent drift that passes validation
Client against serverClient Order Completed count divided by orders in the databaseLoss from blockers, consent, closed tabs
Duplicate rateEvents dropped by message_id dedupe as a share of all eventsRetry storms, flaky collector
Clock correctionShare of events with a correction over 1 hourBroken device clocks, replay bugs
A bar chart of one illustrative day, to scale: 1 million raw events received; 21 thousand duplicates removed by message id; 60 thousand from bots and test runs; 8 thousand failed validation and went to quarantine; 911 thousand clean events remain.raw events received1000kduplicates by message_id21kbots and test runs60kfailed validation8kclean events911k
Illustrative numbers. Each removed slice is small on its own, but together they are about 9% of the raw stream, and bots alone can swing a conversion rate.

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 Completed fired 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.

Naming:
Order properties:
Order Completed from:
Consent:
Delivery:
Funnel for 10,000 shoppers. Product Viewed: true 10000, measured 8160; Cart Item Added: true 3200, measured 2611; Checkout Started: true 1800, measured 1469; Payment Info Entered: true 1300, measured 690; Order Completed: true 1000, measured 805. Measured conversion 9.9% against a true 10.0%.Product Viewed8,160 / 10,000Cart Item Added2,611 / 3,200Checkout Started1,469 / 1,800Payment Info Entered690 / 1,300Order Completed805 / 1,000conversion 9.9% vs 10.0% true; revenue 76,475 of 95,000 euros
For each step, the upper bar is the true number of shoppers and the lower bar is what your instrumentation would report, to scale. A red lower bar is off by more than 10%.
  • Mobile web sends payment_info_entered, everyone else Payment 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 Completed fires 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?

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

  1. The client: generated typed track functions, a consent gate, a persistent queue, and flushes on size, timer, hide, and reconnect.
  2. A first-party collector that answers fast and writes to a durable log.
  3. Processing: validation with a quarantine, dedupe by message_id, bot filtering, clock correction, identity stitching.
  4. Server-side events for money and account state, joined by the anonymous id and user id.
  5. The warehouse and tools on top, plus data-quality monitors with alerts.
A sequence diagram with four lifelines: browser, collector, pipeline, backend. The browser sends a batch with message ids to the collector, which replies 204, but the tab crashes before the reply arrives. The collector writes raw events to the pipeline. The backend sends Order Completed from the payment webhook to the pipeline. On the next visit, the browser resends the same batch, the collector forwards it, and the pipeline drops the copy.BrowserCollectorPipelineBackendbatch, message_id204 (lost)raw eventsOrder Completednext visit: resendsame message_idcopy dropped
The browser never saw the 204, so it resends: delivery is at least once. The 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

LevelWhat a strong answer covers
JuniorClear object-action names in the past tense; properties vs context; batching instead of one request per event; not tracking before consent.
MidA tracking plan with typed functions; flush on visibilitychange with sendBeacon or keepalive; anonymous id, identify, and reset; dedupe by event id.
SeniorPersistent 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.
StaffData 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 identify on every login and reset on logout, and expect stitching across devices to be partial.
  • Deliver at least once: store the queue, flush on size, timer, and visibilitychange with sendBeacon or keepalive, delete after a 2xx, and dedupe by message_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?

Question 2

How do you make sure analytics events are not lost when the user closes the tab or goes offline?

Question 3

How does consent change your analytics design?

Question 4

The conversion rate dropped 25% overnight after a release. Walk me through it.

Question 5

Should we proxy analytics through our own domain to get around ad blockers?

Practice it

These design problems lean on product analytics.

References

Read next