cf.completefrontend
My progress
FUNDAMENTALS · MODULE 15

Logging in the browser

Sending 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.

Why the browser needs its own logs

Picture a food delivery app on a Friday evening. Support gets 40 messages in an hour: "I pressed Pay and nothing happened." The backend dashboards are green, and the payments service shows no errors or slow requests. The on-call engineer tries the checkout on a laptop and it works every time.

The bug is real, but it lives in the browser. A new version of the checkout script throws an error on one older Safari version when a saved card has no expiry date. The click handler dies before any request is sent. The server never hears about it, so the server logs can't show it.

Frontend logging is how you see this. The page records small facts about what happened ("checkout opened", "pay clicked", "card form error") and sends them to a server you control. With those records the engineer filters by event = pay_clicked without a following payment_requested, groups by browser, and finds the Safari pattern in minutes instead of days.

Why it is harder than server logging

On a server, logging is almost free. You write a line to standard output, an agent ships it, and you trust every line because your own code on your own machine wrote it. In the browser, every one of those assumptions breaks.

  • Millions of machines you don't own. Your code runs on phones, old laptops, smart TVs, and in-app browsers, each with its own browser version, extensions, memory, and network. A bug can live on 0.3% of devices and still hit thousands of people a day.
  • Every log costs the user. Each record costs bytes on someone's data plan and work on their CPU. One request per event drains battery and competes with the requests the user actually wants.
  • The network is unreliable. The user goes into a tunnel, the request fails, and the log is gone unless you kept it. You can't retry forever either, because the page might close first.
  • Pages die without warning. The user closes the tab, swipes the app away, or the phone kills a background tab to save memory. Anything still in memory is lost, and the most interesting logs (right before someone gives up) are the most likely to go.
  • Ad blockers and privacy tools. Many blockers drop requests to known analytics and logging domains, and some drop any URL with words like log, track, or analytics in it. A share of your users will send you nothing at all.
  • The data is untrusted. Anyone can send fake logs to your endpoint, or millions of them: it is a public API.
  • Privacy law applies. The browser is where personal data lives: names in forms, emails in URLs, tokens in storage. A careless log line copies it into a system that was never meant to hold it.
A comparison of server logs and browser logs on six questions. Who runs the code: your machines, versus millions of devices. The network to the log store: fast and private, versus flaky and often blocked. When the process stops: rarely and cleanly, versus any time with no warning. Trust: fully trusted, versus anyone can fake it. Cost per line: microseconds, versus the user's data and battery. Personal data: rarely nearby, versus in forms and URLs.server logsbrowser logswho runs ityour machinesmillions of devicesthe networkfast, privateflaky, often blockedwhen it stopsrarely, cleanlyany time, no warningtrustfully trustedanyone can fake itcost per linemicrosecondsuser's data, batterypersonal datararely nearbyin forms and URLs
Every comfortable assumption from server logging flips in the browser. Each row on the right becomes a design rule later in the lesson: batch, flush on hide, sample, validate, and scrub.

What browser logs are for

Frontend logs answer questions the server can't: did the click reach a handler, at which step did the user stop, what was the app state before the error, which feature flag variant were they in, and how long did the client-side work take?

Logging is one of several related tools. Error tracking captures crashes with stack traces and groups them (the next lesson). Real-user monitoring, or RUM, measures speed in real browsers, such as the Core Web Vitals. Analytics counts what users do, for product decisions.

Logging is the general pipe underneath: small structured events with context, sent home reliably. Many teams use one pipeline for all four, with a field that says what kind of event each record is.

Four tools that often share one pipeline but answer different questions.
ToolMain questionTypical volumeWho reads it
LoggingWhat happened in this session, step by step?Medium, sampledEngineers debugging
Error trackingWhat crashed, where, for how many users?Low, every errorEngineers on call
Real-user monitoringHow fast is the app for real users?Medium, sampledPerformance team
AnalyticsWhat do users do, and does a change help?High, often every eventProduct team

Check your understanding · Exercise 1

A team logs every click with one fetch per event and finds their logs show far fewer checkout abandons than their analytics tool. Which causes are likely? Select all that apply.

The mental model: a pipeline with a small suitcase

Think of each browser as a traveller with a small suitcase. Through the day the traveller drops notes into the suitcase. Every so often, and always before leaving a hotel, the traveller posts the whole suitcase home in one parcel. The parcel has a weight limit, and some notes are thrown away before packing because the post is expensive and nobody needs every note.

That is the whole design. A log call doesn't send anything; it adds a record to a small in-memory buffer. A separate flusher empties the buffer in one request when the buffer is full, when a timer fires, or when the page is about to go away. Everything else is a filter on the way in.

The stages, in order

  1. Emit. App code calls something like log.info("checkout_opened", { items: 3 }). It must be cheap and never throw, so logging can't break the feature it watches.
  2. Enrich. The logger adds the context every record needs: timestamp, session ID, page, release version, browser, and the current trace ID if a request is in flight.
  3. Scrub. Remove or mask anything personal: emails, tokens, card numbers, query strings. It happens in the browser, before the data leaves the device.
  4. Sample. Decide whether to keep the record. Errors are usually kept; debug chatter is mostly dropped. The decision is often made once per session.
  5. Buffer. Push the record into an array in memory, with a cap on how many records and bytes it can hold.
  6. Flush. Send the buffer as one request: on a size threshold, on a timer, and when the page becomes hidden.
  7. Ingest. A small public endpoint checks, rate-limits, and stores the batch, then hands it to the log store where engineers search it.
