Span Contracts: Trace-driven API contract testing z OpenTelemetry
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
idznumbernastring - premenuješ
user.emailnauser.primaryEmail - prestaneš posielať pole
itemspri 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.
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} -> 200POST /api/orders -> 201GET /api/orders/{id} -> 404
A ku každému z nich uložíš:
- shape (štruktúru) JSON odpovede - bez hodnôt
- typy na jednotlivých JSON cestách
- (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
integervsfloat - 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)
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í:
- spustí integračné testy
- vygeneruje “observed contract” z test behov
- porovná s baseline v repozitári
- 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:
- do spanov nedávaj raw request/response body
- ukladaj len shape (cesty + typy) a ideálne len hash + pár metrík
- 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_hashdo HTTP server spanov - použi
http.route+http.methodako 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
- Trace-based testing with OpenTelemetry (Malabi): https://www.cncf.io/blog/2021/08/11/trace-based-testing-with-opentelemetry-meet-open-source-malabi/
- OpenTelemetry Collector configuration: https://opentelemetry.io/docs/collector/configuration/
- Semantic conventions for HTTP spans: https://opentelemetry.io/docs/specs/semconv/http/http-spans/
- W3C Trace Context: https://www.w3.org/TR/trace-context-2/
- OTel Collector file exporter: https://aws-otel.github.io/docs/components/misc-exporters
- OTel security best practices: https://opentelemetry.io/docs/security/config-best-practices/
Súvisiace články
Cardinality Contracts: sprav z Prometheus labelov API s budgetom
Definuj budgety na cardinality, over ich v CI a pridaj runtime firewall, aby si zastavil explozie labelov pred produkciou.
Dash Contracts v Go: CI kompilator pre Grafana dashboardy a Prometheus alerty
Vytiahni PromQL z dashboardov a rules suborov, over selektory proti /metrics a zastav CI este pred deployom.
OpenTelemetry Collector backpressure: dropy, memory_limiter a queue ako guardrails
OpenTelemetry Collector pri loade dropuje spany kvôli backpressure exportérov. Oprava cez memory_limiter, queue a batch tuning + verifikácia.
Tail-based sampling v OpenTelemetry: Sizing, pamäťové pády a cost model
Praktický sizing guide pre tail sampling v OpenTelemetry Collector. Od decision_wait cez memory limity až po cost-benefit analýzu.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.