Back to blog

Span Contracts: Trace-Driven API Contract Testing with OpenTelemetry

|
| opentelemetry, observability, testing, api, contract-testing

I wanted CI to catch breaking API changes without maintaining another spec. Green unit tests. Green lint. Green CI. And yet: some clients start failing or UI flows silently break.

The most common silent killer is not a logic bug. It is an accidental breaking change in your API:

  • change id from number to string
  • rename user.email to user.primaryEmail
  • stop returning items for some statuses
  • or remove a field that was “optional” but is not, in practice

OpenAPI? Great, when it stays current. Pact/CDC? Great, when someone keeps contracts updated. Reality: contracts often rot because the maintenance cost is high.

This post shows a workflow that combines observability and testing:

Span Contracts = contracts derived from OpenTelemetry traces that you can use as a CI gate for breaking changes.

Span Contracts flow: API response to shape extractor, span hash, contract baseline, CI gate.
Span Contracts flow: JSON shape -> span attributes -> contract diff in CI.

Trace-based testing exists (for example, Malabi). Here we focus on a very specific, very common problem: automatically detect breaking changes in request/response payload shapes without storing sensitive data.

Tested on: Node.js/TypeScript (Express/Fastify), OpenTelemetry SDK (server spans), CI: GitHub Actions/GitLab CI. Optional OTel Collector.

Why OpenTelemetry?

You already use OpenTelemetry for latency, errors, and debugging. And OTel has a property most tests do not:

It is the ground truth of what actually happens in production-like traffic.

HTTP server spans include standard attributes like http.route that identify endpoints. And trace context is interoperable across services (traceparent / tracestate).

What is a “Span Contract”?

A Span Contract is a definition you commit to Git (as a golden file), for example:

  • GET /api/users/{id} -> 200
  • POST /api/orders -> 201
  • GET /api/orders/{id} -> 404

For each one, you store:

  1. the JSON shape (structure) - no values
  2. types for each JSON path
  3. (optional) required vs optional inferred from samples

That makes the contract:

  • cheap to maintain (generated)
  • anchored in reality (derived from traces/test runs)
  • safer (no PII, just shape)

The key trick: fingerprint JSON shape without values

We need two properties:

  • stable (independent of key order)
  • anonymous (no values, only types + paths)

Shape normalization

A representation we can hash might look like:

$.id:string
$.name:string
$.profile:object
$.profile.age:number
$.tags:array
$.tags[]:string

TypeScript implementation

// contracts/shape.ts
import crypto from "node:crypto";

type ShapeType =
  | "null"
  | "string"
  | "number"
  | "boolean"
  | "object"
  | "array"
  | "unknown";

function valueType(v: unknown): ShapeType {
  if (v === null) return "null";
  if (Array.isArray(v)) return "array";
  switch (typeof v) {
    case "string":
      return "string";
    case "number":
      return "number";
    case "boolean":
      return "boolean";
    case "object":
      return "object";
    default:
      return "unknown";
  }
}

function collectShape(v: unknown, path: string, out: Set<string>) {
  const t = valueType(v);
  out.add(`${path}:${t}`);

  if (t === "array") {
    const arr = v as unknown[];
    if (arr.length === 0) {
      out.add(`${path}[]:empty`);
      return;
    }
    for (const el of arr) collectShape(el, `${path}[]`, out);
    return;
  }

  if (t === "object") {
    const obj = v as Record<string, unknown>;
    for (const key of Object.keys(obj).sort()) {
      collectShape(obj[key], `${path}.${key}`, out);
    }
  }
}

export function shapePaths(v: unknown): string[] {
  const out = new Set<string>();
  collectShape(v, "$", out);
  return Array.from(out).sort();
}

export function shapeHash(v: unknown): string {
  const norm = shapePaths(v).join("|");
  return crypto.createHash("sha256").update(norm).digest("hex");
}

Keep it simple. In practice you can add:

  • integer vs float
  • date normalization (still no values)
  • a max depth limit

Step 1: add shape hash to server spans

We do not want to store raw bodies in traces (PII, cost). We want a fingerprint and a small summary.

Express middleware (example)

// contracts/otel-contract-middleware.ts
import { context, trace } from "@opentelemetry/api";
import { shapeHash } from "./shape";