The logging pipeline. Inside the browser, in order: emit, where app code calls log.info; enrich, which adds session and release; scrub, which drops personal data; sample, which decides whether to keep it; buffer, which holds up to 30 records; and flush, which sends on size, a timer, or the page becoming hidden. One POST with the batch goes to the server side: an ingest endpoint that validates and rate-limits, a queue that absorbs spikes, and a log store kept for 30 days for searching.In the browserEmitlog.info(...)Enrichsession, releaseScrubdrop personal dataSamplekeep this one?Bufferup to 30 recordsFlushsize, timer, hiddenYour serversone POST, one batchIngestvalidate, limitQueueabsorb spikesLog store30 days
A log call never sends anything by itself. It drops a record into a buffer, and the flusher posts whole batches. Scrub and sample come before the buffer, so dropped or personal data never sits in memory waiting to be sent.

The order matters. Enriching before scrubbing means the scrubber also checks the URL and other context fields, not just what the app passed in. Scrubbing and sampling before the buffer mean personal data and dropped records never sit in memory waiting to be sent.

Two rules that shape every choice

First: logging must never hurt the user. No blocking the main thread, no unbounded memory, no extra request per click, no crash when the endpoint is down. If you must choose between losing a log and slowing the page, lose the log.

Second: logs are a sample, not a ledger. Some records will be blocked, some lost when a tab is killed, some dropped by sampling. That is fine for debugging and trends. If something must be counted exactly (orders, payments, ad billing), count it on the server, where the action actually happens.

Check your understanding · Exercise 2

Why scrub personal data in the browser instead of at the ingest endpoint? Select all that apply.

Structured logs and a schema

A log line like "User clicked pay but card was invalid" reads well once. Multiply it by 2 million sessions and it is useless: you can't count it, filter it by browser, or join it with anything. Every developer phrases it a little differently, and the message changes whenever someone edits the code.

A structured log is a record with named fields, usually JSON. The message is a stable event name, and everything that varies goes into its own field. Your log store indexes the fields, so a query like "event = card_invalid by browser in the last hour" takes a second.

json
{
  "ts": "2026-10-16T18:42:07.315Z",
  "level": "warn",
  "event": "card_form_invalid",
  "kind": "log",
  "attrs": { "reason": "missing_expiry", "savedCard": true },
  "session": "s_7f3a9c21",
  "page": "/checkout",
  "release": "web@2026.10.16-3",
  "ua": { "browser": "Safari", "version": "15.6", "mobile": true },
  "trace": "4bf92f3577b34da6a3ce929d0e0e4736",
  "sampleRate": 1,
  "seq": 41
}
One record. The event name is stable; everything that changes lives in its own field.
One structured log record, field by field. level warn: sets sampling and alerts. event card_form_invalid: a stable name from a list. attrs with reason and savedCard: typed values, no free text. session s_7f3a9c21: joins one visit in order. release: which deploy shipped it. trace 4bf92f35 and so on: joins backend logs. seq 41: gaps mean lost records.level: warnsets sampling, alertsevent: card_form_invalidstable name from a listattrs: {reason, savedCard}typed, no free textsession: s_7f3a9c21one visit, in orderrelease: web@...-3which deploy shipped ittrace: 4bf92f35...joins backend logsseq: 41gaps mean lost records
The event name is what you count and alert on; the trace is what you join on. Everything else is context you filter by.

The fields every record needs

  • ts: the time on the client, in UTC ISO format. Client clocks drift, sometimes by minutes, so the server also stamps a receivedAt. Use seq when order matters.
  • level: how serious it is (next chapter).
  • event: a stable, lowercase name such as checkout_opened, from a short list. Renaming it breaks every dashboard that uses it.
  • attrs: event-specific details, flat and small. Numbers as numbers, booleans as booleans, short strings from a known set. No free text from users.
  • session: a random ID for this visit, so you can replay one user's story in order. It is not a user ID and doesn't say who the person is.
  • release: the exact build. It is the first field you filter on after a deploy ("did this start with version -3?").
  • page: the route pattern (/orders/:id), not the full URL, which can hold IDs and personal data.
  • trace: the trace ID of the related network request, if any, to join with backend logs (chapter 8).
  • seq: a counter per session, so you can spot gaps where records were lost.

Treat the schema as a contract

Write the schema down: a TypeScript type in the app and a JSON Schema or similar check at the endpoint. Keep a registry of event names with an owner and a short meaning for each. Reject or quarantine records that don't match, instead of storing junk that costs money and breaks queries.

Keep field count and cardinality low. Cardinality is how many distinct values a field has. browser has a handful; orderId has millions. Log stores and metrics systems often charge by indexed fields and distinct values, so a high-cardinality field you index "just in case" can raise the bill sharply.

