SaaS Pro Max
Sign inStart free
Documentation menu

@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.

Shell
npm install @saaspro/node

One client per process#

TypeScript
// 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:

TypeScript
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#

OptionDefaultNotes
secretKey—Required
hosthttps://saaspro.dev
flushAt20Events buffered before an automatic flush
flushIntervalMs50000 disables the timer (flush manually)
maxRetries3Per batch, on 5xx / 429 / network errors
retryBaseMs250Exponential: 250, 500, 1000 ms
requestTimeoutMs10000Each ingest/flag request; custom fetch must honor AbortSignal
maxQueueSize1000Includes batches awaiting transport; overflow reports through onError
maxBatchBytes512000UTF-8 envelope limit, in addition to 100 events
maxFlagCacheSize1000Bounded identity/property cache entries
release env appVersion—Attached to context.app
debugfalseLog queue and transport activity
fetchglobal fetchInjectable transport
onError—Receives everything the SDK swallows

Events#

TypeScript
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:

TypeScript
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#

TypeScript
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#

TypeScript
// instrumentation.ts (Next.js)
import { spm } from "./lib/spm";
import { nextRequestErrorHandler } from "@saaspro/node";
export const onRequestError = nextRequestErrorHandler(spm);
TypeScript
// Express — register AFTER your routes
import { expressErrorHandler } from "@saaspro/node";
app.use(expressErrorHandler(spm));   // reports, then calls next(err)
TypeScript
// 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)));
TypeScript
// Any long-running process
import { installProcessHandlers } from "@saaspro/node";
const uninstall = installProcessHandlers(spm);  // uncaughtException + unhandledRejection

Metrics#

TypeScript
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#

TypeScript
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#

Node SDK 0.5.0 adds optional typed user ordering and a handler-confirmed applied sort echo. Order before pagination with a unique ID tiebreak; name/email ordering requires PII permission. Existing handlers remain compatible without opting in.

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 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.