Skip to content

EventCatalog

autotel-eventcatalog diffs an EventCatalog against an autotel snapshot of what your tests emit, and fails the PR when the catalog and the runtime disagree.

Two pieces:

PieceLives inRole
ArchitectureSnapshotSubscriberautotel-subscribersRecords every event your tests emit, with field paths, runtime types and sample values
autotel-eventcatalogthis packageReads the snapshot, diffs it against the catalog, reports drift

It uses the same model as Pact, applied to event architectures.

Drift classExample finding
Events observed but undocumentedorder.cancelled emitted by code; no entry in catalog
Events documented but never observedLegacyEvent in catalog; never seen in tests
Field-path drift (extra)personalization_seed in payload; not declared in schema
Field-path drift (missing)customerId declared in schema; never present in payloads
Type driftamount declared number; observed string
Value drift (enum mismatch)status: "placed" observed; schema enum excludes it
Services observed but undocumentedOrdersService is a producer; no service page
Channels observed but undocumentedorders.events carries messages; no channel page

Type and value drift use declared schema constraints from the catalog’s schemaPath plus runtime fieldStats captured by the subscriber (observed types + sampled primitive values per path). JSON Schema’s integer is treated as compatible with JavaScript’s number at the type level; sample values are checked separately against Number.isInteger, so a runtime 1.5 against a declared integer still flags as drift.

Terminal window
npm install autotel autotel-subscribers
npm install -D autotel-eventcatalog

Wire ArchitectureSnapshotSubscriber into your test setup. It records every track() call, every traceProducer / traceConsumer edge, and every LLM step into a single snapshot.

import { init } from 'autotel';
import { ArchitectureSnapshotSubscriber } from 'autotel-subscribers/architecture-snapshot';
const snapshot = new ArchitectureSnapshotSubscriber({ service: 'my-app' });
init({
service: 'my-app',
subscribers: [snapshot],
});
// ...run your integration tests...
// Persist for the drift CLI:
import { writeFile } from 'node:fs/promises';
await writeFile(
'./services/test/snapshot.json',
JSON.stringify(snapshot.toSnapshot(), null, 2),
);

What ends up in the snapshot, per event:

{
"events": {
"order.placed": {
"observedCount": 5,
"fieldPaths": ["currency", "customerId", "items[].priceCents", "totalCents"],
"fieldStats": {
"currency": { "types": ["string"], "sampleValues": ["GBP"] },
"totalCents": { "types": ["number"], "sampleValues": [8499, 8599, 8699] }
},
"producer": "OrdersService",
"channel": "orders.events"
}
}
}
Terminal window
autotel-eventcatalog drift \
--snapshot ./services/test/snapshot.json \
--catalog ./catalog \
--output ./drift.md \
--summary-output ./drift-summary.json \
--policy all \
--fail-on-drift

Real output from the example-eventcatalog app, which ships with two intentional drift conditions so the CLI has something to catch:

# Architecture drift report
## Events documented but never observed
- `PaymentFailed`
## Field-path drift
### `recommendation.generated`
**Extra fields in payloads (not in declared schema):**
- `personalization_seed`
Drift detected in current snapshot.

Exit code 1 fails the build. Both findings are real, with no noise.

The package ships a composite GitHub Action that runs drift on every PR with a sticky comment:

.github/workflows/eventcatalog-drift.yml
name: eventcatalog drift
on:
pull_request:
branches: [main]
jobs:
drift:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: pnpm install --frozen-lockfile
- run: pnpm services:snapshot
- uses: jagreehal/autotel-eventcatalog@v0
with:
snapshot: ./services/test/snapshot.json
catalog: ./catalog
base-ref: origin/${{ github.base_ref }}
fail-on-drift: true
comment-on-pr: true

What lands on the PR:

  • A sticky comment titled “Architecture drift: what this change introduces” with sections for new events, removed events, field-path drift, type drift, value drift, and drift the PR resolved.
  • The check fails only when this PR introduces new drift; pre-existing drift is reported for context but does not block.
import {
loadSnapshot,
readCatalogState,
diffCatalogAgainstSnapshot,
renderMarkdown,
countDriftReport,
hasDrift,
} from 'autotel-eventcatalog';
const snapshot = await loadSnapshot('./services/test/snapshot.json');
const catalog = await readCatalogState('./catalog');
const report = diffCatalogAgainstSnapshot(snapshot, catalog);
console.log(renderMarkdown(report));
console.log('findings:', countDriftReport(report).total);
if (hasDrift(report)) process.exit(1);

Catalog state is read via @eventcatalog/sdk, so CatalogEvent, CatalogService and CatalogChannel extend the SDK’s Event, Service and Channel types directly. New fields the SDK adds in future flow through without changes here.

To keep the scope tight:

  • Does not produce snapshots. Snapshots come from ArchitectureSnapshotSubscriber in autotel-subscribers. This package only consumes them.
  • Does not run a web server or dashboard. Live dashboards live in the example app (apps/example-eventcatalog).
  • Does not modify catalog files outside the stamp markers. Everything the stamp command writes lives between <!-- autotel:stamp-start --> and <!-- autotel:stamp-end -->.
  • example-eventcatalog: a four-service e-commerce catalog with built-in drift, a live dashboard, a mock PR view, and a recorded replay for talks.
  • Event Subscribers, including ArchitectureSnapshotSubscriber, which produces the input snapshot.
  • Architecture for how track(), traceProducer, and traceConsumer produce the signal this package consumes.