Node.js and NestJS SDK
@store-pilot/node reports your backend's exceptions to StorePilot. It is the third way to
install StorePilot, beside the WordPress plugin and the loader tag, and it is for the half of a
shop that has no browser in it — an API, a worker, a checkout service.
npm install @store-pilot/node
Requires Node 18 or newer. It has no runtime dependencies — fetch,
AbortSignal.timeout and AsyncLocalStorage all come from Node itself, so nothing of ours is
added to your production dependency tree. The /nestjs subpath uses @nestjs/common,
@nestjs/core and rxjs, which every Nest service already has; all three are optional peers,
so a plain Express or Fastify service installs none of them.
Getting your key
Dashboard → Settings → Installation → Server SDK. It is the site's secret key — the same one the WordPress plugin uses, and the same one a build pipeline uses to upload source maps. It stays on your server and is never sent to a browser.
There is deliberately no public-key route for a server. The public key is authenticated by
Origin, which names a page; a Node process has none, so a browser-reachable route would let
anybody who can read a public key out of a <script> tag invent server-side issues in your
dashboard.
Node
const storepilot = require('@store-pilot/node');
storepilot.init({
key: process.env.STOREPILOT_KEY,
release: process.env.GIT_SHA, // optional, and what source maps key on
environment: process.env.NODE_ENV, // optional; the site's own setting is the fallback
});
try {
await chargeCard(order);
} catch (error) {
storepilot.captureException(error);
throw error;
}
// Drain before the process goes away.
process.on('SIGTERM', () => storepilot.flush(2000).then(() => process.exit(0)));
NestJS
import { StorePilotModule } from '@store-pilot/node/nestjs';
@Module({
imports: [
StorePilotModule.forRoot({
key: process.env.STOREPILOT_KEY!,
}),
],
})
export class AppModule {}
The forRoot above is the whole installation — there is nothing to add to main.ts. It binds
a global APP_INTERCEPTOR that watches every request, reports anything that is not an
HttpException and any HttpException at 500 or above, and then lets the error continue into
Nest's own exception layer untouched. An interceptor observes; it does not answer. Your own
filters keep running and the response your client receives is unchanged.
A 4xx is the API working — a validation failure or a missing row is an answer, not a fault — and reporting them would bury a real 500 under ordinary traffic.
A platform of your own
The platform address ships with the package — https://api.store-pilot.net — so the hosted
platform needs no address at all, exactly as the WordPress plugin carries its own. Set url for
a self-hosted backend, a staging platform, or local development:
storepilot.init({ key: process.env.STOREPILOT_KEY, url: 'https://storepilot.internal' });
It is deliberately not a way to switch reporting off. With url unset the SDK posts to the
hosted platform, so url: process.env.STOREPILOT_URL with the variable missing reports to
production rather than falling silent. Off is enabled: false, or no key — and init says
which, once, in the log.
Guards and middleware
An interceptor never runs for an exception thrown in a guard or in middleware: both run before it. To cover those as well, register the filter through the injector:
import { APP_FILTER } from '@nestjs/core';
import { StorePilotExceptionFilter } from '@store-pilot/node/nestjs';
@Module({
providers: [{ provide: APP_FILTER, useClass: StorePilotExceptionFilter }],
})
export class AppModule {}
It has to be registered that way — or with the adapter passed by hand,
new StorePilotExceptionFilter(app.getHttpAdapter()) — because it answers through Nest's own
responder, and Nest hands that to a filter only through dependency injection.
It is a catch-all, and Nest lets exactly one global filter answer: the last one registered.
So registering it displaces another global @Catch() filter of your own. That is Nest's rule
rather than ours, and it is why the module does not bind it for you. Either way a fault is
reported once, by whichever layer saw it first.
Upgrading from 0.1.x
Delete app.useGlobalFilters(new StorePilotExceptionFilter()) from main.ts. In 0.1.x that
filter re-threw, and NestJS does not catch what a filter throws: on Express 4 the request was
never answered at all, and on Express 5 it was answered by Express's own error page with your
filters skipped.
StorePilotModule is also available as forRootAsync({ imports, inject, useFactory }) when the
key comes from your own ConfigService.
A failure you handled
A fault your code caught and recovered from is still worth knowing about — a carrier whose API
has been failing for a week looks, from outside, exactly like one that works, because your
fallback hides it. Say how bad it was with severity:
try {
rates = await carrier.quote(cart);
} catch (error) {
storepilot.captureException(error, { severity: 'warning' });
rates = flatRates(cart);
}
error (the default), warning or info — info is shown in the dashboard as notice.
Anything else counts as error, so a typo never makes a fault quieter than you meant. The
severity is what a notification destination's floor reads: with the default floor of error, a
warning lands in your Issues list without waking anybody. What the SDK reports on its own —
the NestJS interceptor, the filter, an uncaught exception — is always error.
Linking a report to the visit
When a request comes from a page running StorePilot, a backend fault can be linked to that
visit — the issue then lists the visit and opens its session replay, as a browser error does.
The page names its visit by sending the id it gets from StorePilot.identity() in an
X-StorePilot-Session header:
// In the browser — on requests to your own API.
const { sessionId } = window.StorePilot?.identity?.() ?? {};
fetch('/api/checkout', { headers: sessionId ? { 'X-StorePilot-Session': sessionId } : {} });
identity() answers null until the visitor may be tracked, so a page that follows this sends
nothing for a visitor who has not consented.
On NestJS there is nothing more to do: the interceptor and the filter read that header. On
plain Node, set it once per request with withRequestContext, and every report the request
makes carries it — or pass it on a single report:
app.use((req, res, next) =>
storepilot.withRequestContext({ method: req.method, sessionId: req.get('x-storepilot-session') }, next),
);
storepilot.captureException(error, { request: { sessionId: req.get('x-storepilot-session') } });
The id is sent only if it has the exact shape of one the StorePilot collector mints; anything
else is dropped, so a garbled or invented header cannot link a fault to somebody else's visit.
Needs @store-pilot/node 0.6.0 or newer.
What is sent, exactly
| Top level | source type message severity stackTrace environment release sessionId context |
context | runtime nodeVersion route method statusCode durationMs handled file |
That table is the whole payload.
The SDK never sends a request body, request headers, a query string, cookies, an
authenticated user, or an IP address. The one value it takes from a header is the
X-StorePilot-Session id above, as sessionId, and only in the collector's own shape. route
is the route template (/orders/:id), never the address, so an identifier in a URL does not
reach us; a request that matched no route reports the literal <unrouted>.
The payload is built by naming what goes rather than by copying a request object and removing what should not: a deny-list over somebody else's data structure is a promise nobody can keep.
What it will not do to your service
- It does not stop your process from crashing. The SDK listens on
uncaughtExceptionMonitor, neveruncaughtException— the latter cancels Node's default handling, so a service that used to die and be restarted would instead carry on in an undefined state. An unhandled rejection keeps whatever mode your process was started with. - It never blocks a request.
captureExceptionreturns immediately, never throws and never rejects. An unreachable platform costs a log line and nothing else. - It is bounded: one report per fault per 5 minutes (per visit and severity, when a report names them), at most 30 reports a minute, at most 30
queued.
/ingest/errorsis rate limited per site, so your crashing worker must not spend the budget your checkout exception needs. - It does not report itself. A fault whose every file-naming stack frame is inside the package is dropped rather than sent — and one frame of your own service makes the whole stack yours, so a fault thrown through the SDK is still reported.
- It does not answer your requests. On NestJS the capture path is an interceptor: it observes the error and re-emits it unchanged, so Nest's exception layer and your own filters decide the response exactly as they did before.
On a hard crash the process is gone before the request finishes, so those reports are
best-effort. The reliable paths are the Nest interceptor — which sees every request the
service handles, and which StorePilotModule binds itself — and a flush() on SIGTERM.
Consent
If the site has Require consent switched on in Settings → Privacy, server-side reports are not stored at all. A process has no visitor and cannot assert consent on anybody's behalf, and the platform refuses a report that does not carry one. The same is true of the WordPress plugin's PHP error reports.
Source maps
Your stack arrives as compiled dist/*.js. Upload the maps with the same key and the platform
symbolicates at read time — see Source maps. Use a distinct release
string per runtime if one site reports from both a browser bundle and a Node service.
How the site is labelled
The SDK sends X-StorePilot-SDK: node/<version> alongside the key, and that is what records
the site's install method as Server SDK rather than as a WordPress plugin. It matters:
without it the platform would try to push notification settings to a wp-json route your
service does not have.
Once a site is recorded as a server install it stays one — a later request with no SDK header (your build pipeline uploading source maps, for instance) never relabels it.
Errors from a server look different on screen
A server-side report is placed on a visit's timeline only when it names its visit. When none of an issue's reports did — a scheduled job, a queue worker, a request with no session header — the issue page says "no visit" rather than drawing an empty timeline, and the counts on the issue are the complete record. When some did, the occurrence timeline and the visitor breakdown show those visits, with a line saying only the reports that named a visit are counted there; each such visit is also added to the issue's Linked recordings, with a link into its replay. A server report carries no trail of the steps before it — that is recorded in the browser.
"Where in the code" will read Elsewhere for every issue. That label describes a WordPress install — theme, plugin, core — and a fault in your own server code is none of those.
Licence
The SDK is proprietary and ships its own LICENSE.md: you may install it and use it to send
data to StorePilot under a key issued to you, for as long as you hold a subscription for that
site. It may not be redistributed or pointed at another service. See
Terms of Service §11.
The WordPress plugin is licensed differently — GPL-2.0-or-later — because a WordPress plugin is a derivative work of WordPress. That licence covers the plugin's own code and reaches neither this SDK nor the platform.