Emitter
Deliberately dumb: a typed track() inferred from the catalog, a two-lane transport with batching and acks — and nothing else.
The emitter is the piece that lives where events happen — components, server actions, background jobs. It is deliberately dumb: it knows the catalog and a transport, and nothing else. No adapter SDKs, no secrets, no routing knowledge.
import { createEmitter, http } from 'dotyc'
import { clerkIdentity } from '@dotyc/clerk/client'
import { catalog } from './events'
export const dotyc = createEmitter({
catalog,
transport: http('/api/ingest', {
batchSize: 20,
flushInterval: 5_000,
}),
identity: clerkIdentity(), // optional — otherwise call identify() manually
})Typed by inference
track() derives its entire signature from the catalog — no codegen, no manual type imports:
dotyc.track('feature_used', { feature: 'gantt_view' }) // ok
dotyc.track('feature_used', {}) // compile error
dotyc.track('typo_event', { feature: 'x' }) // compile errorThe type does more than check payloads. Events declared source: 'trusted' in the catalog are excluded from the client emitter's track() type: the client cannot emit — cannot even name — an event that only the server is allowed to state. The trust boundary is a compile error, not a runtime rejection. See Delivery guarantees.
track() stays fire-and-forget at the call site: the call never blocks on delivery. What happens after the call depends on the event's delivery class — see the two lanes below.
Why dumb is the point
Everything the emitter does not know is a guarantee:
- No routing knowledge → changing where events go — adding PostHog, swapping Matomo, splitting an event into a billing counter — touches zero call sites. Routing lives in the wiring, server-side.
- No adapter SDKs → your client bundle carries the catalog and a thin HTTP transport, not N vendor SDKs.
- No secrets → API keys exist only where the ingest brick is mounted.
- No validation logic → the emitter sends claims; the ingest enforces the contract. One place to trust, one place to audit.
Dumb, not careless: the emitter does stamp each event with a generated eventId (ULID) and the schemaHash of its definition — mechanical metadata, no judgment involved.
Transport: two lanes, never a mixed envelope
The default http transport ships with sensible delivery behavior, all of it configurable. One POST carries an envelope of N events; the ingest accepts the batch natively.
transport: http('/api/ingest', {
batchSize: 20, // everything is a setting, nothing is hardcoded
flushInterval: 5_000,
})Internally the transport keeps two lanes, split by the event's delivery class:
- Best-effort lane — batched lazily by size and interval, flushed with
sendBeaconon page unload so trailing events survive navigation. Fire-and-forget. - Durable lane — flushed immediately, in its own envelopes. An envelope never mixes the two classes, so a burst of best-effort noise can never delay a business fact, and the ingest can prioritize durable envelopes under pressure.
The endpoint is yours — same domain as your app. That makes ingestion first-party and invisible to ad-blockers, without any proxy tricks.
Acks: the emitter knows what landed
The ingest response reports the fate of each event: accepted | rejected | legacy. The emitter reacts per class:
- Durable events are kept in a queue until acked, and retried otherwise. (Whether the client-side queue is persisted to localStorage or memory-only is a draft decision.)
- Best-effort events remain fire-and-forget — no queue, no retry.
- In dev, rejections are surfaced loudly in the console. A typo'd payload or a schema mismatch is no longer an invisible drop.
Server-side emission
The same emitter works on the server. It can go through HTTP like the client, or — since the wiring is available in-process — skip HTTP entirely with the direct transport:
import { createEmitter } from 'dotyc'
import { direct } from 'dotyc/ingest'
const dotycServer = createEmitter({
catalog,
transport: direct(dispatcher), // same wiring, no network hop
})Same catalog, same typed track(), same fan-out — the transport is the only difference. And it is the only path for source: 'trusted' events: a critical fact is emitted by the code that performs it (the API route, the job), not claimed over HTTP. The server never needs to verify a client's claim, because the server is the emitter.
Identity surface
The emitter carries the client side of identity: it generates and persists an anonymousId per device, maintains a volatile sessionId, and attaches both to every event — before any login. On top of that:
dotyc.identify({ userId, accountId, traits }) // deduped — no-op if unchanged
dotyc.reset() // logout: new sessionId, identity purgedWith an identity provider plugged in (clerkIdentity(), …), you never call identify() yourself — the emitter subscribes to your auth state. Either way, what the emitter sends is a claim; verification happens at ingest.
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.
Ingest
The first-party server brick: schema resolution, strict validation, verified identity, durable persistence, acks, then pure parallel fan-out — a pure core (createDispatcher) plus a fetch-standard handler (toHandler).