Adding Breadcrumbs to Error Reports

Attach the events leading up to an extension error — messages, lifecycle events, user actions, storage writes, network calls — as privacy-safe breadcrumbs: a per-context ring buffer, cross-context breadcrumbs via the service worker, and what never to record.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The stack trace says TypeError: Cannot read properties of undefined (reading 'items') at renderList (popup.ts:88). True, but why was the data undefined? Was the worker just restarted? Did a migration run? Did the user click Export twice? The trace shows where the code failed, not the sequence that led there. Breadcrumbs fill that gap: a short, timestamped trail of significant events — worker started, migration ran, message sent, storage written, fetch failed — attached to each error report. In an extension the trail crosses contexts, which makes it more valuable and more delicate, because the events also brush against user data. This guide records breadcrumbs that explain errors without collecting what users browse. It belongs to error monitoring and crash reporting.

What makes a good breadcrumb

A breadcrumb is a tiny structured record: a timestamp, a category (lifecycle, message, storage, fetch, ui), a short message and a few safe fields. Good breadcrumbs describe what the extension did, not what the user was looking at: “message save-page sent from popup”, “storage write keys [items, lastSyncAt]”, “fetch POST /v1/items → 500 (320 ms)”, “worker started (reason: alarm)”. They never contain page URLs, page content, selected text, form values, tokens or full payloads. Keep the last 30–50 per context in a ring buffer; when an error is reported, attach the buffer. For cross-context errors, the service worker can hold a shared trail that every context contributes to.

Breadcrumb trail across contextsThe popup, content script and service worker each add breadcrumbs to their own ring buffers and forward selected ones to a shared trail in the worker; when an error occurs, the report includes the local buffer and the shared trail, scrubbed of sensitive fields.Popup crumbsui, messageContent crumbsinject, messageWorker crumbslifecycle, storage, fetchforward key eventsShared trailworker, last 50Scrubberno URLs, no payloadsError report+ breadcrumbs
Local trails for detail, a shared trail for the cross-context story.

Step-by-step: privacy-safe breadcrumbs

1. Keep a ring buffer per context

 1// breadcrumbs.js — shared by all contexts
 2const MAX = 40;
 3const crumbs = [];
 4const CTX = globalThis.ServiceWorkerGlobalScope ? "sw" : location.protocol.startsWith("chrome-extension") || location.protocol.startsWith("moz-extension") ? location.pathname.replace(/^\/|\.html$/g, "") : "content";
 5
 6export function crumb(category, message, data = {}) {
 7  crumbs.push({ t: Date.now(), ctx: CTX, category, message, data: scrub(data) });
 8  if (crumbs.length > MAX) crumbs.shift();
 9  if (FORWARD.has(category) && CTX !== "sw") chrome.runtime.sendMessage({ type: "__crumb", c: crumbs.at(-1) }).catch(() => {});
10}
11export const getCrumbs = () => crumbs.slice();
12const FORWARD = new Set(["message", "ui", "inject"]);

Execution context: every extension context. A fixed-size buffer keeps memory constant. Selected categories are forwarded to the worker’s shared trail so a popup error can show what the worker was doing. Forwarding is fire-and-forget and must never throw.

2. Scrub every field

 1const ALLOWED = new Set(["type", "status", "ms", "keys", "reason", "method", "path", "count", "action", "frameId", "version"]);
 2
 3export function scrub(data) {
 4  const out = {};
 5  for (const [k, v] of Object.entries(data)) {
 6    if (!ALLOWED.has(k)) continue;
 7    if (k === "path") out[k] = String(v).replace(/\/[0-9a-f-]{8,}|\/\d+/gi, "/:id").slice(0, 80);
 8    else if (Array.isArray(v)) out[k] = v.slice(0, 10).map((x) => String(x).slice(0, 40));
 9    else out[k] = typeof v === "string" ? v.slice(0, 80) : v;
10  }
11  return out;
12}

Execution context: every context. An allowlist of field names is safer than a blocklist: new code cannot leak data by adding a field nobody thought to block. Paths have IDs replaced, strings are truncated, arrays are capped. Storage keys are fine to record; storage values are not. See reporting errors without breaking your privacy policy.

Breadcrumb categories: record versus never recordFor lifecycle, messages, storage, network, UI actions and content scripts, what to record as breadcrumbs and what must never be recorded.CategoryRecordNever recordLifecycleWorker start, onInstalled reason, migrati…—MessagesType, direction, frameId, msPayloadsStorageArea, keys, countValuesNetworkMethod, API path (ids masked), status, msBodies, tokens, third-party URLsUIAction names ("export-click")Typed text, search queriesContent scriptsInjected, frame countPage URL, selection, DOM text
Record what the extension did, never what the user saw or typed.

