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.
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.
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.
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.
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
- Trigger an error in the popup and confirm the report includes worker breadcrumbs in order.
- Search stored reports for
httpand confirm no page URLs appear. - Confirm the trail is at most 60 entries.
- 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.
Do breadcrumbs need consent?
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.
Related
- Capturing uncaught errors in every context — the errors breadcrumbs explain.
- Reporting errors without breaking your privacy policy — privacy rules.
- Tracing messages between contexts — development-time tracing.
- Error monitoring and crash reporting — the parent topic.