Session recording and replay
A run of the server, every exchange of every service and protocol, kept as files that a replay reads back as a mock, in a test suite too.
Session recording
--record DIR keeps a run whole: every exchange the server answered, of every service and every
protocol, in the order it happened, each marked with the test that sent it, as files a replay
can read and a reviewer can diff:
apigenie-server openapi.yaml --proxy-to http://localhost:8080 --record sessions/checkout
apigenie-server --config apigenie-server.yaml --record sessions/checkout # several services
apigenie-server session export sessions/checkout --har checkout.har
sessions/checkout/
session.yaml # the session and the exchanges in order
pets/0001-getPet.yaml # an HTTP exchange of the service pets (a stub)
pets/0002-createPet.yaml
events/0003-ws-in-sendMessage.yaml # a WebSocket message from a client
events/0004-ws-out-sendMessage.yaml # a message to it
chat/0005-Chat.Talk.yaml # a gRPC call with its messages (a stub)
A server of one document writes the files in DIR itself (--stubs DIR and the GraphQL
examples skip session.yaml). session.yaml names the session (the
directory’s name unless record.name or /record/start says), the server, when it started and
stopped, the services (name, document, specification, the SHA-256 of the document’s bytes,
mount) and the exchanges:
name: checkout
server: apigenie-server 1.0.0
started: 2026-10-13T10:00:00.000Z
stopped: 2026-10-13T10:00:42.512Z
services:
- {name: pets, spec: pets.yaml, specification: openapi, sha256: cfada1c0..., mount: /pets}
- {name: events, spec: events.yaml, specification: asyncapi, sha256: 9b2f..., mount: /events}
exchanges:
- {sequence: 1, id: 2, service: pets, test: checkout-happy, kind: http, time: 2026-10-13T10:00:01.555Z,
durationMs: 3, source: proxy, summary: GET /pets/1 200, file: pets/0001-getPet.yaml}
- {sequence: 3, id: 7, service: events, test: checkout-happy, kind: ws, time: ..., source: mock,
summary: ws-in rooms/7 notice, file: events/0003-ws-in-sendMessage.yaml}
The exchange files. Each has a session note (the index’s line) next to the protocol’s own
form, so what already reads that form reads the file:
-
http:{session, stubs: [stub]}, the stub form of--record-dir, so--stubs sessions/checkout/petsmakes a mock answer with the service’s exchanges as before (the first matching stub answers, whatever the test). OpenAPI exchanges, AsyncAPI HTTP channels and GraphQL requests that aren’t one operation answered with JSON (batches, errors, GraphiQL). The answer is kept as the client got it, less the CORS headers the server adds to every answer. The request’sX-Apigenie-Testheader is left out. -
graphql: one operation and its answer, in the example form (name,operation,query,variables,response,status) the mock’s examples load; the note’spathis the endpoint. -
grpc:{session, stubs: [stub]}, the stub form of a.protofile’s--record-dir, with the whole streams (at most 100 messages per side); the note’sorder([in, out, in, out]) says which side each message came from,codethe status. Thex-apigenie-testmetadata is left out. -
wsandmqtt: one file per message of either side:session: {sequence: 3, id: 7, service: events, test: checkout-happy, kind: ws, time: ..., source: mock} message: direction: in # from the client; out: to it channel: rooms/7 # the channel's address, and the route it matched route: rooms/{roomId} operation: sendMessage name: notice contentType: application/json connection: 4 json: {text: hi} # text: for other text, base64: for bytes
Tests. An exchange’s test is the X-Apigenie-Test header of the request or of the
WebSocket handshake, or the x-apigenie-test metadata of a gRPC call; MQTT messages have none
of their own. Without one, it is the current mark: POST /__apigenie/record/mark {"test": "checkout-happy"}
names the test of the exchanges to come ({} clears it), for clients that can’t send the
header. A Playwright suite sends the header with page.setExtraHTTPHeaders and marks the test in
beforeEach.
Order. sequence numbers the exchanges in the order they completed, which is the log’s
order; a request still in flight when a short one is answered comes after it. Each exchange
keeps its time and durationMs (a gRPC call its started), so a tool can sort by arrival.
What isn’t an exchange isn’t recorded: requests the client abandoned or the simulation dropped,
CORS preflights, the admin API’s own traffic, the dashboard.
Redaction. The values of Authorization, Cookie, Set-Cookie, Proxy-Authorization and
X-Api-Key are written as [redacted], as in --record-dir. --record-redact (or
record.redact) adds header and metadata names, and JSON pointers, which start with /, to
values of JSON bodies, message payloads, gRPC messages and GraphQL variables and answers:
--record-redact X-Session --record-redact /owner/password. A pointer is matched exactly
(/items/0/key is the first item’s key). A redacted request body no longer matches on replay.
Start and stop. --record records from the start and writes session.yaml when the server
stops; the startup line says Recording the session to DIR (record in JSON). The admin API
records a part of a run: POST /__apigenie/record/start {"dir": "sessions/run-7", "name": "run 7"}
(dir defaults to record.dir; 409 while a session is on, 400 for a directory that isn’t
empty), POST /__apigenie/record/stop (the session’s dir, name, exchanges, file; 409
without one), GET /__apigenie/record (recording, and the same while one is on, test for the
mark). The dashboard’s Traffic screen has Record session and Save session. A
session has at most 10 000 exchanges: past that it counts on but writes no more files, with one
warning in the log and truncated: true in the index. When the server dies before writing
session.yaml, the exchange files tell: session export rebuilds the index from their notes.
Export. apigenie-server session export DIR --har FILE writes the HTTP exchanges, GraphQL
operations (as POST requests to the endpoint) and gRPC calls (as POST requests with
application/grpc+json bodies, the messages as JSON arrays, and a grpc-status header) as a HAR
1.2 file, in the session’s order, each entry with _apigenie.service, test, sequence and
kind; WebSocket and MQTT messages have no form in HAR and are left out (the command says how
many). URLs keep the services’ mount prefixes.
The design is ADR-168.
Session replay
--replay DIR serves the services of a recorded session the way they
answered when it was recorded, without the real servers: each request gets the next unused
recorded exchange of its test that matches it, so a test suite replays the same run every time,
and fails when the application asks for something the recording doesn’t have.
apigenie-server openapi.yaml --record sessions/checkout --proxy-to http://localhost:8080 # once
apigenie-server openapi.yaml --replay sessions/checkout # every run
apigenie-server --config apigenie-server.yaml # replay: {dir: sessions/checkout}, several services
Tests and order. A request’s test is its X-Apigenie-Test header (of a WebSocket
handshake too) or a gRPC call’s x-apigenie-test metadata. It draws from the exchanges recorded
under that test, in their order: the first GET /pets/1 of test checkout gets the first one
recorded for it, the second the second, and a third matches none. Tests replay side by side,
each with its own counters, so the parallel workers of a suite don’t take each other’s
exchanges. Requests without a tag share one test that draws from the whole session in order, for
suites that don’t tag their requests (and MQTT, whose clients send none).
POST /__apigenie/replay/reset {"test": "checkout"} (or with the header) restarts a test’s
counters, at the start of each test; without a test it restarts every test.
What is compared. The method and path; the query, in any order; the headers the recording
kept (not Host, User-Agent, Accept-Encoding and the other headers a client changes from
run to run: Date, Origin, Referer, Accept-Language, Cache-Control, the Sec-* headers,
traceparent, X-Request-Id, …), a recorded [redacted] value standing for any; and the
body, JSON regardless of key order and number formatting. Left out:
- strings that are both RFC 3339 dates or date-times (
2026-10-13,2026-10-13T10:00:00Z), in bodies and query parameters; - the fields of an OpenAPI request body that its schema marks
readOnly, which a client sends back as it got them (id,createdAt); - what
--replay-ignore(orreplay.ignore) names: headers, and JSON pointers into bodies, messages, gRPC messages and GraphQL variables (--replay-ignore /cartId --replay-ignore /items/*/addedAt;*stands for any index or key).
When nothing matches. --replay-missing fail, the default, answers with a 502
application/problem+json naming the closest recording (of the same test and kind; the same
operation first, then the fewest differences) and how the request differs from it:
{
"type": "https://apigenie.pl/mock/errors#replay-missing",
"title": "No recorded exchange matches",
"status": 502,
"detail": "no recorded exchange of test 'checkout' matches POST /carts; the closest, pets/0004-createCart.yaml, differs at /items/0/quantity",
"test": "checkout",
"closest": {"file": "pets/0004-createCart.yaml", "sequence": 4, "summary": "POST /carts 201", "used": false},
"differences": [{"where": "/items/0/quantity", "recorded": 1, "received": 2}]
}
used: true says the matching exchange was replayed already in that test (a request sent once
more than when it was recorded). generate lets the mock answer (stubs, the document store,
examples, generated values); passthrough forwards the request to --proxy-to, and with
--replay-update records the run (replayed and forwarded exchanges alike) into DIR.update and
puts it in place of DIR when the server stops: the way to refresh a recording after the
application changed on purpose. A request that matched none is reported either way.
Every protocol.
- HTTP (OpenAPI, AsyncAPI HTTP channels, GraphQL batches): the recorded status, headers and body,
with
X-Apigenie-Mock-Source: replay:0002-getPet.yaml. - GraphQL queries and mutations: by operation name, query (formatting aside) and variables;
the recorded answer. One none matches gets a
REPLAY_MISSINGerror (status 502) with the same members inextensions.replay. - GraphQL subscriptions (graphql-transport-ws, graphql-ws, SSE) and AsyncAPI WebSocket channels:
a connection replays a recorded connection of its route and test as the server’s side: what
the server sent first (
connection_ack, the first scheduled messages) at once, then, after each message the client sends that is the recorded one, the messages that followed it. The subscriptions’ ids are the client’s own. A message that isn’t the recorded one gets the problem as a message (GraphQL: anerrormessage). The scheduled messages and generated replies are off. - MQTT: a subscriber of a topic gets the messages a subscriber got before any client published; a published message that is the next recorded one gets the messages that followed it.
- gRPC: unary and server-streaming calls by their metadata and first request, client streams by
all their requests, bidirectional streams in their recorded interleaving (the session’s
order): a message the client sends that isn’t the recorded one fails the call. A call none matches fails withFAILED_PRECONDITION, the problem in theapigenie-replay-problemtrailer.
WebSocket and MQTT messages can’t be forwarded one at a time: under passthrough they are
answered as under fail.
The report. When the server stops it prints how many recorded exchanges were replayed, the
ones never replayed and the requests that matched none, with their tests (with
--log-format json, one {"event": "replay", ...} line), and exits with status 5 if there are
any; --replay-update prints the report but doesn’t fail. GET /__apigenie/replay gives the
same report at any time, with passed and the exchanges each test used since its reset. The
startup line says Replaying the session in DIR (N exchanges, missing: fail) (replay in
JSON). A document that changed since the session was recorded (another SHA-256) and a service
the session lacks are warned about at startup. --stubs, the GraphQL examples and the gRPC stubs
stay: the per-exchange forms, where the first match answers whatever the test.
The design is ADR-169.
Replay in a test suite
A Playwright suite recorded against the real backend once replays it in every run, each test with its own exchanges. The server starts with the suite and every request of a test carries its name:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
webServer: {
command: "apigenie-server openapi.yaml --port 4010 --replay recordings/checkout",
url: "http://127.0.0.1:4010/__apigenie",
reuseExistingServer: false,
},
globalTeardown: "./replay-teardown.ts",
});
// checkout.spec.ts
import { expect, test } from "@playwright/test";
const admin = "http://127.0.0.1:4010/__apigenie";
test.beforeEach(async ({ page }, testInfo) => {
const name = testInfo.titlePath.join(" > ");
// every request of the page names the test, the API's included
await page.setExtraHTTPHeaders({ "X-Apigenie-Test": name });
// the test starts at its first recorded exchange, also when it is retried
await fetch(`${admin}/replay/reset`, { method: "POST", body: JSON.stringify({ test: name }) });
});
test("checks out a cart", async ({ page }) => {
await page.goto("/cart");
await page.getByRole("button", { name: "Check out" }).click();
await expect(page.getByText("Thank you")).toBeVisible();
});
// replay-teardown.ts: the run fails when the application drifted from the recording
export default async function teardown() {
const report = await (await fetch("http://127.0.0.1:4010/__apigenie/replay")).json();
if (!report.passed) {
throw new Error(
`the replay failed: ${report.neverReplayedCount} exchanges never replayed, ` +
`${report.unmatchedCount} requests matched none: ${JSON.stringify(report.unmatched, null, 2)}`,
);
}
}
The server answers the CORS preflight of the header for a page served from another origin.
To record the suite, run it once with --record recordings/checkout --proxy-to https://staging.example.com in place of --replay: the same header tags the recorded exchanges
with the tests. Playwright stops its web servers without letting them finish, so such a
recording has no session.yaml; the replay reads the index from the exchange files, as
session export does. On CI, a request the recording lacks gets a 502, so the test that sent it fails
at that step, with the closest recording and the differences in the response; the teardown
fails the run when a test no longer sends what was recorded (exchanges never replayed). A job
that runs the server itself sees the same as its exit status:
apigenie-server openapi.yaml --port 4010 --replay recordings/checkout --log-format json > replay.log &
server=$!
npx playwright test
curl -s -X POST http://127.0.0.1:4010/__apigenie/shutdown
wait $server # 5 when the run drifted from the recording; replay.log has the report