Reporting Errors Without Breaking Your Privacy Policy

Send extension error reports that contain no browsing data — scrubbing URLs and page content from messages and stacks, consent and opt-out, disclosure in the store form, and an allowlisted payload.

Published September 18, 2026 Updated September 18, 2026 7 min read
Table of Contents

An error report from a web application usually describes your own site. An error report from an extension describes whatever the user was doing: the message says Cannot read properties of null on https://mail.example.com/inbox/thread/8841, the stack includes a frame from the page, the breadcrumbs list the tabs the extension saw. Sent as-is, that is browsing history leaving the device — which your privacy policy probably says does not happen, and which store reviewers treat seriously. Useful reports and a clean privacy story are compatible, but only by design. This guide is part of error monitoring and crash reporting.

What leaks, and from where

Personal data hiding in an error reportError message, stack trace, breadcrumbs, request context and user identifiers compared on what personal data each tends to contain and how to remove it.FieldTypical leakRemove bymessagePage URL, selected textReplace URLs, truncatestackPage script URLsKeep extension frames onlybreadcrumbsNavigations, clicks, consoleDisable or allowlistrequest / tagsTab URL, titleSend host category, not URLuserEmail, account idRandom install id
The message and the breadcrumbs leak most often — both are built from strings the page controls.

Step-by-step

1. Define the payload by allowlist

Scrubbing a rich object tends to miss a field. Building the report from a fixed list of fields cannot.

 1// report.js
 2const EXT = chrome.runtime.getURL("");
 3
 4export function buildReport({ ctx, err, extra = {} }) {
 5  return {
 6    v: chrome.runtime.getManifest().version,
 7    ctx,                                             // "sw" | "popup" | "content"
 8    name: String(err?.name ?? "Error").slice(0, 60),
 9    message: scrubText(String(err?.message ?? "")).slice(0, 300),
10    frames: extensionFrames(err?.stack),
11    extra: pick(extra, ["phase", "from", "code"]),   // known, non-personal keys only
12    t: Math.floor(Date.now() / 60_000) * 60_000,     // minute precision is enough
13  };
14}

Execution context: every context, before a report is queued. Anything not named here cannot be sent, however it got into the error object. Coarsening the timestamp to the minute removes a fingerprinting signal at no diagnostic cost.

2. Scrub URLs and long strings from free text

1export function scrubText(s) {
2  return s
3    .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s'")]+/gi, (u) => (u.startsWith(EXT) ? u.slice(EXT.length - 1) : "<url>"))
4    .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "<email>")
5    .replace(/\b\d{6,}\b/g, "<n>")
6    .replace(/"[^"]{40,}"/g, '"<text>"');
7}

Execution context: any context. Extension URLs are reduced to their path, which is useful and not personal; any other URL, email address, long number or long quoted string is replaced. Quoted strings matter because messages like Unexpected token in JSON at "…" can embed page content verbatim.

3. Keep only your own stack frames

1export function extensionFrames(stack = "") {
2  return stack.split("\n")
3    .filter((l) => l.includes(EXT))
4    .slice(0, 15)
5    .map((l) => l.replace(EXT, "/").trim());
6}

Execution context: any context. Frames from page scripts reveal which site the user was on and do not help you fix your code. Dropping them keeps the report focused and removes the leak. This works in Firefox and Safari too, because getURL("") returns the engine’s own extension scheme.

4. Replace URLs with a coarse, non-identifying hint

Some bugs are site-specific, and “it fails on one site” is useful. Send a category, not the address.

1function siteHint(url) {
2  try {
3    const u = new URL(url);
4    if (!/^https?:$/.test(u.protocol)) return "non-web";
5    return KNOWN_PLATFORMS.find((p) => u.hostname.endsWith(p.suffix))?.name ?? "other-web";
6  } catch { return "unknown"; }
7}
8// KNOWN_PLATFORMS: e.g. [{ suffix: "github.com", name: "github" }, …] — platforms your extension targets

Execution context: any context that knows the tab URL. A fixed list of the platforms your extension explicitly supports is disclosed in your listing anyway; everything else collapses to other-web. That answers “is this a GitHub-specific bug?” without recording anyone’s history.

5. Ask, and let users say no

 1async function reportingAllowed() {
 2  const { errorReporting } = await chrome.storage.sync.get("errorReporting");
 3  return errorReporting === true;          // opt-in for extensions with sensitive permissions
 4}
 5
 6export async function flush() {
 7  if (!(await reportingAllowed())) return chrome.storage.local.remove("errQueue");
 8  const { errQueue = [] } = await chrome.storage.local.get("errQueue");
 9  if (!errQueue.length) return;
10  await fetch("https://errors.example.com/v1/batch", { method: "POST", body: JSON.stringify(errQueue) });
11  await chrome.storage.local.remove("errQueue");
12}

