Skip to content

Stats: Report uncaught dashboard JavaScript errors to Logstash - #54669

Draft
adamwoodnz wants to merge 37 commits into
trunkfrom
stats-582-capture-stats-v2-errors-using-logstash
Draft

adamwoodnz wants to merge 37 commits into
trunkfrom
stats-582-capture-stats-v2-errors-using-logstash

Conversation

@adamwoodnz

Copy link
Copy Markdown
Contributor

Fixes STATS-582

Why

Odyssey (Classic Stats) sent uncaught JavaScript errors to Sentry. That loader was removed for security, and Stats v2 has no error reporting of its own, so a crash in the Premium Analytics dashboard is invisible to us: we only learn about it if a user reports it, and we cannot reproduce most of them. This sends uncaught dashboard exceptions to WordPress.com's JS error log under their own feature, so we can see them.

Proposed changes

  • A new internal package, packages/error-reporting, owns the reporter: payload assembly, stack-frame matching against a PHP-supplied origin allowlist, redaction, dedupe, an hourly budget, and the transport.
  • PHP publishes the gate, the package version and the origin allowlist into the existing premium_analytics script data. Reporting is on only for WordPress.com Simple and Atomic sites.
  • packages/init installs the window error and unhandledrejection listeners. A rejection is reported only when its reason is an Error with a matching stack, so an apiFetch failure object is not an error report.
  • A StatsErrorBoundary mounts inside every route stage() and inside WidgetRoot. It reports from componentDidCatch, then rethrows the original error so the host's own boundary renders its normal fallback.
  • packages/ads, packages/videopress and packages/woocommerce-stats each add their own build URL through a new filter, so a window error from any registered widget is recognised rather than dropped as foreign.

Does this pull request change what data or activity we track or use?

Yes. This is the reason for the [Status] Needs Privacy Updates label.

On WordPress.com Simple and Atomic sites only, an uncaught dashboard exception now sends one request to public-api.wordpress.com/rest/v1.1/js-error containing: the error message, its stack, its name, the React component stack for a render crash, the page URL as origin plus pathname, the WordPress.com blog ID, the Premium Analytics package version, the route and widget names, and the page's query parameters as one masked string.

What is deliberately excluded, and the code that excludes it:

  • No query strings or hashes. url is origin + pathname. The message, stack and component stack have the page's own query and hash stripped, and the query of any absolute URL in them stripped, which covers a _wpnonce appearing inside a stack.
  • No email addresses, in the message, stack or component stack.
  • Query parameter values are masked except for an allowlist of 18 names that shape analytics queries. A parameter whose name is not a plain identifier is dropped entirely rather than masked, because in a query string the name itself can be the leak.
  • No credentials. credentials: 'omit', so no signed-in identity is attached to an anonymous report.
  • Self-hosted sites send nothing. The gate is a single PHP condition; widening it to self-hosted sites is a follow-up that needs Terms_Of_Service::has_agreed().

Rate limiting is deliberate, because the endpoint limits by IP and a block there reaches every other Jetpack reporter sharing that IP, Comments included: reports are deduped for the life of the page, capped at 10 per browser per hour, and stop entirely for the rest of the hour on any refusal from the endpoint.

Related product discussion/links

  • Design, review and decisions: STATS-582
  • wpcom PR adding jetpack_stats to the endpoint's feature allowlist: 257838-ghe-Automattic/wpcom

Blocked on

Do not merge before 257838-ghe-Automattic/wpcom has deployed. Until then the endpoint rewrites an unknown feature to calypso, so reports would land in the wrong search rather than failing visibly.

Testing instructions

The gate answers only to Simple and Atomic, so a self-hosted dev site reports nothing by design. To exercise it locally, filter jetpack_admin_js_script_data at priority 21 from an mu-plugin and set premium_analytics.error_reporting.enabled to true.

