Schema drift detection
Sample real traffic, surface mismatches against declared schemas in CI.
When a route declares a request schema (via defineRoute, @DocRoute, JSDoc,
or Fastify native JSON Schema), DocTreen compares each incoming payload
against the declared shape. Mismatches are sampled, aggregated per route, and
surfaced three ways: a console.warn line, a structured /drift.json
endpoint, and a UI tab.
[doctreen] schema drift on POST /users body: missing required "email"
[doctreen] schema drift on POST /users body: unexpected "legacy_field" (got string)
[doctreen] schema drift on POST /users body: "age" expected number, got stringWhat it catches (top-level shape):
- Missing required properties
- Unexpected properties not declared in the schema
- Type mismatches between declared and runtime values
Available on every adapter (Express, Fastify, Hono, Koa, NestJS) since v1.10. The same pipeline powers the warning log, the UI tab, the JSON endpoint, and the CLI.
Configuration
expressAdapter(app, {
drift: {
enabled: true, // default: NODE_ENV !== 'production'
sampleRate: 0.05, // record 5% of mismatching requests; default 0.01
maxSamples: 5, // last-N samples per route; default 5
logLevel: 'warn', // 'warn' or 'silent'; default 'warn'
onDrift: (event) => metrics.increment('api.drift', event.issues.length),
webhook: 'https://hooks.example.com/drift',
// store: customStore, // implement { record, report, reset } for Redis/Postgres
},
});Pass drift: false to disable entirely. Pass drift: true to enable with
defaults (useful in CI scripts).
The drift report endpoint
GET <docsPath>/drift.json returns a structured snapshot:
{
"generatedAt": 1764234567890,
"totalIssues": 42,
"routes": [
{
"method": "POST",
"path": "/users",
"total": 27,
"kinds": { "missing-required": 4, "unexpected-field": 12, "type-mismatch": 11 },
"parts": { "body": 22, "query": 5 },
"fields": { "age": 9, "extra_field": 12 },
"firstSeen": 1764230000000,
"lastSeen": 1764234500000,
"samples": [/* last N */],
"buckets": { "2026-05-26T14": 12, "2026-05-26T15": 15 }
}
]
}CI integration
The bundled doctreen CLI prints a table and exits non-zero when drift is
present — drop it into any pipeline that already has the app reachable:
npx doctreen drift report --url http://localhost:3000/docs --fail-on-mismatch--json prints the raw payload; --route /users filters by path substring;
--min-issues 5 only fails when the total crosses a threshold.
Drift only fires when real traffic hits a declared route, so the useful question to answer in CI is: "of the routes my integration tests just exercised, did any of them deviate from their declared schema?" See the GitHub Actions guide for end-to-end workflow examples (PR-time boot + replay, nightly post-deploy).
Resetting the store
By default the in-memory drift store persists until process restart. Opt in to a reset endpoint when you want to clear between integration runs, after a deploy, or once a misbehaving client has been fixed:
expressAdapter(app, {
drift: {
enabled: true,
allowReset: true,
resetToken: process.env.DOCTREEN_RESET_TOKEN, // optional but recommended
},
});# CI / cron / one-off
npx doctreen drift reset --url http://localhost:3000/docs --token "$DOCTREEN_RESET_TOKEN"
# Or directly
curl -X POST -H "x-doctreen-drift-token: $TOKEN" http://localhost:3000/docs/drift/resetWithout resetToken the endpoint is open — only enable that on internal-only
networks. Without allowReset: true the endpoint returns 405 regardless.
Daily and hourly buckets
Each entry in /drift.json includes both buckets (rolling 24 hourly counts,
keys like 2026-05-27T14) and dailyBuckets (rolling 7 daily counts, keys
like 2026-05-27). Same sampling, no extra cost — pick whichever resolution
suits your dashboard.
Pluggable storage
The default in-memory store is fine for single-process apps. For multi-replica
or long-running deployments, swap in an external store. The DriftStore
interface is minimal:
interface DriftStore {
record(event: DriftEvent): void | Promise<void>;
report(): DriftReport | Promise<DriftReport>;
reset(): void | Promise<void>;
}A complete Redis-backed reference implementation ships at
example/drift-redis-store.js (BYO ioredis / redis@4+):
const Redis = require('ioredis');
const { createRedisDriftStore } = require('doctreen/example/drift-redis-store');
const redis = new Redis(process.env.REDIS_URL);
expressAdapter(app, {
drift: {
enabled: true,
sampleRate: 0.01,
store: createRedisDriftStore({ client: redis, prefix: 'doctreen:drift:' }),
allowReset: true,
resetToken: process.env.DOCTREEN_RESET_TOKEN,
},
});The Redis store survives restarts and lets multiple replicas share a single aggregated view.