Skip to content

Repository files navigation

conversion-relay

CI License: MIT

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.

Why

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 eventId and 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.

Quick start

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
    }
  }
}

Architecture

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
Loading

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.

Design decisions (ADRs)

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

Lessons learned and future work

  • Keeping the core pure paid off immediately in the tests. Retry and backoff are the trickiest behaviour here, and injecting Sleep turned "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. The HttpSink sketch 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.

Development

npm install
npm run check   # lint + typecheck + test + build

License

MIT, see LICENSE.

About

Typed, transport-agnostic relay for server-side conversion events: idempotent de-duplication, batching, and retry with exponential backoff on a pure, deterministic core.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages