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.

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.