Record exposures
An exposure is the analytics event recorded when a real user is served a flag. It's what powers everything worth having: per-flag usage (checks/hr, pass rate, staleness), always-on impact (how each variant moves your metrics), and experiments (lift, significance). The raw evaluation is always free; the exposure is the billable analytics event, so you pay to measure, never to serve.
Exposures are on by default
When you evaluate a flag remotely (the standard OpenFeature OFREP provider POSTs
one request per check), Flagon logs the exposure for you automatically. There is
nothing to wire up. It is deduped per user, flag, and variant per hour, so a user
reloading a hot page bills once an hour, not once per request. Disable
it per key with the Auto exposures toggle on the client key,
or per request with the X-Flagon-Exposure: off header. The rest of this page is
for cached evaluation, where the SDK fetches the whole config and resolves flags
locally, so Flagon never sees each check and you log exposures yourself.
Send the served variant and the targetingKey with each exposure and Flagon
attributes it to the arm the unit saw (the targeting key is stored only as a salted
hash), so impact and experiments light up automatically.
All requests go to the Flagon API host and authenticate with a client key, the same key you evaluate with:
POST /ofrep/v1/exposures
Authorization: Bearer flagon_client_...
Content-Type: application/jsonBase URL
Examples use https://api.flagon.io. Running Flagon locally, the API is at
http://localhost:3002.
Cached evaluation: log with an OpenFeature hook
If you evaluate from a cached config (the SDK fetches all flags and resolves locally),
log exposures yourself with an OpenFeature hook registered once, globally. Its
after stage fires on every successful evaluation, so you never have to remember to
record an exposure, it just happens, with the served variant and targeting key
already in hand.
import { OpenFeature, type Hook } from "@openfeature/server-sdk";
import { recordExposure } from "./exposures"; // the batching helper below
/** Records an exposure for every flag your app evaluates. */
const exposureHook: Hook = {
after(hookContext, details) {
recordExposure({
key: hookContext.flagKey,
variant: details.variant,
targetingKey: hookContext.context.targetingKey as string | undefined,
});
},
};
// Register once at startup. Now every check across your app logs an exposure.
OpenFeature.addHooks(exposureHook);Every OpenFeature SDK exposes the same hook interface, so the pattern is identical in Python, Go, Java, and the rest: register one hook, get exposures everywhere. Prefer to be explicit instead? Call the endpoint directly, below.
Send a batch
The body is an events array. Each entry names the flag key it relates to. To
attribute an exposure to a running experiment, also send the
served variant and the targetingKey (the unit). Flagon records which arm the
unit saw (the targeting key is stored only as a salted hash). Any other fields are
ignored. Send up to 1,000 events per request and chunk anything larger.
curl -X POST "https://api.flagon.io/ofrep/v1/exposures" \
-H "Authorization: Bearer $FLAGON_CLIENT_KEY" \
-H "Content-Type: application/json" \
-d '{"events":[{"key":"new-checkout","value":true},{"key":"theme","value":"dark"}]}'A 202 means the batch was recorded. recorded is how many events this request
counted; duplicate is true when the batch was a retry Flagon had already seen
(see below):
{ "recorded": 2, "duplicate": false }Idempotent retries
Exposures are billable, so recording is durable, not fire-and-forget: a failed
request is safe to retry. To make a retry count exactly once, send an
Idempotency-Key header with a stable, unique id for the batch. Flagon treats two
requests with the same key as the same batch, so a network retry never
double-counts:
POST /ofrep/v1/exposures
Authorization: Bearer flagon_client_...
Idempotency-Key: 6f9c2b1e-8a3d-4c77-b0a1-2f5e9d4c1a20A repeat of a key Flagon has seen returns 202 with { "recorded": 0, "duplicate": true }
and records nothing further. Omit the header and each request is a distinct batch
(still durable, but a retry would count twice).
A batching helper
You rarely want a network call per exposure. Buffer them and flush in batches on a timer (and once more as the page unloads, so nothing is lost):
const FLAGON_API = "https://api.flagon.io";
const MAX_BATCH = 1000;
// Buffer exposures and flush them in batches.
export function createExposureRecorder(clientKey, { flushMs = 5000 } = {}) {
let queue = [];
async function flush() {
if (queue.length === 0) return;
const events = queue.splice(0, MAX_BATCH);
// One id per batch: if this flush fails and you retry it, reuse the SAME key
// so the retry is counted once, not twice.
const idempotencyKey = crypto.randomUUID();
await fetch(`${FLAGON_API}/ofrep/v1/exposures`, {
method: "POST",
headers: {
Authorization: `Bearer ${clientKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ events }),
keepalive: true, // let an in-flight flush finish during unload
});
}
const timer = setInterval(flush, flushMs);
if (typeof window !== "undefined") {
window.addEventListener("pagehide", flush);
}
return {
// Call right after you evaluate a flag you want analytics on.
record(key, extra) {
queue.push({ key, ...extra });
if (queue.length >= MAX_BATCH) flush();
},
flush,
stop() {
clearInterval(timer);
return flush();
},
};
}Wire it in next to your evaluations:
const exposures = createExposureRecorder(process.env.FLAGON_CLIENT_KEY);
const value = client.getBooleanValue("new-checkout", false);
exposures.record("new-checkout", { value });Errors
| Status | errorCode | Meaning |
|---|---|---|
400 | PARSE_ERROR | The body was not valid JSON. |
400 | INVALID_CONTEXT | events was missing, not an array, or larger than 1,000. |
401 | AUTHENTICATION_ERROR | Missing, malformed, or revoked client key. |
403 | PLAN_LIMIT_REACHED | The plan's monthly event allowance is exhausted (the Hobby hard cap). Upgrade or wait for the cycle to reset. |
429 | None | Too many requests; back off. A Retry-After header says for how long. |
Notes on behavior
- Recording is durable and idempotent. A
202means the batch was recorded, not just queued. Because exposures are billable, a failed request is safe to retry; send anIdempotency-Keyso a retry counts exactly once. Still, do not block a user path on the flush: buffer and send it in the background. - Only the count is metered. Flagon stores per-day counts, not the exposure detail, so extra fields you attach do not change your bill.
- Checks stay free. Evaluating flags never counts against your plan, only the exposures you choose to send do. See pricing for each plan's included events.
- Allowances are enforced. Each plan includes a monthly exposure allowance
(your current allowance is shown on the usage page in the app). A free-plan org past
its cap gets a
403here and needs to upgrade to continue. Flag evaluation stays free and unaffected: only metering stops. Pro meters the overage instead of blocking. See pricing for current numbers.
Next
- Client keys: mint the key exposures authenticate with.
- Evaluate with REST: the free evaluation calls these pair with.