Error Tracking
StorePilot captures JavaScript and PHP errors from your WordPress site and centralises them in the Platform dashboard.
How Errors Are Captured
JavaScript Errors
The plugin injects a tracking script into every page. The script listens for:
window.onerror— uncaught exceptionswindow.onunhandledrejection— unhandled Promise rejections
When an error fires, the script sends a payload to the Platform (via the WordPress proxy) containing:
{
"message": "Cannot read property 'x' of undefined",
"source": "https://mysite.com/wp-content/themes/my-theme/js/app.js",
"lineno": 42,
"colno": 18,
"stack": "TypeError: Cannot read ...\n at ...",
"context": {
"url": "https://mysite.com/shop/",
"userAgent": "Mozilla/5.0 ...",
"referrer": ""
}
}
Failed Asset Loads
A picture, stylesheet or script that does not load is not a fault in a script — nothing throws, there is no stack, and no line number will ever exist for it. The browser reports it on the element itself, and StorePilot treats it by what it is:
- Media (
img,source,video,audio,track) is a measurement, never an issue: each failure counts into a daily table, grouped by directory — a catalogue with a missing image folder is one thing to go and fix, not five hundred rows — and it costs nothing an issue costs: no notification, no event against your plan, no entry in the spike baseline. One visit reports at most ten of them: a browser that loads no pictures at all — usually an automated one — would otherwise report every picture of every page it walks. - Third-party scripts and stylesheets (an address on somebody else's domain — a tag manager, a chat widget, an analytics pixel) are measured the same way: their failures are usually a visitor's ad blocker, not your site, and an issue you cannot fix is noise.
- Analytics tags served from your own subdomain (server-side tagging — a Google tag on
sst.mysite.com, say) are measured too. A tag is recognised by the path its vendor serves (/gtag/js,/gtm.js, the Meta pixel'sfbevents.js), and the subdomain that served one is remembered, so its other files — a tag server's custom loader with a random name — follow it. A tag that did not load cannot stop a purchase; what it does say is how many of your visitors block it. Your main site address is never treated this way. - Your OWN scripts and stylesheets stay real issues, named after what failed
(
Failed to load script: https://mysite.com/wp-content/themes/shop/app.js) — a broken first-party script breaks the page, and that is exactly what the issues list is for.
The measurements live on the Issues page's Assets tab, split into your own hosts,
third-party ones and your analytics tags, each directory with its most recent failing file
as an example. A
cache-busting ?ver= or a build hash in a filename never splits one broken file into a new
entry on every deploy, and a page reports a bounded number of distinct failures, because a CDN
outage is one finding rather than a transcript.
Filtering by Type = resource in the errors list narrows to your own failed scripts and
stylesheets — the only resource failures that are issues.
PHP Errors
The plugin registers a custom error handler and exception handler early in WordPress boot (via the MU-plugin loader). Fatal errors, warnings, notices and uncaught exceptions are captured server-side and forwarded to the Platform.
Which plugin's errors you see is your choice, except for fatals. A busy store's PHP log is mostly deprecation notices from plugins you did not write and cannot fix, so WP Admin → StorePilot → Settings → Plugins lists your installed plugins and you tick the ones you want watched. Nothing is ticked to begin with.
That list governs warnings, deprecations and notices. It never governs a fatal error or an uncaught exception: those are reported from any plugin whether or not you ticked it, because a fault that takes a page down is not something anyone selects a checkbox for in advance.
Errors raised in your theme or in WordPress core are always reported and are not affected by that list.
Reporting a failure your theme handled
A failure your own code catches never reaches PHP's error handler, and a fallback that works
hides it completely — a shipping API that has failed all week looks exactly like one that works.
storepilot_capture() reports it on purpose:
try {
$rates = my_theme_fetch_rates($cart);
} catch (\Throwable $e) {
if (function_exists('storepilot_capture')) {
storepilot_capture($e, ['carrier' => 'dhl'], 'warning');
}
$rates = my_theme_fallback_rates();
}
- The message — a string, or the caught exception itself, whose class, file, line and stack are kept.
- The context — an array of facts worth reading on the issue page: at most 20 keys, each value cut at 500 characters.
- The severity —
error,warning(the default) orinfo, which is shown as notice. Anything else counts aswarning.
It never throws and never stops the page, whatever it is handed. It goes through the same queue
as PHP's own errors, so one message is reported at most once every 5 minutes and no more than
30 reports a minute leave the site. On the issue page PHP error kind reads php_capture,
which is how a handled report is told from one PHP raised. Guard the call with
function_exists(): while StorePilot is paused, or switched off with STOREPILOT_DISABLE, the
function does not exist. A call made from a plugin follows the Plugins list exactly as that
plugin's warnings do; a call from your theme is always reported.
Which visit, and which build
A PHP report carries the release your storepilot_release filter returns (see
Releases) and — when the request that raised it came from a
visitor's page — that visit's session, so the issue links to its replay:
- the classic WooCommerce checkout sends it by itself, so a fault while an order is being placed is linked to that checkout's visit;
- for your theme's own AJAX and REST calls, send
StorePilot.identity().sessionIdas anX-StorePilot-Sessionheader. It isnulluntil the visitor may be tracked, so nothing is sent for a visitor who has not consented.
Both are read in the request that broke, not later when the report leaves the site. A value that is not exactly the shape of a StorePilot session id is dropped. On a site that requires consent no server-side report is stored at all, and none names a visit.
Error Fingerprinting
To group repeated occurrences of the same error into a single record rather than creating thousands of duplicates, the Platform creates a fingerprint for each error.
The fingerprint is computed on the Platform, from two things only:
- the error type (
js,ajax,promise,php,console,resource) - the error message, normalised
Normalising removes what changes between two reports of one fault: query strings, id-shaped
path segments, timestamps, long numbers and hashes, and a build hash inside a filename. So
/product/917 and /product/918 are one issue, and a bundle that stops loading does not
become a new issue on every deploy.
It also removes the browser's dialect. Chrome, Firefox and Safari describe the same event in three different sentences, and without this one bug is three issues:
| What the browser says | Grouped as |
|---|---|
Uncaught ReferenceError: x is not defined · Can't find variable: x | ReferenceError: x is not defined |
Cannot read properties of undefined (reading 'p') · undefined is not an object (evaluating 'a.p') · can't access property "p", a is undefined | TypeError: cannot read 'p' of undefined |
x is not a function. (In '…') | x is not a function |
What is not removed is anything that tells two faults apart — the missing variable, the property being read, the function called. Two different missing globals stay two issues.
If an incoming error matches an existing fingerprint, the occurrenceCount is incremented and the lastSeenAt timestamp is updated.
Error Statuses
| Status | Meaning |
|---|---|
new | Newly captured; not yet reviewed |
investigating | A team member is looking into it |
resolved | Fix deployed; the error should not recur |
ignored | Known, not worth fixing (e.g. browser extension noise) |
Muting is not a status: switching an error's notifications off leaves its status as it is.
Automatic Re-open
If an error with status resolved or ignored is received again (same fingerprint), it is automatically:
- Reset to status
new occurrenceCountreset to1- A new notification fires
This ensures that a regression doesn't go unnoticed because the error was previously marked resolved.
Error Detail
Each error record contains:
| Field | Description |
|---|---|
message | Error message text |
source | JavaScript file URL or "PHP" |
lineno / colno | Source location |
stack | Full stack trace |
status | Current status |
occurrenceCount | How many times this error has been seen |
firstSeenAt | Timestamp of first occurrence |
lastSeenAt | Timestamp of most recent occurrence |
context | URL, user agent, referrer at time of capture |
recording | Linked session recording (if the error occurred during a recorded session) |
Occurrence Timeline
The fields above are an aggregate: how many times, first seen, last seen. They answer is this still happening — not was it once every ten minutes all morning, or four hundred times in ninety seconds, which is usually the question that says what broke.
The Occurrence timeline on an issue's page lists the individual moments: when each one happened on the visitor's own clock, which page it was on, which visit it belonged to, and a link straight into the replay where footage still exists.
It shows the most recent moments rather than all of them, and the panel states three numbers so a partial list is never read as the whole history:
| Number | What it means |
|---|---|
| Shown | Moments on this page of the list |
| Recorded moments | How many individual moments are stored for this issue |
| Reported in total | Every occurrence ever counted, which is the occurrenceCount above |
Two things make the second number smaller than the third, and both are deliberate. A single visit stores a bounded number of moments, so one visitor stuck in an error loop cannot fill the timeline. And moments are kept for 30 days, a much shorter window than the issue itself — the issue and its counts remain long after the individual positions are swept.
A server-side error has a timeline only where its reports named a visit, and the panel says which. A moment is placed against a visit. A report from a server — the WordPress plugin's PHP, or a backend SDK — names one only when the request that raised it carried the visitor's session (see above); a scheduled job, a command-line run or an older plugin names none. When none of an issue's reports named a visit, the counts on the issue are the complete record, and the timeline and the impact panel say "no visit" — not "nothing happened", and not "nobody was affected". When some did, those visits, their moments and their reach are shown like a browser error's, with a line saying that only the reports that named a visit are counted there; the rest are in the reported total.
The same moments are what the replay scrubber marks, so a session that hit one bug five times shows five marks rather than one mark and a count.
Linked Recordings
When an error is captured during a session that is being recorded, the recording session ID is stored alongside the error. In the dashboard you can click "View Recording" to replay exactly what the user was doing when the error occurred.
A server-side report that names its visit — a PHP fault raised while that visitor's checkout or AJAX call was being handled, or a backend SDK report — is linked the same way. Its replay opens from the start: the moment of a server fault is on the server's clock, and it is not marked on the visitor's replay.
Steps Before the Error
Each visit that hit an error carries a short trail of what the visitor did on the way into
it: pages they moved between, elements they clicked, the requests the page made to your own
site, the custom events your page sent, and anything the site's own scripts
printed with console.error. It is shown under the visit in Linked Recordings, open by
default where no recording survives — a server-side report, a visitor who declined recording,
or footage already removed by retention — and collapsed where you can simply watch the session
instead.
The trail is deliberately structural. A click records the element's selector (tag, id and
first class), never text from the page; a request records its method, address, status code and
how long it took, and never its body — and never its query string either, so
/my-account/lost-password/?key=… is recorded as /my-account/lost-password/. Two parameters
are the exception, because WordPress and WooCommerce put the route in the query rather than
in the path: action on admin-ajax.php (so a step reads
POST /wp-admin/admin-ajax.php?action=get_refreshed_fragments rather than a bare
admin-ajax.php) and wc-ajax on your own domain (so /?wc-ajax=update_order_review is no
longer recorded as /). Only a value made of letters, digits, _ and - is kept,
clipped to 64 characters; anything else drops the parameter, and every other parameter beside it
is dropped as before. An action sent in the request's body — jQuery's $.post(ajaxurl, { action: … }) —
is not recorded, because the body never is. A request that got no answer at all (the network
failed, or the page cancelled it) is recorded as having had none. A custom event records its name and up to 3 of its attributes, after the same privacy
rules as the event itself. A click on a password, payment or hidden field records nothing at
all. At most 20 steps are kept, and only the first time a given visit hits a given error — the
first account of how somebody got into a fault is the useful one.
Every request to your own domain is a step, successful or not, because what the page asked for just before it broke is usually the question. A page that keeps asking for the same address with the same answer — a cart refresh, a status poll — takes one step, not twenty, so it cannot push the clicks that matter out of the trail. StorePilot's own reports are never steps.
Requests to your other domains
A request to another domain is recorded only when you name that domain. If your site's own
API runs on a separate host (api.example.com beside www.example.com), list it in
breadcrumb_hosts — exact hostnames, no https://, no port, no wildcards, at most 20.
On WordPress, return the list from the storepilot_breadcrumb_hosts filter, for example in
your theme's functions.php:
add_filter('storepilot_breadcrumb_hosts', function () {
return ['api.example.com'];
});
On a site without the plugin, set it before the StorePilot script tag:
window.StorePilot = window.StorePilot || {};
window.StorePilot.breadcrumb_hosts = ['api.example.com'];
Nothing else on the page changes: the trail only observes the request, which goes out and comes back exactly as your code made it.
Because it describes one person's own actions, the trail is part of that visitor's personal data: it is included in a visitor data export and removed by the erasure endpoint, together with the rest of the visit.
Reporting Errors Your Own Code Catches
An error your code catches — an upload the server refused, a price that would not compute — never reaches the browser's error handlers, so StorePilot cannot see it on its own. Hand it over:
try {
await uploadArtwork(file);
} catch (error) {
StorePilot.captureException(error, {
message: 'Artwork upload failed',
context: { step: 'upload', fileType: file.type },
});
showUploadError();
}
The report arrives exactly like an error the browser reported itself: with the visit and the
visitor, the link to the recording, the steps before it, your release and your environment. It
counts towards the same per-visit limits, and the same error repeated within a minute is sent
once. StorePilot.captureException returns nothing and never throws.
| Option | Meaning |
|---|---|
message | The issue's title. Replaces the error's own message. StorePilot groups errors into issues by message, so write one stable sentence per kind of failure and put what varies — an order number, a file name, the server's reply — in context. Without it, the error's own message is used. |
context | Details shown on the issue page. Plain values, objects and short lists: at most 20 keys at each level and three levels deep, text cut at 500 characters, about 4 KB in all. A key named like a password, a token or a street address is kept with its value replaced by [hidden], and the same privacy rules as every other error then apply. |
severity | 'error' (the default), 'warning' or 'info'. 'info' is shown as notice. |
type | 'js' (the default), 'promise' or 'ajax' — the type the issue is filed and filtered under. |
The stack trace is taken from the error when it carries one. Pass the Error itself rather
than a string where you can: StorePilot reads its message, name and stack, and nothing else from
it, so an HTTP client's error object does not send its request headers along.
Calling it before the script has loaded
StorePilot.captureException exists as soon as the StorePilot script tag has run, and queues
calls until the rest of StorePilot arrives. For code that runs even earlier, push onto the
queue yourself:
window.StorePilot = window.StorePilot || {};
(window.StorePilot.cq = window.StorePilot.cq || []).push([error, { message: 'Artwork upload failed' }, Date.now()]);
Each entry is [error, options, timestamp], and StorePilot reads the first 20. Calls made
while your site is waiting for the visitor's consent are dropped, not held back — the rule
custom events follow.
Telling your own backend which visit a request belongs to
StorePilot.identity() returns the ids of the current visit, so your page can send them to your
own server and a fault your server logs can be matched to the visit in StorePilot:
const { sessionId } = StorePilot.identity();
fetch('/wp-json/shop/v1/quote', {
method: 'POST',
headers: sessionId ? { 'X-StorePilot-Session': sessionId } : {},
body,
});
It returns { sessionId, visitorId }, and both are null until StorePilot is running for this
visitor: before the script has loaded, while your site is waiting for consent, after a visitor
withdraws it, and for automated traffic. Asking does not keep a visit open — a visit still ends
after 30 minutes without activity, however often your page reads it. On WordPress the plugin
reads that header on your theme's own requests: see Which visit, and which
build.
"Script error." — when the browser hides the details
Some errors arrive with the message "Script error.", no file and no stack. This is the
browser deliberately withholding them, not StorePilot losing them: when a script served from
another domain throws, the browser refuses to describe the fault to the page unless that
script's <script> tag carries crossorigin="anonymous" and its server sends an
Access-Control-Allow-Origin header.
The error detail page says so, and lists every cross-origin <script src> on the page that was
missing the attribute when the fault happened. Add crossorigin="anonymous" to the tag
responsible and the next occurrence arrives with its real message and stack. StorePilot's own
loader has carried the attribute since 1.9.0, so the script in question is always another
vendor's.
That list is the address the page already publishes in its own HTML, reduced to origin and path; no query strings are stored.
Whose Fault It Is
Every issue carries a Whose value, decided when it arrives:
| Whose | What it means |
|---|---|
| Shop | Your own code or server — your theme, your plugins, WordPress, your inline scripts, your own endpoints answering with an error, any PHP report. |
| Payment | A payment provider's script or plugin — PayPal, Stripe, Klarna and the like. Kept apart from other third parties because its failure can be a lost sale. |
| Third party | Another service on your pages — email marketing, reviews, pixels, chat. The service is named under the value. |
| Visitor's browser | Something in the visitor's own browser: an extension, a crypto wallet, a password manager, an in-app browser, or the browser's own complaint. Nobody at the shop can fix these. |
| Network | A request that got no answer at all — no code was wrong. The address's owner is named when it is not your own site. |
| Unknown | The report says too little to tell. |
The Issues list opens on Shop and Payment. Nothing else is hidden silently: a line above the table says how many issues the filter is holding back, per kind, and Show all opens every one. The Whose column's own filter narrows the list to any combination, and a link you share carries it. An issue that arrived before this classification existed is shown in the default view until it has been classified.
A failed request to WordPress's admin-ajax.php names the handler it asked for (action=…), and a
failed XMLHttpRequest names its method and address, so two different failures that share one
address are two issues rather than one.
What an issue affects
Beside Whose, the Affects column says what an issue costs:
| Affects | What it means |
|---|---|
| Purchase | Buying is blocked — a payment provider's fault, or a fault that was on an unpaid cart's checkout. |
| Feature | A visible part of the page fails — an upload, search, a configurator, a popup. |
| Not visible | A visitor would not notice — a tracking tag, the visitor's own browser, a log line. |
| Expected | Your shop refused a customer's input on purpose — a required field, an email it could not verify. Nothing to fix. |
| Unknown | Nobody has said yet. |
Purchase from a payment provider or a blocked cart, and Not visible for tags and the visitor's browser, are set automatically; the rest comes from an explanation (below) or from you. Who should act is shown under it. The column's filter has two sections — Affects and Who acts — so you can, for example, list only what a developer should look at.
Setting a label yourself. On the issue page, members can set Whose and Affects by hand. Your label is final — the automatic rules and the model never change it — and Automatic hands it back.
Expected issues are quiet. An issue marked Expected sends no notification and no bell entry. It is marked after it first appears, so the first notification about a new issue still arrives.
Explaining an issue
The issue page opens on What this means: whose fault it is and, once asked, a two- or three-sentence explanation of what happened and whether it needs action, written by an AI model from the issue's message and stack — never from visitor data. Members press Explain; asking again about an unchanged issue costs nothing.
The explanation also names who should act — a developer, the store admin, hosting or CDN, the service's support, or nobody — and one first step. It never writes a code fix: the model sees the message and stack, not your code. For the fix itself, Copy for coding agent hands the stack, the steps before the error and this reading to a coding agent that can read your code.
When the rules above say Unknown, the model is asked once per issue — never per occurrence,
never for issues only crawlers met, at most 20 issues per site per day
(AI_ERROR_CLASSIFY_PER_SITE_DAY). Its answer moves Whose only when it is sure, and is marked
AI in the list. A value you set yourself is never changed by the rules or by the model.
Noise Reduction
Trusted Origins only — The WordPress proxy validates the Origin and Referer header, ensuring only your own domain can submit error events.
The visitor's browser does not alert you — an issue whose Whose is Visitor's browser stays in the list but sends no notification and no bell entry.
Muted errors — Errors you don't want to hear about (e.g. from browser extensions) can be muted from the bell in the Issues list; they keep their status and never trigger notifications again until you switch them back on.
Ignored status — Errors set to ignored still accumulate occurrences but do not send notifications.