Check your understanding · Exercise 3

Which of these make good attrs on a search_submitted event? Select all that apply.

Levels, and what to log

A log level says how much a record matters. It decides three things: whether the record is created at all in production, how heavily it is sampled, and whether it can wake someone up. Most loggers use the five levels below, and console has methods for the first four.

Levels from least to most serious. Production keeps fewer of the low ones.
LevelMeansExampleIn production
debugDetail for a developer tracing a flowcart_state_recomputed with item countsOff, or on for a few opted-in sessions
infoA normal step worth knowing aboutcheckout_opened, payment_requestedSampled, for example 10% of sessions
warnSomething unexpected the app recovered fromretry_succeeded after a 503, image_fallback_usedKept at a higher rate, often 100%
errorA user-visible failurepayment_failed, a caught exceptionAlways kept; also sent to error tracking
fatalThe app can't continuea blank screen after a chunk failed to loadAlways kept; can page on-call

What is worth logging

Log decisions and boundaries, not every line. The most useful frontend records sit where the app talks to something it doesn't control: the network, storage, third-party scripts, the user, and the browser itself.

  • Steps of key flows: sign-up, checkout, upload, publish. Start, each step, success, and failure with a reason, so you can find where users get stuck.
  • Network outcomes the app handled: a retry, a timeout, a fallback to cached data, an offline queue replay. Only the client knows it gave up and showed stale data. The retries and backoff lesson covers which retries to log.
  • State that explains errors: feature flag variants, which tab or panel was open, how many items were in the list, whether the user was offline.
  • Recoveries: an error boundary that showed a fallback, a chunk that failed and was reloaded, storage that was full. These are warnings today and incidents tomorrow.
  • Slow client work: a long render or a long task over a budget, with the component name.
A decision tree for one moment in the app. Does it contain user input or secrets? If yes, scrub it or skip it. If no: did the user see a failure? If yes, log an error and keep all of them. If no: did the app recover, for example with a retry or fallback? If yes, a warning, keep all. If no: is it a step in a key flow? If yes, info, sampled. If no, debug, which is off in production.user input or secrets?yesscrub or skip itnodid the user see a failure?yeserror: keep allnodid the app recover?yeswarn: keep allnoa step in a key flow?yesinfo: samplednodebug: off in production
Ask the privacy question first, then work down from the most serious level. Most moments fall through to the bottom, which is why production logs stay small.

What not to log

  • Anything a user typed, unless it's from a fixed set (a dropdown value) or reduced to a length or a category.
  • Tokens, cookies, auth headers, full URLs with query strings, and full request or response bodies.
  • Every render, every mouse move, every scroll. Use counts and summaries ("scrolled 80% of the page") if you need them.
  • The same error 500 times in a loop. Rate-limit repeated records in the logger: after 5 identical records in a session, count the rest and send one summary.

A common mistake is shipping console.log calls and calling it logging. console output stays on the user's machine and nobody ever sees it. Worse, an object logged to console can be kept in memory while devtools are open. Use a logger that writes to console in development and to the buffer in production, and strip debug calls from the production bundle with a build flag.

Check your understanding · Exercise 4

A photo upload fails, the app retries twice, and the third try works. What should it log? Pick one.

Batching, and sending before the page goes away

Sending one request per record is the most common mistake. A busy page can produce 50 records a minute. As separate requests, that is 50 sets of headers (several hundred bytes each before compression, more with cookies), 50 chances to wake the phone's radio, and 50 requests competing with the API calls the user is waiting for. As one batch, it is one request of a few kilobytes.

Three flush triggers

  • Size: flush when the buffer holds N records (often 20 to 50) or about 16 to 32 KB. This keeps each request small and well under the browser's limits.
  • Time: flush every few seconds (often 5 to 15 s) if anything is waiting, so a quiet page doesn't hold records for minutes.
  • Page hidden: flush immediately when the page stops being visible. This is the one most people get wrong, and the one that matters most.

When does a page go away?

Older code flushed in the unload or beforeunload event, which is unreliable. On phones these events often don't fire: the user switches app, and later the system kills the tab without running more code. Chrome finished a gradual rollout in version 155 (October 2026) after which unload handlers don't run unless the page opts back in with a Permissions-Policy header. Where unload still runs, as in Firefox, a handler usually keeps the page out of the back/forward cache, so Back gets slower.

The reliable signal is visibilitychange with document.visibilityState === "hidden". It fires when the user switches tab, minimizes the window, switches app on a phone, locks the screen, or starts to close or leave the page. After it, the page may be frozen or discarded with no further events, so treat "hidden" as possibly your last moment. Also listen to pagehide as a backup for older browsers that missed visibilitychange on some closes.

A state machine of the page lifecycle. Active, the page is visible. On visibilitychange it becomes hidden, marked as the place to flush. From hidden, it can become visible again and return to active; on pagehide it can be terminated when closed; or after a while it can be frozen, where no code runs. A frozen page can be discarded when memory is low, killed with no events. A note says the last safe moment to send is the hidden state.ActivevisibleHiddenflush hereTerminatedclosedFrozenno code runsDiscardedno eventsvisibilitychangevisible againpagehideafter a whilememory lowlast safe moment:the hidden state
Only the move to hidden is reliable on every device. After it, the page may be frozen or discarded without running another line, so flush in the visibilitychange handler.

