tracklane
Providers

Writing a provider

The contract, which is the same one the built-in providers use

Any tool that needs to receive events about what your users do can be a destination, not just ad pixels. If it has a notion of track(), you can write a provider for it: this repository requires no registry entry, no allow-list, and no release to support it.

The providers shipped with this library use no shortcut yours cannot use. The contract enforces this: if the built-in ones needed something private, the contract would be a lie.

Browser

import type { BrowserProvider, EventData, TrackOptions } from 'tracklane';

export const acme = (siteId: string): BrowserProvider => ({
  name: 'acme',
  default: 'passthrough',
  install: () => loadAcmeTag(siteId),
  track: (name: string, data: EventData, options: TrackOptions) => {
    window.acme?.push(name, data, options.dedupId);
  },
});

name must be unique: registering the same vendor twice is a hard error, because there is nothing a second registration could say that the first cannot.

default decides what happens to an event you have no mapping for. passthrough sends the canonical name as-is; ignore stays silent. Use ignore only when your vendor mints event identifiers in its own dashboard, so no name could possibly be guessed. That is the case for LinkedIn and X, and it is the only honest reason to send nothing.

install runs when tracking is created. It may be async, and the core does not wait on it.

Make it idempotent. A host that rebuilds its tracking creates again and installs again, including providers that were already there. @tracklane/consent does exactly that on every consent answer, so a second install is a normal event and not a misuse.

Event names, and refusing to send

events maps canonical names to whatever your vendor calls them. A null entry means never send this event here, which is how a host keeps an internal product event out of the ad platforms.

export const acme = (siteId: string, events?: Record<string, string | null>): BrowserProvider => ({
  name: 'acme',
  default: 'passthrough',
  events: { purchase: 'Order', internal_debug: null, ...events },
  track: (name, data) => window.acme?.push(name, data),
});

Resolution order is your map, then default. The name your track receives is already resolved, so you never look anything up. With no map and passthrough, that resolved name is the canonical one, unchanged.

A binding renames an event. It does not reshape a payload. EventBinding is the vendor's name or null, and there is deliberately no third option that transforms data on the way through: a hook there would make what leaves a function of host code, which is the one thing this library promises it is not.

So the work of turning your domain objects into the canonical vocabulary — an order into transaction_id, value, currency and items — is yours, and it is where a migration spends most of its time. Do it once, at your own call site or in a thin function in front of track, and what each vendor needs from those canonical fields is then the adapter's job: items becoming Meta's contents is ours, your database row becoming items is yours.

A binding can also carry an identifier rather than a name. Some vendors do not have event names at all: LinkedIn's conversion identity is an id minted per account in Campaign Manager, so the binding holds that id — events: { purchase: '<conversionId>' } with default: 'ignore'. The field is called events because that is what it binds, not because the value is always a spelling. Passing such an id through data instead sends nothing, silently, because the provider then has no binding for the event at all.

Both are optional, and you implement them only if your vendor documents the concept. Omitting one is the honest translation of "this does not exist here", so the core simply skips you.

identify: (user, traits) => window.acme?.setUser(user.userId, traits),
consent: (command, state) => window.acme?.consent(state.ad_storage === 'granted'),

Consent arrives in Google's vocabulary, which is the most granular of the five. Reduce it to what your vendor understands: a collapse never claims more than the host declared. Expanding one coarse signal into several is how a library ends up telling a vendor a visitor agreed to something they refused.

Server

import type { ServerProvider } from 'tracklane';

export const acme = (apiKey: string): ServerProvider => ({
  name: 'acme',
  default: 'passthrough',
  track: async (name, data, context, report) => {
    const response = await fetch('https://api.acme.com/events', { /* … */ });
    if (!response.ok) throw new Error(`acme: ${response.status}`);
    if (!context.user?.email) report('sent without an email; match rate will be low');
  },
});

Throw when the send failed: the dispatcher isolates it, reports it, and the other providers are unaffected. Use report for something the caller should know about a request that already succeeded; throwing there would tell a retry loop to resend a delivered conversion.

Throw when nothing was sent, report when something was. Never the other way round, because both reach the same channel and what differs is the claim being made. A report on an event that never left teaches a host that its diagnostics are advisory, and by the time that matters the channel is already ignored.

There is a third case that is neither, and it catches people out: a vendor account that cannot receive at all — a scope not approved, credentials for a product not enabled — refuses every event permanently. That is configuration, not an event-time condition. Leave the provider out of providers until the account can accept, the same way you leave out a vendor you have not signed up for. Reasons in docs/decisions/0012.

Use Web Crypto, not node:crypto. The server providers here hash and sign with crypto.subtle so the bundle runs on Workers, Deno and the edge as well as on Node. A provider intended for this repository is expected to do the same; one you keep in your own codebase can do whatever your runtime allows, but porting later is real work — OAuth 1.0a signing especially.

