Späť na blog

Span Contracts: Trace-driven API contract testing z OpenTelemetry

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

Chcel som, aby CI chytilo breaking API zmeny bez dalsej spec. Zelené unit testy. Zelené linty. Zelené CI. A napriek tomu: niektorým klientom začnú padať requesty alebo sa rozbijú UI flowy.

Najčastejší tichý vrah nie je bug v logike. Je to neúmyselný breaking change v API:

  • zmeníš typ id z number na string
  • premenuješ user.email na user.primaryEmail
  • prestaneš posielať pole items pri niektorých statusoch
  • alebo “len” vyhodíš field, ktorý bol “predsa optional”… ale niekde nie je

OpenAPI špecifikácia? Super, kým sa udržiava. Pact/CDC kontrakty? Super, kým ich niekto reálne píše a aktualizuje. Realita: kontrakty často hnijú, lebo majú vysoké náklady na údržbu.

V tomto článku ukážem prístup, ktorý kombinuje observability a testing:

Span Contracts = kontrakty odvodené z OpenTelemetry traceov, ktoré vieš použiť ako CI gate na breaking zmeny.

Span Contracts flow: API response to shape extractor, span hash, contract baseline, CI gate.
Flow: JSON shape -> span atributy -> kontrakt v Gite -> CI gate.

Trace-based testing ako idea existuje (napr. Malabi). Tu však riešime špecifický a veľmi častý problém: automaticky odhaliť breaking zmeny v štruktúre request/response payloadov bez ukladania citlivých dát.

Testované na: Node.js/TypeScript (Express/Fastify), OpenTelemetry SDK (server spans), CI: GitHub Actions/GitLab CI. Voliteľne OTel Collector.

Prečo práve OpenTelemetry?

OpenTelemetry už aj tak často máš v systéme kvôli latency, errorom a debuggingu. A OTel má jednu vlastnosť, ktorú testy často nemajú:

Je to “ground truth” o tom, čo sa v systéme reálne deje.

HTTP server span má štandardizované atribúty (napr. http.route), ktoré vieš použiť na identifikáciu endpointu. A trace context je interoperabilný naprieč službami (traceparent / tracestate).

Čo je “Span Contract”

Span Contract je definícia, ktorú uložíš do Git repa (ako “golden file”), napr.:

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

A ku každému z nich uložíš:

  1. shape (štruktúru) JSON odpovede - bez hodnôt
  2. typy na jednotlivých JSON cestách
  3. (voliteľne) “required vs optional” odhadnuté zo vzoriek

Takto sa z contract testu stane:

  • lacný na údržbu (generuješ ho)
  • “zakotvený” v realite (vychádza z traceov / test behov)
  • bezpečnejší (neukladáš PII, iba shape)

Kľúčový trik: Fingerprint JSON shape bez ukladania hodnôt

Potrebujeme dve vlastnosti:

  • stabilné (nezávislé od poradia kľúčov)
  • anonymné (žiadne reálne hodnoty, len typy + cesty)

Normalizácia “shape”

Reprezentácia, ktorú budeme hashovať, môže vyzerať takto:

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

TypeScript implementácia

// 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");
}

Toto je zámerne jednoduché. V praxi si môžeš pridať:

  • rozlíšenie integer vs float
  • normalizáciu dátumov (stále bez hodnôt)
  • “max depth” limit

Krok 1: Pridaj shape hash do server spanov

My nechceme ukladať celé body do traceov (PII, náklady). Chceme uložiť len fingerprint a ideálne aj malý “summary”.

Express middleware (ukážka)

// 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();
  };
}

Na identifikáciu endpointu použi http.method + http.route (route template). To je súčasť semantických konvencií pre HTTP spany.

Krok 2: Zber Span Contracts - 2 režimy

Režim A: CI (najjednoduchší a najpraktickejší)

V CI spustíš integračné/API testy a popri tom zbieraš spany. Na konci:

  • vytiahneš z nich http.route, http.method, api.contract.*
  • vygeneruješ contracts.json (baseline) alebo diff oproti repu

Režim B: Staging/Prod (odtiaľ príde “required vs optional”)

