Deduplication, in full
How an event's key is derived from what it means rather than generated, the two forms that derivation takes, and why that is what makes a retry free instead of a double charge.
If this plugin has one idea worth taking away, it is this one. Everything else - the sink contract, the buffer, the billing periods - is built to preserve the property this page describes, not the other way around.
The asymmetry that decides everything here
Usage that double-counts is worse than usage that goes missing. A total that came out too small is visible: it looks wrong, and whoever is watching the number notices. A total that is quietly too large becomes an invoice, and nobody finds out until a customer disputes it - by which point the money has already moved. Every rule below exists to make that second failure structurally impossible rather than merely unlikely.
A key is a pure function of what the event means
dedupeKeyFor in src/lib/usage/dedupe.ts builds a SHA-256 over the event's own
facts, and nothing else. No random bytes, no Date.now(), no process id, no
hostname, no counter, no arrival order, no database sequence. Call it twice with
the same facts, on two different machines, in two different years, and it
returns the same key both times.
That single property is what turns three ordinary failure modes into non-events:
- a retry, because a client's HTTP call timed out on a request that had, in fact, already succeeded on the server;
- a redeploy or crash that replays a queue, an unacknowledged message, or a webhook your infrastructure resends until it is told otherwise;
- a backfill run twice, by an operator who was not sure the first run finished.
Each of those re-derives the identical key, and the append becomes a no-op because a sink is contractually required to keep at most one row per key. Retrying is not merely tolerated here - it is the correct response to any doubt about whether a call landed, and the deduplication key is what makes that true.
The key is also the primary key
dedupeKeyFor returns a string prefixed uev_, and that string is the row's own
primary key in the sink, not a value the sink separately indexes. That
distinction matters more than it looks: it means deduplication is enforced by the
database engine's own uniqueness constraint, not by application code that reads
before it writes.
A read-then-write check has a gap in it - between the read and the write, another
process can insert the very row the check just failed to find - and closing that
gap usually means a lock, a transaction, or accepting the race. Making the
dedupe key the primary key removes the gap instead of guarding it: INSERT ... ON CONFLICT DO NOTHING either inserts the row or it does not, atomically, and
there is no window in between for a second writer to slip through.
Two forms, because a key needs two different kinds of input
Explicit
Pass idempotencyKey when you already have a natural identity for the thing
being metered - the id of the request, of the upstream webhook, of the row you
are importing:
sha256( "usg1" US "explicit" US meter US subject US idempotencyKey )It is scoped to (meter, subject) rather than taken bare, and that scoping is
not incidental. A caller that meters both tokens_in and tokens_out for the
same request id needs two rows, not one - and without the scope, the second
record call would derive the same key as the first and silently vanish into
the sink's "already have this" branch. Scoping the explicit key to the grain
aggregation actually runs at (a meter, for a subject) makes that class of mistake
unrepresentable rather than merely documented against.
Derived
Omit idempotencyKey, and the key comes from the full statement of fact instead:
sha256( "usg1" US "derived" US meter US subject US occurredAt US quantity US source US properties )where US is the ASCII unit separator, U+001F. occurredAt is serialized as an
ISO 8601 instant at millisecond precision in UTC, so the key does not depend on
which time zone the recording process happens to run in. quantity is a decimal
integer. properties is canonical JSON - keys sorted, no incidental whitespace -
which is what stops {"a":1,"b":2} and {"b":2,"a":1} from deriving different
keys for what is obviously the same event.
What makes the join itself safe
Joining fields with a separator is only an injective encoding of those fields if
none of them can contain the separator - otherwise two different field tuples
could join to the same string, and two genuinely different events would collapse
into one key. meter, subject, source and an explicit key are all validated
in src/lib/usage/event.ts to contain no control characters at all, which rules
out U+001F along with everything else in that range. The join is therefore safe
by construction, not by convention.
Two smaller details are worth knowing because they are easy to get wrong when reimplementing this scheme elsewhere:
- No property bag and an empty property bag hash identically.
{}andundefineddescribe the same event - no dimensions were recorded - sonormalizeUsageEventcollapses an empty object tonullbefore anything is hashed. Without that collapse, a client library that always sends{}and one that omits the field entirely would derive different keys for the same fact. explicitandderivedare separate domains inside the hashed text. Prefixing the material with which scheme produced it means the two spaces cannot collide even in principle - an explicit key and a coincidentally matching derived tuple can never land on the same hash.
What the derived form costs, stated plainly
Two events that are genuinely distinct but identical in every field the derived key reads - down to the millisecond - collapse into one row. That is a real undercount, and it is the deliberate side of the asymmetry this page opened with: the scheme errs toward counting less rather than counting twice.
If your producer can genuinely emit two such events - several units of the same meter, for the same subject, in the same millisecond, with nothing else to tell them apart - you have three ways out, and the plugin will not choose one for you:
- Pass an explicit
idempotencyKey(usually the right answer, if one exists). - Make the events distinguishable with an ordinal in
properties. - Combine them into one event with a larger
quantity- which, most of the time this comes up, is what was actually meant in the first place.
Versioning the scheme itself
The literal string usg1 is the first thing hashed, in both forms. It pins this
exact derivation. If the rules above ever change - a new field joins the derived
tuple, or the separator changes - the version prefix changes with them, so the
two schemes can never collide, and every row already written keeps the key it
was written with. Nothing about deduplication ever rehashes existing rows;
rehashing a log is indistinguishable from rewriting it, and rewriting an
append-only log defeats the reason it is append-only.
The four vectors below are pinned in src/lib/usage/dedupe.test.ts and are the
actual contract, not documentation of one:
sha256("usg1\x1Fexplicit\x1Fapi_request\x1Fcus_1\x1Freq_1")
sha256("usg1\x1Fderived\x1Fapi_request\x1Fcus_1\x1F2026-01-01T00:00:00.000Z\x1F1\x1F\x1Fnull")Moving them would mean every key already sitting in a production log stops matching the key the same event derives today - and every one of those events would be counted again the next time it arrived.
What this buys you, concretely
Once a key is derived rather than generated, every layer above it gets to be naive about retries instead of careful about them:
recordcan be called with the same event twice, from two different processes, and the log gains one row.- The ingestion buffer can dedupe a burst before it even reaches the sink, for free, because two events with the same key are the same map entry.
- A sink built on a store with no primary key can still offer the same guarantee, by enforcing uniqueness on read instead of on write - see that package's docs for exactly how.
- A host application can retry
POST /admin/usage/eventson any doubt about whether the first call landed, with no coordination and no idempotency layer of its own to build.
None of that is a separate feature. It all falls out of one fact: the key means the event, so the event can only ever occupy one row.