On the dashboard (?page=jetpack-premium-analytics-wp-admin):

  1. Confirm the origin allowlist matches the real scripts. In the console, compare JetpackScriptData.premium_analytics.error_reporting.script_origins against the src of the dashboard's loaded module scripts. Each loaded script URL must start with one of the origins. There should be four origins: Premium Analytics, woocommerce-stats, videopress and ads.
  2. Force a widget render crash and confirm one report is sent with properties.source: boundary, and that the widget's normal fallback UI still appears, unchanged.
  3. Throw from a click handler and confirm one report with properties.source: error.
  4. Reject a promise with an Error thrown from dashboard code, and confirm properties.source: unhandledrejection. Reject with a plain object and confirm nothing is sent.
  5. Confirm redaction. Throw an error whose message embeds a wp-admin URL with a _wpnonce and a fragment, and confirm neither reaches the payload.
  6. Confirm the gate. Disable the mu-plugin, reload, repeat step 3, and confirm no request is made while script_origins is still published.

Watch the request in the Network panel rather than Logstash until the wpcom PR deploys.

What still needs checking on Simple or Atomic

The origin allowlist is derived from plugins_url(), and the scripts are served as WordPress Script Modules. Those resolve to the same string on a self-hosted site, which I verified below, but if WordPress.com rewrites script-module URLs through a CDN and not plugins_url(), every error and unhandledrejection report would be silently dropped there. Please confirm step 1 on a Simple sandbox or an Atomic site. Render crashes are unaffected either way, because a boundary catch skips the origin filter.

Verification

Checks run, all green: premium-analytics 4901 JS tests and 837 PHP tests, ads 9, woocommerce-stats 144, monorepo typecheck, lint-changed, and PHPCS on every changed PHP file.

Two notes on what I could not verify locally:

  • Phan was not run. It crashes parsing wordpress-stubs.php under this machine's PHP 8.5, identically on trunk for untouched packages, and the only other local PHP has no ast extension. CI covers it.
  • videopress has 2 pre-existing test failures in Admin_UI_Test and Channel_Blocks_Test, in a built checkout with local absolute paths. Neither test nor its subject is touched by this branch, which changes only class-analytics-dashboard.php and its two test files there.

Exercised in a browser on a dev site with the gate forced on, which confirmed: the real loaded script URL starts with the published origin (the property the whole origin design rests on, and the one no unit test can prove); a _wpnonce and fragment embedded in an error message are both absent from the payload; the payload's nine top-level keys are all within the eleven the endpoint keeps, with no headers and keepalive set; and with the gate off, a dashboard error produces no request while script_origins is still published.

The boundary source and the host fallback were not exercised by a real render crash in the browser. They rest on 11 mount-point tests plus the host boundaries in @wordpress/boot and @wordpress/widget-dashboard, which wrap every stage and every widget.

Follow-ups

  • Widen the gate to self-hosted sites behind Terms_Of_Service::has_agreed().
  • Source maps for route and widget builds, kept privately per release.

🤖 Generated with Claude Code

The JavaScript error reporter needs to know, before it installs any window listener, whether the host qualifies, which package version to tag events with, and which stack frames are dashboard code. Deciding the host in one PHP condition means widening reporting to self-hosted sites later is a change to that line alone.

The origin list is filterable so sibling packages that ship their own build can add their base URL.
Reporting must skip errors that did not come from dashboard code. Matching the first stack frame against the allowlisted script origins gives a cheap, robust test that works on minified V8 and Firefox stacks and also yields the module and widget for the report.
A lazy-load failure message such as 'Failed to fetch dynamically imported module: <build url>' sits on the Error header line and matched the origin, so network failures were reported as dashboard crashes. Require the V8 or Firefox/Safari frame shape before matching, and cover the empty-origin guard.
Frames such as 'module code@<url>' carry a space before the @. The previous shape check rejected them, so a load-time crash could lose its only dashboard frame. Allow any label up to the first colon, which still rejects an 'Error: message' header.
Error reports carry the page's query string. Only parameters the dashboard defines keep their value; every other one is reported by name with its value redacted.
A parameter name is part of the payload and can carry what a site owner typed, such as an email address with no equals sign. Names outside a safe pattern now collapse into one [unreportable] key, and the result has no prototype so names like __proto__ are reported like any other.
The js-error endpoint bans an IP for 60 minutes after 30 requests in an hour, and that ban also silences the other Jetpack reporters on the same IP, Comments included. A render error that loops must therefore cost at most one request per message and frame, 10 per browser per hour, and 3 per page when storage is unavailable. Any non-2xx response stops reporting for the rest of the hour.

