# Node.js SDK

`@cohorly/node` is the server-side Node.js SDK. The API mirrors the official `mixpanel` npm library: stateless, `distinct_id` passed explicitly on every call, optional Node-style callbacks plus first-class promises. Node 22+, one runtime dependency (`@cohorly/core`).

## Install

```bash
pnpm add @cohorly/node
```

## Initialize

```typescript
import Cohorly from "@cohorly/node";

const cohorly = Cohorly.init("YOUR_PROJECT_TOKEN", {
  host: "https://cohorly-service.velloalabs.com", // your Cohorly server
});

// distinct_id is required (server-side style)
cohorly.track("signed_up", { distinct_id: "user-13793", plan: "premium" });

// Profile updates (sent immediately via /engage)
cohorly.people.set("user-13793", { $email: "ada@example.com", plan: "free" });

// Link ids after signup
cohorly.alias("user-13793", "anonymous-abc");

// Before process exit: stop the timer + final flush (never throws)
process.on("SIGTERM", async () => {
  await cohorly.shutdown();
  process.exit(0);
});
```

Events are queued in memory and delivered in batches: auto-flush every
5s, or as soon as 20 events accumulate (server cap 500 per request).
The flush timer is `unref`'d, so it never keeps your process alive.

## API

| Member                                                                                   | Notes                                                                                                          |
| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `Cohorly.init(token, config)`                                                            | Config: `host`, `flushIntervalMs` (5000), `batchSize` (20), `debug`, `maxQueueSize` (1000), `maxRetryDelayMs`. |
| `track(event, properties, callback?)`                                                    | Queue an event. Stamps `time`, `$insert_id`, `$lib`, `$lib_version`; your values win.                          |
| `trackBatch(events)` / `import(event, time, props)`                                      | Bulk queueing / historical events with an explicit timestamp (Date or unix ms). `importBatch` for arrays.      |
| `people.set / set_once / increment / unset / delete_user`                                | Mirror mixpanel-node people API, mapped to /engage ops. camelCase aliases (`setOnce`, `deleteUser`) exist.     |
| `alias(distinctId, alias)`                                                               | POST /alias.                                                                                                   |
| `flush()` / `shutdown()`                                                                 | Force-drain now / stop the timer and final-flush (never rejects).                                              |
| `captureException(error, options?)`                                                      | Queue a `$exception` event. See [Error tracking](#error-tracking).                                             |
| `withCohorly(handler, options?)` / `flushWithTimeout(ms?)` / `flushExceptions(options?)` | Serverless flush API. See [Serverless](#serverless-flush-before-the-instance-freezes).                         |

## Delivery and retries

The queue survives transient failures: 429/5xx/network errors are
retried with exponential backoff (2s base, doubling, 10 min cap,
+/-20% jitter) honoring `Retry-After`; a 413 halves the
batch size; a 400 drops the rejected batch; a 401 keeps the queue at
max backoff. The queue holds at most 1000 events (oldest dropped
first). The project token travels as the `X-Cohorly-Token`
header.

## Error tracking

Errors are ordinary events named `$exception`, so they can be broken down,
filtered and put into funnels like any other event. Capture one yourself with
`captureException(error, options?)`:

```ts
try {
  await chargeOrder(order);
} catch (error) {
  cohorly.captureException(error, {
    distinctId: order.userId, // who hit it; default: serverName, else "server"
    level: "error", // "fatal" | "error" (default) | "warning" | "info"
    title: "Charge failed", // display title, also prefixes the fingerprint
    tags: { provider: "stripe" }, // top-level properties
    extra: { amount: order.total },
  });
}
```

`captureException` never throws and returns the queued event (or `null` when
`beforeSendException` dropped it). The event carries the error and its
`cause` chain (outermost first, at most 5) with the stack parsed into frames,
the current breadcrumbs, the release, and `runtime: "node"`, `node_version`
and `server_name` (when `serverName` is set). `tags` and `extra` become
top-level properties (`tags` wins) but never overwrite a reserved key
(`$exception_*`, `error_title`, `distinct_id`, `time`, `$insert_id`, `$lib*`,
`token`). A value JSON cannot carry (a circular `req`/`res`, a BigInt) is
sent as a short string instead, so one bad value never holds the queue back.
An exception does not wait for `batchSize`: a flush starts right away (it
still respects retry backoff).

Without `distinctId` the exception is attributed to `serverName`, or to the
fixed id `"server"`: always the same id, never a new one per error, so
crash-free users does not count users that do not exist. Pass the real user
whenever the error happened on their behalf.

Frames outside `node_modules` and Node's internals are `in_app`. `filename`
and `abs_path` are sent relative to `projectRoot` (default `process.cwd()`),
`file://` URLs decoded; paths outside it are sent as they are.

### Options

| Option                  | Default         | Notes                                                                                                              |
| ----------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------ |
| `release`               | -               | Sent as `$exception_release` on exceptions and `$app_version_string` on every event. See below.                    |
| `dist`                  | -               | Build inside a release, sent as `$exception_dist`.                                                                 |
| `appVersion`            | `release`       | Overrides `$app_version_string` only.                                                                              |
| `serverName`            | -               | `server_name` on exceptions, and their fallback `distinct_id`.                                                     |
| `projectRoot`           | `process.cwd()` | Frame paths are made relative to it.                                                                               |
| `captureUncaught`       | `false`         | Capture `uncaughtException` and `unhandledRejection`.                                                              |
| `captureExceptions`     | `true`          | `false` turns error capture off: `captureException()` sends nothing and `captureUncaught` installs no handlers.    |
| `flushTimeoutMs`        | `2000`          | Deadline of the flush in `withCohorly()` and the uncaught handlers.                                                |
| `beforeSendException`   | -               | Change or drop (`null`) an exception before it is queued. A hook that throws drops it: nothing is sent unscrubbed. |
| `maxExceptionQueueSize` | `50`            | Exception queue capacity.                                                                                          |

### Releases

Set `release` to the version you deploy, from the same source as your build
(a git sha or tag, e.g. `"api@2026.11.3"`). It is sent as `$exception_release`
on every exception and as `$app_version_string` on every event (a value you
pass yourself wins). That is what makes crash-free users by release possible
(ADR-0019): the numerator (users with a fatal exception in the release) and
the denominator (users active in the release) read the same string. Without
`release` (or `appVersion`) neither property is stamped.

### Uncaught errors

```ts
const cohorly = Cohorly.init(process.env.COHORLY_TOKEN!, {
  release: process.env.RELEASE,
  captureUncaught: true,
});
```

With `captureUncaught: true` the SDK listens for `uncaughtException` (sent
with `level: "fatal"`) and `unhandledRejection` (`level: "error"`), both with
`mechanism.handled: false`, flushes with `flushTimeoutMs` as the deadline, and
then lets the process end exactly as it would without the SDK:

- An uncaught exception still crashes the process with exit code 1 and the
  error printed, unless your app has its own `uncaughtException` listener; then
  the SDK does not force anything.
- An unhandled rejection keeps Node's behaviour for the
  `--unhandled-rejections` mode: exit 1 in the default `throw` mode, a warning
  and exit 0 with `warn`, and so on.
- The same error is reported once, even when Node re-raises a rejection as an
  uncaught exception.
- A server that never answers cannot hold the exit longer than
  `flushTimeoutMs`.

`shutdown()` removes the listeners.

### Readable stacks from TypeScript

The SDK sends the stack exactly as the process reports it: it never reads
source maps and there is nothing to upload. Turn source maps on in Node and
the frames point at your `.ts` files and lines:

```bash
node --enable-source-maps dist/index.js
# or, where you cannot pass flags (Cloud Functions, Cloud Run):
NODE_OPTIONS=--enable-source-maps
```

`process.setSourceMapsEnabled(true)` does the same from code, but only for
modules loaded after the call, so call it in a small entry file before
importing the rest of the app. Compile with `"sourceMap": true` (or
`"inlineSourceMap": true`) in `tsconfig.json`.

### Breadcrumbs

```ts
cohorly.addBreadcrumb({
  category: "payment",
  message: "Charging card",
  level: "info",
  data: { provider: "stripe" },
});
```

The last 50 travel with every exception (messages cut to 1000 characters).
Outside `withCohorly()` they live in one trail for the whole client. **Inside
`withCohorly()` they belong to that invocation only**: a Cloud Run instance
serves several requests at once, and one request's breadcrumbs never show up
on another's exception.

### Serverless: flush before the instance freezes

On serverless platforms (Firebase / Cloud Functions, Cloud Run, Lambda) the
instance stops getting CPU as soon as the handler finishes. An exception still
waiting in the queue is lost. Three tools cover this:

- `cohorly.withCohorly(handler, { distinctId?, flushTimeoutMs? })` wraps a
  handler, sync or async. If it throws or rejects, the error is captured
  (`mechanism: "serverless"`), the queues are flushed with a timeout, and the
  **same** error is rethrown, so the platform still sees the failure. The
  event reaches the server before the rethrow, even with only one event
  queued. If the flush times out or fails, the error is still rethrown on
  time; it is never swallowed. A handler that returns normally gets no
  capture and no extra flush. The wrapped function always returns a promise.
- `cohorly.flushWithTimeout(timeoutMs = flushTimeoutMs)` force-flushes
  exceptions, then events. Resolves `true` when both queues drained, `false`
  on timeout or failure; never rejects. At the deadline the request in
  flight is aborted (it is retried later, nothing is lost), so a server that
  never answers cannot keep the process alive past `timeoutMs`. Call it at
  the end of a handler when you also want ordinary events delivered before
  the freeze. A custom `transport` receives the abort as its 4th argument,
  an `AbortSignal`; one that ignores it still works, but a hung request then
  outlives the deadline.
- `cohorly.flushExceptions({ fatalOnly? })` sends only the exception queue,
  right now, ignoring retry backoff.

`flushTimeoutMs` (default 2000) bounds how much time a failing invocation
spends on reporting. Keep it well under your function's timeout.

### Example: Firebase Functions v2

```ts
// src/index.ts - compiled with "sourceMap": true, run with
// NODE_OPTIONS=--enable-source-maps
import Cohorly from "@cohorly/node";
import { onDocumentWritten } from "firebase-functions/v2/firestore";
import { onCall, onRequest } from "firebase-functions/v2/https";
import { onSchedule } from "firebase-functions/v2/scheduler";

const cohorly = Cohorly.init(process.env.COHORLY_TOKEN!, {
  release: process.env.RELEASE ?? process.env.K_REVISION,
  serverName: "cloud-functions",
  flushTimeoutMs: 1500,
});

// Callable: attribute the error to the signed-in user.
export const createRide = onCall(
  cohorly.withCohorly(
    async (request) => {
      cohorly.addBreadcrumb({ category: "ride", message: "validating" });
      const ride = await rides.create(request.auth?.uid, request.data);
      return { id: ride.id };
    },
    { distinctId: (request) => request.auth?.uid },
  ),
);

// HTTP: the error is rethrown, so the platform answers and logs it as before.
export const stravaWebhook = onRequest(
  cohorly.withCohorly(async (req, res) => {
    await webhooks.handle(req.body);
    res.status(204).end();
  }),
);

// Firestore trigger.
export const onRideWritten = onDocumentWritten(
  "rides/{rideId}",
  cohorly.withCohorly(
    async (event) => {
      await stats.recompute(event.params.rideId);
    },
    { distinctId: (event) => event.data?.after.get("userId") },
  ),
);

// Scheduled job: no user, attributed to serverName.
export const nightlyCleanup = onSchedule(
  "every day 03:00",
  cohorly.withCohorly(async () => {
    await cleanup.run();
  }),
);
```

`withCohorly` reports everything the handler throws, including an
`HttpsError` you throw on purpose for a client mistake. Catch those inside the
handler, or drop them in `beforeSendException`.

Building with NestJS? Use `@cohorly/nest`, a dynamic module
wrapping this SDK, with a global exception filter.
