Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/notifications/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@automattic/components": "workspace:^",
"@automattic/i18n-utils": "workspace:^",
"@automattic/webpack-extensive-lodash-replacement-plugin": "workspace:^",
"@sentry/browser": "^7.54.0",
"@wordpress/components": "^35.0.0",
"@wordpress/compose": "^8.1.0",
"@wordpress/data": "^10.48.0",
Expand Down
50 changes: 50 additions & 0 deletions apps/notifications/src/lib/sentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as Sentry from '@sentry/browser';

// The standalone notifications widget ships with no error reporting of its own,
// so boot failures (a hung proxy-auth request, a render crash) leave the user
// staring at a spinner with no signal on our side. Report through the shared
// Calypso Sentry project, tagged so widget events stay filterable.
const DSN = 'https://61275d63a504465ab315245f1a379dab@o248881.ingest.sentry.io/6313676';

let enabled = false;

const isProductionHost = () =>
typeof window !== 'undefined' && window.location.hostname === 'widgets.wp.com';

/**
* Initialize Sentry for the standalone notifications widget.
*
* Calling `Sentry.init` also installs the global `error` / `unhandledrejection`
* handlers, so unhandled boot rejections are captured for free. Safe to call
* more than once; only the first call takes effect.
*/
export function initSentry() {
if ( enabled || typeof window === 'undefined' ) {
return;
}

Sentry.init( {
dsn: DSN,
environment: isProductionHost() ? 'production' : 'development',
// Errors only — no performance tracing — to keep the widget bundle light.
// Active incident: capture every error until the failure modes are understood.
sampleRate: 1.0,
// Drop noise from browser extensions injecting into the iframe.
denyUrls: [ /^[a-z]+(-[a-z]+)?-extension:\/\//i ],
} );
Sentry.setTags( { feature: 'notifications', surface: 'wp-admin-standalone' } );

enabled = true;
}

/**
* Report an error to Sentry. No-op until `initSentry` has run, so the shared
* REST client stays silent on surfaces that never opt in (the old panel, the
* in-Calypso popover).
*/
export function captureException( error: unknown, context?: Record< string, unknown > ) {
if ( ! enabled ) {
return;
}
Sentry.captureException( error, context ? { extra: context } : undefined );
}
55 changes: 55 additions & 0 deletions apps/notifications/src/lib/test/sentry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @jest-environment jsdom
*/

const mockInit = jest.fn();
const mockCaptureException = jest.fn();
const mockSetTags = jest.fn();

jest.mock( '@sentry/browser', () => ( {
init: ( ...args: unknown[] ) => mockInit( ...args ),
captureException: ( ...args: unknown[] ) => mockCaptureException( ...args ),
setTags: ( ...args: unknown[] ) => mockSetTags( ...args ),
} ) );

describe( 'notifications Sentry wrapper', () => {
beforeEach( () => {
// Reset the module so its `enabled` flag starts fresh for each case.
jest.resetModules();
mockInit.mockClear();
mockCaptureException.mockClear();
mockSetTags.mockClear();
} );

it( 'does not report until initialized', async () => {
const { captureException } = await import( '../sentry' );
captureException( new Error( 'boom' ) );
expect( mockCaptureException ).not.toHaveBeenCalled();
} );

it( 'initializes Sentry and tags widget events', async () => {
const { initSentry } = await import( '../sentry' );
initSentry();
expect( mockInit ).toHaveBeenCalledTimes( 1 );
expect( mockSetTags ).toHaveBeenCalledWith(
expect.objectContaining( { feature: 'notifications', surface: 'wp-admin-standalone' } )
);
} );

it( 'reports once initialized, passing context as extra', async () => {
const { initSentry, captureException } = await import( '../sentry' );
initSentry();
const error = new Error( 'boom' );
captureException( error, { phase: 'createClient' } );
expect( mockCaptureException ).toHaveBeenCalledWith( error, {
extra: { phase: 'createClient' },
} );
} );

it( 'initializes Sentry only once', async () => {
const { initSentry } = await import( '../sentry' );
initSentry();
initSentry();
expect( mockInit ).toHaveBeenCalledTimes( 1 );
} );
} );
8 changes: 8 additions & 0 deletions apps/notifications/src/panel/rest-client/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import debugFactory from 'debug';
import { captureException } from '../../lib/sentry';
import repliesCache from '../comment-replies-cache';
import { store } from '../state';
import actions from '../state/actions';
Expand Down Expand Up @@ -221,6 +222,13 @@ function getNotes( before ) {
store.dispatch( actions.ui.loadedNotes() );
return;
}
// A top-window fetch failure never clears the loading state — it just
// retries with backoff — so the panel spins forever. Report the first
// failure of a streak (no-op unless the host opted into Sentry) so the
// stuck state is visible. `this.retries` resets to 0 on success.
if ( this.retries === 0 ) {
captureException( error, { phase: 'getNotes', locale: this.locale } );
}
/*
* Something failed, so try again and reset the local noteList copy.
* We might have optimistically modified it when we last compared it
Expand Down
52 changes: 52 additions & 0 deletions apps/notifications/src/standalone-app/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { captureException } from '../lib/sentry';

/**
* Shown when the widget can't render — either a render-time crash caught by the
* boundary below, or a boot failure (e.g. the proxy-auth request never
* resolving). Surfaces the failure and a way to recover instead of a frozen
* spinner.
*/
export const NotificationsErrorFallback = () => (
<div className="wpnc-app__error-boundary">
<p>{ __( 'Notifications couldn’t load.', 'notifications' ) }</p>
<Button variant="secondary" onClick={ () => window.location.reload() }>
{ __( 'Reload', 'notifications' ) }
</Button>
</div>
);

type Props = {
children: ReactNode;
};

type State = {
hasError: boolean;
};

/**
* Catches render-time crashes in the notifications widget and reports them to
* Sentry. Without this, an exception unmounts the React tree and leaves the
* user on a frozen spinner.
*/
export default class ErrorBoundary extends Component< Props, State > {
state: State = { hasError: false };

static getDerivedStateFromError(): State {
return { hasError: true };
}

componentDidCatch( error: Error, errorInfo: ErrorInfo ) {
captureException( error, { componentStack: errorInfo.componentStack } );
}

render() {
if ( ! this.state.hasError ) {
return this.props.children;
}

return <NotificationsErrorFallback />;
}
}
40 changes: 36 additions & 4 deletions apps/notifications/src/standalone-app/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ import { setLocale } from 'i18n-calypso';
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import NotificationApp, { refreshNotes } from '../app';
import { captureException, initSentry } from '../lib/sentry';
import { SET_IS_SHOWING } from '../panel/state/action-types';
import actions from '../panel/state/actions';
import { createClient } from '../standalone/client';
import { receiveMessage, sendMessage } from '../standalone/messaging';
import ErrorBoundary, { NotificationsErrorFallback } from './error-boundary';

import './style.scss';

// Install error reporting before anything else runs, so boot failures below
// (createClient, render) are captured rather than silently spinning forever.
initSentry();

const debug = debugFactory( 'notifications:standalone-app' );

const localePattern = /[&?]locale=([\w_-]+)/;
Expand All @@ -34,7 +40,11 @@ const fetchLocale = async ( localeSlug ) => {
// Sync @wordpress/i18n first — setLocale triggers re-renders that call __()
setLocaleData( localeData );
setLocale( localeData );
} catch {}
} catch ( error ) {
// Non-fatal: the widget falls back to English. Report so a broken locale
// bundle is visible rather than silently degrading every translated user.
captureException( error, { phase: 'fetchLocale', locale: localeSlug } );
}
};

