Migrating an existing tracking
From scattered capture() calls to a typed catalog, incrementally.
Most codebases don't start from zero — they start from a few years of posthog.capture('Signed Up', {...}) calls scattered across components, with event names that exist only as strings and properties that exist only by convention. This guide migrates that to a Dotyc catalog incrementally: both pipelines coexist, PostHog keeps receiving the same data throughout, and you move event by event instead of doing a big-bang rewrite.
The examples use PostHog, but the same steps apply to any provider you're wiring an adapter for.
Step 1 — Inventory what you actually track
Before defining anything, find out what exists:
grep -rn "posthog.capture(" src/ | sortTurn the output into a plain list: event name, properties observed at each call site, and how many call sites emit it. Expect to find the usual tracking debt — near-duplicates (signup vs Signed Up), properties spelled three ways (plan, planName, plan_name), events nobody remembers adding. Decide now, per event: keep as-is, rename, or drop. This list is your migration backlog.
Step 2 — Define events one by one
Start with the events that matter most (activation, conversion) and give each a typed definition. Keep the existing event name — that's what your PostHog insights, dashboards and cohorts are built on:
// packages/analytics/events.ts
import { defineEvent, defineCatalog } from 'dotyc'
import { z } from 'zod'
const signedUp = defineEvent('Signed Up', {
tags: ['marketing'],
properties: z.object({
plan: z.enum(['free', 'pro']),
referrer: z.string().optional(),
}),
})
const featureUsed = defineEvent('feature_used', {
tags: ['feature'],
properties: z.object({
feature: z.string(),
}),
})
export const catalog = defineCatalog({ signedUp, featureUsed })Writing the schema forces the conversations the string version let you skip: is plan required? What are its legal values? The catalog becomes the first written definition of your tracking coverage — readable by humans, by the type system, and by AI tools.
Ingestion is strict: once an event goes through Dotyc, a payload that doesn't match its schema is rejected and reported via onError, never dispatched. Define the schema to match what call sites really send today — tighten it in a later pass if needed.
Step 3 — Wire the PostHog adapter
Set up the ingest side so that events routed through Dotyc land in PostHog exactly like the direct capture() calls do. Follow the Next.js guide (or your framework's equivalent) for the full setup; the wiring itself is:
// packages/analytics/wiring.ts
import { createWiring } from 'dotyc/ingest'
import { posthog } from '@dotyc/posthog'
import { catalog } from './events'
export const wiring = createWiring(catalog)
.use(posthog({ apiKey: process.env.POSTHOG_KEY! })) // no criteria = every eventDuring migration, matching everything is the right default — the goal is that PostHog can't tell the difference. Since event names are preserved, migrated and not-yet-migrated events land in the same PostHog event streams, and your existing insights keep working uninterrupted.
Step 4 — Point emission at Dotyc, call site by call site
Now replace calls for the events you've defined:
// before
posthog.capture('feature_used', { feature: 'export_pdf' })
// after
import { dotyc } from '@acme/analytics/emitter'
dotyc.track('feature_used', { feature: 'export_pdf' })This is where the typing pays off immediately: a call site passing a property the schema doesn't know, or misspelling the event name, stops compiling. Each converted call site is one less string in your codebase.
For identity, replace posthog.identify(...) calls with either an identity provider on the emitter (clerkIdentity(), etc.) or manual dotyc.identify({ userId, accountId, traits }) — identity transitions reach PostHog through the adapter's native identify/alias handling.
Migrate at whatever pace suits you. Un-migrated events keep flowing through posthog.capture() directly; migrated ones flow through Dotyc. Both arrive in PostHog. Delete each direct call as you convert it, and Step 1's list tells you when you're done.
Step 5 — Expand
Once emission goes through the catalog, the abstraction starts earning its keep:
- Narrow the wiring. Replace the match-everything registration with intent-based routing (
{ tags: ['feature', 'marketing'] }) and stop sending internal noise to PostHog. - Add consumers. The same events can now drive business logic — usage counters, quotas — or a second analytics destination, with zero changes to call sites.
- Track server-side. Events emitted from server code go through the same pipeline via the
direct()transport.
What does not carry over
Be clear-eyed about scope: Dotyc routes the declared events of your catalog. Provider capabilities that work by instrumenting the page are out of its scope by design:
- Autocapture (automatic clicks/pageviews)
- Session replay
- Other client-native features (heatmaps, surveys, …)
If you use these, keep the provider's SDK (posthog-js) installed alongside Dotyc and leave those features on it. This is a deliberate boundary, not a gap: Dotyc's promise is a typed, routable catalog of intentional events — not a re-implementation of every provider's browser instrumentation. The two coexist fine; anonymous/identified reconciliation in the destination is handled by the identity ids Dotyc events carry.
Recap
- Inventory existing
capture()calls into a backlog. - Define high-value events in the catalog, keeping their names.
- Wire the PostHog adapter with no criteria so destinations see the same data.
- Convert call sites incrementally; both pipelines coexist.
- Then narrow routing, add consumers, and track from the server.