@saaspro/node
Server-side events, errors with parsed stack frames, metrics, feature flags, framework error handlers, and the tenant side of the connector.
Node ≥ 20 (global fetch). The core event runtime is dependency-free; the build-artifact CLI includes its parser dependencies.
npm install @saaspro/node
One client per process
// lib/spm.ts
import { SaaSProMax } from "@saaspro/node";
export const spm = new SaaSProMax({
secretKey: process.env.SPM_SECRET_KEY!, // spm_sec_prod_xxxxxxxx
release: process.env.GIT_SHA,
env: process.env.NODE_ENV,
onError: (error) => console.warn("[spm]", error),
});
Keep the secret key server-side. In a browser bundle use a public key with
@saaspro/browser.
Flush before the process exits:
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.once(signal, () => void spm.shutdown().then(() => process.exit(0)));
}
In a serverless function, await spm.flush() before returning — the runtime may
freeze the process the moment your handler resolves.
React Native / Expo
For managed consent, persistent identity, screen hooks and lifecycle delivery, use the dedicated React Native / Expo package. The low-level transport below remains compatible for existing manual integrations.
Starting with @saaspro/node@0.3.0, import SaaSProNative from
@saaspro/node/native and pass { key: publicKey, randomUUID }, using a secure
generator such as expo-crypto's randomUUID.
The native entry has no Node built-ins or DOM dependency. It shares the event,
metric, error and flag methods below, with a 240,000-byte batch limit by default.
It does not capture automatically, store identity or manage user consent.
Use 0.3.1 or newer for native delivery: its explicit SaaSProNative user agent
avoids Android HTTP-library bot classification. Per-event ingestion rejections
and invalid HTTP 202 acknowledgements report through onError; partial batches
are not replayed.
Instantiate only after consent; provide pseudonymous identity on each event,
flush on app background, and use shutdown({ flush: false }) on opt-out or an
account switch to drop queued events and abort active requests. Create a fresh
client after a later opt-in. Already-delivered events cannot be retracted.
Memory-only queues are not durable offline storage. Record usage_platform
(web, ios, android) separately from purchase_platform, purchase_provider
and subscription plan/status/interval. Client-side purchase traits never authorize
access or prove payment; only verified server billing events do that.
Options
| Option | Default | Notes |
|---|---|---|
secretKey |
— | Required |
host |
https://saaspro.dev |
|
flushAt |
20 |
Events buffered before an automatic flush |
flushIntervalMs |
5000 |
0 disables the timer (flush manually) |
maxRetries |
3 |
Per batch, on 5xx / 429 / network errors |
retryBaseMs |
250 |
Exponential: 250, 500, 1000 ms |
requestTimeoutMs |
10000 |
Each ingest/flag request; custom fetch must honor AbortSignal |
maxQueueSize |
1000 |
Includes batches awaiting transport; overflow reports through onError |
maxBatchBytes |
512000 |
UTF-8 envelope limit, in addition to 100 events |
maxFlagCacheSize |
1000 |
Bounded identity/property cache entries |
release env appVersion |
— | Attached to context.app |
debug |
false |
Log queue and transport activity |
fetch |
global fetch |
Injectable transport |
onError |
— | Receives everything the SDK swallows |
Events
spm.track({
distinctId: "user_123",
event: "subscription started",
properties: { plan: "pro", seats: 3 },
value: 49,
currency: "EUR",
});
spm.page({ distinctId: "user_123", name: "$pageview", url: "https://example.com/app", path: "/app" });
spm.identify({ distinctId: "user_123", traits: { email: "ada@example.com", plan: "pro" } });
spm.alias({ distinctId: "user_123", previousId: anonymousIdFromCookie });
await spm.flush();
Every call takes an optional timestamp (a Date or ISO string) and context,
plus anonymousId and sessionId when you can read them from the request. That
is what stitches a server event to the browser session:
spm.track({
event: "order placed",
distinctId: session.userId,
anonymousId: req.cookies.spm_aid,
properties: { total: order.total },
context: { ip: req.ip, userAgent: req.headers["user-agent"] },
});
context.ip and context.userAgent are only honoured for secret keys, and the
IP is hashed at ingest — it is never stored raw.
Errors
try {
await chargeCard(order);
} catch (error) {
spm.captureException(error, {
distinctId: order.userId,
tags: { area: "billing", provider: "stripe" },
extra: { orderId: order.id, amount: order.total },
request: { method: "POST", url: "/api/checkout", status: 500, headers: req.headers },
});
throw error;
}
Stack frames are parsed into { file, function, line, column, inApp }, innermost
first, with inApp: false for node_modules and Node internals — that is what
the console uses to pick the culprit frame. authorization, cookie,
set-cookie, proxy-authorization, x-api-key, x-spm-key and x-csrf-token
never leave your process.
Anything can be thrown at captureException: Error, a string, a plain object,
or a value with a cause (whose message is appended).
Framework handlers
// instrumentation.ts (Next.js)
import { spm } from "./lib/spm";
import { nextRequestErrorHandler } from "@saaspro/node";
export const onRequestError = nextRequestErrorHandler(spm);
// Express — register AFTER your routes
import { expressErrorHandler } from "@saaspro/node";
app.use(expressErrorHandler(spm)); // reports, then calls next(err)
// Hono
import { honoErrorHandler } from "@saaspro/node";
app.onError(honoErrorHandler(spm));
// or with your own response:
app.onError(honoErrorHandler(spm, (error, c) => c.json({ error: "oops" }, 500)));
// Any long-running process
import { installProcessHandlers } from "@saaspro/node";
const uninstall = installProcessHandlers(spm); // uncaughtException + unhandledRejection
Metrics
spm.metrics.counter("orders.created", 1, { region: "eu" });
spm.metrics.gauge("queue.depth", await queue.size(), { queue: "email" });
spm.metrics.histogram("payload.bytes", body.length, { route: "/orders" }, "bytes");
spm.metrics.timing("db.query", elapsedMs, { table: "orders" });
const rows = await spm.metrics.time("nightly.sync", () => sync(), { job: "billing" });
time() emits a timer metric in milliseconds with outcome: "ok" | "error"
and re-throws whatever your function throws.
Labels: at most 20 keys, string values.
Feature flags
const flags = await spm.flags.evaluate({
distinctId: "user_123",
properties: { plan: "pro", country: "FR" },
});
if (await spm.flags.isEnabled("new-checkout", { distinctId: "user_123" })) { … }
const copy = await spm.flags.get("checkout-copy", { distinctId: "user_123" }, "control");
spm.flags.invalidate({ distinctId: "user_123" }); // or invalidate() for everything
Evaluations are cached in memory for 30 seconds, keyed by both identity fields
and the properties, with at most maxFlagCacheSize entries. If the endpoint
fails, the last good values are returned and onError is called.
Connector
See connector.md for the full contract, and the Express and Next.js recipes for mounted examples.
Exports
SaaSProMax, createConnectorHandler, verifyServiceToken, ReplayGuard,
ServiceTokenError, toPublicKey, toNextRouteHandler, toNodeListener,
nextRequestErrorHandler, expressErrorHandler, honoErrorHandler,
installProcessHandlers, parseStack, normalizeError, isInApp,
redactHeaders, redactUser, plus the shared constants and connector types.
Intentional telemetry drops
Ingest may acknowledge an observation in discarded with reason sampled, budget, cardinality or disabled. The SDK completes the batch without retrying or raising onError for these policy decisions. Malformed acknowledgements and invalid-event rejections still report errors. Configure policies and inspect counts under Settings → Data → Manage telemetry. Reports use retained observations without extrapolation.
Build artifacts and custom frames
JavaScript errors automatically attach registered build debug IDs. Use the build-artifact integration to inject IDs before deployment and privately upload source maps. captureException(error, { frames }) also accepts explicit frames from a custom parser or native bridge, including debugId, architecture, hexadecimal instructionAddress/imageAddress, and optional isReturnAddress. Native collection and unwinding are provided by your host bridge.
OpenTelemetry error correlation
Set traceContext: () => trace.getActiveSpan()?.spanContext() using your
existing @opentelemetry/api instance. Captured errors keep the trace/span IDs
that were active when capture was called, including deferred consent or native
queues. An explicit captureException(error, { trace: span.spanContext() })
overrides the callback; { trace: null } suppresses correlation for that error.
Invalid IDs and throwing callbacks are ignored independently of error capture.
This attaches context only. Configure your OpenTelemetry exporter separately using the trace integration guide. Browser and native clients keep their public ingest key; the trace collector's secret key stays on a server. Open Metrics → Traces for the request timeline or follow View request trace from a captured error.