Quoin docs

Events

track() and identify() — what a metric reads, the queue that carries it, retention, and the emailHash promise.

A metric that is built on the events source counts what track() sends. identify() tells Quoin who a unit is. With this, a report can restrict a population to a tier, a signup window, or a hashed email. A report never uses a plaintext email. See Metrics for the way to define a metric against an event. This page covers the SDK side and the ingestion side.

Built, not published

Everything on this page is real, tested code as of QN-05a. This includes the events and identities tables, track()/identify() and their queue, POST /api/events, the 13-month retention sweep, and the "seen events" probe that metrics.eventSeen polls. The package is still not published to a registry. See SDK. The SQL-template metric source (a read-only read of your own database) is the other half of Metrics. It is still design only. One correction to the record: the team built and tested POST /api/events, but did not mount it in the route table of the app. The round-5 pass of QN-02b registered it (07d qn02b-qa-5). Between the commit of QN-05a and that fix, the route answered a 404.

track() and identify()

await quoin.track('items_saved', { unit: userId });

quoin.identify(userId, { tier: 'pro', signedUpAt: Date.now() });

track(event, { unit, at?, idempotencyKey? }) records that event happened for unit at at (default: now). Every call creates its own idempotencyKey. The key is a crypto.randomUUID() that the SDK fixes once at call time. A retried delivery therefore never counts twice. Quoin stores event as sent, in lower case, with dot-separated segments. items_saved.14d is a metric key. It is not an event name. An event name has no window in it, for example items_saved. unit is required, unless the event starts with quoin.. Quoin itself can emit such a diagnostic event, and it has no unit.

identify(unit, traits) attaches tier, signedUpAt and client to a unit. The last write wins for each field. A call that sends only tier never clears a signedUpAt that Quoin recorded before. identify() refuses a trait named email. Quoin never sends a plaintext address over the wire. Hash the address yourself first:

import { hashEmail } from '@jingsu96/quoin-flags';

quoin.identify(userId, { emailHash: await hashEmail(email, workspaceSalt) });

Use your own stable salt. Quoin stores only the hash and never sees the salt. Nothing in Quoin can therefore match a hash back to an address. The salt gives the protection, not the hashing alone. A person who holds your salt and a list of candidate addresses could hash each address and compare. Keep the salt as private as an API key.

The queue

track() and identify() never await the network. Both put their items in the same in-memory queue from which the exposure queue of decide() is built. These rules apply:

  • The queue flushes every 1 second or 500 items, whichever comes first.
  • Delivery is best-effort, not guaranteed. If there is a network failure, a 5xx or a 429, the queue retries up to 3 times with backoff (250ms, 1000ms, 4000ms), with the same idempotency key on every attempt. If all three retries fail, the queue drops the batch and counts it (the onEventsDropped hook on createClient).
  • The queue never retries a 400, 401 or 403.
  • Each item stands on its own (as of QN-05b). An identify() travels in its own request and never in a track() batch. The route checks every event and every identify separately. One malformed trait, or one event outside the accepted time range, drops only itself. The response keeps ok: true and adds accepted and rejected ([{ kind, index, code }], where code is invalid_item or timestamp_out_of_range). The queue counts the rejected items as dropped and records the rest. If nothing in a batch is accepted, the answer is still 400. An SDK that is older than this change still works against the route. This SDK still works against an older route.
  • In a Worker or serverless handler, pass waitUntil. This is the same way that the exposure queue of createClient takes it. See the queue section of Exposures. This queue mirrors that section exactly.

Retention

Quoin keeps an event for 13 calendar months after it receives the event. Then an hourly sweep deletes it. The sweep uses the time at which Quoin received the event. It never uses the at or timestamp that you sent. A backfilled event can be a year old on the day it arrives. It still gets its full 13 months before the sweep considers it. Retention never sweeps identities rows. They are the population, not the event stream. Quoin removes them only if the workspace itself is deleted.

Backfilling

A regular server-scope key can send a timestamp up to 7 days in the past or the future. To import older history, use an operator-scope key. quoin login mints this key, and a signed-in admin carries it. An operator key has no environment of its own, so a backfill batch must name one. The key sends "backfill": true and "environment": "production". It can then send a timestamp up to 13 calendar months in the past. This is the same cutoff that the retention sweep purges by (one shared monthsAgo(), never 13 × 30 days). An import is therefore never refused inside the window for which the sweep would keep it. Quoin refuses a server key that sends backfill: true: "Backfilling is an admin action — sign in and use an admin's CLI-minted operator key."

Only a server key may write

POST /api/events refuses a client-scope key with 403 forbidden. This is the same shape as the refusal of Exposures. An operator key may write only a backfill batch. It may write it only when the role of the signed-in admin can manage keys and members.

Next

On this page