DocTreen
Features

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 string

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

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

On this page