Wiring Sentry into a Manifest V3 Extension

Use Sentry (or a similar SDK) in an MV3 extension correctly — one client per context instead of global init, a bundled SDK, disabled default integrations, beforeSend scrubbing, and uploading source maps from CI.

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

Hosted error services save you building grouping, alerting and symbolication yourself, and their browser SDKs mostly work inside an extension — with caveats that matter. The default Sentry.init installs global handlers and integrations that assume it owns the page, which in a content script means capturing the host website’s errors and breadcrumbs. The SDK must be bundled rather than loaded from a CDN. And stacks point at chrome-extension://<random-id>/ paths that need rewriting before your uploaded source maps will match. This guide is part of error monitoring and crash reporting.

Global init versus a scoped client

Sentry initialisation styles in an extensionGlobal Sentry.init and a manually constructed BrowserClient with a Scope compared per context on whether they capture page errors, collect page breadcrumbs, and conflict with a page's own Sentry.ApproachWorker / extension pagesContent scriptSentry.init (global)AcceptableCaptures the site's errorsDefault integrationsTrim themLeak page breadcrumbsScoped BrowserClientWorksRequiredConflicts with page's SentryNoGlobal init can clash
In a content script, global init is wrong on all three counts — a scoped client is the only safe option there.

Step-by-step

1. Bundle the SDK and allow its host

1npm install @sentry/browser
1{
2  "host_permissions": ["https://o123456.ingest.sentry.io/*"],
3  "content_security_policy": {
4    "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://o123456.ingest.sentry.io"
5  }
6}

Execution context: your shell and the manifest. The SDK is imported and bundled like any dependency — loading it from Sentry’s CDN would be remotely hosted code, which MV3 prohibits. The ingest host appears in connect-src so the only telemetry destination is declared, as described in writing a strict Content Security Policy for MV3.

2. Build one scoped client factory

 1// sentry-client.js
 2import { BrowserClient, Scope, defaultStackParser, makeFetchTransport, dedupeIntegration } from "@sentry/browser";
 3import { scrubEvent } from "./scrub.js";
 4
 5export function makeSentry(ctx) {
 6  const client = new BrowserClient({
 7    dsn: "https://publickey@o123456.ingest.sentry.io/789",
 8    release: `reader@${chrome.runtime.getManifest().version}`,
 9    environment: import.meta.env.MODE,
10    transport: makeFetchTransport,
11    stackParser: defaultStackParser,
12    integrations: [dedupeIntegration()],          // nothing that hooks the page
13    sendDefaultPii: false,
14    beforeSend: (event) => scrubEvent(event),
15    beforeBreadcrumb: () => null,                 // no automatic breadcrumbs at all
16  });
17  const scope = new Scope();
18  scope.setClient(client);
19  scope.setTag("ctx", ctx);
20  client.init();
21  return scope;
22}

Execution context: a shared module imported by every context. A BrowserClient attached to its own Scope does not install global handlers or patch fetch, console or the DOM — which is exactly the isolation a content script needs. The DSN’s public key is designed to be public; it identifies the project, not a secret.

3. Capture explicitly from your own handlers

1// service-worker.js — first import
2import { makeSentry } from "./sentry-client.js";
3const sentry = makeSentry("sw");
4
5self.addEventListener("error", (e) => sentry.captureException(e.error ?? new Error(e.message)));
6self.addEventListener("unhandledrejection", (e) => sentry.captureException(e.reason));

Execution context: the service worker. Because the scoped client installs nothing globally, you wire the handlers yourself — the same handlers from capturing uncaught errors in every context, including the extension-URL filter in content scripts.

4. Survive worker eviction

The SDK’s transport sends asynchronously. If the worker is evicted before the request completes, the event is lost. Flush before handlers return.

1chrome.alarms.onAlarm.addListener(async (alarm) => {
2  try { await runJob(alarm); }
3  catch (err) { sentry.captureException(err); }
4  finally { await sentry.getClient()?.flush(2000); }
5});

Execution context: the service worker. flush resolves when queued events are sent or the timeout expires; awaiting it keeps the returned promise — and the worker — alive long enough. For errors outside an event handler, fall back to a durable queue in storage flushed by an alarm.

5. Scrub in beforeSend

 1// scrub.js
 2import { scrubText, extensionFrames } from "./report.js";
 3
 4export function scrubEvent(event) {
 5  delete event.request;                                   // page URL, headers
 6  delete event.user;
 7  event.breadcrumbs = [];
 8  for (const ex of event.exception?.values ?? []) {
 9    ex.value = scrubText(ex.value ?? "");
10    ex.stacktrace && (ex.stacktrace.frames = ex.stacktrace.frames.filter((f) => f.filename?.startsWith("app:///")));
11  }
12  return event;
13}

