# Core SDK

@cohorly/core is the transport-agnostic client that every JavaScript SDK is built on. It owns the event queue, batching, flush scheduling, identity, and super properties. Storage and network transport are injected, so the package has zero DOM, Node, or React Native dependencies. Use it directly only when you are building a new platform SDK; most apps should reach for a platform package instead.

## Install

```bash
pnpm add @cohorly/core
```

## Quickstart

You supply a `CohorlyStorage` (persist the queue and identity) and a
`CohorlyTransport` (send batches over the network). The package ships
`fetchTransport` for environments with a global `fetch`.

```typescript
import { CohorlyClient, fetchTransport } from "@cohorly/core";
import type { CohorlyStorage } from "@cohorly/core";

const storage: CohorlyStorage = {
  get: (key) => myStore.get(key) ?? null,
  set: (key, value) => myStore.set(key, value),
  remove: (key) => myStore.delete(key),
};

const client = new CohorlyClient({
  // apiHost defaults to the hosted Cohorly API; set it to target a different deployment.
  // Project token: attached to every event's properties and to every
  // /engage and /alias body so the server routes data to your project.
  token: "YOUR_PROJECT_TOKEN",
  storage,
  transport: fetchTransport, // or a custom CohorlyTransport
});

client.track("Signed Up", { plan: "pro" });
await client.identify("user_123");
await client.people.set({ name: "Ada Lovelace" });
```

## API