export function spanContractMiddleware() {
  return (req: any, res: any, next: any) => {
    const span = trace.getSpan(context.active());
    if (!span) return next();

    const originalJson = res.json.bind(res);

    res.json = (body: unknown) => {
      try {
        span.setAttribute("api.contract.response_shape_hash", shapeHash(body));
        span.setAttribute("api.contract.status_code", res.statusCode);
        span.setAttribute(
          "api.contract.response_bytes",
          Buffer.byteLength(JSON.stringify(body))
        );
      } catch (e) {
        span.recordException(e as Error);
      }
      return originalJson(body);
    };

    next();
  };
}

Use http.method + http.route as the endpoint key (route template). That is part of the HTTP semantic conventions for spans.

Step 2: Collect Span Contracts - two modes

Mode A: CI (simple and practical)

In CI, run your integration/API tests and collect spans. At the end:

  • extract http.route, http.method, api.contract.*
  • generate contracts.json (baseline) or a diff against the repo

Mode B: Staging/Prod (where required vs optional comes from)

Over time you can refine contracts from staging/prod samples:

  • which fields are always present (required)
  • which are only present sometimes (optional)
  • whether there are role-based variants (admin vs user)

If you want to pull contracts from an OTel pipeline, the Collector file exporter is fine for a demo.

Minimal Collector config (file exporter)

# otel-collector.yaml
receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  batch:

exporters:
  file:
    path: ./traces.json

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [file]

Note: JSON export field names are not guaranteed stable across versions. In CI, an in-memory exporter or a trace backend query is often more reliable.

Step 3: A contract is not just a hash - define breaking logic

A hash is great for detecting change, but it is too strict:

  • add a field -> hash changes, but likely non-breaking
  • remove a field -> often breaking
  • change a type -> almost always breaking

So compare the path:type set and apply rules:

Practical breaking rules

Breaking:

  • a required path disappears
  • a type changes (number -> string, object -> array, …)

Non-breaking:

  • a new path appears (if clients tolerate extra fields)
Contract diff example showing removed paths and type changes as breaking, new paths as non-breaking.
Diff rules in practice: removals or type changes break; new fields are typically safe.

Step 4: Infer required vs optional from real traces

With enough samples per endpoint, you can infer:

  • required fields = present in almost every response
  • optional fields = present only sometimes

Algorithm

For endpoint signature METHOD + ROUTE + STATUS:

  • take N samples
  • count present_count per JSON path
  • presence_rate = present_count / N
  • if presence_rate >= 0.99 -> required

Example contract JSON

{
  "GET /api/users/{id} 200": {
    "samples": 1432,
    "required": {
      "$.id": "string",
      "$.name": "string",
      "$.profile": "object"
    },
    "optional": {
      "$.profile.avatarUrl": "string",
      "$.profile.bio": "string"
    }
  }
}

Step 5: CI gate (fail only on breaking changes)

Your CI job:

  1. runs integration tests
  2. builds an observed contract
  3. compares against baseline
  4. fails on breaking

Example diff output

BREAKING: GET /api/users/{id} 200
- required field removed: $.profile
BREAKING: POST /api/orders 201
- type changed: $.total number -> string

NON-BREAKING: GET /api/users/{id} 200
+ new optional field: $.profile.avatarUrl string

Security: PII, GDPR, and “observability is not a dumping ground”

If you use OTel pipelines for contracts, stick to three rules:

  1. never put raw request/response bodies into spans
  2. store only shape (paths + types) or a hash + small metrics
  3. secure Collector config and exports (follow OTel security best practices)

Production checklist

Implementation

  • compute JSON shape + hash (no values)
  • add api.contract.response_shape_hash to server spans
  • use http.route + http.method as the contract key

CI

  • generate contracts from integration tests
  • compare required paths (removal/type change = breaking)
  • allow additive changes as non-breaking

Operations

  • if using staging/prod, watch sampling bias
  • handle role/variant responses (split contracts by audience)

FAQ

Is this just another snapshot test?

No. Snapshot tests store full payloads (including values), which is expensive and often unsafe. Span Contracts store only shape.

Why not just use OpenAPI?

OpenAPI is great. Span Contracts solve drift: when the spec is not reality. Ideally, use both.

Won’t this be expensive for trace storage?

Not necessarily. Hash + a few attributes are cheap. Sampling strategy still matters.

Conclusion

The biggest win with Span Contracts is not “another test”. It is a mindset shift:

A contract is not a document. A contract is what the system actually sends.

OpenTelemetry is the easiest way to measure that reality and make it a CI gate.

References

Related posts

Cite this article

If you reference this post, please link to the original URL and credit the author.

Michal Drozd. "Span Contracts: Trace-Driven API Contract Testing with OpenTelemetry". https://www.michal-drozd.com/en/blog/span-contracts-otel-contract-testing/ (Published December 31, 2025).