Dotyc
Concepts

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'). defineCatalog enforces uniqueness.
  • Properties — a schema from any Standard Schema validator: Zod, Valibot, ArkType, whatever you already use. Dotyc's core depends only on the ~standard interface, 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-side direct() transport and removes the event from the client emitter's track() type entirely.
  • Computed (optional) — derived fields applied at ingest, so the emitter stays dumb and every consumer sees the same enrichment.
The rule for 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. defineCatalog anchors 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 error

This 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 adapters

The catalog file is client-safe: it contains definitions and schemas, never secrets or adapter SDKs.

On this page