Window errors and unhandled rejections report only when the stack has a dashboard frame, because wp-admin also runs other plugins' and extensions' scripts. A boundary catch always reports, since React already pinned the error to our tree.
A stack frame for code in the page document carries the page URL, and an error message can embed it. That text bypassed redactQuery, so the query string and hash reached the log even though the url field drops them. Both are now replaced by exact string in the message, trace and component stack.

The endpoint rejects an oversized keepalive request, which costs a report and a budget slot for nothing. The message is capped at 1000 characters and the trace and component stack at 8000 each.
A budget record that failed to parse threw before the write, so that browser stayed on the 3 per page cap on every reload and the record was never repaired. The parse now has its own guard and a corrupt record counts as absent.

Error text and stack frames can embed other absolute URLs, such as a failed REST request that carries a nonce. Every http(s) URL in the message, trace and component stack is now cut at its first query or hash, keeping a trailing stack position.

The payload now has a type that names the only top-level keys the endpoint keeps, because an extra key is dropped silently. The thrown value is described once per report so the dedupe key and the payload cannot disagree. New tests cover the stop flag across an hour boundary, duplicates not spending budget, and the stored record guards.
Uncaught errors and unhandled rejections outside React never reach the error boundary. Installing the listeners at the top of init() also catches a failure later in init() itself. The listeners only choose candidates; reportError does the filtering and budgeting.

Rejections count only when the reason is an Error with a stack, so handled API failures such as apiFetch's { code, message } objects stay out of the log.
The host boundaries log nothing, so a render crash never reached the team. This boundary reports the error, then rethrows the same object so the host fallback UI still renders unchanged.
A thrown null or 0 made the caught check fail, so children rendered again, threw again and looped until React's update limit hid the real error. Wrapping the value distinguishes presence from falsiness. Also drops a test that could not fail.
An empty message gets a 400 from the endpoint, and any non-2xx response stops reporting for the rest of the hour. A single throw of an empty string would therefore have silently switched error reporting off for that browser. The primitive path now falls back the way the object path already does, for an empty or whitespace-only result.
Nothing reported a React render crash until the error boundary was mounted. The seven route stages and WidgetRoot together cover every surface the dashboard renders, including widgets other plugins ship through the SDK. The boundary rethrows after reporting, so the host fallback is unchanged.
…cement

A hand-listed route test stays green when someone adds a route whose stage() is unwrapped, which is the bug it exists to catch. The test now reads routes/ and checks every stage. A second WidgetRoot test makes a provider throw, so moving the boundary inside the providers fails.
…discovery

A boundary moved between the two providers still passed the ChartsProvider test, leaving a crash in AnalyticsQueryClientProvider unreported. The discovery test also skipped a route whose stage lives in stage.ts.
A window error thrown by a widget another package ships has no stack frame under the Premium Analytics build, so the dashboard's error reporter dropped it. Each widget package now appends its own build URL to the origin filter from register_widget_types(), the one entry point both the init hook and the WordPress.com Simple mu-plugin call.
The package origins are added while the widget type registry hydrates. The reporter now triggers that itself, so the order no longer depends on generated build code. The has_filter guard went too: add_filter already dedupes, and the origin test now checks the origin prefixes a real asset URL.
The registry hydration test nulled the Widget_Type_Registry singleton and
set a widget-manifest global, then undid both after calling get_config().
WorDBless's BaseTestCase restores $wp_filter from an #[After] method, so
the hook removals were redundant, but it cannot restore a $GLOBALS entry
or a nulled singleton. A failed assertion inside get_config() therefore
skipped that cleanup and left the registry hydrated from an empty
manifest, which fails later tests that read it for reasons they do not
cause.
…eporter