// The notifications app (src/app) owns its Redux store internally and exposes
Expand Down Expand Up @@ -232,7 +242,22 @@ const render = ( wpcom ) => {
applyAdminThemeVars();

const root = createRoot( document.getElementsByClassName( 'wpnc__main' )[ 0 ] );
root.render( <NotesWrapper wpcom={ wpcom } /> );
root.render(
<ErrorBoundary>
<NotesWrapper wpcom={ wpcom } />
</ErrorBoundary>
);
};

// Replace the boot spinner with a recoverable error message when the widget
// can't start at all — most often because the proxy-auth request never
// resolves (e.g. blocked third-party cookies in the wp-admin iframe).
const renderBootError = () => {
const container = document.getElementsByClassName( 'wpnc__main' )[ 0 ];
if ( ! container ) {
return;
}
createRoot( container ).render( <NotificationsErrorFallback /> );
};

const setTracksUser = ( wpcom ) => {
Expand All @@ -242,12 +267,19 @@ const setTracksUser = ( wpcom ) => {
window._tkq = window._tkq || [];
window._tkq.push( [ 'identifyUser', ID, username ] );
} )
.catch( () => {} );
.catch( ( error ) => captureException( error, { phase: 'setTracksUser' } ) );
};

const init = ( wpcom ) => {
setTracksUser( wpcom );
render( wpcom );
};

createClient().then( init );
createClient()
.then( init )
.catch( ( error ) => {
// The widget can't start without an authenticated client. Report it and
// show the fallback so the user isn't left on an endless spinner.
captureException( error, { phase: 'createClient' } );
renderBootError();
} );
41 changes: 41 additions & 0 deletions apps/notifications/src/standalone-app/test/error-boundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @jest-environment jsdom
*/
import { render, screen } from '@testing-library/react';
import { captureException } from '../../lib/sentry';
import ErrorBoundary from '../error-boundary';

jest.mock( '../../lib/sentry', () => ( {
captureException: jest.fn(),
} ) );

const Boom = () => {
throw new Error( 'render crash' );
};

describe( 'notifications ErrorBoundary', () => {
it( 'renders children when there is no error', () => {
render(
<ErrorBoundary>
<div>child content</div>
</ErrorBoundary>
);
expect( screen.getByText( 'child content' ) ).toBeTruthy();
} );

it( 'shows the fallback and reports a render crash', () => {
// React logs caught render errors to console.error; silence it.
const consoleError = jest.spyOn( console, 'error' ).mockImplementation( () => {} );

render(
<ErrorBoundary>
<Boom />
</ErrorBoundary>
);

expect( screen.getByText( 'Notifications couldn’t load.' ) ).toBeTruthy();
expect( captureException ).toHaveBeenCalledWith( expect.any( Error ), expect.any( Object ) );

consoleError.mockRestore();
} );
} );
3 changes: 2 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1776,6 +1776,7 @@ __metadata:
"@automattic/languages": "workspace:^"
"@automattic/webpack-extensive-lodash-replacement-plugin": "workspace:^"
"@automattic/wp-babel-makepot": "workspace:^"
"@sentry/browser": "npm:^7.54.0"
"@wordpress/components": "npm:^35.0.0"
"@wordpress/compose": "npm:^8.1.0"
"@wordpress/data": "npm:^10.48.0"
Expand Down Expand Up @@ -8053,7 +8054,7 @@ __metadata:
languageName: node
linkType: hard

"@sentry/browser@npm:7.120.4":
"@sentry/browser@npm:7.120.4, @sentry/browser@npm:^7.54.0":
version: 7.120.4
resolution: "@sentry/browser@npm:7.120.4"
dependencies:
Expand Down
Loading