Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
fb03853
Publish the error reporting gate, version and script origins
adamwoodnz Nov 9, 2026
3f044c3
Add the dashboard-frame check for error reporting
adamwoodnz Nov 9, 2026
4f66cb7
Match origins only on stack frame lines
adamwoodnz Nov 9, 2026
00e7eb8
Accept spaced labels in Safari and Firefox stack frames
adamwoodnz Nov 9, 2026
c0cd05d
Keep personal data out of dashboard error reports
adamwoodnz Nov 9, 2026
7adeab7
Stop echoing user-controlled parameter names in error reports
adamwoodnz Nov 9, 2026
779f004
Add the dashboard error reporter with a dedupe and a request budget
adamwoodnz Nov 9, 2026
485a0de
Keep the page query string out of error text and cap its size
adamwoodnz Nov 9, 2026
81ab2aa
Repair a corrupt error budget record and redact URLs in error text
adamwoodnz Nov 9, 2026
2ea6f02
Premium Analytics: install window error listeners from init
adamwoodnz Nov 9, 2026
0d2361e
Report render crashes from a boundary that rethrows to the host fallback
adamwoodnz Nov 9, 2026
2adeb6a
Rethrow falsy thrown values from the error boundary
adamwoodnz Nov 9, 2026
4df273a
Report an empty thrown string as Non-error thrown
adamwoodnz Nov 9, 2026
0f4e018
Report render crashes from every route stage and from WidgetRoot
adamwoodnz Nov 9, 2026
32645e2
Discover route stages in the boundary test and pin the WidgetRoot pla…
adamwoodnz Nov 9, 2026
c4fee1a
Cover the outermost WidgetRoot provider and accept stage.ts in route …
adamwoodnz Nov 9, 2026
a37526b
Stats: Let widget packages add their script origin to error capture
adamwoodnz Nov 9, 2026
2ce11a5
Stats: Hydrate the widget registry before publishing error origins
adamwoodnz Nov 9, 2026
046f411
test(premium-analytics): Reset the widget registry in tear_down
adamwoodnz Nov 9, 2026
40efed3
changelog: Add entries for the widget script origin change
adamwoodnz Nov 9, 2026
c97e6ea
Premium Analytics: Test the error reporting wiring and document the r…
adamwoodnz Nov 9, 2026
4c936d0
docs(premium-analytics): Correct the error reporting README
adamwoodnz Nov 9, 2026
e1be2fd
fix: share one error reporter across every build unit
adamwoodnz Nov 9, 2026
8cb652b
fix: stop the reporter only for the statuses that ask it to
adamwoodnz Nov 9, 2026
c22f432
fix: keep an internal service name out of the changelog
adamwoodnz Nov 9, 2026
efc4cb4
fix: do not let a filter break the script origin list
adamwoodnz Nov 9, 2026
b74c1d4
fix: pin the origin filter above the registrant's early return
adamwoodnz Nov 9, 2026
ed3ac56
fix(premium-analytics): Close four gaps in the error reporter
adamwoodnz Nov 9, 2026
628d986
fix(premium-analytics): Keep the reported query out of the log index
adamwoodnz Nov 9, 2026
38c861d
fix(premium-analytics): Redact email addresses from reported free text
adamwoodnz Nov 9, 2026
ead5279
fix(premium-analytics): State that an error report carries no credent…
adamwoodnz Nov 9, 2026
1e07da4
fix(premium-analytics): Report the package version under properties
adamwoodnz Nov 9, 2026
2a36470
fix(premium-analytics): Back off on any refusal, not only 403 and 429
adamwoodnz Nov 9, 2026
de5c487
fix(premium-analytics): Tag a failed module load so searches can excl…
adamwoodnz Nov 9, 2026
bc1d151
docs(premium-analytics): Bring the error reporting README up to date
adamwoodnz Nov 9, 2026
744dc54
fix(premium-analytics): Tag a webpack chunk failure too
adamwoodnz Nov 9, 2026
e7ae8d5
feat(premium-analytics): Tag a crash a page-mutating agent caused
adamwoodnz Nov 9, 2026
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
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Significance: patch
Type: changed
Comment: Report this package's dashboard widget script origin so the Premium Analytics error reporter recognises its frames.
15 changes: 15 additions & 0 deletions projects/packages/ads/src/class-analytics-dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ public static function get_default_layout() {
* @return void
*/
public static function register_widget_types( $registry ) {
add_filter( 'jetpack_premium_analytics_error_reporting_script_origins', array( __CLASS__, 'add_error_reporting_origin' ) );

if ( ! self::widget_contract_is_supported() ) {
return;
}
Expand All @@ -144,6 +146,19 @@ public static function register_widget_types( $registry ) {
);
}

/**
* Append this package's build base URL to the dashboard's error reporting origins.
*
* @param string[] $origins Absolute URL prefixes, each ending in a slash.
* @return string[]
*/
public static function add_error_reporting_origin( $origins ) {
$origins = is_array( $origins ) ? $origins : array();
$origins[] = trailingslashit( plugins_url( '', self::build_dir() . '/build.php' ) );

return $origins;
}

/**
* Whether the dashboard's widget contract is one the widgets were built against: at least the
* minimum, and not the next major.
Expand Down
15 changes: 15 additions & 0 deletions projects/packages/ads/tests/php/Analytics_Dashboard_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class Analytics_Dashboard_Test extends BaseTestCase {
public function tear_down() {
remove_action( Analytics_Dashboard::REGISTER_SECTIONS_ACTION, array( Analytics_Dashboard::class, 'register_section' ), 20 );
remove_action( Analytics_Dashboard::REGISTER_WIDGET_TYPES_ACTION, array( Analytics_Dashboard::class, 'register_widget_types' ), 20 );
remove_filter( 'jetpack_premium_analytics_error_reporting_script_origins', array( Analytics_Dashboard::class, 'add_error_reporting_origin' ) );

foreach ( array( Dashboard_Section_Registry::class, Widget_Type_Registry::class ) as $class ) {
$instance = new \ReflectionProperty( $class, 'instance' );
Expand Down Expand Up @@ -133,6 +134,20 @@ public function test_registers_the_widget_types_from_the_manifest_with_the_catal
$this->assertSame( 'already-set', $registry->get_registered( 'wordads/highlights' )->textdomain );
}

/**
* The dashboard reports errors thrown by scripts under the package's build.
*/
public function test_publishes_its_build_url_as_an_error_reporting_origin() {
$registry = new Widget_Type_Registry();

Analytics_Dashboard::register_widget_types( $registry );

$origins = apply_filters( 'jetpack_premium_analytics_error_reporting_script_origins', array() );

$this->assertCount( 1, $origins );
$this->assertStringStartsWith( $origins[0], $registry->get_registered( 'wordads/chart-tabs' )->i18n_manifest );
}

/**
* An administrator, the role that can view ad reports.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
require_once __DIR__ . '/../../vendor/automattic/jetpack-premium-analytics/src/default-dashboard-sections.php';

/**
* Without the widget contract version, the section still registers and the widget types wait.
* Without the widget contract version, the section still registers, the widget types wait, and the
* error reporting origin is published anyway.
*
* Each test runs in its own process: the other test files define the constant for the whole run.
*
Expand Down Expand Up @@ -51,4 +52,19 @@ public function test_the_widget_types_wait_for_the_widget_contract_version() {

$this->assertSame( array(), $registry->get_all_registered() );
}

/**
* On WordPress.com Simple the registrant is called directly, so the origin must reach the
* dashboard even on the run where the registrant registers nothing.
*/
public function test_the_error_reporting_origin_does_not_wait() {
$this->assertFalse( defined( 'Automattic\\Jetpack\\PremiumAnalytics\\WIDGET_API_VERSION' ) );

Analytics_Dashboard::register_widget_types( new Widget_Type_Registry() );

$origins = apply_filters( 'jetpack_premium_analytics_error_reporting_script_origins', array() );

$this->assertCount( 1, $origins );
$this->assertStringEndsWith( '/', $origins[0] );
}
}
4 changes: 2 additions & 2 deletions projects/packages/premium-analytics/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ English until the next `pnpm run build`.

