# Signals

> Events, logs, metrics, traces, and audits in one store — a console log viewer, a CLI tail, and an export API.

[HTML version](https://snoozestack.com/docs/signals/) · [Docs index](https://snoozestack.com/llms.txt)

One envelope for everything the app reports: **events** your apps track, **logs** your functions print, plus **metrics**, **traces**, and **audits**. Apps send events with the SDK; functions emit anything with `capabilities.signals.emit(type, name, props)`; and everything lands in the project's signals store — a SQLite file the project's own runtime owns and replicates, next to its database — read in the console, streamed by the CLI, and readable through the query/export API below. The ingest key is write-only: it can send signals but never read them back, which is what makes it safe to ship in browsers and mobile apps.

**Why use it**

Logs tell you what your *servers* did; Signals tells you what your *users* did. Those are different questions, and the second one can't be reconstructed after the fact — if you didn't record that someone opened the checkout screen and then left, no amount of querying production tables later will recover it. Your database holds current state (this user has a subscription), not the sequence of actions that produced it.

The design point here is one vocabulary for the product *and* the system. Because a function's log lines, its emitted events, and the client's tracked events share an envelope and a store, “what did this user do” and “what did the software do to them” are the same query — and no user data leaves for a third-party analytics service, which is often the deciding factor for anything privacy-sensitive.

Instrument deliberately: a handful of events named after user intent (`checkout_started`, `invite_sent`) is far more useful than tracking every click. Event names are effectively permanent — every query and dashboard you build on them breaks when one is renamed, so pick names you can keep.

- **What broke, and when** — A function's errors stack in red on the histogram, so a burst of failures is visible as shape before you read a line — then filter to `log` + Error and expand the row.
- **Did the feature land?** — Ship the change, then compare event volume before and after — the overview aggregates on the management API, or the export API into a dashboard of your own.
- **Debugging a specific user's report** — “It didn't work for me yesterday” — filter the stream to their id (the Person box on the console's Signals page, or `snoozestack signals --user <id>`) and read the exact sequence of what they did and what the software did to them, in one timeline.
- **Your own dashboard** — The export API hands every signal to any tool you already use, so a funnel or a retention chart is a query you own rather than a page you wait for.

### In the console

**Signals** is one page, and it is a log viewer rather than an analytics dashboard — the instrument you open at 2am. The sidebar facets the window by signal type (`event`, `log`, `metric`, `trace`, `audit`) and, for logs, by level, with live counts; a histogram shows the shape of activity over the window with errors stacked on top; the table filters by message text or by one person's id, and any row expands in place to show every property (its `distinct_id` is a click away from becoming the filter). Hosted, the window is 24 hours, 7, 30, or 90 days; the local dashboard shows the latest signals in memory with a Refresh button in the same spot.

| Surface | Availability |
| --- | --- |
| Portal UI | One log-viewer page: facet by type and level, filter by text, histogram over the window, rows expand in place — read-only |
| CLI | `snoozestack signals` streams the hosted feed; `signals tail/query/clear` work the local dev store |
| SDK / HTTP | `snoozestack.signals.*` in the app, `capabilities.signals.emit()` in a function |

**Diagram:** capabilities.signals.emit() · in a function · signals.track() · in the app, batched · Signals store · event · log · metric · trace · audit · Console + CLI · stream, charts, tail · Query / export API · bring your own dashboard

### From a function

A function that declares the `signals` capability writes with one method — the type is what separates a product event from a log line or a metric:

```bash title="inside a function"
capabilities.signals.emit("event", "note_created", { userId: user.id });
capabilities.signals.emit("metric", "render_ms", { value: elapsed });
capabilities.signals.emit("audit", "note_deleted", { by: user.id, noteId });
```

Under `snoozestack dev` these land in the local store (`.snoozestack/dev-signals.db`) — tail them while you build, without sending development noise to hosted ingestion:

```bash title="terminal"
snoozestack signals tail --type event,log       # watches live; Ctrl+C to stop
snoozestack signals query --name note_created
snoozestack signals clear
```

### JavaScript / TypeScript

Every `snoozestack-js` client has a `signals` handle. Events are batched in memory and flushed every 10 seconds, at 20 queued events, and when the page is hidden — `track()` never blocks and never throws.

```bash
const snoozestack = createClient(SNOOZESTACK_URL, SNOOZESTACK_ANON_KEY);

snoozestack.signals.track('page_view');                         // anonymous device id
snoozestack.signals.identify(user.id);                          // tie events to a user
snoozestack.signals.track('signup', { plan: 'pro' });
snoozestack.signals.reset();                                    // on sign-out
await snoozestack.signals.flush();                              // force-send (tests, CLIs)

// Standalone (no database/auth client needed):
import { SnoozeStackSignals } from 'snoozestack-js';
const signals = new SnoozeStackSignals(SNOOZESTACK_URL, SNOOZESTACK_ANON_KEY);
```

### iOS / Swift

The `SnoozeStackSignals` Swift package (iOS, macOS, tvOS, watchOS) queues events on disk — they survive app kills — and flushes on a timer, at batch size, and when the app is backgrounded. It ships in the same [snoozestack-swift](https://snoozestack.com/docs/sdk-swift.md) package (add `SnoozeStackSignals` instead of, or alongside, `SnoozeStack`).

```swift
SnoozeStackSignals.configure(url: "https://<ref>.snzzz.com", apiKey: ANON_KEY)

SnoozeStackSignals.shared.identify(user.id)
SnoozeStackSignals.shared.track("purchase", properties: ["sku": "pro", "price": 9.99])
```

### HTTP API (/api/signals/v1/events)

The SDKs are thin wrappers over one endpoint, so any HTTP client can send events:

```bash title="signals — /api/signals/v1/events"
curl -X POST "https://<ref>.snzzz.com/api/signals/v1/events" \
  -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \
  -d '{"events": [
        {"name": "signup", "distinct_id": "u-42", "props": {"plan": "pro"},
         "ts": "2026-07-06T12:00:00Z", "insert_id": "b3f1c1e2-…"}
      ]}'
# → {"ok": true, "ingested": 1}
```

| Field | Required | Meaning |
| --- | --- | --- |
| `name` | yes | Event name (≤ 200 chars), e.g. page_view, signup |
| `distinct_id` | no | Who did it — a user id or device id; powers the unique-user counts |
| `props` | no | JSON object of properties (≤ 8 KB serialized) |
| `ts` | no | When it happened (ISO 8601 or epoch ms); defaults to arrival time. Clamped to the last 7 days / next 5 minutes |
| `insert_id` | no | Idempotency key — a retried batch is never double-counted |

Up to 100 events per request. `ts` is clamped into a window around now — no earlier than 7 days ago, no later than 5 minutes ahead — so events queued on a device with a wrong clock still land somewhere sane, but **historical backfill is not possible**: importing a year of events from another tool would file them all under the 7-day floor. The key can come as an `apikey` header, `Authorization: Bearer`, or an `?apikey=` query parameter (for `navigator.sendBeacon`). Raw events show up on the console's Signals page and are readable in bulk through the export API below; the aggregates (events over time, top names, people) are available to scripts via the [management API](https://snoozestack.com/docs/management-api.md).

### Retention and ingestion quota

Signals are kept for a per-project retention window — **90 days by default** — after which the project's runtime deletes them; ask us to raise it if you need longer history. Ingestion is also subject to a per-project rolling 24-hour volume quota (**1,000,000 signals/day by default**). A batch that would push the project over quota is rejected outright with `429`, never partially ingested or silently dropped:

```bash title="429 — over quota"
curl -i -X POST "https://<ref>.snzzz.com/api/signals/v1/events" \
  -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \
  -d '{"events": [...]}'
# HTTP/1.1 429 Too Many Requests
# {"error": "Signals ingestion quota exceeded for this project (resets on a rolling 24h window). Contact support for a higher limit."}
```

Both numbers are per-project account limits, same as your project/table/storage limits — contact us to raise either one.

### Export API (/signals/v1/query)

The ingest endpoint above is write-only — a key can send signals but never read them back through it. For building an external dashboard, polling for new signals, or a one-time bulk export, use the export endpoint instead: same public key-based auth as ingest, but`GET` and read-only.

```bash title="query — /api/signals/v1/query"
curl "https://<ref>.snzzz.com/api/signals/v1/query?type=event&since=2026-08-01T00:00:00Z" \
  -H "apikey: $ANON_KEY"
# → application/x-ndjson, newest first, one JSON object per line:
# {"id":"9214","type":"event","name":"signup","distinct_id":"u-42","ts":"2026-08-22T18:04:11.000Z","props":{"plan":"pro"}}
# {"id":"9213","type":"event","name":"page_view","distinct_id":"u-42","ts":"2026-08-22T18:03:58.000Z","props":{}}
```

| Param | Default | Meaning |
| --- | --- | --- |
| `type` | event | One of event / log / metric / trace / audit |
| `since` | now − 24h | ISO 8601 or epoch ms — start of the window (inclusive) |
| `until` | now | ISO 8601 or epoch ms — end of the window (exclusive) |
| `limit` | 1000 | Rows per page, max 5000 |
| `cursor` | — | Continuation token from a prior response's X-Next-Cursor header |

Each line has the same field names the ingest endpoint accepts (`id`, `type`, `name`, `distinct_id`, `ts`, `props`), so a consumer never has to reverse-engineer an internal shape. If a page doesn't reach the end of the requested window, the response carries an `X-Next-Cursor` header — pass it back as `?cursor=` to fetch the next page; a response with no such header means you've reached `since`. The key can come as an `apikey` header, `Authorization: Bearer`, or an `?apikey=` query parameter, same as ingest.

### Identity — who a person is

Until you call `identify()`, events carry an anonymous id (`$anon-…`) that the SDK mints once and stores — in `localStorage` on the web, `UserDefaults` on iOS — so a returning visitor is the same person, not a new one. There is no fingerprinting: nothing is derived from the device, IP, or browser characteristics.

When `identify()` runs, the SDK reports both ids and snoozestack records that they are the same person. Everything the visitor did before signing up stays attached to their account, so an export that spans sign-up sees one person and unique-user counts stop counting one human twice. The id is persisted too, so a page reload keeps them identified — call `identify()` on every load, it's a no-op when nothing changed.

```bash
snoozestack.signals.track('landing_view');       // $anon-3f2a… (anonymous)
snoozestack.signals.identify('u-42');            // → both ids are now one person
snoozestack.signals.track('signup');             // u-42

snoozestack.signals.alias('legacy-id-9');        // link an id you minted elsewhere
snoozestack.signals.reset();                     // sign-out: start a fresh person
```

| Rule | Why |
| --- | --- |
| Two account ids never merge | A shared laptop or kiosk would otherwise chain everyone who signed in on it into one person. Applies transitively, so linking through a shared device is refused too |
| A person stops merging past 20 ids | Anything larger is a shared device, not a person. The merge is refused rather than allowed to swallow the project |
| `reset()` ends device continuity | Sign-out means the next person on this browser starts clean instead of inheriting the last one's identity |
| Reserved `$`-prefixed events | `$identify`, `$alias`, and `$reset` are identity plumbing. They are stored (visible in the stream) but never counted as activity in the people listing or the overview aggregates |

Resolution applies at query time, so the people listing (`snoozestack signals --user`, or `/signals/users` on the management API) lists people rather than ids — labelled with the account id, with how many ids merged into it. Raw signal rows are never rewritten; the identity graph lives alongside them. Identity is recorded from the moment you deploy this — links that were never captured can't be reconstructed, so history from before stays as it was.

### Anonymous identity that survives storage clearing

Browsers evict script-writable storage aggressively — Safari caps it at seven days — so an anonymous visitor can come back looking brand new. Where the project API shares your site's domain, snoozestack also sets a first-party `sb_did` cookie and uses it to reconnect a cleared visitor to who they were. It is `HttpOnly`, holds nothing but an opaque id, and no code of yours has to touch it.

| Setup | Anonymous identity survives |
| --- | --- |
| Site hosted on snoozestack (`<ref>.snzzz.com`) | Fully — the API is same-origin, so the cookie is first-party |
| App and API on the same origin via the relative `/api` base | Fully — the API is same-origin however the site is reached |
| Default `<ref>.snzzz.com` API, app on another domain | Until storage is cleared — the cookie is third-party there and browsers drop it, so identity falls back to localStorage alone |

There is nothing to configure for any of these. The one that costs identity is an app calling the project across origins; serving the app from SnoozeStack | on the project URL or on a [custom domain](https://snoozestack.com/docs/domains.md) — keeps the call same-origin and the cookie first-party.