| Member                                                                       | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `new CohorlyClient(options)`                                                 | Options: `storage` is required, `apiHost` defaults to the hosted API; `transport`, `token`, `flushIntervalMs`, `batchSize`, `debug`, `lib`, `maxQueueSize` (1000), `maxRetryDelayMs` (10 min), `fetcher`, `sendExposureEvents`, `flagPersistence`, `flagCacheTtlMs`, `captureExceptions` (default `true`; `false` makes `captureException()` a no-op) are optional.                                                                                                            |
| `track(event, properties?)`                                                  | Enqueues an event and auto-flushes once `batchSize` is reached. Returns the `TrackedEvent`.                                                                                                                                                                                                                                                                                                                                                                                    |
| `identify(id)`                                                               | Switches the distinct id synchronously, then sends `/alias` the first time if the user was previously anonymous - the switch is applied before the network call, so a slow or offline `/alias` request never delays it or any `track()` issued right after. The POST is best effort: a failure neither throws nor blocks, since the server also links `$device_id`/`$user_id` implicitly from the next event either id emits (see [Identity](/reference/data-model#identity)). |
| `reset()`                                                                    | Assigns a fresh anonymous distinct id. Call this on logout, especially on shared devices.                                                                                                                                                                                                                                                                                                                                                                                      |
| `register(props)` / `unregister(key)`                                        | Super properties merged into every event.                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `people.set / setOnce / increment / unset / delete`                          | Profile updates sent to `/engage`.                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `flush(transportOverride?)`                                                  | Manually flush the queue; always forces an attempt.                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `getDistinctId / isAnonymous / getApiHost`                                   | Accessors.                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `getFeatureFlag(key, fallback?)` / `isFeatureEnabled(key, fallback?)`        | Flag reads; the fallback (default `false`) is returned for unknown/unloaded flags and never counts as an exposure.                                                                                                                                                                                                                                                                                                                                                             |
| `getFeatureFlagDetails(key, fallback?)`                                      | Full flag result (`FlagDetails`): `value`, `enabled`, `payload`, `source` (`"network"` / `"persistence"` / `"fallback"`), plus `ttlMs` - and `persistedAt` when the cache carries `stored_at` - when served from persistence.                                                                                                                                                                                                                                                  |
| `getFeatureFlagPayload(key)` / `reloadFeatureFlags()` / `onFeatureFlags(cb)` | Payload getter; re-evaluate `POST /flags/evaluate`; subscribe to reloads. Flags persist per `distinct_id` (`flagPersistence` policy, `flagCacheTtlMs` default 86400000 ms / 24 h; under the default `"persistenceUntilNetworkSuccess"` an expired cache still serves up to 7 days stale, `"networkFirst"` is strict) - see [Feature flags](/feature-flags#flag-persistence).                                                                                                   |
| `metrics.count / gauge / distribution(name, value, tagsOrOptions?)`          | Custom metrics, aggregated per 60s window into `$metric` events. See [Custom metrics](#custom-metrics). `metrics.flush()` queues the pending window now; `metrics` option: `{ flagKey? }`.                                                                                                                                                                                                                                                                                     |
| `stop()`                                                                     | Queues the pending metrics window and stops the auto-flush timer.                                                                                                                                                                                                                                                                                                                                                                                                              |
| `optOutTracking()` / `optInTracking()` / `hasOptedOutTracking()`             | Device opt-out, persisted in `storage`. Opting out drops everything queued and clears breadcrumbs; while opted out nothing is queued or sent (events, people, alias, `$exception`, `$metric`) and `reloadFeatureFlags()` makes no request. Opting back in does not restore what was dropped.                                                                                                                                                                                   |
| `isExceptionCaptureEnabled()`                                                | `false` when opted out or `captureExceptions: false`. The `enabled` flag a platform SDK hands to a native crash reporter.                                                                                                                                                                                                                                                                                                                                                      |

## Custom metrics

`client.metrics` records counters, gauges and distributions with an API close
to Sentry's. Samples are aggregated in the client: each 60 second window sends
one `$metric` event per metric name and tag combination, never one per call.

```typescript
client.metrics.count("checkout.completed", 1, { plan: "pro" });
client.metrics.gauge("queue.depth", queue.length);
client.metrics.distribution("js_cost.service.max_ms", elapsedMs, {
  tags: { service: "pricing" },
  unit: "millisecond",
});
```

The third argument is a tags map or `{ tags, unit }`. It is read as options
when it is non-empty, has only the keys `tags` and `unit`, and `tags` (if
present) is an object; otherwise it is the tags map. To use a tag literally
named `unit` or `tags`, pass `{ tags: { unit: "x" } }`.

Each event carries the client's normal `distinct_id`, so you analyse metrics in
Explore like any event, and a user deletion removes them too.

| Property                                                 | Notes                                                                                                                |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `metric_name`, `metric_type`, `metric_unit`              | `metric_type` is `count`, `gauge` or `distribution`; the unit only when given                                        |
| `metric_window_start`, `metric_window_ms`                | Window start (unix ms, also the event time) and length (60000)                                                       |
| `metric_count`, `metric_sum`, `metric_min`, `metric_max` | Over the window's samples; for `count`, `metric_count` is the number of calls and `metric_sum` the sum of the values |
| `metric_last`                                            | Gauges only                                                                                                          |
| `metric_p50`, `metric_p95`, `metric_bucket`              | Distributions only. `metric_bucket` is the range of the window's p95, e.g. `3: 10-50` (sorts as text)                |
| `metric_tag_<key>`                                       | One per tag, as a string. At most 10 tags per metric, values cut to 64 characters                                    |

At most 200 series are kept per window (new ones past that are dropped), and a
distribution keeps up to 2048 samples for its percentiles; count, sum, min and
max stay exact. `metric_p95` is the percentile of one client in one window:
averaging it over a period does not give the period's p95.

### Quota cost

`$metric` events are ordinary events: they count toward your monthly event
quota. The worst case is the number of series times 1440 windows per day.

### Kill switch

Metrics are controlled by the feature flag `cohorly_metrics` (change the key
with `metrics: { flagKey }`). Create the flag and deactivate it, or set its
rollout to 0%, to turn metrics off for everyone without a release: the client
stops recording and drops the window in progress. If the flag does not exist,
metrics are on. The switch is read from the loaded flags, so call
`reloadFeatureFlags()` when you start the client. Reading it does not emit
`$feature_flag_called`.

Until the first flag set loads, metrics are on, but `$metric` events do not
leave the client while a flag evaluation is in progress and no flags are
loaded (for example right after a cold start without a flag cache): they wait
in the queue, other events are sent as usual. When the flags arrive, a
disabled `cohorly_metrics` drops every `$metric` still in the queue (including
older ones not sent yet), and an enabled or missing flag releases them. An evaluation that fails releases them too, and so does a
client that never evaluates flags. There is no timeout of its own: a flag
request that never answers keeps them waiting, and the queue cap drops them
first.

### Pending samples and shutdown

The window in progress lives in memory only. A process that dies, or an app
killed in the background, loses up to one window of samples.
`@cohorly/react-native` sends the pending window when the app goes to the
background. `@cohorly/web` queues its pending window on `pagehide` and when the
page becomes hidden. With the core client alone, call `metrics.flush()` and
then `flush()` on shutdown to send them. When the offline queue is full, `$metric` events
are dropped before any other event.

## Delivery and retries

Non-2xx responses surface as a `TransportError` (`status` plus optional
`retryAfterMs` parsed from `Retry-After`) that drives the retry contract: 429
and 5xx or network errors keep the queue and enter exponential backoff (base
2s, doubling, 10 min cap, +/-20% jitter, honoring `Retry-After`); 413 halves
the effective batch size and retries smaller without dropping events; 400
drops the rejected batch; 401 keeps the queue at max backoff. A success
resets the failure counter. The persisted queue is bounded at
`maxQueueSize` (oldest dropped first).

Building for the browser, React, Next.js, or React Native? Use the platform
packages (`@cohorly/web`, `@cohorly/react`, `@cohorly/nextjs`,
`@cohorly/react-native`), which wire up storage and transport for you.
