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
5xxor a429, 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 (theonEventsDroppedhook oncreateClient). - The queue never retries a
400,401or403. - Each item stands on its own (as of QN-05b). An
identify()travels in its own request and never in atrack()batch. The route checks every event and everyidentifyseparately. One malformed trait, or one event outside the accepted time range, drops only itself. The response keepsok: trueand addsacceptedandrejected([{ kind, index, code }], wherecodeisinvalid_itemortimestamp_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 still400. 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 ofcreateClienttakes 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.