The four stages
Catalog → emitter → ingest → adapters. Why full decoupling, who trusts whom, and which delivery guarantees hold where.
Every event in Dotyc travels through four decoupled stages. Each stage knows as little as possible about the others — that constraint is the architecture.
catalog (shared, typed)
│
emitter ───── track(): knows ONLY the catalog + a transport
│
│ first-party HTTP (your endpoint)
│ — or direct(), in-process, for trusted events
▼
ingest ────── Dotyc brick mounted on YOUR infrastructure
schema resolution → validate → identity →
persist durables (store) → ack → wiring → dispatch
│
adapters ──── interchangeable consumers
├── official: PostHog, Matomo, …
└── custom: your business logic1. Catalog
The shared source of truth. Every event of your product, defined once: name, typed properties (any Standard Schema validator), free-form tags, a delivery class, and a source. It is client-safe, imported by both sides, and it anchors all type inference — event names and tags become literal unions across the whole pipeline. More →
2. Emitter
Deliberately dumb. The code that triggers events — client or server — knows only the catalog and a transport. track() is typed by inference and sends to your ingestion endpoint. It embeds no adapter SDKs, holds no secrets, and has no idea who consumes what. On the server, the same emitter can skip HTTP entirely and dispatch in-process through the same wiring. More →
3. Ingest
The server-side brick, mounted on your domain — first-party ingestion, invisible to ad-blockers. This is where everything the emitter must not know lives: strict validation, identity verification, context enrichment, wiring, secrets, and the durable outbox. A pure typed core (createDispatcher) with a fetch-standard handler (toHandler) on top, mountable in Next, Hono, Remix, Bun, and any framework that speaks Web Request/Response. More →
4. Adapters
The consumers. An event does not have one destination, it has N consumers, selected by declarative matching on tags and names. Official adapters cover analytics providers; custom adapters are five-line objects wrapping your business logic. Fan-out is parallel and isolated — one failing adapter never affects the others. More →
The brick families
Around the four stages, Dotyc's extension points come in pluggable families:
- Adapters — the consumers (Adapters).
- Identity providers — the resolvers, plugging into the auth you already run (Identity).
- Stores — pipeline state: the durable outbox, retry bookkeeping, and dedupe caches.
memoryStore()in dev; Redis, Postgres, or KV drivers in production (Delivery guarantees).
Transports (http, direct) round out the picture on the emitter side. Each family is an interface first; official packages are just implementations of it.
Why the decoupling matters
Call sites never change. Routing lives on the ingest side, never on the emitter. Adding a provider, removing one, or splitting an event between analytics and billing logic touches one wiring file — zero track() calls.
Secrets stay server-side. The client bundle contains the catalog and a thin transport. API keys, adapter SDKs, and database access exist only where you mount the ingest brick.
Analytics and business logic share one truth. The canonical example: feature_used goes to PostHog and increments the account's usage counter. Same definition, same emitting code, two consumers — the business logic that drives your product and the analytics that measure it can no longer diverge. And because delivery guarantees are declared per event, the billing-relevant half of that truth can be durable while the analytics half stays fire-and-forget.
The trust model
Data crossing the client/server boundary is never trusted as-is:
- Events are claims until validated. The ingest validates every event strictly against the catalog. An invalid event is rejected (surfaced via
onError) and never dispatched — the promise to every adapter is that it only ever receives data conforming to the catalog. Old-shape events from not-yet-reloaded clients are the one nuance: validated against their own schema version and flagged, never blocked. More → - Identity is a claim until verified. The emitter attaches lightweight ids and whatever it believes about the user; the ingest resolves the real identity from the session or a JWT. When both exist, the verified version wins. More →
- Some facts are never claims at all. An event declared
source: 'trusted'can only be emitted in-process viadirect()— by the server code that performs the action. The client emitter'strack()type excludes it (a compile error), and the HTTP transport rejects it. The rule: any event with monetary consequence isdelivery: 'durable', source: 'trusted'. More → - Delivery is a declared contract.
best-effortevents are fire-and-forget;durableevents are persisted to an outbox, acked, and retried per consumer until success or dead-letter. The two never share an envelope, and under pressure the ingest sheds best-effort traffic first. - Consumers are isolated. Fan-out is pure and parallel: no ordering, no inter-consumer transformation, no blocking. An adapter that throws affects neither its siblings nor the response to the emitter.
What Dotyc deliberately does not do
- No analytics storage, no dashboards. Dotyc routes; destinations store. (The store brick holds transient pipeline state — outbox, dedupe — not your data warehouse.)
- No event sourcing, no transactional bus. Durable delivery protects business facts feeding projections and counters; if losing an event would break a transactional invariant of your app, that belongs to your database or queue, not to an adapter. More →
- No client-native capture. Autocapture, session replay, heatmaps are provider features that live in the provider's own SDK, installed alongside if you want them. Dotyc routes the declared events of your catalog — that boundary is the design.
Quickstart
From install to events flowing into PostHog and your own usage counter — a best-effort analytics event from the client, a durable trusted business fact from the server.
Event catalog
Every event of your product, defined once: name, typed properties via any Standard Schema validator, computed fields, free-form tags — plus its delivery class and source.