sendBeacon and fetch keepalive

A normal fetch started while the page is closing is usually cancelled with the document. Two APIs exist to hand a request to the browser so it finishes even after the page is gone.

  • navigator.sendBeacon(url, data) queues a POST and returns at once with true if the browser accepted it, false if not (for example, the body is too large or the keepalive budget is full). You can't set headers or read the response. A string body is sent as text/plain, a simple request, so a cross-origin endpoint needs no CORS preflight.
  • fetch(url, { method: "POST", body, keepalive: true }) does the same job with the full fetch API: custom headers, other methods, a promise. Custom headers on a cross-origin URL trigger a preflight, which can be lost if the page dies.

Both share one budget: the bodies of all keepalive requests in flight at the same time can total at most 64 KiB. The Fetch standard sets this limit per page and Chrome enforces it; other browsers count slightly differently. A request over the budget is refused (sendBeacon returns false; fetch rejects). So keep the batch sent on hide small, which is another reason to flush on size and time during the session.

ts
const MAX_BATCH = 30;
let buffer: LogRecord[] = [];

export function log(rec: LogRecord) {
  buffer.push(rec);
  if (buffer.length >= MAX_BATCH) flush();
}

function flush() {
  if (buffer.length === 0) return;
  const body = JSON.stringify({ sentAt: Date.now(), records: buffer });
  buffer = [];
  const queued = navigator.sendBeacon("/api/i/batch", body);
  if (!queued) saveForNextVisit(body); // over budget or refused
}

setInterval(flush, 10_000);
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") flush();
});
window.addEventListener("pagehide", flush);
A minimal flusher: size, time, and page-hidden triggers, with a fallback when the beacon is refused.

saveForNextVisit writes the refused batch to localStorage or IndexedDB, with a cap, and the next page load sends it first. That turns "lost" into "late", which is fine for logs. Keep the cap small (say 100 KB) and drop the oldest batches first; the lesson on client storage covers the quotas. Clear the stored batch only after a later send is accepted.

Try the trade-offs in the lab. Start with no batching and no hide flush on a phone, then add a batch size, a timer, and a visibilitychange flush, and watch requests, bytes, and lost events change. Then turn the timer off, pick "no limit" and a very busy session, and flush on visibilitychange to see the 64 KiB budget refuse the final beacon.

Activity:
Batch size:
Timer:
Flush on:
The user:
Sessions logged:
One 60-second visit with 68 events. 68 requests sent, 62.4 KB in total including headers, average wait before sending 0.0 seconds, 0 events lost when the visit ended.eventsrequestsleaves0 s30 s60 s68 requests, 62.4 KB, 0 lost
Top row: each tick is one event. Bottom row: each bar is one request, as tall as its batch; the last bar is the flush when the page is hidden, red if refused. The dashed line is the moment the user leaves. Assumptions: 300 bytes per record and 600 bytes of headers per request.

Nothing was left in the buffer when the visit ended. Records waited 0.0 s on average before being sent. For 1,000 visits like this with 100% of sessions logged: 1000 sessions send 68,000 requests and 61.0 MB.

Decision How should the logger send batches?

sendBeacon for every flush

Serialize the batch to a string and call navigator.sendBeacon on size, timer, and hide.

Pros

  • Simplest code
  • No preflight with a text body
  • Survives the page closing

Cons

  • No headers, so no auth header or traceparent
  • No response, so no retry or backoff signal
  • 64 KiB in-flight budget shared with other beacons

fetch while visible, beacon on hide

Use a normal fetch for size and timer flushes, read the status, and retry with backoff on 429 or 503.

Switch to sendBeacon or fetch with keepalive only in the hide handler.

Pros

  • Full control and retries for most batches
  • Respects server back-pressure
  • Only the last batch depends on the keepalive budget

Cons

  • Two code paths to test
  • A fetch still in flight when the page closes can be cancelled

Vendor SDK transport

Use the SDK's built-in queue and transport, pointed at a first-party proxy URL.

Pros

  • Handles retries, offline queues, and limits for you
  • Tested across many browsers

Cons

  • Adds tens of KB of script
  • Less control over schema and costs
  • Its default domain is often on block lists

What we pick: For most apps, use fetch while the page is visible and a beacon in the hide handler. With a vendor SDK, keep its transport but send through your own domain. Plain sendBeacon everywhere suits only tiny scripts.

Check your understanding · Exercise 5

A user on Android switches from your app to WhatsApp and never comes back. The system kills the tab 10 minutes later. Which handlers ran? Pick one.

Sampling: keeping a few to learn about all

Suppose an app has 2 million sessions a day and each session sends 60 records of about 300 bytes. That is 120 million records and 36 GB of raw logs per day, before indexes and copies. Most of it says "everything went fine". Sampling keeps a representative share and drops the rest, so you pay for 5% or 10% and still see every pattern that affects more than a few hundred users.

Head sampling and tail sampling

