snoozestackdocs

Signals

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.

SurfaceAvailability
Portal UIOne log-viewer page: facet by type and level, filter by text, histogram over the window, rows expand in place — read-only
CLIsnoozestack signals streams the hosted feed; signals tail/query/clear work the local dev store
SDK / HTTPsnoozestack.signals.* in the app, capabilities.signals.emit() in a function

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:

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:

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 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:

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}
FieldRequiredMeaning
nameyesEvent name (≤ 200 chars), e.g. page_view, signup
distinct_idnoWho did it — a user id or device id; powers the unique-user counts
propsnoJSON object of properties (≤ 8 KB serialized)
tsnoWhen it happened (ISO 8601 or epoch ms); defaults to arrival time. Clamped to the last 7 days / next 5 minutes
insert_idnoIdempotency 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.

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:

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, butGET and read-only.

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":{}}
ParamDefaultMeaning
typeeventOne of event / log / metric / trace / audit
sincenow − 24hISO 8601 or epoch ms — start of the window (inclusive)
untilnowISO 8601 or epoch ms — end of the window (exclusive)
limit1000Rows 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
RuleWhy
Two account ids never mergeA 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 idsAnything larger is a shared device, not a person. The merge is refused rather than allowed to swallow the project
reset() ends device continuitySign-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.

SetupAnonymous 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 baseFully — the API is same-origin however the site is reached
Default <ref>.snzzz.com API, app on another domainUntil 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 — keeps the call same-origin and the cookie first-party.