Deleting the filter registration in register_admin_page() left every test green while the script data key vanished and the reporter silently turned off, so one wiring test now pins it. The README records the rules a reader cannot get from the code: the PHP-owned gate, the origin filter other packages extend, and why the hourly budget exists (the endpoint's IP ban would silence other Jetpack reporters).
Three claims did not match the code. The budget is a clock-hour bucket,
not a rolling window, so a reader planning around it would expect a
report to free up sixty minutes after it was sent. The window handlers
are installed by installGlobalErrorHandlers, not installGlobalHandlers,
which is the name a reader would grep for and fail to find. The origin
list already holds this package's own build URL before any filter runs,
which the filter section read as though it started empty.

Also records that the boundary rethrows after reporting, since nothing
else says the host still renders its own fallback.
The reporter held its dedupe set, page counter and stop flag at module
scope, but the package was bundled into each build unit that imported it:
init, widgets-toolkit and all seven route stages. Each copy kept its own
state, so the per-page-load fallback cap counted per build unit and a route
change could reset the dedupe set.

Declaring the module exports makes wp-build externalize it, the way the
eight other stateful internal packages are, leaving one instance. The
package installs window listeners and holds state, so it is not side-effect
free either.
Any non-2xx used to disable the browser for the rest of the hour, so a
systematic 4xx would silence every browser after its first report, and zero
reports in the log reads exactly like a healthy dashboard. The endpoint's IP
block is what the budget guards against and it answers 403, so only 403 and
429 stop the hour now; a 400 means a payload to fix, which the hourly budget
already bounds. Every refusal is named once in the console outside
production, so this cannot go quiet again unnoticed.

The dedupe key used the raw message, so a message embedding the page query
string produced a new key on every date-range change and one repeating bug
could spend the budget that protects every reporter behind the same IP. It
now keys on the message that goes into the payload.

A missing origins array would have thrown inside the reporter's catch and
lost every report with no signal. PHP publishes the array with the gate, so
it cannot happen today, but the failure is total and the guard is free.

The endpoint drops an unknown top-level key silently, so a test now pins the
posted keys to the names it keeps, and the two the payload never set are
gone from the interface rather than inviting someone to fill them in.
Site owners read this entry, and the name of the pipeline the reports end up
in means nothing to them. Where the reports go is what they can act on.
The filter result went straight to array_values, so a third party returning
a non-array fataled the dashboard request, an entry without a trailing slash
produced module paths with a leading slash and no widget name, and a bare
slash matched every stack line, which would attribute every error on the
page to dashboard code. The package's own origin is kept first either way.
Each package's existing origin test took a path that clears the guard, so
moving the add_filter call below it still passed in all three. That
placement is the whole point: WordPress.com Simple calls the registrant
directly, and the origin has to reach the dashboard on the runs where the
registrant registers nothing. Moving the call now fails the new case.
An origin filter returning "https://" or "/build/" passed every check:
trailingslashit leaves them alone, and neither is the bare slash the
guard looked for. Matched with a substring test, each then flagged every
frame of that shape as dashboard code, which is the blast radius the
guard exists to prevent. Origins now need a host.

Dropping "sideEffects": false from the manifest did not have the effect
it looked like. wp-build reads moduleSideEffects as a boolean cast of the
field, so an absent field is still false, and a future side-effect-only
import of this package could be elided along with the listeners it
installs. Declared true.

The console warning for a refused report fired once per page load, so a
harmless 400 permanently hid a later 403 or 429, the two statuses that
stop reporting for the rest of the hour and the ones an operator needs
to see. It now fires once per status.

The bare-slash rationale sat in both the function and its test. The
function owns it.
Each parameter name in the query object became a field in a log index that
every other reporter shares, so one dashboard URL could widen the index for
everybody. Reporting the query as a single masked string keeps it to one field.

An unsafe parameter name is now dropped rather than used as a key: in string
form the name is the leak, and there is no key left to hide it behind. A single
marker records that something was removed.

`properties.route` read `p` from that object, so it reads the page query
directly instead. It is what a Logstash search groups on and must not regress.
An error message or a stack can carry the address of whoever was signed in, and
no personal data may leave the browser. The pass is deliberately broad, because
under-matching leaks an address while over-matching costs a few characters, and
it runs before the length cap so a redaction cannot be cut in half.
…ials

A cross-origin fetch sends no cookies today, so this changes nothing now. It
stops a future change to that default from attaching a signed-in identity to a
report that is meant to be anonymous. Unlike a custom header, it triggers no
CORS preflight, which this endpoint would never answer.
The top-level `jetpack_version` conventionally carries the Jetpack plugin
version, so filling it with the Premium Analytics package version makes the
shared field disagree with every other reporter that sets it. The comments
reporter already puts its own package version in `properties.version`, so
follow it.

The endpoint contract constant keeps `jetpack_version`: it records what the
endpoint accepts, not what we send.
Narrowing the stop to the two rate-limit statuses assumed a refusal of another
kind costs nothing. It does: a request sent while a ban is already active
restarts the ban, so continuing to try extends the block that reaches every
other reporter sharing the IP. Any refusal is now a reason to stop for the hour.

The per-status console warning stays. It is what keeps a silent stall from
looking like a healthy dashboard.
…ude it

A tab left open across a deploy asks for a module file that is gone. React.lazy
turns that into a render error, so the boundary reports it even though it is not
a bug of ours, and it crowds out real crashes in a search. Tagging the report
lets a search drop them without losing the record that it happened.

The window path already ignores these, because the messages carry no stack
frames. Reloading the page would be the other answer, and a user-visible change
is an anti-goal here.
The README described the stop behaviour that has since been reverted, and said
nothing about why the query is a string, why the version is not in the field
whose name suggests it, or what the chunk-load tag is for. Those are the parts
a reader cannot recover from the code.
The chunk-load tag matched the error message only, which catches how
Chrome, Firefox and Safari word a failed dynamic import. webpack says it
differently: the message is "Loading chunk 7 failed." and the only sign
is the name, ChunkLoadError. Those reports would have landed in the
feature's search untagged, which is the deploy-window noise the tag
exists to let a search exclude.
A translation tool or an extension that reparents React-managed text
nodes crashes the commit phase with a NotFoundError from insertBefore.
The boundary reports it like any other render error, so without a marker
those land in this feature's search indistinguishable from our own bugs,
and there is no way to tell from the stack that the page was rewritten
under React.

The probes key on artefacts the agents leave behind and run only while a
report is being built. They are collapsed into one properties field
naming the agents found, rather than one field per agent, because the log
index is shared with every other reporter posting to this endpoint.
@adamwoodnz adamwoodnz added Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Status] Needs Review This PR is ready for review. [Status] Needs Privacy Updates Our support docs will need to be updated to take this change into account labels Nov 9, 2026
@adamwoodnz adamwoodnz self-assigned this Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack or WordPress.com Site Helper), and enable the stats-582-capture-stats-v2-errors-using-logstash branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack stats-582-capture-stats-v2-errors-using-logstash
bin/jetpack-downloader test jetpack-mu-wpcom-plugin stats-582-capture-stats-v2-errors-using-logstash

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