Head sampling decides at the start, before anything happens: "this session is in the 10%". It is cheap, because dropped sessions send nothing. Its weakness is that it decides before it knows whether the session will be interesting. A rare bug in an unsampled session leaves no trace.

Tail sampling decides at the end, after seeing what happened: "keep every session that had an error, and 5% of the rest". It catches the interesting sessions, but someone has to hold everything until the decision, which on a server is a collector with memory.

In a browser, the client itself can act as the buffer: keep the last few dozen info and debug records in a ring buffer, and send them only if an error happens. Error trackers call these records breadcrumbs.

Sample:
Rate:
40 sessions of 30 records each, 4 of which end in an error. Sampling head: whole sessions at 25%: 480 of 1,200 records sent, 2 of 4 errors seen, 2 with the 5 steps before them.00300 E00030003030 E00030300030303030 E0300003000303000 E0303000480 of 1,200 records senterrors: 2/4 seen, 2/4 with steps
A made-up sample: each box is one session; the number is how many of its 30 records were sent, and E marks the 4 sessions that end in an error. Strong boxes: full sessions. Hatched: sessions with holes. Highlighted: errors that reached you. Faded: nothing sent.

Whole sessions or nothing: every kept session tells a full story, but errors in dropped sessions are invisible.

Sample per session, not per record

If each record is kept at random with 10% chance, every session is full of holes. You see pay_clicked but not the card_form_invalid before it, and you can't follow any story. Decide once per session instead, and keep all of that session's records or none.

Make the decision deterministic: hash the session ID into a number from 0 to 9,999 and keep the session if the number is below 1,000. Every tab and every record of the same session then makes the same choice, and the backend can repeat it too. Store the rate on each record (sampleRate: 0.1) so the dashboards can scale counts back up: 1,200 sampled checkout_opened records at 10% means about 12,000 real ones.

