Exposures
What gets recorded when decide() is called, the two tables it lands in, and the queue that carries it there.
An exposure is the one fact that everything else in Quoin is built on: this unit was actually served this variant, at
this moment. Quoin records it once, at decide(). Quoin never records it at isOn() or value(). See
Quickstart for the reason for this split.
Built, not published
Everything on this page is real, tested code as of QN-02b. This includes the two exposure tables, the rollout signal
of evaluate(), onRolloutExpose of createFlags, the exposure queue of the SDK, and POST /api/exposures. The
package is still not published to a registry. See SDK.
The two kinds
decide() can record one of two kinds of exposure. The kind depends on which rule answered the flag:
- Experiment exposure — an experiment rule assigned a variant to a unit. Quoin records it once for each
(environmentId, experiment, phase, unitId), inexperiment_exposures. - Rollout exposure — a unit reached a rollout rule whose coverage is below 100%. Quoin records it once for each
(flagId, environmentId, unitId), inrollout_exposures. Quoin records it for both the in-bucket subject and the out-of-bucket subject. A later informational read is built from exactly this split between in-bucket and out-of-bucket, and a table of served units only could not provide it. A rollout with 100% coverage writes nothing, because there is no "out of bucket" set to compare against. The row keeps the coverage at which Quoin first recorded the unit (coverageAt). It does not keep whether the unit was served. You can always derive the arm of a unit from that coverage and the fixed-seed hash of the flag. The arm therefore stays correct after the percentage of the rollout moves.
Both tables are insert-if-absent (sticky-first-write). A redelivered exposure never creates a second row. It never overwrites the variant or the coverage of the first row.
Quoin checks both kinds in the same way when they arrive. Quoin looks up a rollout exposure against the flags of your
workspace before it stores the exposure. As of QN-06 (07d qn02b-followup-1), Quoin looks up an experiment exposure
against the experiments table of your workspace in the same way. If a key is unknown, in either kind, Quoin drops
the exposure and logs it (exposures.unknown_flag / exposures.unknown_experiment). Quoin does not store it under the
misspelling.
What a unit id should be
Quoin stores unitId exactly as your subject mapper sends it. Quoin never hashes it. Use an opaque identifier,
such as a user id or a device id. Do not use an email address or a name. You hash emails only on the events path
(identify(), a later slice). The exposure path has no equivalent, by design. The id that you choose is the id that
Quoin keeps.
The queue
The SDK never writes an exposure itself. decide() gives the exposure to an in-memory queue. The queue batches
deliveries to POST /api/exposures. These rules apply:
- The queue flushes every 1 second or 50 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). If all three retries fail, the queue drops the batch and counts it (see below). Nothing survives an unflushed process exit. Callflush()before your process ends. - The queue never retries a
400,401or403. These are configuration problems, not delivery problems. For example, the key can be aclient-scope key or a revoked key, or an item can be malformed. Three retries of a refused batch would only add load. - One malformed item drops only itself (as of QN-05b). The route checks each exposure on its own and records the valid
ones. It answers
200withacceptedandrejected([{ kind: 'exposure', index, code: 'invalid_item' }]) besideok: true. The queue counts the rejected items as dropped. If every item in a batch is malformed, the answer is still400. - The queue counts a dropped batch in these places:
- the
droppedproperty of the queue - the
onExposuresDroppedhook oncreateClient - one
quoin.exposures_droppedwarning (logged once for each queue for a rejected batch, and every time for an unreachable batch)
- the
import { createClient } from '@jingsu96/quoin-flags';
import { flags as registry } from './flags';
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const quoin = createClient(registry, {
apiKey: env.QUOIN_API_KEY,
subject: (input) => ({ id: getUnitId(input as Request) }),
waitUntil: (work) => ctx.waitUntil(work),
});
const flags = await quoin.flags(request);
return renderOneClickCheckout(flags.decide('checkout.one_click'));
},
};Some runtimes do not guarantee that a timer keeps running after a response is sent. A Cloudflare Worker and most
serverless functions are such runtimes. In these runtimes, pass waitUntil. The queue then groups the synchronous
burst of decide() calls in a request into one POST on the next macrotask. The queue gives the promise of that flush
to waitUntil. It does not rely on its interval. Build the client inside the request handler, as above. The
ctx.waitUntil of a request is valid only for that request. createClient is cheap to call for each request,
because the flag rows, the first-read beacon and the queue itself are all shared for each key. The latest
createClient call for a key is the call whose waitUntil and onExposuresDropped the queue uses. In any other
runtime, call await quoin.flush() before your process exits. This drains the items that are still queued.
Only a server key may write
POST /api/exposures refuses a client-scope key with 403 forbidden: "A client key can't write exposures —
call decide() from your backend with a server key." It refuses an operator key in the same way: "An operator key
cannot write exposures — use a server key." A client key is safe to embed in a browser because it can only read.
Writing into the two tables that every verdict is built from is an operation for a server key. The first-read
beacon is different. A client key may also call the beacon to confirm a definition. A client key may never call
it to create a definition.