The context you receive is already resolved: cookies parsed into a map, timestamp as epoch milliseconds whatever the caller passed. It carries user, cookies, dedupId, timestamp, source, url, ip, userAgent, traits and consent. Take what your vendor documents and ignore the rest. See the API reference for the full shape.

Disagreeing with a shipped provider, without replacing it

You do not have to choose between using a provider as it comes and rewriting it. A provider is a plain object, so wrap one and delegate the rest:

const base = posthog({ apiKey });

const patched: ServerProvider = {
  ...base,
  async track(name, data, context, report) {
    const user = context.user ?? (await myOwnLookup(context));
    await base.track(name, data, { ...context, user }, report);
  },
};

Every other behaviour in the shipped provider is kept and keeps being maintained here: the cookie parsing, the field placements, the error text that names the vendor's own field. On the browser half, identify and consent are optional, so a vendor whose identity belongs in your own base snippet needs no wrapper at all.

Where this stops. Delegation reaches what a provider reads — the context, the data, the event name, the bindings. It does not reach a field the adapter builds inside track. There are three cases and only one of them is yours:

You disagree withWhat to do
What the provider readsWrap it, as above
What the provider sendsThat is our bug. Open an issue
A verb the contract does not haveA question about the contract. Open an issue too

The third one is easy to mistake for the second. Adding a field to a request we already send is a payload disagreement; performing a second operation the contract has no method for is not, and the answer there is usually that the operation belongs in your own code, once, rather than inside every track.

The event name your wrapper sees is already resolved. A provider with no events map whose default is passthrough receives the canonical name unchanged, which is what lets a wrapper look up its own configuration by that name before delegating. This is contract, not an accident of the current dispatcher, and the shared conformance suite holds it in place.

On the server, await the delegate. This is the one mistake here that costs money:

// Wrong. Compiles, passes a naive test, and on a serverless runtime the
// conversion request can be killed when the function freezes.
async track(name, data, context, report) {
  base.track(name, data, context, report);
}

// Right.
async track(name, data, context, report) {
  await base.track(name, data, context, report);
}

Nothing in the types catches it, because the wrapper does return a promise — just one that settles before the request does. A vendor refusal then never reaches onError either; it becomes an unhandled rejection somewhere else. Turn on a floating-promise lint rule (Biome's noFloatingPromises, or the TypeScript ESLint rule of the same name) and run the wrapper through the conformance suite below, which fails on exactly this.

Dropping the report parameter, on the other hand, is fine. A wrapper that has nothing to report can declare three parameters and the compiler is happy, correctly.

Do not port a browser wrapper to the server, or the reverse. The two track signatures differ in arity and in synchronicity, and the halves share a vocabulary and nothing else. Write each one against its own contract.

One trap, and it is the expensive one. A wrap that hands over an identifier you stored is the supported shape. A wrap that fabricates one is the thing the shipped providers refuse to do, and wrapping does not make it safe — patching context.cookies with an invented _ga produces a 204 and nothing in any GA4 report. If you need identity in a place the browser is not, store it and hand it back.

And if two people write the same wrap, the shipped provider is wrong: tell us, and it stops being your problem.

Checking your work

The invariants the types cannot express ship as a test suite, and it is published for exactly this reason: the claim that a provider written elsewhere uses the same contract as the ones here is only true if it can run the same checks.

import { conformsAsServerProvider } from 'tracklane/conformance';

describe('my provider', () => {
  conformsAsServerProvider({
    create: () => myProvider(),
    captured: () => fetchMock.mock.calls,
    reset: () => fetchMock.mockClear(),
    fail: () => fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })),
  });
});

vitest is an optional peer dependency, so this costs you nothing unless you import it. There is a conformsAsBrowserProvider alongside it.

Run wrappers through it too. A wrapped provider is an object the dispatcher cannot tell apart from any other, so it owes the same invariants — and on the server the suite is what catches the missing await above.

Three rules worth following

Map to nothing rather than to something invented. If your vendor has no slot for a concept, send nothing. A made-up destination looks mapped and arrives nowhere, which is worse than an honest absence.

Never put payload contents in an error. Events carry raw personal data by design. Report the status, the event name and the vendor. Do not report the body.

Buffer for yourself if your SDK needs it. Every vendor shipped here queues its own commands until its script loads, so the core holds no queue for anyone. If yours arrives through a promise or a React context, keep your own list and replay it: a track that returns silently because the client is not ready yet is a defect in the provider, not a gap in the core.

Bring your own event typing. EventData names the fields adapters actually read — transaction_id, value, currency, items — and then accepts anything else, deliberately: the library is not the arbiter of what a product may measure, and a closed type would make every new vendor parameter a release here. The cost is that any payload compiles. If you want your own events checked, keep a typed map in your codebase and narrow at the call site; that split is the intended one, not a gap.

On this page