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.
The catalog is the source of truth for your analytics coverage: every event of your product, defined in one place, readable by humans, by AI, and by the type system.
Defining events
import { defineEvent, defineCatalog } from 'dotyc'
import * as v from 'valibot' // or zod, arktype, … (Standard Schema)
const featureUsed = defineEvent('feature_used', {
tags: ['feature'], // static metadata, free-form strings
delivery: 'durable', // 'best-effort' (default) | 'durable'
source: 'anonymous', // 'anonymous' (default) | 'trusted'
properties: v.object({
feature: v.string(),
}),
computed: { // optional — applied at ingest
day: ({ timestamp }) => new Date(timestamp).toISOString().slice(0, 10),
},
})
export const catalog = defineCatalog({
featureUsed,
projectCreated,
checkoutCompleted,
})An event definition has six parts:
- Name — the wire identifier (
'feature_used').defineCatalogenforces uniqueness. - Properties — a schema from any Standard Schema validator: Zod, Valibot, ArkType, whatever you already use. Dotyc's core depends only on the
~standardinterface, never on a specific library. - Tags — free-form strings categorizing the definition (
'feature','marketing','trace', …). Static metadata, and the primary routing dimension for wiring. - Delivery — the event's reliability contract:
'best-effort'(default, fire-and-forget) or'durable'(at-least-once: outbox, acks, per-consumer retries). See Delivery guarantees. - Source — who may emit it:
'anonymous'(default) or'trusted', which restricts emission to the server-sidedirect()transport and removes the event from the client emitter'strack()type entirely. - Computed (optional) — derived fields applied at ingest, so the emitter stays dumb and every consumer sees the same enrichment.
delivery and source: any event with monetary consequence — usage that bills, quota that gates, subscription changes — is delivery: 'durable', source: 'trusted', emitted by the server code that performs the action.What every event carries at runtime
Two identifiers are generated per emitted event — you never declare them, but every consumer sees them:
eventId— a stable ULID generated at emission. It makes at-least-once delivery safe (consumers dedupe on it), protects the ingest against replays, and sorts by time.schemaHash— a stable hash of the event's schema definition, computed at build time. It lets the ingest validate old-shape events from not-yet-reloaded clients against the schema they were written for, instead of rejecting them. See Schema evolution.
Why one catalog
Scattered track("some_string", {...}) calls are how tracking debt starts: names drift, payloads mutate, and the only record of your analytics surface is a provider's UI. Centralizing definitions gives you:
- A reviewable artifact. Adding or changing an event is a diff in one file, versioned with the code it instruments — and with
.dotyc/schema-history.json, the repo doubles as your schema registry across versions. - Inference everywhere.
defineCataloganchors the type system: the union of event names and the literal union of tags are derived from it.track()is typed by it; wiring matchers are typed by it. A typo anywhere is a compile error. - A shared contract. The client emitter and the server ingest import the same catalog. Validation at ingest is validation against the exact schemas the call site was typed with.
Tags: free at definition, typed at consumption
Tags are intentionally free-form strings — no enum to maintain, no registration step. Discipline comes from the other side: the catalog's inferred tag union means wiring can only reference tags that actually exist.
const wiring = createWiring(catalog)
.use(posthog({ apiKey: env.POSTHOG_KEY }), { tags: ['feature', 'marketing'] }) // ok
.use(debugLog, { tags: ['featur'] }) // compile errorThis is what makes the wiring scale by intention instead of by event: tag a new event feature and every consumer listening to feature picks it up automatically — no wiring edit, no call-site edit.
Because tags are static metadata on the definition, they can carry more than routing over time (the current draft keeps them routing-only).
Strict by design
There is no loose mode. At ingest, every event on the current schema version is validated strictly against its catalog schema; an invalid event is rejected via onError and never dispatched. The catalog is not documentation of what events should look like — it is the enforced contract of what consumers will receive.
The one nuance is versioning, not looseness: an event carrying a known older schemaHash is validated against that older schema and dispatched flagged legacy — still conforming to a catalog contract, just an earlier one. More →
Where it lives
packages/analytics/
├── events.ts → defineEvent × N + defineCatalog (shared, client-safe)
├── emitter.ts → createEmitter(…)
├── wiring.ts → createWiring(catalog).use(…) (server only)
├── ingest.ts → createDispatcher + toHandler (server only)
└── adapters/ → custom adaptersThe catalog file is client-safe: it contains definitions and schemas, never secrets or adapter SDKs.