DocTreen
Features

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 string

What 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/reset

Without 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.

On this page