diff --git a/rspack.config.ts b/rspack.config.ts
index de07270f55d9..da34be6f93d2 100644
--- a/rspack.config.ts
+++ b/rspack.config.ts
@@ -606,7 +606,40 @@ const workerConfig: Configuration = {
context: staticPrefix,
experiments: appConfig.experiments,
lazyCompilation: appConfig.lazyCompilation,
- module: appConfig.module,
+ module: {
+ rules: [
+ {
+ test: /\.ts$/,
+ // core-js: Avoids recompiling core-js based on usage imports
+ // compiled via swc.
+ exclude: /node_modules[\\/](core-js)/,
+ loader: 'builtin:swc-loader',
+ options: {
+ env: {
+ mode: 'usage',
+ // https://rspack.rs/guide/features/builtin-swc-loader#polyfill-injection
+ coreJs: '3.45.0',
+ targets: packageJson.browserslist.production,
+ shippedProposals: true,
+ },
+ jsc: {
+ parser: {
+ syntax: 'typescript',
+ tsx: true,
+ },
+ transform: {
+ react: {
+ runtime: 'automatic',
+ development: DEV_MODE,
+ refresh: false,
+ },
+ },
+ },
+ isModule: 'unknown',
+ },
+ },
+ ],
+ },
plugins: [
/**
* Without this, webpack will chunk the locales but attempt to load them all
@@ -626,7 +659,7 @@ const workerConfig: Configuration = {
resolve: appConfig.resolve,
// Don't clean: app's compiler owns cleaning `dist` (see its `clean.keep`).
output: {...appConfig.output, clean: false},
- optimization: appConfig.optimization,
+ optimization: {...appConfig.optimization, runtimeChunk: false},
devtool: appConfig.devtool,
};
diff --git a/static/app/bootstrap/initializeApp.tsx b/static/app/bootstrap/initializeApp.tsx
index feef0a35d3a2..19206cb659d5 100644
--- a/static/app/bootstrap/initializeApp.tsx
+++ b/static/app/bootstrap/initializeApp.tsx
@@ -1,7 +1,6 @@
import './legacyTwitterBootstrap';
import './exportGlobals';
-import {registerWorker} from 'sentry/serviceWorker/client/register';
import type {Config} from 'sentry/types/system';
import {metric} from 'sentry/utils/analytics';
@@ -21,5 +20,4 @@ export function initializeApp(config: Config) {
metric.mark({name: 'sentry-app-init'});
renderOnDomReady(renderMain);
processInitQueue();
- registerWorker();
}
diff --git a/static/app/main.tsx b/static/app/main.tsx
index db54c836448f..7f0d11cf33a6 100644
--- a/static/app/main.tsx
+++ b/static/app/main.tsx
@@ -18,6 +18,7 @@ import {SENTRY_RELEASE_VERSION} from 'sentry/constants/sdk';
import {preload} from 'sentry/router/preload';
import {RouteConfigProvider} from 'sentry/router/routeConfigContext';
import {routes} from 'sentry/router/routes';
+import {ServiceWorkerProvider} from 'sentry/serviceWorker/client/serviceWorkerContext';
import {createReactRouter3Navigate} from 'sentry/utils/useNavigate';
function buildRouter() {
@@ -39,28 +40,30 @@ export function Main() {
-
-
-
-
-
-
-
-
- {USE_TANSTACK_DEVTOOL && (
- ,
- },
- formDevtoolsPlugin(),
- pacerDevtoolsPlugin(),
- ]}
- />
- )}
-
+
+
+
+
+
+
+
+
+
+ {USE_TANSTACK_DEVTOOL && (
+ ,
+ },
+ formDevtoolsPlugin(),
+ pacerDevtoolsPlugin(),
+ ]}
+ />
+ )}
+
+
diff --git a/static/app/serviceWorker/client/register.ts b/static/app/serviceWorker/client/register.ts
deleted file mode 100644
index 3fa0b07f7cb5..000000000000
--- a/static/app/serviceWorker/client/register.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import {addIntegration, webWorkerIntegration} from '@sentry/react';
-
-type WebWorkerIntegration = ReturnType;
-let integration: WebWorkerIntegration | null = null;
-
-function getWorkerUrl(): string {
- return window.__SENTRY_DEV_UI ? '/entrypoints/service-worker.js' : '/service-worker.js';
-}
-
-function connectWorker(worker: ServiceWorker): void {
- const w = worker as unknown as Worker;
- if (integration) {
- integration.addWorker(w);
- } else {
- integration = webWorkerIntegration({worker: w});
- addIntegration(integration);
- }
-}
-
-function waitForActivation(worker: ServiceWorker): void {
- if (worker.state === 'activated') {
- connectWorker(worker);
- return;
- }
- worker.addEventListener('statechange', () => {
- if (worker.state === 'activated') {
- connectWorker(worker);
- }
- });
-}
-
-export function registerWorker(): void {
- if (!('serviceWorker' in navigator)) {
- return;
- }
-
- navigator.serviceWorker
- // https://rspack.rs/guide/features/web-workers
- .register(getWorkerUrl(), {scope: '/'})
- .then(registration => {
- const incoming = registration.installing ?? registration.waiting;
- if (incoming) {
- waitForActivation(incoming);
- } else if (registration.active) {
- connectWorker(registration.active);
- }
-
- registration.addEventListener('updatefound', () => {
- if (registration.installing) {
- waitForActivation(registration.installing);
- }
- });
- })
- .catch(() => {
- // Registration failed — not critical, silently ignore
- });
-}
diff --git a/static/app/serviceWorker/client/serviceWorker.stories.tsx b/static/app/serviceWorker/client/serviceWorker.stories.tsx
new file mode 100644
index 000000000000..fa7fe0377b82
--- /dev/null
+++ b/static/app/serviceWorker/client/serviceWorker.stories.tsx
@@ -0,0 +1,23 @@
+import {Button} from '@sentry/scraps/button';
+import {Flex, Stack} from '@sentry/scraps/layout';
+import {Text} from '@sentry/scraps/text';
+
+import {useServiceWorker} from 'sentry/serviceWorker/client/serviceWorkerContext';
+import * as Storybook from 'sentry/stories';
+
+export default Storybook.story('ServiceWorker', story => {
+ story('Basic Events', () => {
+ const {controller} = useServiceWorker();
+
+ return (
+
+
+
+
+ Look in the console for the ping event.
+
+ );
+ });
+});
diff --git a/static/app/serviceWorker/client/serviceWorkerContext.tsx b/static/app/serviceWorker/client/serviceWorkerContext.tsx
new file mode 100644
index 000000000000..718a5dbeac89
--- /dev/null
+++ b/static/app/serviceWorker/client/serviceWorkerContext.tsx
@@ -0,0 +1,136 @@
+import {createContext, useContext, useEffect} from 'react';
+import * as Sentry from '@sentry/react';
+
+import {useFrontendVersion} from 'sentry/components/frontendVersionContext';
+import {ServiceWorkerController} from 'sentry/serviceWorker/client/serviceWorkerInterface';
+
+const DEBUG_LOGGING = false;
+
+function log(message: string, options?: Sentry.metrics.MetricOptions) {
+ Sentry.metrics.count(`service-worker.register.${message}`, 1, options);
+ if (DEBUG_LOGGING) {
+ // eslint-disable-next-line no-console
+ console.log(`service-worker.register.${message}`);
+ }
+}
+
+function getWorkerUrl(): string {
+ return window.__SENTRY_DEV_UI ? '/entrypoints/service-worker.js' : '/service-worker.js';
+}
+
+const Context = createContext({
+ controller: new ServiceWorkerController(),
+});
+
+export function ServiceWorkerProvider({children}: {children: React.ReactNode}) {
+ const context = useContext(Context);
+
+ useRegisterServiceWorker();
+ useServiceWorkerUpdateCheck();
+ useLogControllerChangeEvent();
+
+ return {children};
+}
+
+/**
+ * @public
+ * @returns The service worker context.
+ * @example
+ * const {controller} = useServiceWorker();
+ * controller.postMessage({name: 'ping', type: 'event'});
+ */
+export function useServiceWorker() {
+ return useContext(Context);
+}
+
+/**
+ * Register a service worker and send event `worker.init` to the newest worker
+ * available.
+ */
+function useRegisterServiceWorker() {
+ useEffect(() => {
+ if (!('serviceWorker' in navigator)) {
+ log('not-supported');
+ return;
+ }
+
+ navigator.serviceWorker
+ // https://rspack.rs/guide/features/web-workers
+ .register(getWorkerUrl(), {scope: '/'})
+ .then(registration => {
+ log('registered', {
+ attributes: {
+ // An old version could be active while the new instance is incoming
+ active: registration.active ? 'true' : 'false',
+ // The new instance should be `installing` to start, and should skip
+ // `waiting` to become `active` soon.
+ installing: registration.installing ? 'true' : 'false',
+ waiting: registration.waiting ? 'true' : 'false',
+ },
+ });
+
+ const worker = registration.installing;
+ worker?.addEventListener('statechange', () => {
+ log('statechange', {
+ attributes: {
+ state: worker.state,
+ },
+ });
+ });
+ })
+ .catch(error => {
+ log('error');
+ Sentry.captureException(error);
+ });
+ }, []);
+}
+
+function useServiceWorkerUpdateCheck() {
+ const {state} = useFrontendVersion();
+
+ useEffect(() => {
+ if (!('serviceWorker' in navigator) || state === 'current') {
+ return;
+ }
+
+ // A long-lived tab only learns about a new worker on the browser's
+ // infrequent periodic update check. Re-check whenever the tab becomes
+ // visible so a fresh deploy is picked up (trigger: install -> activate ->
+ // clients.claim -> controllerchange) as soon as the user returns to it.
+ const checkForUpdate = () => {
+ if (document.visibilityState !== 'visible') {
+ return;
+ }
+ log('update-check');
+ navigator.serviceWorker.ready
+ .then(registration => registration.update())
+ .catch(error => {
+ Sentry.captureException(error);
+ });
+ };
+
+ document.addEventListener('visibilitychange', checkForUpdate);
+ return () => {
+ document.removeEventListener('visibilitychange', checkForUpdate);
+ };
+ }, [state]);
+}
+
+function useLogControllerChangeEvent() {
+ useEffect(() => {
+ if (!('serviceWorker' in navigator)) {
+ return;
+ }
+
+ // Log whenever controllerchange happens, which means we have a new worker
+ // ready to listen to handle fetch and push requests.
+ // This event means that users have multiple tabs open at the same time, and
+ // the new workers are taking control of the pages.
+ const handler = () => log('controllerchange');
+
+ navigator.serviceWorker.addEventListener('controllerchange', handler);
+ return () => {
+ navigator.serviceWorker.removeEventListener('controllerchange', handler);
+ };
+ }, []);
+}
diff --git a/static/app/serviceWorker/client/serviceWorkerInterface.ts b/static/app/serviceWorker/client/serviceWorkerInterface.ts
new file mode 100644
index 000000000000..388520241b2e
--- /dev/null
+++ b/static/app/serviceWorker/client/serviceWorkerInterface.ts
@@ -0,0 +1,47 @@
+import * as Sentry from '@sentry/react';
+
+import type {EventMessage} from 'sentry/serviceWorker/types';
+
+/**
+ * Sends messages from the page to the service worker.
+ *
+ * Every message must be an `EventMessage` (see `sentry/serviceWorker/types`).
+ * Use to notify the worker that something has happened on the page.
+ */
+export class ServiceWorkerController {
+ /**
+ * Find the service worker that this page should send messages to.
+ *
+ * Since we only need to post messages & not intercept network requests,
+ * we use `navigator.serviceWorker.ready` to get the active worker.
+ * We don't need to wait for the page to be controlled by the worker.
+ */
+ private async getWorker(): Promise {
+ if (!('serviceWorker' in navigator)) {
+ return null;
+ }
+ // For more read: https://web.dev/articles/service-worker-lifecycle
+ const registration = await navigator.serviceWorker.ready;
+ return registration.installing ?? registration.waiting ?? registration.active;
+ }
+
+ public postMessage(message: EventMessage): Promise {
+ return Sentry.startSpan(
+ {
+ name: 'service-worker.controller',
+ op: 'sw.postMessage',
+ attributes: {type: message.type, name: message.name},
+ },
+ async () => {
+ const worker = await this.getWorker();
+ if (!worker) {
+ return;
+ }
+ if (message.type === 'event') {
+ worker.postMessage(message);
+ return;
+ }
+ }
+ );
+ }
+}
diff --git a/static/app/serviceWorker/types.ts b/static/app/serviceWorker/types.ts
new file mode 100644
index 000000000000..30069eda072c
--- /dev/null
+++ b/static/app/serviceWorker/types.ts
@@ -0,0 +1,4 @@
+export type EventMessage = {
+ name: 'ping';
+ type: 'event';
+};
diff --git a/static/app/serviceWorker/worker/getUnhandledRejectionError.ts b/static/app/serviceWorker/worker/getUnhandledRejectionError.ts
new file mode 100644
index 000000000000..451a97a32757
--- /dev/null
+++ b/static/app/serviceWorker/worker/getUnhandledRejectionError.ts
@@ -0,0 +1,72 @@
+/**
+ * The file is extracted from `@sentry/javascript/packages/core/src/integrations/globalhandlers.ts`
+ */
+
+type Primitive = number | string | boolean | bigint | symbol | null | undefined;
+
+type ParameterizedString = string & {
+ __sentry_template_string__?: string;
+ __sentry_template_values__?: unknown[];
+};
+
+/**
+ * Checks whether given string is parameterized
+ * {@link isParameterizedString}.
+ *
+ * @param wat A value to be checked.
+ * @returns A boolean representing the result.
+ */
+function isParameterizedString(wat: unknown): wat is ParameterizedString {
+ return (
+ typeof wat === 'object' &&
+ wat !== null &&
+ '__sentry_template_string__' in wat &&
+ '__sentry_template_values__' in wat
+ );
+}
+
+/**
+ * Checks whether given value is a primitive (undefined, null, number, boolean, string, bigint, symbol)
+ * {@link isPrimitive}.
+ *
+ * @param wat A value to be checked.
+ * @returns A boolean representing the result.
+ */
+function isPrimitive(wat: unknown): wat is Primitive {
+ return (
+ wat === null ||
+ isParameterizedString(wat) ||
+ (typeof wat !== 'object' && typeof wat !== 'function')
+ );
+}
+
+export function getUnhandledRejectionError(error: unknown): unknown {
+ if (isPrimitive(error)) {
+ return error;
+ }
+
+ // dig the object of the rejection out of known event types
+ try {
+ type ErrorWithReason = {reason: unknown};
+ // PromiseRejectionEvents store the object of the rejection under 'reason'
+ // see https://developer.mozilla.org/en-US/docs/Web/API/PromiseRejectionEvent
+ if ('reason' in (error as ErrorWithReason)) {
+ return (error as ErrorWithReason).reason;
+ }
+
+ type CustomEventWithDetail = {detail: {reason: unknown}};
+ // something, somewhere, (likely a browser extension) effectively casts PromiseRejectionEvents
+ // to CustomEvents, moving the `promise` and `reason` attributes of the PRE into
+ // the CustomEvent's `detail` attribute, since they're not part of CustomEvent's spec
+ // see https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent and
+ // https://github.com/getsentry/sentry-javascript/issues/2380
+ if (
+ 'detail' in (error as CustomEventWithDetail) &&
+ 'reason' in (error as CustomEventWithDetail).detail
+ ) {
+ return (error as CustomEventWithDetail).detail.reason;
+ }
+ } catch {} // eslint-disable-line no-empty
+
+ return error;
+}
diff --git a/static/app/serviceWorker/worker/handleInboundEvent.ts b/static/app/serviceWorker/worker/handleInboundEvent.ts
new file mode 100644
index 000000000000..87de8007034a
--- /dev/null
+++ b/static/app/serviceWorker/worker/handleInboundEvent.ts
@@ -0,0 +1,14 @@
+import type {EventMessage} from 'sentry/serviceWorker/types';
+
+export function handleInboundEvent(
+ _sw: ServiceWorkerGlobalScope,
+ message: EventMessage
+): void | Promise {
+ switch (message.name) {
+ case 'ping':
+ // eslint-disable-next-line no-console
+ return console.log('pong!');
+ default:
+ return;
+ }
+}
diff --git a/static/app/serviceWorker/worker/initializeSentry.ts b/static/app/serviceWorker/worker/initializeSentry.ts
new file mode 100644
index 000000000000..f09900fb64df
--- /dev/null
+++ b/static/app/serviceWorker/worker/initializeSentry.ts
@@ -0,0 +1,139 @@
+import * as Sentry from '@sentry/browser';
+import type {Breadcrumb} from '@sentry/browser';
+
+import {
+ IGNORED_BREADCRUMB_FETCH_HOSTS,
+ IGNORED_SPAN_NAMES,
+ SENTRY_RELEASE_VERSION,
+ SPA_DSN,
+ SPA_MODE_ALLOW_URLS,
+ SPA_MODE_TRACE_PROPAGATION_TARGETS,
+} from 'sentry/constants/sdk';
+
+let lastEventId: string | undefined;
+
+/**
+ * @public
+ * @returns The last event id.
+ */
+export function getLastEventId(): string | undefined {
+ return lastEventId;
+}
+
+// A subset of the Config interface. (see: sentry/static/app/types/system.tsx)
+export interface Props {
+ apmSampling: number;
+ customerDomain: {
+ organizationUrl: string | undefined;
+ sentryUrl: string;
+ subdomain: string;
+ } | null;
+ dsn: string;
+ sentryConfig: {
+ allowUrls: string[];
+ dsn: string;
+ release: string;
+ tracePropagationTargets: string[];
+ environment?: string;
+ profileSessionSampleRate?: number;
+ };
+ userIdentity: {
+ email: string;
+ id: string;
+ ip_address: string;
+ isStaff: boolean;
+ };
+}
+
+export function initializeSentry({
+ apmSampling,
+ customerDomain,
+ dsn,
+ sentryConfig,
+ userIdentity,
+}: Props) {
+ Sentry.init({
+ allowUrls: SPA_DSN ? SPA_MODE_ALLOW_URLS : sentryConfig.allowUrls,
+ dsn: SPA_DSN || dsn,
+ release: SENTRY_RELEASE_VERSION ?? sentryConfig.release,
+ environment: sentryConfig.environment,
+
+ dataCollection: {},
+ enableLogs: true,
+ _experiments: {
+ enableMetrics: true,
+ },
+
+ defaultIntegrations: false,
+
+ beforeBreadcrumb(crumb) {
+ return isFilteredBreadcrumb(crumb) ? null : crumb;
+ },
+
+ beforeSend(event, hint) {
+ lastEventId = event.event_id || hint.event_id;
+
+ return event;
+ },
+
+ ignoreErrors: [
+ /**
+ * There is a bug in Safari, that causes `AbortError` when fetch is
+ * aborted, and you are in the middle of reading the response. In Chrome
+ * and other browsers, it is handled gracefully, where in Safari, it
+ * produces additional error, that is jumping outside of the original
+ * Promise chain and bubbles up to the `unhandledRejection` handler, that
+ * we then captures as error.
+ *
+ * Ref: https://bugs.webkit.org/show_bug.cgi?id=215771
+ */
+ /AbortError: Fetch is aborted/i,
+ /AbortError: The operation was aborted/i,
+ /AbortError: signal is aborted without reason/i,
+ /AbortError: The user aborted a request/i,
+ ],
+ ignoreSpans: IGNORED_SPAN_NAMES,
+
+ tracesSampleRate: apmSampling ?? 0,
+ tracePropagationTargets: [
+ 'localhost',
+ /^\//,
+ ...(SPA_DSN
+ ? SPA_MODE_TRACE_PROPAGATION_TARGETS
+ : sentryConfig.tracePropagationTargets),
+ ],
+ });
+
+ Sentry.addEventProcessor((event: Sentry.Event, _hint?: Sentry.EventHint) => {
+ event.tags = event.tags || {};
+ return event;
+ });
+
+ if (SENTRY_RELEASE_VERSION) {
+ Sentry.setTag('sentry_version', SENTRY_RELEASE_VERSION);
+ }
+
+ if (userIdentity) {
+ Sentry.setUser(userIdentity);
+ }
+
+ if (customerDomain) {
+ Sentry.setTag('isCustomerDomain', 'yes');
+ Sentry.setTag('customerDomain.organizationUrl', customerDomain.organizationUrl);
+ Sentry.setTag('customerDomain.sentryUrl', customerDomain.sentryUrl);
+ Sentry.setTag('customerDomain.subdomain', customerDomain.subdomain);
+ }
+}
+
+function isFilteredBreadcrumb(crumb: Breadcrumb): boolean {
+ // Ignore fetch/xhr requests to certain hosts
+ const isFetch = crumb.category === 'fetch' || crumb.category === 'xhr';
+ if (
+ isFetch &&
+ IGNORED_BREADCRUMB_FETCH_HOSTS.some(host => crumb.data?.url?.includes(host))
+ ) {
+ return true;
+ }
+
+ return false;
+}
diff --git a/static/app/serviceWorker/worker/worker.ts b/static/app/serviceWorker/worker/worker.ts
index 626ce9960e6b..b248095cd247 100644
--- a/static/app/serviceWorker/worker/worker.ts
+++ b/static/app/serviceWorker/worker/worker.ts
@@ -1,30 +1,80 @@
-import {registerWebWorker} from '@sentry/browser';
-import {startSpan} from '@sentry/core';
+import * as Sentry from '@sentry/browser';
+
+import {getUnhandledRejectionError} from 'sentry/serviceWorker/worker/getUnhandledRejectionError';
+import {handleInboundEvent} from 'sentry/serviceWorker/worker/handleInboundEvent';
+import {initializeSentry} from 'sentry/serviceWorker/worker/initializeSentry';
const sw = self as unknown as ServiceWorkerGlobalScope;
-// registerWebWorker expects DedicatedWorkerGlobalScope.postMessage, which
-// doesn't exist on ServiceWorkerGlobalScope. The cast is safe: the only
-// call to postMessage sends debug IDs and will be a no-op here; the
-// unhandledrejection handler still registers correctly.
-registerWebWorker({self: sw as any});
+const DEBUG_LOGGING = false;
+
+function log(message: string) {
+ Sentry.metrics.count(`service-worker.worker.${message}`);
+ if (DEBUG_LOGGING) {
+ // eslint-disable-next-line no-console
+ console.log(`service-worker.worker.${message}`);
+ }
+}
-sw.addEventListener('install', () => {
- startSpan({name: 'service-worker.install', op: 'sw.lifecycle'}, () => {
- return sw.skipWaiting();
- });
+sw.addEventListener('install', event => {
+ log('onInstall');
+ event.waitUntil(
+ Promise.all([
+ // Force activation to happen without waiting for the current worker to
+ // have zero clients. This worker instance will activate and claim all
+ // the clients for itself.
+ sw.skipWaiting(),
+ // If this fetch fails, or initializeSentry throws an error, the worker
+ // will not be 'installed' and cannot be activated. We'll try again when
+ // the next update-check downloads a new version.
+ fetch('/api/client-config/', {
+ credentials: 'include',
+ headers: {'Content-Type': 'application/json'},
+ })
+ .then(data => data.json())
+ .then(initializeSentry),
+ ]).then(() => {
+ log('didInstall');
+ })
+ );
});
sw.addEventListener('activate', event => {
+ log('onActivate');
+
+ event.waitUntil(sw.clients.claim());
+});
+
+sw.addEventListener('unhandledrejection', (event: unknown) => {
+ log('onUnhandledRejection');
+ const reason = getUnhandledRejectionError(event);
+ Sentry.captureException(reason);
+});
+
+sw.addEventListener('message', event => {
+ if (!event.data || typeof event.data !== 'object') {
+ return;
+ }
event.waitUntil(
- startSpan({name: 'service-worker.activate', op: 'sw.lifecycle'}, () =>
- sw.clients.claim()
+ Sentry.startSpan(
+ {
+ name: 'service-worker.worker.onMessage',
+ op: 'sw.onmessage',
+ attributes: {
+ type: event.data.type,
+ name: event.data.name,
+ messageId: event.data.messageId,
+ },
+ },
+ async () => {
+ if (DEBUG_LOGGING) {
+ // eslint-disable-next-line no-console
+ console.log('service-worker.worker.onMessage');
+ }
+ if (event.data.type === 'event') {
+ await handleInboundEvent(sw, event.data);
+ }
+ }
)
);
});
-
-sw.addEventListener('message', _event => {
- startSpan({name: 'service-worker.message', op: 'sw.message'}, () => {
- // No custom message handlers yet
- });
-});