Dotyc
Concepts

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 error

The 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 sendBeacon on 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 purged

With 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.

On this page