Documentation menu

Express

Server events, error reporting, metrics and the connector in an Express app.

npm install @saaspro/node

1. One client per process

// src/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),
});

2. Request instrumentation

// src/middleware/spm.ts
import type { NextFunction, Request, Response } from "express";
import { spm } from "../spm";

export function spmMiddleware(req: Request, res: Response, next: NextFunction) {
  const startedAt = Date.now();

  res.on("finish", () => {
    const route = req.route?.path ?? "unmatched";
    spm.metrics.timing("http.request", Date.now() - startedAt, {
      method: req.method,
      route,
      status: String(res.statusCode),
    });
    spm.metrics.counter("http.requests", 1, { method: req.method, route });
  });

  next();
}
app.use(spmMiddleware);

Labels are low-cardinality on purpose — use the route pattern (/orders/:id), never the resolved URL, or you will create a metric series per order.

3. Business events

app.post("/api/orders", async (req, res) => {
  const order = await createOrder(req.body);

  spm.track({
    distinctId: req.user.id,
    anonymousId: req.cookies.spm_aid,   // stitches to the browser session
    event: "order placed",
    properties: { items: order.items.length, coupon: order.coupon ?? null },
    value: order.total,
    currency: order.currency,
    context: { ip: req.ip, userAgent: req.get("user-agent") },
  });

  res.json({ id: order.id });
});

context.ip is honoured only for secret keys and is hashed at ingest — the raw address is never stored.

app.post("/api/signup", async (req, res) => {
  const user = await createUser(req.body);
  spm.identify({ distinctId: user.id, traits: { email: user.email, plan: user.plan } });
  if (req.cookies.spm_aid) {
    spm.alias({ distinctId: user.id, previousId: req.cookies.spm_aid });
  }
  res.status(201).json({ id: user.id });
});

4. Error handler

import { expressErrorHandler } from "@saaspro/node";

// …all your routes and routers first…

app.use(expressErrorHandler(spm));

app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
  res.status(500).json({ error: "Internal Server Error" });
});

Order matters: Express only treats a middleware as an error handler if it takes four arguments, and only handlers registered after your routes see their errors. expressErrorHandler reports and then calls next(error), so your own handler still runs and still decides the response.

It picks up req.user.id as the distinct id when your auth middleware sets it, and strips authorization, cookie and the other credential headers before anything leaves the process.

For everything Express cannot catch:

import { installProcessHandlers } from "@saaspro/node";
installProcessHandlers(spm);   // uncaughtException + unhandledRejection

5. Feature flags

app.get("/api/dashboard", async (req, res) => {
  const newLayout = await spm.flags.isEnabled("new-dashboard", {
    distinctId: req.user.id,
    properties: { plan: req.user.plan },
  });
  res.json({ layout: newLayout ? "v2" : "v1" });
});

Cached in memory for 30 seconds per identity + properties, so this adds nothing measurable to a hot path.

6. Connector

import { createConnectorHandler, toNodeListener } from "@saaspro/node";
import { handlers } from "./spm-connector";

const connector = createConnectorHandler({
  publicKey: process.env.SPM_CONNECTOR_PUBLIC_KEY!,
  audience: process.env.SPM_APP_ID!,
  handlers,
});

// Mount BEFORE express.json() so the raw body reaches the handler untouched,
// or after it — toNodeListener re-serializes a parsed body either way.
app.use("/api/spm/connector", toNodeListener(connector));

Your connector base URL in the console is then https://api.example.com/api/spm/connector. See connector.md for the handlers.

7. Graceful shutdown

const server = app.listen(process.env.PORT ?? 3000);

for (const signal of ["SIGTERM", "SIGINT"] as const) {
  process.once(signal, () => {
    server.close(async () => {
      await spm.shutdown();   // flush, then stop accepting events
      process.exit(0);
    });
  });
}

Without this, whatever is still buffered when the process exits is lost.

Checklist

  • SPM_SECRET_KEY comes from the environment, never from source
  • expressErrorHandler is registered after every route
  • Metric labels use route patterns, not resolved URLs
  • spm.shutdown() runs on SIGTERM
  • Connector mounted and reachable; the console health check passes