Execution context: the service worker, from the flush alarm. Whether reporting defaults to on or off depends on what the extension can see: an extension holding history or all-sites permissions should ask first; one with narrow permissions can default on with a clear opt-out. Discarding the queue when reporting is off — rather than keeping it “in case” — is part of honouring the choice.

6. Say so in the disclosure form and the policy

The store’s data-disclosure form asks what the extension collects. Diagnostic data counts. State it plainly: “Error reports containing the extension version, the error type and message with URLs removed, and stack frames from the extension’s own code. No browsing history, page content or personal identifiers.” Then make the allowlist in step 1 the thing that makes that sentence true — the discipline described in justifying sensitive data permissions.

From raw error to a sendable reportA raw error is reduced to allowlisted fields, its message scrubbed, its stack cut to extension frames, the site replaced by a category, and it is only sent if the user has allowed reporting.Raw Errormessage, stack, contextAllowlist fieldsbuildReport()scrubText()URLs, emails, long textthenExtension frames onlypage frames droppedsiteHint()category, not URLConsent checksend or discard
Every step removes information; nothing in the pipeline adds any.

Testing the scrubber like security code

A scrubber that misses one pattern leaks until someone notices, and nobody notices because the leak is in a dashboard only you read. Test it the way you would test input validation: with hostile fixtures.

1test.each([
2  ["Failed on https://mail.example.com/inbox/8841", "Failed on <url>"],
3  ["user alice@example.com not found", "user <email> not found"],
4  ["Unexpected token in \"Dear Bob, the contract for the house on Elm Street…\"", "Unexpected token in \"<text>\""],
5  ["at chrome-extension://abc/content/main.js:88", "at /content/main.js:88"],
6])("scrubs %s", (input, expected) => {
7  expect(scrubText(input)).toBe(expected);
8});

Execution context: Vitest or Jest. Every leak ever found in a real report becomes a new row. The fixture list is also the most convincing thing to show a reviewer who asks what your error reports contain — alongside the contract tests described in contract testing a storage schema.

Reports containing personal data, by pipeline stageShare of sampled raw error reports that contained a URL, email or page text before scrubbing, after message scrubbing only, and after the full allowlist pipeline.Raw SDK defaults63 % of repor…Message scrubbed only27 % of repor…Full allowlist pipeline0 % of reports
Scrubbing the message alone leaves breadcrumbs and page frames; the allowlist closes the rest.

Cross-browser variation

  • Chrome / Edge: the Chrome Web Store data-disclosure form covers diagnostics; the limited-use policy applies to anything derived from user data.
  • Firefox: AMO requires a data collection disclosure and, for many categories, explicit opt-in consent shown by the extension. Firefox’s newer manifest key for declaring data collection makes the categories machine-readable.
  • Safari: the App Store privacy label for the containing app must list diagnostics. Apple treats crash and performance data as its own category.
  • All three: stack frame formats differ but all contain the extension’s URL scheme, so filtering by runtime.getURL("") works everywhere.

Verification

  1. Trigger an error on a page with a distinctive URL and inspect the queued report:
1JSON.stringify((await chrome.storage.local.get("errQueue")).errQueue.at(-1));
2// {"v":"2.4.1","ctx":"content","name":"TypeError","message":"Cannot read properties of null","frames":["/content/main.js:88:12"],…}

Execution context: any extension page’s console. Search the string for the page’s hostname — it must not appear.

  1. Turn reporting off and confirm the queue is discarded on the next flush, not sent.
  2. Run the scrubber fixtures and add any new leak you find as a row.
  3. Inspect an outgoing batch in the worker’s Network panel and compare it with the disclosure form’s wording.

FAQ

Are error reports “personal data”?

They can be, if they contain URLs, identifiers or content. Built by allowlist as above, they are aggregate diagnostics — which is the claim your policy should make and your tests should enforce.

Can I keep breadcrumbs for debugging?

Only breadcrumbs you generate yourself with known, non-personal content — “sync started”, “migration phase 2”. Disable automatic navigation, console and DOM breadcrumbs from any SDK.

Is an install id a personal identifier?

A random id generated on install, not linked to an account, is generally treated as pseudonymous. Rotate it periodically if you want reports to be unlinkable over time.

What about errors from users in incognito windows?

If your extension is allowed in incognito, treat errors from those contexts with extra care — the user explicitly asked for no history. A reasonable rule is to drop the site hint entirely for incognito tabs (tab.incognito), or not report content-script errors from them at all.

Other Testing, Debugging & Performance Optimization Resources