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.
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
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.
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.
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
- Throw a test error in the worker and confirm it appears in Sentry with a readable, source-mapped stack and the
ctx:swtag. - 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.
- 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.
- Unzip the release package and confirm there are no
.mapfiles.
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.
Related
- Capturing uncaught errors in every context — the handlers that feed the client.
- Reporting errors without breaking your privacy policy — the rules beforeSend enforces.
- Managing store credentials in CI secrets — protecting the upload token.
- Error monitoring and crash reporting — the parent guide.