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_KEYcomes from the environment, never from source -
expressErrorHandleris registered after every route - Metric labels use route patterns, not resolved URLs
-
spm.shutdown()runs onSIGTERM - Connector mounted and reachable; the console health check passes