V stagingu/produkcii vieš dlhodobo zbierať štatistiku a kontrakty spresniť:

  • ktoré polia sú vždy prítomné (required)
  • ktoré sú len občas (optional)
  • či existujú role-based rozdiely (admin vs user)

Ak chceš kontrakty vyberať z OTel pipeline cez Collector, na demo sa hodí file exporter.

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]

Pozor: v OTel ekosystéme nie je garantovaná stabilita presných field názvov v JSON exporte. V CI je preto často lepšie ísť cez in-memory exporter alebo query do trace backendu.

Krok 3: Kontrakt nie je len hash - potrebujeme “breaking logic”

Hash je dobrý na rýchlu detekciu zmeny, ale samotný hash je príliš prísny:

  • pridáš nové pole -> hash sa zmení, ale to môže byť non-breaking
  • odstrániš pole -> hash sa zmení, to je často breaking
  • zmeníš typ -> to je takmer vždy breaking

Preto urob kontrakt ako množinu path:type a porovnávame:

Breaking pravidlá (praktické defaulty)

Breaking:

  • path zmizne (bol required)
  • typ sa zmení (number -> string, object -> array, …)

Non-breaking:

  • pribudne nový path (ak klient toleruje extra fields)
Príklad difu kontraktu: odstránený path a zmena typu sú breaking, nový path je non-breaking.
Praktický diff: odstránenie alebo zmena typu je breaking; nový field je typicky non-breaking.

Krok 4: “Required vs Optional” z reálnych traceov

Keď máš viac vzoriek pre ten istý endpoint, vieš odhadnúť:

  • required polia = tie, ktoré sú v takmer každej odpovedi
  • optional polia = tie, ktoré sa objavujú len niekedy

Algoritmus

Pre endpoint signature METHOD + ROUTE + STATUS:

  • zober N vzoriek (z traceov)
  • pre každú JSON path počítaj present_count
  • presence_rate = present_count / N
  • ak presence_rate >= 0.99 -> required

Ukážka formátu kontraktu v Gite

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

Krok 5: CI gate (fail len na breaking changes)

Tvoj CI job urobí:

  1. spustí integračné testy
  2. vygeneruje “observed contract” z test behov
  3. porovná s baseline v repozitári
  4. ak breaking -> fail

Ukážka diff výstupu

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

Bezpečnosť: PII, GDPR a “observability nie je dumping ground”

Ak riešiš kontrakty cez OTel pipeline, drž sa troch pravidiel:

  1. do spanov nedávaj raw request/response body
  2. ukladaj len shape (cesty + typy) a ideálne len hash + pár metrík
  3. ošetri konfiguráciu Collectora a export (security best practices)

Production checklist

Implementácia

  • vypočítaj JSON shape a hash (bez hodnôt)
  • pridaj api.contract.response_shape_hash do HTTP server spanov
  • použi http.route + http.method ako kľúč kontraktu

CI

  • generuj kontrakty z integračných test behov
  • porovnávaj “required” polia (removal/type change = breaking)
  • additive zmeny nechaj ako non-breaking

Prevádzka

  • ak kontrakty berieš zo staging/prod, sleduj sampling
  • ošetri role/varianty odpovedí (rozdeľ kontrakty na “audience”)

FAQ

Nie je to len ďalší snapshot test?

Nie. Snapshot test typicky snapshotuje celé payloady (vrátane dát), čo je drahé na údržbu a často nevhodné. Span Contract snapshotuje iba shape.

Prečo nie OpenAPI?

OpenAPI je super. Span Contracts riešia problém driftu: keď špecifikácia nie je pravda. Ideálne je kombinovať oboje.

Nebude to drahé na trace storage?

Nemusí. Ak dávaš iba hash + pár atribútov, náklady sú nízke. Pri sampling-u si to treba dobre nastaviť.

Súvisiace články

Záver

Najväčšia výhra Span Contracts nie je “ďalší test”. Je to zmena mindsetu:

Kontrakt nie je dokument. Kontrakt je to, čo systém reálne posiela.

A OpenTelemetry je najjednoduchší spôsob, ako túto realitu zmerať a spraviť z nej CI gate.

Referencie

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Span Contracts: Trace-driven API contract testing z OpenTelemetry". https://www.michal-drozd.com/sk/blog/span-contracts-otel-contract-testing/ (Publikované 31. decembra 2025).