Storybook's Vite config maps bare `@jetpack-premium-analytics/*` imports to `src/`, but that only
takes effect for packages whose `package.json` declares no `module` field (`datetime`,
`formatters`, `icons`, `routing`). The rest — `data`, `externals`, `fields`, `init`, `sdk`,
`site-sync`, `ui`, `widgets-toolkit` — point `module` at `build-module/index.mjs`, and Storybook
`formatters`, `icons`, `routing`). The rest — `data`, `error-reporting`, `externals`, `fields`,
`init`, `sdk`, `site-sync`, `ui`, `widgets-toolkit` — point `module` at `build-module/index.mjs`, and Storybook
loads that artifact instead, so an edit to their source is invisible there until it is rebuilt. A
newly added export surfaces as `The requested module '…/build-module/index.mjs' does not provide an
export named 'X'`, and a changed one silently renders the old behaviour. Run `pnpm run build` (or
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Report uncaught dashboard JavaScript errors to WordPress.com on WordPress.com Simple and Atomic sites.
1 change: 1 addition & 0 deletions projects/packages/premium-analytics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@date-fns/tz": "1.5.0",
"@jetpack-premium-analytics/data": "link:packages/data",
"@jetpack-premium-analytics/datetime": "link:packages/datetime",
"@jetpack-premium-analytics/error-reporting": "link:packages/error-reporting",
"@jetpack-premium-analytics/externals": "link:packages/externals",
"@jetpack-premium-analytics/fields": "link:packages/fields",
"@jetpack-premium-analytics/formatters": "link:packages/formatters",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Error reporting

Reports uncaught Premium Analytics dashboard errors to WordPress.com's JS error log, so crashes on sites we cannot reproduce become visible.

It reports uncaught exceptions only. An API failure the UI already handles and shows to the user is not an error report, and must not be sent.

## Sources

Every report carries one of three sources.

- `boundary`: `StatsErrorBoundary` caught a render error. React already proved the error came from our tree, so the report is sent whether or not the stack has a dashboard frame. The boundary then rethrows, so the host's own boundary still renders the fallback UI.
- `error`: the window `error` event, installed by `installGlobalErrorHandlers()`.
- `unhandledrejection`: the window `unhandledrejection` event, installed by the same call.

The last two are dropped unless the stack has a frame inside a known dashboard script origin. A wp-admin page also runs other plugins' and browser extensions' scripts, and their errors are not ours to report.

## The gate

PHP decides whether reporting is on. The package only reads the boolean `premium_analytics.error_reporting.enabled` from the script data, and sends nothing when it is false or missing. Today only WordPress.com Simple and WoA sites report. Widening that to self-hosted sites is a change to that one PHP condition, with no change here.

## Script origins

`premium_analytics.error_reporting.script_origins` lists the URL prefixes that mark a stack frame as dashboard code. It starts with this package's own `build/` URL. A package that ships its own build for the dashboard adds its build base URL, ending in a slash, through the `jetpack_premium_analytics_error_reporting_script_origins` filter. `ads`, `videopress` and `woocommerce-stats` already do this. Without it, an error thrown in that package's widget bundle is dropped as foreign when it arrives through `error` or `unhandledrejection`.

## Redaction

Nothing a reader typed may leave the browser, and the log index on the other side is shared with every other reporter that posts to this endpoint.

The message, the stack and the component stack lose the page's own query string and hash, the query of any absolute URL, and any email address. Truncation to the length limits runs after all of that, so a redaction cannot be cut in half. The email pattern is deliberately broader than a valid address: under-matching leaks an address, over-matching costs a few characters.

The page query reaches the report as one masked string, not as a field per parameter, because each parameter name would otherwise become a field in that shared index. A parameter whose name is not a plain identifier is dropped rather than masked: in string form the name itself is the leak, and there is no key left to hide it behind. A single marker records that something was removed.

## Dedupe and budget

A report is dropped when its redacted message and first dashboard frame match one already sent during the life of the page. The key uses the redacted message so that a message embedding the page query string does not produce a new key on every date-range change.

Each browser may send 10 reports per clock hour, counted in `localStorage` against the current hour bucket rather than a rolling window. When storage throws, the cap is 3 per page load.

Any refusal from the endpoint stops reporting for the rest of the hour, whatever its status, because a request sent while a ban is already active restarts that ban. Each refusal status is named once in the console outside production builds, so a stall does not pass for a healthy dashboard.

The budget exists because the endpoint rate-limits by IP, and a block there reaches the other Jetpack reporters sharing that IP, so a crash loop here must not be able to trigger one.

## The package version

The Premium Analytics package version travels in `properties.version`. The endpoint's top-level `jetpack_version` conventionally carries the Jetpack plugin version, so filling it with a package version would make a shared field disagree with every other reporter that sets it. `projects/packages/comments` reports its own version the same way.

## Errors that are not ours

Two kinds of crash reach us through the boundary without being a bug of ours. Both carry a tag so a search on this feature can exclude them.

A tab left open across a deploy asks for a module file that no longer exists. `React.lazy` turns the failed import into a render error, so the boundary reports it. Those carry `chunk-load`, matched from the four wordings that differ per browser and bundler, one of which only names the error rather than wording the message. Reloading the page would be the other answer, and no user-visible change is allowed here.

A translation tool or an extension that reparents React-managed text nodes crashes the commit phase with a `NotFoundError` from `insertBefore`. Those carry `dom-interference`, and `properties.dom_interference` names the agents found, as one field rather than one per agent. The probes run only while a report is being built, and each keys on an artefact only Chromium and extension translators leave behind: Safari and Firefox translate in place with no marker, so an absent value means undetectable rather than absent.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@automattic/jetpack-premium-analytics-error-reporting",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "build-module/index.mjs",
"wpScriptModuleExports": "./build-module/index.mjs",
"types": "src/index.ts",
"sideEffects": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { findDashboardFrame } from '../dashboard-frame';

const base =
'https://example.com/wp-content/plugins/jetpack/jetpack_vendor/automattic/jetpack-premium-analytics/build/';
const origins = [ base ];

describe( 'findDashboardFrame', () => {
it( 'returns the first frame under an allowlisted origin', () => {
const stack = [
'Error: boom',
' at helper (https://example.com/wp-includes/js/other.min.js:1:2)',
` at render (${ base }routes/dashboard/content.min.js:12:345)`,
` at later (${ base }routes/other/content.min.js:1:1)`,
].join( '\n' );

expect( findDashboardFrame( stack, origins )?.path ).toBe( 'routes/dashboard/content.min.js' );
} );

it( 'parses the line and column of that frame', () => {
const stack = `Error: boom\n at render (${ base }routes/dashboard/content.min.js:12:345)`;

expect( findDashboardFrame( stack, origins ) ).toMatchObject( {
url: `${ base }routes/dashboard/content.min.js:12:345`,
line: 12,
column: 345,
} );
} );

it( 'reads the widget name from a widgets/<name>/ path', () => {
const stack = `Error: boom\n at render (${ base }widgets/top-posts/render.min.js:3:4)`;

expect( findDashboardFrame( stack, origins )?.widget ).toBe( 'top-posts' );
} );

it( 'leaves widget undefined for a route frame', () => {
const stack = `Error: boom\n at render (${ base }routes/dashboard/content.min.js:3:4)`;

expect( findDashboardFrame( stack, origins )?.widget ).toBeUndefined();
} );

it( 'returns null when no frame is under an allowlisted origin', () => {
const stack = 'Error: boom\n at x (https://example.com/wp-includes/js/other.min.js:1:2)';

expect( findDashboardFrame( stack, origins ) ).toBeNull();
} );

it( 'returns null for an undefined stack', () => {
expect( findDashboardFrame( undefined, origins ) ).toBeNull();
} );

it( 'returns null for an empty origin list', () => {
const stack = `Error: boom\n at render (${ base }routes/dashboard/content.min.js:3:4)`;

expect( findDashboardFrame( stack, [] ) ).toBeNull();
} );

it( 'ignores an error message that contains a dashboard URL', () => {
const stack = [
`Error: Failed to fetch dynamically imported module: ${ origins[ 0 ] }routes/dashboard/content.min.js`,
' at https://example.com/wp-includes/js/other.min.js:1:2',
].join( '\n' );

expect( findDashboardFrame( stack, origins ) ).toBeNull();
} );

it( 'returns null when the origin list is absent', () => {
const stack = `Error: boom\n at render (${ base }routes/dashboard/content.min.js:3:4)`;

expect( findDashboardFrame( stack, undefined ) ).toBeNull();
} );

it( 'returns null when an origin is an empty string', () => {
const stack = 'Error: boom\n at x (https://example.com/wp-includes/js/other.min.js:1:2)';

expect( findDashboardFrame( stack, [ '' ] ) ).toBeNull();
} );

it( 'parses a Firefox-style frame', () => {
const stack = `fn@${ base }widgets/x/render.min.js:4:9\nother@https://example.com/a.js:1:1`;

expect( findDashboardFrame( stack, origins ) ).toEqual( {
url: `${ base }widgets/x/render.min.js:4:9`,
path: 'widgets/x/render.min.js',
widget: 'x',
line: 4,
column: 9,
} );
} );

it( 'parses a Safari module code frame', () => {
const stack = [
'fn@https://example.com/wp-includes/js/other.min.js:1:2',
`module code@${ origins[ 0 ] }routes/dashboard/content.min.js:3:7`,
].join( '\n' );

expect( findDashboardFrame( stack, origins ) ).toEqual( {
url: `${ origins[ 0 ] }routes/dashboard/content.min.js:3:7`,
path: 'routes/dashboard/content.min.js',
widget: undefined,
line: 3,
column: 7,
} );
} );

it( 'strips a query string from the path', () => {
const stack = `Error: boom\n at fn (${ base }widgets/x/render.min.js?ver=1:5:6)`;

expect( findDashboardFrame( stack, origins )?.path ).toBe( 'widgets/x/render.min.js' );
} );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { detectDomInterference } from '../dom-interference';

describe( 'detectDomInterference', () => {
afterEach( () => {
document.documentElement.className = '';
document.body.innerHTML = '';
} );

it( 'detects nothing on an untouched page', () => {
expect( detectDomInterference() ).toBeUndefined();
} );

it( 'detects Google Translate from the class it puts on the document', () => {
document.documentElement.classList.add( 'translated-ltr' );

expect( detectDomInterference() ).toBe( 'google-translate' );
} );

it( 'detects Google Translate from the font elements it wraps text in', () => {
document.body.innerHTML = '<font style="vertical-align: inherit;">translated</font>';

expect( detectDomInterference() ).toBe( 'google-translate' );
} );

it.each( [
[ 'edge-translate', '<span _msttexthash="123">x</span>' ],
[ 'immersive-translate', '<span class="immersive-translate-target">x</span>' ],
[ 'grammarly', '<div data-gr-ext-installed=""></div>' ],
[ 'dark-reader', '<style class="darkreader"></style>' ],
] )( 'detects %s', ( expected, markup ) => {
document.body.innerHTML = markup;

expect( detectDomInterference() ).toBe( expected );
} );

it( 'names every agent it finds', () => {
document.documentElement.classList.add( 'translated-rtl' );
document.body.innerHTML = '<div data-gr-ext-installed=""></div>';

expect( detectDomInterference() ).toBe( 'google-translate,grammarly' );
} );
} );
Loading
Loading