Dotyc
Guides

Events as business facts

Drive usage counting, quotas and billing from the same events as your analytics — durably and from a trusted source.

An analytics event is usually treated as exhaust: fire-and-forget data for a dashboard. Dotyc's model is different — an event is a business fact, defined once, consumed by as many systems as care about it. This guide builds the canonical example: feature_used goes to PostHog for product analytics and to a small custom adapter that increments the account's usage counter in your own database, driving quotas and billing.

Same definition, same call site, two consumers. And because this fact has a monetary consequence, it follows Dotyc's rule for such events: delivery: 'durable', source: 'trusted'.

The drift problem

Without a shared pipeline, "count feature usage" gets implemented twice:

// somewhere in the UI
posthog.capture('feature_used', { feature: 'export_pdf' })

// somewhere in the API, months later, by someone else
await db.update(accounts)
  .set({ usageCount: sql`usage_count + 1` })
  .where(eq(accounts.id, accountId))

Two codepaths counting "the same thing" — except they never stay the same. One gets a new filter ("don't count internal users"), the other doesn't. A new feature ships with the capture call but not the increment. Someone renames the event. Six months in, the usage number your billing page shows and the usage number your PostHog dashboard shows disagree, and nobody can say which one is right.

The root cause is structural: the business logic that should be driven by the fact and the analytics about the fact live in different codepaths, with no shared definition.

One fact, N consumers — defined for what it is

In Dotyc, feature_used is defined once in the catalog. Since its count feeds billing, it is declared durable and trusted:

// packages/analytics/events.ts
import { defineEvent, defineCatalog } from 'dotyc'
import * as v from 'valibot'

const featureUsed = defineEvent('feature_used', {
  tags: ['feature'],
  delivery: 'durable',   // at-least-once: a billing fact must not be lost
  source: 'trusted',     // emittable only in-process: a billing fact must not be forged
  properties: v.object({
    feature: v.string(),
  }),
})

export const catalog = defineCatalog({ featureUsed })
  • delivery: 'durable' — losing a marketing ping is fine; losing a billed usage unit is a refund conversation. Durable events get at-least-once delivery (details below).
  • source: 'trusted' — a browser must not be able to mint billable facts. Trusted events are rejected by the HTTP ingest, and better: they are excluded from the client emitter's track() type, so emitting one from the client is a compile error, not a runtime surprise.

Emit where the fact happens

There is no "client claims, server verifies" dance. The API route that executes the action emits the event, in-process, through the direct transport:

// packages/analytics/emitter.server.ts
import { createEmitter } from 'dotyc'
import { direct } from 'dotyc/ingest'
import { catalog } from './events'
import { dispatcher } from './ingest'

export const dotycServer = createEmitter({ catalog, transport: direct(dispatcher) })
// app/api/export/route.ts — the place where the feature actually runs
const pdf = await renderExport(project)
dotycServer.track('feature_used', { feature: 'export_pdf' })

The server doesn't have to check whether the client's claim is true — the server is the emitter. Who consumes the event is declared in the wiring, never at the call site.

The custom adapter

An adapter is an object with a name and a batch-aware handle. One property of durable delivery shapes how you write it: at-least-once means handle may see the same event twice (a retry after a crash or timeout). Every event carries a ULID eventId for exactly this — key your write on it:

// packages/analytics/adapters/usage-counter.ts
import type { DotycAdapter } from 'dotyc/ingest'
import { db } from '@acme/db'

export const usageCounter: DotycAdapter = {
  name: 'usage-counter',
  async handle(events) {
    for (const event of events) {
      const { accountId } = event.identity
      if (!accountId) continue
      // Idempotent: the eventId's unique constraint makes a redelivery a no-op
      await db.recordUsage({
        eventId: event.eventId,
        accountId,
        feature: (event.properties as { feature: string }).feature,
      })
    }
  },
}

A few guarantees keep it this small:

  • Events arrive validated. An event that doesn't conform to the current catalog schema is rejected before dispatch (out-of-date clients are handled by schema resolution and flagged, never silently dropped).
  • Identity is resolved. event.identity carries the verified userId / accountId, not a client-side claim — and for a trusted event, the whole payload originated in your own server code.
  • Failures are isolated. If this adapter throws, PostHog still receives the event and the error surfaces through the dispatcher's onError hook.

Why this is safe enough to bill from

The reason a usage counter can live in an analytics pipeline at all is the durable delivery guarantee. For a delivery: 'durable' event, the dispatcher:

  1. Persists the event to an outbox — in the pluggable store (Redis, Postgres, KV; memoryStore() in dev) — before anything else touches it.
  2. Acks the transport. From this point the fact survives a process crash.
  3. Fans out, tracking completion per consumer: PostHog succeeding doesn't mark the counter done, and vice versa.
  4. Retries a failed consumer with backoff, from the store — your database being down for a minute delays the increment, it doesn't lose it.
  5. Dead-letters an event whose retries are exhausted and reports it through onError — a fact is never silently dropped, only explicitly parked.

That story — outbox, ack, per-consumer retry, dead-letter — is what makes "drive billing from an event" a sound design rather than a hope. Best-effort events (the default) skip all of it and stay zero-overhead.

Draft — subject to change: the exact store interface, the default retry policy (attempts, backoff) and the dead-letter shape are not finalized.

The wiring and the dispatcher

// packages/analytics/wiring.ts
import { createWiring } from 'dotyc/ingest'
import { posthog } from '@dotyc/posthog'
import { usageCounter } from './adapters/usage-counter'
import { catalog } from './events'

export const wiring = createWiring(catalog)
  .use(posthog({ apiKey: process.env.POSTHOG_KEY! }), { tags: ['feature'] })
  .use(usageCounter, { events: ['feature_used'] })
// packages/analytics/ingest.ts
export const dispatcher = createDispatcher({
  catalog,
  wiring,
  store: redisStore(process.env.REDIS_URL!), // required: the catalog has a durable event
  onError: (err, ctx) => logger.error('dotyc', { err, ctx }),
})

Note the two matching styles, deliberately different:

  • PostHog matches by tag (feature) — an analytics role consumer. Every event tagged feature, including future ones, flows there automatically. The wiring scales by intention, not by event.
  • The counter matches by event name — a domain role consumer. Billing logic should be precise, so it opts into exactly feature_used. Both tags and events are typed from the catalog: a typo is a compile error.

Fan-out is pure, parallel and isolated: both consumers receive the event; neither can block, reorder or transform what the other sees.

What this buys you

  • No drift. One definition of "a feature was used", one emission point — the code that performs the action. Every consumer counts the same fact.
  • Billing-grade and dashboard-grade from one pipeline. Durable delivery for the fact, per-consumer retry for the counter, and the analytics reporting on it share the catalog — they cannot disagree about what happened.
  • Unforgeable by construction. source: 'trusted' makes "the client fakes usage events" a compile error, and the HTTP ingest rejects any attempt anyway.
  • Cheap evolution. Ship a new feature, tag its event feature: PostHog picks it up with no wiring change, and the counter stays untouched until you explicitly widen its match.

Going further

The same pattern covers most "react to a business fact" needs: quota enforcement (checkout_completed → decrement credits), internal notifications (plan_upgraded → Slack webhook adapter), an append-only audit adapter consuming trusted durable events, a CRM upsert on identity transitions via the adapter's optional identify() hook.

Write to your own database for state your product depends on (quotas, billing) — that's the domain adapter role: projections and counters, not event sourcing. If losing an event would break a transactional invariant of your app, that job belongs to your app's database or queue, not to an adapter. Dotyc stores nothing itself — it routes facts to the systems that own them.

On this page