ts
function inSample(sessionId: string, rate: number): boolean {
  let h = 2166136261; // FNV-1a, 32-bit
  for (let i = 0; i < sessionId.length; i++) {
    h ^= sessionId.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return (h >>> 0) % 10_000 < rate * 10_000;
}

const RATES = { debug: 0, info: 0.1, warn: 1, error: 1, fatal: 1 };
A stable per-session decision, plus different rates per level.

Decision How should a browser logger sample?

Head sampling per session

Hash the session ID once; keep all records of 10% of sessions.

Pros

  • Cheapest: unsampled sessions send nothing
  • Whole stories for sampled sessions
  • Easy to scale counts back up

Cons

  • Rare bugs outside the sample are invisible
  • Errors are undercounted unless kept separately

Per-level rates

Keep every warn and error for all sessions; sample info by session; drop debug.

Pros

  • Never miss an error
  • Volume still drops by 80% or more

Cons

  • Errors from unsampled sessions arrive without their steps

Client-side tail with a ring buffer

Keep the last few dozen low-level records in memory for every session.

Send them only when an error happens, along with the error.

Pros

  • Full context for every error
  • Normal sessions cost almost nothing on the wire

Cons

  • Uses memory on every device
  • Records before the window are lost
  • If the tab dies before the error is sent, all is lost

What we pick: Combine them: keep all errors, head-sample info by session, drop debug, and attach a small ring buffer to every error. Raise the rate for a new release or flagged feature for the first hours, then lower it.

Check your understanding · Exercise 6

Dashboards show 300 payment_failed errors yesterday. Errors are kept at 100% and info at 10%. A teammate multiplies by 10 and reports 3,000. What is wrong? Select all that apply.

Keeping personal data out: scrubbing and consent

PII, personally identifiable information, is any data that points to a person: a name, an email, a phone number, an address, an IP address, a precise location, a government ID, and often a user ID too. Laws such as the EU's GDPR and California's CCPA limit how you collect, keep, and share it, and give people the right to have it deleted.

A log store is the worst place for it: it is copied widely, kept for weeks, searched by many engineers, and hard to delete from one record at a time.

How personal data sneaks into logs

  • URLs. /reset-password?token=...&email=jane%40example.com. Logging location.href copies both. Search pages put the query in the URL too.
  • Error messages. A validation library throws Invalid email: jane@example.com. The message is logged as-is.
  • Whole objects. log.info("profile_saved", { user }) sends the entire user object, address included.
  • Request and response bodies captured "for debugging", which carry everything the API carries.
  • DOM text. Session replay tools and click loggers that capture the text of the clicked element pick up names and messages.

Allowlist first, patterns second

There are two ways to scrub. A denylist removes fields or patterns you know are bad: keys named password, token, email; strings that look like emails or card numbers. An allowlist keeps only fields you have approved and drops everything else. Denylists always miss something new (a field called contact, a token format you didn't expect), while allowlists fail safe: an unknown field is dropped, not leaked.

  1. Allowlist the shape. Only fields in the schema survive. Unknown keys in attrs are dropped and counted.
  2. Reduce URLs. Log the route pattern (/orders/:id) and drop the query string and hash, or keep only allowlisted parameters such as utm_source.
  3. Mask by pattern. As a second net, replace anything that looks like an email, card number, phone number, or long token with [email], [card], [token].
  4. Cap lengths. Cut every string at, say, 256 characters. Long strings are where pasted data hides, and they cost money.
  5. Scrub again on the server, because an old client or a hand-made request can skip yours.
Turn on:
A log record before and after scrubbing with no steps. After: event: profile_save_failed; page: /account?e=jane@ex.com; error: bad email jane@ex.com; user: {name, email, address}; token: eyJhbGciOiJIUzI1...; stack: 1,800 chars. 4 fields still leak personal data.beforeafterevent: profile_save_failedevent: profile_save_failedpage: /account?e=jane@ex.compage: /account?e=jane@ex.comerror: bad email jane@ex.comerror: bad email jane@ex.comuser: {name, email, address}user: {name, email, address}token: eyJhbGciOiJIUzI1...token: eyJhbGciOiJIUzI1...stack: 1,800 charsstack: 1,800 chars4 fields still leak0 dropped
Left: what the app tried to log. Right: what leaves the device. Red rows still carry personal data; highlighted rows were changed; empty rows were dropped.

Try each step alone: masking catches the email in two places, but only the allowlist removes the user object, and only reducing the URL removes the whole query.

Debugging logs with no personal data that are needed to run the service are usually treated differently from analytics or ad tracking, but the rules depend on the country and your legal team. A safe default is to respect the user's consent choices in the logger itself. Before consent, send only strictly necessary error records with a random session ID; after consent for analytics, add product events. Never set a long-lived identifier cookie just for logging.

To connect logs to a user for support, log a pseudonymous ID instead of the email: a random ID the backend can map to the account, kept in a separate, access-controlled table. Deleting the mapping cuts the link between old logs and the person without touching the logs. Also set a short retention, often 14 to 30 days for debug logs, because data you no longer have can't leak.

Check your understanding · Exercise 7

Which scrubbing steps protect against a field nobody has thought of yet? Select all that apply.

Correlation IDs: one story across browser and server

A browser record says payment_failed at 18:42:07. The payments service logged 4,000 requests that second; which one was this user's? Without a shared key, you guess by time. A correlation ID is that key: an ID created for one request, sent with it, and written into the logs on both sides.

Three IDs, three scopes

Each ID answers a different question. Logs usually carry the first two; the server adds the third.
IDCreated byLives forJoins
Session IDThe browser, on first loadOne visit (or 30 minutes idle)All records from one user's visit, in order
Trace IDThe browser, per user action or requestOne request and everything it causes on the backendA browser record with the server spans that served it
Request IDThe edge or server, per requestOne HTTP requestLoad balancer, gateway, and service logs for that hop

The W3C traceparent header

You could invent an X-Request-Id header, and many teams did. Today the standard is W3C Trace Context: a traceparent header that tracing tools such as OpenTelemetry, Datadog, and others understand. It has four parts separated by dashes.

The header traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01, split into four parts. 00 is the version. 4bf92f3577b34da6a3ce929d0e0e4736 is the trace-id, 16 random bytes, the same for every span of the operation. 00f067aa0ba902b7 is the parent-id, 8 bytes, the caller's span. 01 is the flags, meaning sampled. 55 characters in all, sent only on calls to your own APIs.traceparent: 00-4bf92f35...0e0e4736-00f067aa0ba902b7-01004bf92f35...0e0e473600f067aa0ba902b701versiontrace-id, 16 bytesparent-id, 8 bytessame in every spanthe caller's spanflags: 01 = sampled55 characters, only to your own APIs
The trace-id is the part you log on both sides and search for. The flags carry the sampling decision, so the backend knows whether to keep the whole trace.
  • version: 00, the only version so far.
  • trace-id: 32 hex characters (16 random bytes) shared by every span of the whole operation. Not all zeros.
  • parent-id: 16 hex characters (8 bytes), the ID of the caller's span. From the browser, it identifies the browser's span for this request.
  • trace-flags: 2 hex characters; 01 means "this trace is sampled, please record it", 00 means not sampled.

The browser makes a new trace ID per user action ("pay clicked"), sets traceparent on the fetch, and writes the same trace ID into its own log records. The backend tracing library reads the header, continues the trace, and stamps the ID on every log line and span. Now a single search for the trace ID shows the click, the API gateway, the payment service, the database call, and the error, in one timeline.

A sequence diagram between the browser, the API gateway, the payments service, and the log store. The browser sends POST /pay with a traceparent header carrying trace 4bf92f35. The gateway calls payments with the same trace. Payments replies with an error, and the gateway returns 500 to the browser with reference 4bf92f35. The browser logs payment_failed with trace 4bf92f35 to the log store, and payments writes its span and error with the same trace. A note: one search for trace 4bf92f35 shows the click, the request, and the error together.BrowserAPI gatewayPaymentsLog store/pay + traceparentsame traceerror500, ref 4bf92f35log: payment_failedspan + errorsearch trace 4bf92f35:click, request, and error
The browser creates the ID and both sides write it down. Without it, you would match a browser record to one of thousands of server requests by timestamp alone.

The sampled flag connects to the previous chapter: if the browser session is in the sample, it sends 01, and the backend keeps the full trace. Other sessions send 00, and the backend can still decide to keep a trace that ends in an error (tail sampling on the server side).

Cross-origin requests, and getting the ID back

traceparent is not a CORS-safelisted header. On a cross-origin API (app.example.com calling api.example.com), adding it makes the browser send a preflight first, an extra OPTIONS round trip, and the server must list the header in Access-Control-Allow-Headers. The server can cache that answer with Access-Control-Max-Age so the preflight isn't repeated on every call.

Only add the header to your own APIs: sending it to a third-party API can make its requests fail CORS. Tracing SDKs have a setting for this, such as an allowlist of URLs to propagate to.

Sometimes the server starts the trace, for example for the HTML document itself. The server can send its trace ID back in a response header, or in a Server-Timing header that the page reads through the Performance API (cross-origin responses also need Timing-Allow-Origin). Put the trace or request ID in your API error bodies too, so the error screen can show "Reference: 4bf92f35" and support can find the exact trace.

Check your understanding · Exercise 8

After adding traceparent to every fetch, image uploads to a third-party storage service start failing. Why? Pick one.

The endpoint, cost, and what breaks in production

The other end of the pipe is an endpoint on the public internet that accepts writes from anyone, with no login required, at high volume. Treat it like any public API, with a few extra rules because the callers are browsers that may be closing.

What the endpoint does

  1. Accept fast. POST a batch, reply 204 No Content in a few milliseconds, and do the real work later from a queue. Beacons can't read the response anyway.
  2. Accept the formats you send. sendBeacon with a string body arrives as text/plain; parse it as JSON anyway.
  3. Limit size. Reject bodies over, say, 64 KB and batches over a few hundred records. Cut long strings.
  4. Validate. Check each record against the schema; drop unknown events and fields; count drops by reason so you notice a bad release.
  5. Enrich on the server. Add receivedAt, the country from the IP address, and the parsed user agent, then drop the raw IP address if you don't need it.
  6. Scrub again, then write to the queue that feeds the log store.
A flowchart of the ingest endpoint for one batch. Is the body under 64 KB? If not, reply 413 and drop it. Is the sender under the rate limit? If not, reply 429. Do the records match the schema? Records that don't are dropped and counted. Then scrub, enrich, and put the batch on a queue, and reply 204 No Content.body under 64 KB?no413: drop ityesunder the rate limit?no429: slow downyesrecords match schema?nodrop bad ones, countyesscrub, enrich, enqueuereply 204 No Content
Cheap checks come first, so abusive traffic is rejected before it costs parsing time. The reply is a fast 204; storage happens later, from the queue.

Rate limits and abuse

Anyone can copy the request from devtools and replay it a million times, or fill your logs with fake errors to hide a real attack or run up your bill. You can't stop this with secrets: any key shipped in the bundle is public. Instead, make abuse cheap to absorb and easy to spot.

  • Rate-limit per IP and per session, for example 60 batches per minute per session and a higher cap per IP for offices behind one address. Reply 429 and drop the excess.
  • Cap per session in the client too, say 1,000 records per session, so one stuck loop on one device can't flood you.
  • Mark trust. Records are untrusted claims. Never use them to grant anything, bill anyone, or trigger actions.
  • Watch the endpoint itself: requests per second, reject rate, and bytes per session. A jump from one network suggests abuse; a jump everywhere after a deploy suggests a logging bug.
  • Kill switch. Ship the sampling rates in remote config, so you can turn info down to 0% in minutes without a deploy when volume explodes.

Getting past blockers, honestly

Content blockers drop requests to well-known vendor domains. Many teams route logs through their own domain instead, such as a path on the app's origin that the server or CDN forwards to the vendor (Sentry calls this its tunnel option). Keep it honest: respect the user's consent choices whatever the domain, and pick a plain path rather than one designed to dodge filters.

What it costs

Log vendors often charge per GB ingested and per million records indexed, plus more for longer retention. The levers, in order of effect: what you log at all, the sampling rate, the size of each record, how many fields are indexed, and how long you keep it. Compression on the wire (gzip with the CompressionStream API) saves user bandwidth; it doesn't change what the vendor counts once it's unpacked.

A bar chart of raw log volume per day for 2 million sessions, drawn to scale. Debug on, 150 records of 300 bytes per session: 90 GB. No debug, 60 records: 36 GB. Info sampled at 10% with every warning and error kept, about 10.5 records per session: 6.3 GB. The same with records trimmed to 200 bytes: 4.2 GB.GB per day, 2 million sessions, to scaledebug on, 150 records per session90 GBno debug, 60 records per session36 GBinfo sampled 10%, all warn and error6.3 GBsame, records trimmed to 200 bytes4.2 GB
Turning off debug and sampling info cut volume by more than 90%. Trimming the record shape helps less, but it adds up across every record. Indexes and retention multiply all of these.

Check your understanding · Exercise 9

Log volume triples overnight. Every extra record is info level widget_rendered from the latest release. What are good first moves? Select all that apply.

What breaks in production, and how you notice

Most problems show up as gaps or spikes. Give the logging pipeline its own dashboard.
FailureHow you noticeFix
Logs from the last seconds before leaving are missingFunnels show users vanish mid-step; seq gaps at the end of sessionsFlush on visibilitychange to hidden, keep the last batch small, store refused batches for the next visit
The logger itself throws and breaks a featureErrors whose stack points into the loggerWrap every logger entry point in try/catch; never let logging throw
A log call inside a render or scroll handlerVolume jumps after a deploy; one event dominatesPer-session caps and de-duplication; remote kill switch
Logs pile up offline and burst on reconnectSpikes of old ts values; 429s from the endpointCap the stored queue, drop oldest first, retry with backoff and jitter
Personal data in logsA scan of the store for email or card patterns finds hitsAllowlist fields, reduce URLs, scrub on both sides, delete affected data
Clock skew breaks orderingRecords appear to happen before the page loadedOrder by seq within a session; trust server receivedAt across sessions
A large share of sessions sends nothingServer-side page views far above log sessionsFirst-party endpoint path; compare against server counts and report the gap

Logging can also hurt speed: JSON.stringify is synchronous work on the main thread. A 30 KB batch is cheap, but large batches, deep objects, and frequent flushes add up on a cheap phone. Keep batches small, serialize in idle time with requestIdleCallback where available, or move the buffer into a Web Worker. Heavy logging inside an input handler can push Interaction to Next Paint (INP, see the Core Web Vitals lesson) over budget.

How real tools do it

  • Sentry keeps a ring of breadcrumbs (clicks, navigations, network calls, console output) and attaches them to each error. It has sampleRate for errors, tracesSampleRate for traces, a beforeSend hook to scrub or drop events, and a tunnel option.
  • Datadog's browser SDKs batch records in the page, send what is left when the page may be closing, and sample by session with sessionSampleRate. Its RUM SDK adds trace headers, including traceparent, only to URLs listed in allowedTracingUrls.
  • OpenTelemetry for the web gives vendor-neutral tracing: it creates spans for fetch and document load, adds traceparent to allowed URLs, and exports spans with a batch processor that has a queue cap, a batch size, and a delay: the same size and timer knobs as the lab above.
  • Big product companies often run their own pipeline: a first-party endpoint, a shared event schema with a reviewed registry, and per-event sampling rates in remote config. The analytics lesson later in this module covers event registries.

Check your understanding · Exercise 10

Your server counts 100,000 page views yesterday, but logs show only 82,000 sessions with any record. Info sampling is 100% for this test. What explains the gap? Select all that apply.

Using it in a system design interview

Logging is rarely the whole question, but it comes up in almost every app design as "how would you know it works in production?" or "how would you debug a report from one user?". It also comes up directly: "design a client-side logging SDK" or "design the analytics pipeline for our web app".

When to bring it up

  • In the non-functional requirements: "we need to see client errors and key funnel steps in production, at low cost, without personal data".
  • When you add something that can fail silently on the client: an offline queue, optimistic updates, a retry, a third-party widget.
  • At the end, in a short "monitoring" section: logs, errors, Web Vitals, and the alerts you'd set.

What to draw

One row is enough: app code, a logger box (enrich, scrub, sample, buffer), a flush arrow labelled "batch: 30 records or 10 s or hidden", an ingest endpoint with a rate limiter, a queue, and a log store. Add the traceparent arrow to the API, and a dashed line from both the API and the endpoint into the same store. Then talk through one record end to end.

How deep to go, by level.
LevelWhat a strong answer covers
JuniorStructured events with a session ID; batch instead of one request each; don't log passwords; send errors to an error tracker.
MidLevels with rates; flush on visibilitychange with sendBeacon; per-session sampling; URL and field scrubbing; release version on every record.
SeniorHead vs tail sampling with a ring buffer; traceparent to join backend traces and the CORS cost; the 64 KiB keepalive budget; endpoint validation, rate limits, and a kill switch; cost math; consent and retention.

Think first

How would you size the batch and flush interval for a logging SDK?

Summary

  • Browser logs show what the server can't see, but they come from millions of untrusted, unreliable devices: some will be blocked, lost, or faked.
  • Log structured records with a stable event name, level, session ID, release, route, and trace ID; keep fields few and low-cardinality.
  • Never send one request per record: buffer and flush on size, on a timer, and when visibilitychange says the page is hidden.
  • Use sendBeacon or fetch with keepalive for the last flush, keep it under the shared 64 KiB budget, and store refused batches for the next visit.
  • Sample per session with a stable hash, keep every error, attach a ring buffer of recent steps to errors, and record the rate so counts can be scaled back.
  • Scrub in the browser with an allowlist, route patterns, masks, and length caps, then scrub again on the server; respect consent.
  • Send a W3C traceparent to your own APIs and log the same trace ID, so one search joins the click with the backend error.
  • Treat the ingest endpoint as a public API: fast 204, size limits, validation, rate limits, and a remote kill switch for sampling.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Users report that a button sometimes does nothing, but backend logs show no errors. How do you find out what's happening?

Question 2

Why not just send each log with fetch as it happens?

Question 3

How do you keep logging costs under control at 10 million sessions a day?

Question 4

How do you connect a browser error to the backend request that caused it?

Question 5

How do you make sure no personal data ends up in logs?

Practice it

These design problems lean on logging in the browser.

References

Read next