3. Instrument the key events

 1// sw.js
 2crumb("lifecycle", "worker started");
 3chrome.runtime.onInstalled.addListener(({ reason, previousVersion }) => crumb("lifecycle", "installed", { reason, version: previousVersion }));
 4chrome.alarms.onAlarm.addListener(({ name }) => crumb("lifecycle", "alarm", { action: name }));
 5chrome.storage.onChanged.addListener((changes, area) => crumb("storage", `write ${area}`, { keys: Object.keys(changes) }));
 6
 7export async function apiFetch(path, init = {}) {
 8  const t0 = performance.now();
 9  try {
10    const res = await fetch(API_BASE + path, init);
11    crumb("fetch", "api", { method: init.method ?? "GET", path, status: res.status, ms: Math.round(performance.now() - t0) });
12    return res;
13  } catch (e) {
14    crumb("fetch", "api failed", { method: init.method ?? "GET", path, ms: Math.round(performance.now() - t0) });
15    throw e;
16  }
17}

Execution context: the service worker. “Worker started” as the first breadcrumb is especially useful in MV3 — an error shortly after it often means state was not rebuilt after termination. Instrument the wrappers you already use (fetch helper, message helper) so coverage comes for free. See tracing messages between contexts.

Breadcrumbs that explain an errorThe trail shows: worker started (alarm), storage write of schemaVersion, migration to version 3, popup opened, message get-items, then the popup error reading items of undefined — revealing that the migration renamed items to entries.Service workerPopupReportworker started; alarm syncmigration v3; write keys [entries]message get-itemsTypeError reading 'items'error + trail
The trail turns 'undefined' into 'the migration renamed the key'.

4. Collect the shared trail in the worker

1// sw.js
2const shared = [];
3chrome.runtime.onMessage.addListener((m) => {
4  if (m.type !== "__crumb") return;
5  shared.push(m.c); if (shared.length > 50) shared.shift();
6});
7chrome.runtime.onMessage.addListener((m, _s, reply) => {
8  if (m.type === "__getSharedCrumbs") { reply(shared.slice()); }
9});

Execution context: the service worker. The shared trail lives in memory, which matches its purpose — recent events within the current worker lifetime. If you want trails to survive worker restarts, mirror them to storage.session with a debounce.

5. Attach breadcrumbs when reporting

1export async function reportError(err, context) {
2  let shared = [];
3  if (CTX !== "sw") shared = await chrome.runtime.sendMessage({ type: "__getSharedCrumbs" }).catch(() => []);
4  const trail = [...getCrumbs(), ...shared].sort((a, b) => a.t - b.t).slice(-60);
5  send({ ...normalise(err, context), breadcrumbs: trail.map(({ t, ctx, category, message, data }) => ({ t: t - trail[0].t, ctx, category, message, data })) });
6}

Execution context: every context. Merging local and shared crumbs by timestamp gives one ordered story. Converting timestamps to offsets from the first crumb removes absolute times, which are not needed for debugging. With Sentry or similar SDKs, use their addBreadcrumb API instead and keep the same scrubbing — SDKs often auto-record URLs and console messages, so configure or disable those integrations. See wiring Sentry into a Manifest V3 extension.

6. Disable automatic breadcrumbs you can’t control

Error SDKs record DOM clicks (with element text), console output, navigation URLs and XHR/fetch URLs by default. In a content script those are the page’s events and URLs. Turn off automatic breadcrumb integrations in content scripts entirely, and review them for extension pages.

7. Test the scrubber

1test("breadcrumbs never contain URLs or payloads", () => {
2  crumb("message", "sent", { type: "save", payload: { text: "secret" }, url: "https://bank.example/acct" });
3  const [c] = getCrumbs().slice(-1);
4  expect(JSON.stringify(c)).not.toMatch(/secret|bank\.example/);
5});

Execution context: a unit test. A test that tries to record sensitive fields and asserts they are gone protects the allowlist from future changes.

Common mistakes

  • Recording payloads or values. Leaks user data.
  • SDK auto-breadcrumbs in content scripts. Records the page’s URLs and clicks.
  • Unbounded trails. Memory grows; reports get huge.
  • Only local breadcrumbs. Cross-context causes are invisible.
  • Blocklist scrubbing. New fields leak; use an allowlist.

Cross-browser variation

  • Chrome / Edge: worker restarts are frequent — the “worker started” crumb is especially informative.
  • Firefox: background event pages live longer; trails cover longer periods.
  • Safari: same approach; messaging behaviour for forwarding matches the WebExtensions model.

Verification

  1. Trigger an error in the popup and confirm the report includes worker breadcrumbs in order.
  2. Search stored reports for http and confirm no page URLs appear.
  3. Confirm the trail is at most 60 entries.
  4. Run the scrubber test.

FAQ

How many breadcrumbs are enough?

Thirty to fifty per context usually covers the minute before an error, which is what matters.

They are part of error reporting; include them in the same disclosure and opt-out.

Can I record which site the content script ran on?

Prefer not. If a site-specific bug requires it, record only a hashed or bucketed domain and disclose it.

Should breadcrumbs include timing?

Yes — relative timestamps and durations are often the key clue, for example a fetch that took 30 seconds just before a timeout error.

Other Testing, Debugging & Performance Optimization Resources