const url = line.slice( start ).split( /[\s)]/ )[ 0 ];
const position = POSITION.exec( url );
const withoutPosition = position ? url.slice( 0, position.index ) : url;
const path = withoutPosition.slice( origin.length ).replace( /[?#].*$/, '' );
@jp-launch-control

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 7 files. Only the first 5 are listed here.

File Coverage Δ% Δ Uncovered
projects/packages/premium-analytics/packages/init/src/index.ts 0/20 (0.00%) 0.00% 1 ❤️‍🩹
projects/packages/ads/src/class-analytics-dashboard.php 51/59 (86.44%) 0.99% 0 💚
projects/packages/premium-analytics/src/class-analytics.php 178/190 (93.68%) 0.07% 0 💚
projects/packages/videopress/src/class-analytics-dashboard.php 43/47 (91.49%) 0.79% 0 💚
projects/packages/woocommerce-stats/src/class-analytics-dashboard.php 112/121 (92.56%) 0.25% 0 💚

8 files are newly checked for coverage. Only the first 5 are listed here.

File Coverage
projects/packages/premium-analytics/packages/error-reporting/src/dom-interference.ts 9/10 (90.00%) 💚
projects/packages/premium-analytics/src/error-reporting.php 21/23 (91.30%) 💚
projects/packages/premium-analytics/packages/error-reporting/src/dashboard-frame.ts 19/19 (100.00%) 💚
projects/packages/premium-analytics/packages/error-reporting/src/install-global-handlers.ts 11/11 (100.00%) 💚
projects/packages/premium-analytics/packages/error-reporting/src/redact-query.ts 24/24 (100.00%) 💚

Full summary · PHP report · JS report

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Docs Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Package] Ads [Package] Premium Analytics [Package] VideoPress [Package] Woocommerce Stats [Status] In Progress [Status] Needs Privacy Updates Our support docs will need to be updated to take this change into account [Status] Needs Review This PR is ready for review. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants