Typed, transport-agnostic relay for server-side conversion events. It gives the delivery layer of a server-side tracking pipeline three things that are easy to get wrong by hand: idempotent de-duplication, batching, and retry with exponential backoff, all built on a pure, deterministic core.
This is a generic library. It is not an integration with any specific tracking platform, and it deliberately ships no vendor SDK, endpoint, token, or pixel. The problem space is adjacent to server-side conversion APIs (the kind used for offline-conversion upload and server-to-server event forwarding), but every type here is neutral. You map the neutral event onto a concrete wire format in an adapter you own.
It also never touches raw personally identifiable information. userData
carries identifiers that the caller has already normalised and hashed. Hashing
raw PII inside a shared library is a footgun, so this library refuses to do it.
See ADR-0004.
Server-side event delivery has two failure modes that dominate everything else:
- The same conversion arrives more than once. Client retries, at-least-once
queues, and replays all produce duplicates. Counting a purchase twice is worse
than counting it late, so the relay de-duplicates by a caller-assigned
eventIdand treats that id as the unit of idempotency. - A single delivery attempt fails transiently. Networks and downstream services are flaky. Dropping the event on the first error loses data; hammering the service loses it differently. The relay retries with exponential backoff and reports what actually made it through.
Everything else in the design exists to keep those two behaviours correct and testable.
import { ConversionRelay, InMemorySink } from 'conversion-relay';
// InMemorySink records what it receives; swap it for your own EventSink
// (HTTP, queue, log) in production.
const sink = new InMemorySink();
const relay = new ConversionRelay({
sink,
maxAttempts: 3,
backoffBaseMs: 200,
});
const report = await relay.send([
{
eventId: 'order-1001',
eventName: 'purchase',
timestamp: Date.now(),
value: 49.9,
currency: 'USD',
// already normalised and hashed by the caller, never raw PII
userData: { email: '5e88...c1a', phone: '9f2b...447' },
},
// a duplicate of the same conversion; it is dropped, not delivered twice
{ eventId: 'order-1001', eventName: 'purchase', timestamp: Date.now() },
{ eventId: 'order-1002', eventName: 'lead', timestamp: Date.now() },
]);
console.log(report);
// { received: 3, deduplicated: 1, batches: 1, attempts: 1,
// sent: 2, failed: 0, startedAt: ..., durationMs: ... }
console.log(sink.delivered.map((e) => e.eventId)); // ['order-1001', 'order-1002']Writing a real transport is a matter of implementing one method:
import type { EventSink, ConversionEvent } from 'conversion-relay';
class HttpSink implements EventSink {
readonly name = 'http';
async deliver(events: readonly ConversionEvent[]): Promise<void> {
const res = await fetch('https://example.test/collect', {
method: 'POST',
body: JSON.stringify(events),
});
if (!res.ok) {
throw new Error(`sink failed: ${res.status}`); // reject -> the relay retries
}
}
}The relay depends only on ports (interfaces), never on concrete transports or on the system clock. Everything impure is injected, so the core control flow is a pure function of its inputs. This is a small hexagonal (ports and adapters) design.
flowchart LR
caller([caller]) -->|send events| relay
subgraph core[pure core]
dedup[deduplicate\nby eventId]
batchfn[batch\nby size]
relay[ConversionRelay\nretry + backoff]
dedup --> batchfn --> relay
end
relay -->|deliver batch| sink[[EventSink port]]
relay -.->|await between attempts| sleep[[Sleep port]]
relay -.->|now| clock[[Clock port]]
sink --> mem[InMemorySink\nadapter]
sink --> http[your HTTP / queue\nadapter]
relay -->|on failure: backoff, retry up to maxAttempts| relay
relay -->|RelayReport| caller
A batch is delivered through the EventSink port. On rejection the relay waits
through the Sleep port for backoffBaseMs * backoffFactor ** retryIndex
milliseconds (plus optional injected jitter), then retries, up to maxAttempts.
Under test the Sleep and Clock ports are fakes, so retry and backoff are
verified exactly and instantly, with no real waiting.
| ADR | Decision |
|---|---|
| 0001 | Idempotency via de-duplication on a caller-assigned eventId |
| 0002 | Inject Clock and Sleep so timing is deterministic under test |
| 0003 | Model the transport as an EventSink port (hexagonal) |
| 0004 | Accept only pre-hashed identifiers; never hash raw PII |
| 0005 | Retry failed batches with deterministic exponential backoff |
- Keeping the core pure paid off immediately in the tests. Retry and backoff are
the trickiest behaviour here, and injecting
Sleepturned "wait and hope" into exact assertions on the recorded delay sequence ([100, 200]), running in milliseconds. - A real HTTP sink belongs outside the core, as an adapter, precisely so the
core never grows a dependency on
fetch, a specific endpoint, or auth. TheHttpSinksketch above is illustrative and intentionally not shipped. - Next steps I would add before production use: a circuit breaker so a persistently failing sink stops absorbing attempts, per-batch (partial) failure reporting so a caller can requeue only what did not land, and a dead-letter sink for batches that exhaust every retry.
- The backoff is currently per batch. A shared token bucket or concurrency limit across batches would be the natural next layer for high-volume senders.
npm install
npm run check # lint + typecheck + test + buildMIT, see LICENSE.