Schema drift detection
Sample real traffic, surface mismatches against declared schemas in CI — in every language.
When a route declares a request schema, DocTreen compares each incoming
payload against the declared shape. Mismatches are sampled, aggregated per
route, and surfaced three ways: a warning log line, a structured
/drift.json endpoint, and a UI tab. Validation rejects a payload that does
not match; drift measures the gap instead — the only option for an API whose
clients you do not control and cannot break.
[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
Query strings and path segments are compared leniently, because "5" and
"true" are the only spellings a URL has — flagging ?limit=5 would fill the
report with noise about HTTP rather than about clients. A JSON body is held to
the declared types exactly: {"age": "30"} genuinely is a string where a
number was promised, and a client that starts sending it is precisely what
this exists to catch.
Available on every adapter — Express, Fastify, Hono, Koa, NestJS (since v1.10), Flask, FastAPI, Django, and Laravel — with the same event kinds and the same report format, so a polyglot backend gets one drift surface. On FastAPI and Django, drift is the adapter's main job: those frameworks keep their own docs and validation, and DocTreen measures traffic against the contract they publish.
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
},
});flask_adapter(app, {
"drift": {
"enabled": True, # default: on outside production
"sampleRate": 0.05, # default 0.01
"maxSamples": 5,
"logLevel": "warn",
"onDrift": lambda event: metrics.increment("api.drift"),
"webhook": "https://hooks.example.com/drift",
# "store": RedisDriftStore(),
},
})// config/doctreen.php
'drift' => [
'enabled' => env('DOCTREEN_DRIFT', env('APP_ENV') !== 'production'),
'sampleRate' => 0.01,
'maxSamples' => 5,
'logLevel' => 'warn',
'webhook' => null,
'store' => null, // default store rides your cache driver
'ttl' => 604800, // cache-store retention, seconds
'cacheKey' => 'doctreen:drift',
'allowReset' => false,
'resetToken' => null,
],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 the same structured snapshot in every
language:
{
"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
Because the report format is shared, one CLI checks any DocTreen-enabled service regardless of its implementation language:
# The npm CLI, against any doctreen service (Node, Python, or PHP)
npx doctreen drift report --url http://localhost:3000/docs --fail-on-mismatch
# Or the PHP package's bundled CLI
vendor/bin/doctreen drift report --url https://api.example.com/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 drift store persists until process restart (Node, Python) or cache expiry (Laravel). 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.
The same allowReset / resetToken keys work on Flask and Laravel.
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 DriftStore interface is minimal and identical across languages:
record(event), report(), reset(), plus an optional announce_routes
hook that receives the route inventory at startup so endpoints no traffic has
reached yet are still visible.
The default in-memory store is fine for single-process apps. For multi-replica or long-running deployments, swap in an external store:
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.
Under gunicorn, uWSGI, or any pre-forking server, each worker holds its own
in-memory store and /docs/drift.json reports whichever worker answered.
That is what "in memory" means when there are several memories. The default
store is for development and single-worker deployments; anything else wants a
shared one:
class RedisDriftStore:
def record(self, event): ... # required
def report(self): ... # required
def reset(self): ... # required
def announce_routes(self, routes, meta): ... # optional
flask_adapter(app, {"drift": {"store": RedisDriftStore()}})Laravel's default store rides your cache driver — so on a Redis- or
Memcached-backed cache, multiple workers already share one aggregated view,
with retention controlled by ttl and cacheKey. To take over storage
entirely, pass a DriftStore implementation (or class name) as
'store' in config/doctreen.php.