Execution context: every context, just before an event is sent. beforeSend is the last point of control; returning null drops the event. The filename filter relies on the rewrite in the next step. The scrubbing rules themselves are in reporting errors without breaking your privacy policy.

6. Normalise frame paths so source maps match

Stack frames arrive as chrome-extension://<id>/chunks/main-3fa1.js, and the id differs per install on Firefox and Safari. Rewrite them to a stable prefix before sending.

 1import { rewriteFramesIntegration } from "@sentry/browser";
 2
 3const EXT = chrome.runtime.getURL("");
 4integrations: [
 5  dedupeIntegration(),
 6  rewriteFramesIntegration({ iteratee: (frame) => {
 7    if (frame.filename?.startsWith(EXT)) frame.filename = "app:///" + frame.filename.slice(EXT.length);
 8    return frame;
 9  } }),
10],

Execution context: the client configuration. Every frame from your own code becomes app:///chunks/main-3fa1.js, identical across installs and engines — which is what the uploaded source maps will be keyed on.

7. Upload source maps from CI, keep them out of the package

1npx @sentry/cli sourcemaps inject dist/chrome
2npx @sentry/cli sourcemaps upload --release "reader@$(jq -r .version dist/chrome/manifest.json)" \
3  --url-prefix "app:///" dist/chrome
4find dist/chrome -name '*.map' -delete

Execution context: the CI job, after building and before packaging. inject adds debug ids that link each bundle to its map; upload sends the maps to Sentry under the release; deleting them afterwards keeps the original source out of the shipped extension, as covered in handling API keys without shipping them in the bundle. The auth token for the upload is a CI secret like any publishing credential.

Sentry in an extension, build to dashboardCI builds the bundle, injects debug ids, uploads source maps under the release and deletes them; at runtime a scoped client per context captures, scrubs, rewrites frames and flushes before the worker idles.Buildhidden source mapssentry-cli inject + uploadrelease, app:/// prefixDelete .map filesthen packageat runtime, per contextScoped BrowserClientno global hooksbeforeSend + rewriteFramesscrub, app:///flush() before idleworker survives send
The frame rewrite and the release name are the two strings that must agree between CI and runtime.

Keeping the SDK’s footprint in check

The Sentry browser SDK is tens of kilobytes after tree-shaking — acceptable in the worker and extension pages, heavier than you want in a content script that runs on every page. Two options keep content scripts light.

The first is not to load the SDK in content scripts at all: forward errors to the worker with a small message, as in the capture guide, and let the worker’s client send them. The content script ships a few lines instead of a library, and the worker becomes the single place that talks to Sentry.

The second, for a content script that genuinely needs local context such as timing around a DOM operation, is to load the client lazily only after the first error, via a dynamic import of a web-accessible chunk. Either way, measure against the content-script budget described in keeping the extension bundle small.

Added script per context for error reportingApproximate added minified size for Sentry with default integrations, a trimmed scoped client, and a forward-to-worker shim in content scripts.Sentry, default integrations78 KBScoped client, trimmed32 KBContent-script forwarding shim0.6 KB
Forwarding from content scripts keeps the per-page cost to a few hundred bytes.

Cross-browser variation

  • Chrome / Edge: frames use chrome-extension://<id>/, where the id is stable per listing. The SDK runs in the module service worker without changes.
  • Firefox: frames use moz-extension://<per-install-uuid>/, so the path rewrite is essential for source maps to match. Firefox’s AMO review will read the SDK; keep it unminified or supply sources.
  • Safari: frames use safari-web-extension://<uuid>/; the same rewrite applies. Consider whether the Safari build reports at all if its privacy label does not declare diagnostics.
  • All three: runtime.getURL("") gives the correct prefix for the rewrite on each engine — never hard-code a scheme.

Verification

  1. Throw a test error in the worker and confirm it appears in Sentry with a readable, source-mapped stack and the ctx:sw tag.
  2. Throw in a content script on a site that runs its own Sentry and confirm your project receives only your error, and the site’s project is unaffected.
  3. Inspect an outgoing event in the worker’s Network panel:
1// in beforeSend, temporarily
2console.debug(JSON.stringify(event).includes("https://") ? "URL LEAK" : "clean");

Execution context: the worker console in a development build. Any URL LEAK means a field escaped the scrubber.

  1. Unzip the release package and confirm there are no .map files.

FAQ

Is the DSN a secret?

No. It contains a public key intended to be embedded in clients. Protect the separate auth token used for source-map uploads, which is a real secret.

Can I use Sentry’s CDN loader script?

No — that is remotely hosted code, blocked by MV3 and grounds for rejection. Bundle the SDK.

Do I need performance tracing too?

Usually not in an extension. Tracing adds size and sends more data for a use case — page performance — that belongs to the site, not to you. Keep to error reporting unless you have a specific extension-side latency question.

Other Testing, Debugging & Performance Optimization Resources