Skip to content

Ingesting External Captures

aryehcitron@gmail.com edited this page Aug 23, 2026 · 10 revisions

Overview

Kronikol's report generators read one thing: RequestResponseLog entries in the in-process RequestResponseLogger store. Until 3.0.44 the only way to fill that store was a .NET capturer running in the same process. NDJSON ingestion is the language-neutral on-ramp: any capturer — an out-of-process proxy tap, a Java or Node service, a shell script tailing a log — writes one JSON object per tracked request or response, and kronikol ingest (or IngestPipeline) replays the file into a full TestRunReport.html, the data files and the component diagram.


The interaction format

One line per request or response. The shape is exactly the httpInteraction object Kronikol already publishes in TestRunReport.json (same camelCase names), plus the attribution fields a capturer outside the test process must supply.

{"type":"Request","method":"POST","uri":"http://localhost:8081/sidekick","serviceName":"graphql","callerName":"web","content":"{\"query\":\"query Overview { overview }\"}","headers":[{"key":"Content-Type","value":"application/json"}],"traceId":"0af7651916cd43dd8448eb211c80319c","requestResponseId":"6f1c0d8e-9b7a-4c2e-8e1a-2d3f4b5c6d7e","timestamp":"2026-08-21T10:00:01.000Z","testId":"0af7651916cd43dd8448eb211c80319c","testName":"overview › renders"}
{"type":"Response","method":"POST","uri":"http://localhost:8081/sidekick","serviceName":"graphql","callerName":"web","content":"{\"data\":{}}","headers":[{"key":"Content-Type","value":"application/json"}],"statusCode":"200","traceId":"0af7651916cd43dd8448eb211c80319c","requestResponseId":"6f1c0d8e-9b7a-4c2e-8e1a-2d3f4b5c6d7e","timestamp":"2026-08-21T10:00:01.120Z","testId":"0af7651916cd43dd8448eb211c80319c"}
Property Required Meaning
type Request or Response.
uri Full URI of the call (the arrow label uses path + query). Relative paths are tolerated.
serviceName / callerName The two participants (receiver / caller).
testId The correlation key — must equal the report's Scenario.Id byte-for-byte. For browser-driven runs use the test's W3C trace id.
testName Display name (cosmetic; a tests file wins when both exist).
method HTTP verb (GET, POST, …) or any custom label (Query, generate [gemma]).
content Body text, already decoded and capped by the capturer.
headers [{ "key", "value" }]. Redact secrets — or rely on --redact at ingest.
statusCode Responses only: "200" or a custom label.
requestResponseId Pairs a request with its response (one arrow). Any string; non-UUIDs are hashed to a stable GUID.
traceId Groups the hops of one call chain. Defaults to requestResponseId.
timestamp ISO-8601. Used for ordering (the diagram follows timestamp order at ingest) and loop durations.
dependencyCategory / callerDependencyCategory A DependencyCategories value (BigQuery, AI, Redis, …) for shape/colour.
phase Setup / Action / Unknown.
metaType Default or Event (fire-and-forget styling).
activityTraceId / activitySpanId W3C ids for cross-linking to Tempo/Jaeger.
trackingIgnore true to store but not draw.
capturedBy Which capture path produced the record — wire (a proxy/TCP tap that decoded the protocol) or span (an OTLP receiver). Only used by --merge-duplicates (see below).
kind ui for a user action (see below); step / assertion for diagram markers emitted by an interaction capturer (the tests NDJSON is the usual source of those). Absent = an ordinary request/response.
durationMs How long the call took, when you measured it. Use it when you send one record for a whole call rather than a request/response pair — Kronikol otherwise derives duration from the two timestamps and a lone record would have none. For ui records it is the interval the action owns, so calls starting inside it nest under it in the diagram. It reaches httpInteractions[].durationMs in the report from 3.0.47; before that it was consumed for flow nesting and then dropped, making the round trip lossy.

The round trip is lossless from 3.0.47. Every field above now survives into TestRunReport.jsonphase, metaType, both dependency categories, the W3C ids, capturedBy and durationMs. Previously a capturer could send activityTraceId, watch Kronikol store it and the diagram use it, and then find it absent from the data file. See Generated-Reports.

User actions (kind: "ui")

What the person (or the browser test acting as one) did — Navigate to, Click, Fill, … — rendered as a single one-way arrow from an actor to the service, with the calls the app made in response nested beneath it. Playwright's step tree is the natural source (the sidekick reporter is the reference implementation of the planned @kronikol/playwright reporter).

{"kind":"ui","type":"Request","method":"Click \"Accept trial\"","uri":"http://localhost:4000/intelligence-pro/overview","serviceName":"web","callerName":"User","callerDependencyCategory":"User","content":"Click getByRole('button', { name: 'Accept trial' })","timestamp":"2026-08-21T10:00:06Z","durationMs":4000,"testId":"0af7651916cd43dd8448eb211c80319c","requestResponseId":"7f7c…"}
  • method is the arrow label, content the note (full title / locator), callerName the actor (User by default; callerDependencyCategory: "User" gives it the actor shape and colour wherever it appears), serviceName the thing acted on.
  • No response record: a user action has no reply arrow.
  • durationMs should run to the next action (or the test end) — that is what makes "everything the app did after the click" nest under the click.

.NET: InteractionRecord.UserAction(testId, label, pageUrl, timestamp, durationMs, detail); RequestResponseLog.IsUserAction; DependencyCategories.User.

Unknown properties are ignored, so capturers can add their own diagnostics. Anything you want rendered goes in content (the diagram note) — e.g. fold tokens/sec or a coalescing verdict into the JSON body.

In .NET the record is Kronikol.Ingestion.InteractionRecord (FromLog, ToLog, Pair(...), ToJson, FromJson); NdjsonInteractionWriter is an IRequestResponseSink that appends lines (thread-safe, flushed per line, tail-able), and NdjsonInteractionReader reads them back.


The tests format (optional)

A companion NDJSON of start / step / end events supplies each scenario's outcome, duration and steps:

{"event":"start","testId":"0af7651916cd43dd8448eb211c80319c","testName":"overview › renders","feature":"overview.spec.ts","timestamp":"2026-08-21T10:00:00Z"}
{"event":"step","testId":"0af7651916cd43dd8448eb211c80319c","text":"open the overview","timestamp":"2026-08-21T10:00:00.5Z"}
{"event":"end","testId":"0af7651916cd43dd8448eb211c80319c","status":"failed","durationMs":9000,"error":"expected 1 got 2","timestamp":"2026-08-21T10:00:09Z"}
{"event":"step","testId":"","text":"the user accepts the trial","keyword":"When","timestamp":"2026-08-21T10:00:05.5Z","durationMs":4500,"status":"passed"}
{"event":"step","testId":"","text":"the button is clicked","level":1,"timestamp":"2026-08-21T10:00:05.9Z","status":"passed"}
{"event":"assertion","testId":"","text":"\"trial banner\" to be visible","status":"passed","timestamp":"2026-08-21T10:00:08Z"}
{"event":"assertion","testId":"","text":"\"customers\" to have text 42","status":"failed","error":"Expected 42, received 41","timestamp":"2026-08-21T10:00:09Z"}
  • Steps draw in the diagram too. A top-level step (level 0 or absent) injects the same black hnote across <<stepDelimiter>> bar at its timestamp that Step Tracking emits (keyword text, or just text); nested steps (level > 0) are sub-steps in the step list only. status / durationMs / error fill the step row.
  • assertion events draw the same green ✓ / red ✗ hnote across <<assertionNote>> that Assertion Tracking emits (a failed one shows error under the text) and appear as sub-steps of the enclosing step (StepTrackingOptions.IncludeTrackedAssertionsInStepList). The report's Show/Hide Steps and Show/Hide Assertions toggles apply exactly as for in-process tracking.
  • Markers are placed by timestamp in call-tree order: under the user action in flight at that moment (a kind: "ui" record whose interval contains the timestamp), otherwise at top level — never inside a backend call.
  • status accepts the Playwright / Jest / JUnit vocabularies: passed, failed, timedOut, interrupted, skipped, pending, bypassed (anything unknown is Failed).
  • feature groups scenarios under one heading (a spec file, a class); without it they land in the default feature (Ingested, or --feature).
  • Tests seen only in the interaction file still become scenarios (verdict Passed by default — there is no outcome information without an end record).
  • Make the tests file per-run (truncate it at run start). Kronikol renders exactly what it is handed; a file accumulated across runs will render every old test too.

The full step and scenario vocabulary

A runner that knows Gherkin — or a reporter that can reconstruct it — can say so without a Cucumber Messages file. Everything below is optional; a producer that emits only event/testId/text still works exactly as before.

start — what the scenario is:

Field Meaning
featureDescription The prose under Feature:Feature.Description. Taken from the first scenario of the feature that carries one.
description The prose under Scenario:Scenario.Description, rendered above the step list.
rule The Rule: this scenario sits under → Scenario.Rule; scenarios group by it.
tags ["@gemma","@category:smoke","@endpoint:/overview","@happy-path"] — the leading @ is optional. The ReqNRoll conventions apply: @category:xCategories, @endpoint:x → the feature's Endpoint, @happy-path (happy_path, happypath) → IsHappyPath, everything else → Labels. A tag carried by every scenario of a feature also becomes the feature's own label — in Gherkin that is exactly what a feature tag is.
outlineId The name of the Scenario Outline this row came from. Rows sharing it render as one parameterised group.
exampleValues { "plan": "pro" } — this row's Examples: columns, which drive the pivot table and the row's display name.

step — what the step was:

Field Meaning
background: true The step came from Background:Scenario.BackgroundSteps. It draws no delimiter bar: a background is not part of the run's timeline. When any scenario supplies an explicit background, the heuristic background detector is not run at all.
keywordType Context | Action | Outcome | Conjunction | Unknown — Cucumber's PickleStepType. Used for phase assignment when the literal keyword cannot be trusted; Conjunction inherits the previous step's meaning, exactly as And/But do. keyword is still what the report renders.
docString / docStringMediaType A doc-string argument and its content type (json, xml), rendered as a highlighted code block under the step.
table [["name","plan"],["ada","pro"]] — first row is the header. Becomes the step's table parameter and a toggle in the step line. A ragged row is padded; a header-only table is ignored.
stackTrace Added to the step's comments under error.
bypassReason Why the step was skipped, when status is bypassed.

end gains stackTraceScenario.ErrorStackTrace.

{"event":"start","testId":"","testName":"Overview renders for pro","feature":"overview.feature","featureDescription":"The overview summarises the account.","description":"A returning customer opens the overview.","rule":"Only signed-in customers see figures","tags":["@gemma","@category:smoke"],"outlineId":"Overview renders for <plan>","exampleValues":{"plan":"pro"},"timestamp":"2026-08-22T10:00:00Z"}
{"event":"step","testId":"","keyword":"Given","keywordType":"Context","background":true,"text":"the account exists","timestamp":"2026-08-22T10:00:00.2Z"}
{"event":"step","testId":"","keyword":"When","keywordType":"Action","text":"these customers are seeded","table":[["name","plan"],["ada","pro"]],"durationMs":900,"timestamp":"2026-08-22T10:00:01Z"}

Attachments (event: "attachment")

Screenshots, traces, videos and links a runner produced, carried into the report as scenario- or step-level artefacts:

{"event":"attachment","testId":"","name":"screenshot-start.png","path":"C:/run/.logs/attachments/abc/screenshot-start.png","mediaType":"image/png","step":0,"timestamp":"2026-08-22T10:00:01Z"}
{"event":"attachment","testId":"","name":"trace.zip","path":"/run/test-results/trace.zip","mediaType":"application/zip","timestamp":"2026-08-22T10:00:09Z"}
{"event":"attachment","testId":"","name":"Grafana trace","path":"http://localhost:3900/explore?traceId=…","timestamp":"2026-08-22T10:00:09Z"}
Field Meaning
name Display name. Defaults to the file name.
path An absolute path, a path relative to --attachments-base, or a URL. A file is copied into <reports>/attachments/ and the link rewritten; a http/https URL is left exactly as it is and rendered as a plain link.
mediaType The IANA type. image/* renders inline with a lightbox; anything else renders as a link. Without it the extension is sniffed (.png .jpg .jpeg .gif .webp .svg .avif .bmp are inline).
step 0-based index of the top-level step the artefact belongs to. Absent → the scenario itself. An index that no longer resolves falls back to the scenario, so an artefact is never silently lost.

--clean-attachments empties <reports>/attachments/ before generating, so the folder holds exactly this run's files — nothing else ever removes stale copies, and a host that renders several runs into one folder relies on that, which is why it is off by default.

ResultWhenUnknown — read this before trusting a green report

A test that has interactions but no end record — a worker that crashed, a run that was killed, a capturer that only ever writes start — renders as Passed. That is a compatibility decision, not a judgement: ingest predates the tests file, and a capture that is nothing but interactions has no verdicts at all, so marking every such scenario red would make the common case (replay a tap capture to see the diagrams) look like a catastrophe.

The consequence is worth stating plainly: a test whose process died mid-run renders as passed. If your producer always writes an end record — Kronikol's Playwright reporter does, on failure and on timeout — set IngestRequest.ResultWhenUnknown = ExecutionResult.Failed and a missing verdict becomes the alarm it should be. CI pipelines that gate on the report should.

.NET types: TestRunRecord, NdjsonTestRunReader, FeatureSynthesizer.


kronikol ingest

dotnet tool install --global Kronikol.Tool
kronikol ingest ./captures --tests ./captures/tests.ndjson -o ./Reports -t "E2E run"
Option Default
<inputs…> Files, directories (searched recursively for *.ndjson / *.jsonl) or globs.
--tests <file> The tests NDJSON.
-o, --output <dir> ./Reports Output directory (absolute or relative).
--render <mode> browserjs browserjs (client-side, needs internet at view time), nodejs (offline SVG, needs node), local, server.
-t, --title <text> Report title.
--feature <name> Ingested Feature for tests without one.
--collapse / --no-collapse, --collapse-threshold <n> on / 2 Collapse consecutive identical calls into a loop ×N fragment.
--max-arrows <n> unlimited Cap pairs per diagram (… +N more calls omitted …).
--no-component-diagram Skip ComponentDiagram.html.
--no-redact, --redact-header <name> redact on Capture-time redaction of credential headers during replay (Capture-Time-Redaction).
--allow-empty Generate even when nothing was ingested.
--chronological off Strict timestamp order. By default each test is ordered as a call tree — each response directly after its request, calls a service made while handling a request nested inside it, siblings by request time — so concurrent calls stay readable — and collapsible, since collapsing works on adjacent pairs.
--fold-unknown <name> off Collect interactions whose testId is not in --tests (or all interactions when there is no tests file) into one scenario with this name — warm-ups, health probes, manual browsing, background jobs — instead of one hex-named scenario per trace.
--merge-duplicates off Fold the wire and span views of the same call into one arrow — see Merging the wire and the span views.
--strict off Fail on the first malformed capture line. By default torn lines are skipped and counted — see Torn lines.
--no-capitalise on Leave step and assertion labels and feature/rule/scenario titles exactly as the producer wrote them — see Capitalisation.
--run-window off Keep only this run's traffic: drop interaction pairs whose request lies before the earliest tests record (a testrun/started marker, if the host writes one) or after the testrun end marker — see The run window
--run-start <iso> / --run-end <iso> Explicit run window bounds (UTC); each implies --run-window
--attribute-by-window [id] off Attribute interactions that carry no testId to the test that was running at their timestamp; the optional value is the capturer's fallback marker (session) — see Attribution without a test id.
--phase-from-steps off Give interactions the phase of the Given/When/Then step they happened during, so SeparateSetup / HighlightSetup work for an ingested run.
--attachments-base <dir> cwd Resolve relative attachment paths against this directory.
--clean-attachments off Empty the report's attachments/ folder first, so it holds exactly this run's artefacts.
--cucumber-messages <file> A Cucumber Messages NDJSON (repeatable) — the Gherkin structure of a BDD run. Wins over --tests for the scenarios it owns; see Cucumber Messages.
--include-hooks off Keep the Cucumber hook steps (BeforeEach hook, …) in the step list.
--diagnostic <kind>:<message> Carry a host diagnostic into the report (repeatable) — typically a tap's capture health, e.g. "CaptureDegraded:tap-di-redis: decoding disabled on 1 connection(s)". The kind is a DiagnosticKind name (anything else, or no colon, counts as Other); the message is free text. See Host diagnostics.

Exit codes: 0 ok, 1 runtime failure (nothing found, malformed line), 2 usage.

Programmatic

var options = IngestPipeline.DefaultOptions();   // BrowserJs, no internal-flow, component diagram, collapse on
options.ReportsFolderPath = ".logs/kronikol";
var result = IngestPipeline.Run(new IngestRequest
{
    InteractionFiles = Directory.GetFiles(".logs/taps", "*.ndjson"),
    TestsFile = ".logs/taps/tests.ndjson",
    Options = options,
    // Optional: one scenario for everything no test caused (default: a scenario per unknown test id).
    FoldUnknownTestsInto = new UnknownTestFold("Traffic outside any test", "session"),
    // Optional: strict timeline instead of call-tree order (default true).
    CallTreeOrdering = true,
});
Console.WriteLine(result.TestRunReportHtml);

The pipeline reads everything, clears the store (ClearExistingLogs), sorts by timestamp, folds unknown tests if asked, orders each test as a call tree (CallTreeOrdering: response after request, nested calls inside, siblings by request time), normalises each log's TestName from the tests file, resets the diagram cache, synthesises Feature[] and calls ReportGenerator.CreateStandardReportsWithDiagrams. Because it goes through RequestResponseLogger.Log, RequestResponseLogger.Redaction applies during replay. The readers open files with FileShare.ReadWrite, so a capture can be ingested while its writer (a proxy tap, a test fixture) still holds it open — live/incremental reporting works.


Cucumber Messages

A BDD runner knows more than the tests format can express — which steps came from a Background:, which Rule: a scenario belongs to, which Examples: row produced it, what the data table under a step held. Any runner that emits the Cucumber Messages protocol (playwright-bdd's cucumberReporter('message'), cucumber-js --format message, Cucumber-JVM --plugin message:…) can hand Kronikol that structure directly:

kronikol ingest ./captures --tests ./captures/tests.ndjson   --cucumber-messages ./cucumber/messages.ndjson -o ./Reports

--cucumber-messages <file> is repeatable (one per shard or worker); --include-hooks keeps the runner's before/after hooks as steps. The two sources combine as messages win for structure — feature description, rule, background, keywords, tables, doc strings, example values and step outcomes come from Gherkin, while --tests still contributes assertions, UI actions, attachments and the identity. Scenarios the messages do not own are kept as they were. The join is a kronikol-test-id attachment carrying the 32-hex test id.

Keep the messages file outside the directories you pass as inputs — they are scanned recursively for *.ndjson/*.jsonl and a messages file is not an interaction capture.

Full mapping table, tag conventions and diagnostics: Integration Cucumber Messages.


Merging the wire and the span views

When a stack is captured from both sides — a proxy or TCP tap that decodes the wire, and an OTLP tap that reads the spans the services already export — the same call arrives twice: the wire record has the payload, the status and the hit/miss label but had to guess which test it belongs to; the span record carries the exact trace id but usually no payload. kronikol ingest --merge-duplicates (IngestRequest.MergeDuplicateInteractions, with MergeOverlapThreshold, default 0.8) folds them into one arrow: the span's testId/traceId/activityTraceId/activitySpanId win, the wire's content/statusCode/label win, and the merged request carries an x-kronikol-captured-by: wire + span pseudo-header so the note says where the arrow came from.

Two records are the same call when caller, service, the verb of the label (first word — Get (Hit) matches GET, Find ← Trial matches Find) and the last path segment of the URI (the Redis key, the Mongo collection) all agree, and their [start, end] intervals overlap by at least the threshold of the shorter interval. Matching is greedy by best overlap and strictly one-to-one, so a burst of N near-identical calls pairs off N times instead of collapsing. Capturers should stamp capturedBy: "wire" or "span" (Kronikol.Ingestion.InteractionMerger.WireSource / .SpanSource); without it the source is inferred — a record with a span id and no content is span-like, one with content and no span id is wire-like — and anything ambiguous is left alone. Unmatched records are never dropped.


Attribution without a test id

A capturer on an HTTP hop reads the test identity off the request headers. A capturer on a database connection cannot: the Redis and MongoDB wire protocols have nowhere to put one, and the connection is pooled besides. Two answers, in order of preference:

Ingest-time window attribution (--attribute-by-window, IngestRequest.AttributeByTestWindow). An interaction with no testId — or with the capturer's placeholder marker, named by --attribute-by-window session / WindowAttributionFallbackId — is given to the test whose [start.timestamp, end.timestamp] window contains its own timestamp. The rule is deliberately boring so a reader can predict it:

  • overlapping windows → the test that started latest wins (the innermost test in flight);
  • a tie on start time resolves to the first window in the file;
  • a record in no window is left exactly as it is, so --fold-unknown still collects it;
  • a response follows its request (matched by requestResponseId), never its own timestamp — a slow query answered after the test's end record must not be orphaned or, worse, given to the next test;
  • a test killed before its end record is bounded by the last timestamp seen for it.

Deterministic, and exactly right for a suite running one worker at a time.

An in-flight registry (Kronikol.Extensions.ProxyTap.InFlightIdentityRegistry) is the live alternative for concurrent suites — see Integration ProxyTap Extension. It couples the database tap to an HTTP tap in the same process, which is why window attribution is the default advice.

Dropping traffic nobody asked for

A session-wide capturer also sees the seeder, the health probes, the warm-up. IngestRequest.DropUnattributed is a predicate evaluated on every record that is still unattributed after window attribution — by the same definition it uses, plus "names a test that does not exist" when FoldUnknownTestsInto is set. Returning true discards the record and its paired response, so a dropped request never leaves a reply arrow behind. Drops are counted in IngestResult.Diagnostics; a predicate that throws keeps the record and says so.

DropUnattributed = record => record.ServiceName == "redis",   // the tee also sees the seeder

Programmatic only — there is no CLI flag, because a predicate is not a command-line argument.

The run window: only this run's traffic

Taps that append for as long as the stack is up, read against a tests file that is per run, fold the previous run's traffic (its test ids are no longer in the tests file) and the stack's start-up into --fold-unknown — which then dwarfs the run it is meant to describe. --run-window (IngestRequest.DropOutsideRunWindow) drops every interaction pair whose request lies before the run began or after it ended, before attribution, and counts them as DroppedOutsideRunWindow. The window is:

  • explicit: --run-start <iso> / --run-end <iso> (RunStartedAt / RunEndedAt; each implies the flag);
  • else derived from the tests records: start = the earliest record of any kind — a host that writes {"event":"testrun","testId":"__run__","status":"started","timestamp":…} before the runner starts keeps the runner's own set-up (a global login) inside the run; end = the latest testrun marker that is not started (a reporter's onEnd verdict), else open, so a run that died never loses its late traffic;
  • with no tests records and no explicit start nothing is dropped, and an Other diagnostic says why.

A pair is judged on its earliest timestamp, so a late response to an in-run request stays. Traffic inside the window that belongs to no test — set-up, a health probe — is untouched and still reaches --fold-unknown / DropUnattributed.

Phases from steps

--phase-from-steps (IngestRequest.PhaseFromSteps) gives an interaction the phase of the top-level step whose window contains it: Given/ContextSetup, When/Then (Action/Outcome) → Action, And/But/Conjunction inherit the previous step's phase. A step's window is its timestamp plus its durationMs. Only records whose own phase is absent or Unknown are touched, so a capturer that knows better still wins. This is what makes Phase Aware Tracking's SeparateSetup and HighlightSetup partitions work for an ingested run — until now they needed the in-process TestPhaseContext.


Resilience: a broken part must not cost you the whole report

Torn lines: a killed capturer must not cost you the report

A process killed mid-write leaves a truncated last line. By default such a line is skipped and counted, not thrown: kronikol ingest prints N malformed line(s) skipped with the file, line number and the first 80 characters of each, and every entry appears in IngestResult.Diagnostics as MalformedLine. A run that died halfway still produces a report from every complete line.

--strict (IngestRequest.StrictParsing) restores the old behaviour — a FormatException naming the line — for a pipeline where a producer emitting garbage should be loud.

The readers take an optional collector directly if you need the same tolerance yourself:

var malformed = new List<MalformedLine>();
var records = NdjsonInteractionReader.ReadFile(path, malformed);

Diagrams: one broken scenario costs you that scenario

Producing or rendering a diagram can fail for reasons that have nothing to do with the other scenarios — a node render that timed out, a PlantUML server 5xx, a formatting processor that could not parse one body. Each scenario's diagram production and each render call is isolated: the scenario that failed shows a red note in place of its picture

hnote across <<renderError>> #ffdddd
⚠ diagram could not be generated: TimeoutException: the render process did not answer
end note

every other diagram is untouched, and the failure is recorded as a RenderFailure diagnostic naming the scenario. Report outputs are isolated the same way: an HTML file, a data file or the component diagram that cannot be written is recorded as an OutputFailure and every other output is still produced.

Capitalisation: step labels read as sentences

Step and assertion labels arrive from wildly inconsistent producers — a Playwright assertion message, a LightBDD sub-step, a hand-written expect(x, "message"). ReportConfigurationOptions.CapitaliseStepText (default on; --no-capitalise off) upper-cases the first letter of every step and assertion label that carries no Gherkin keyword:

  • leading whitespace and marker glyphs (✓ ✗ ⚠ • -) are skipped, so ✓ the envelope was empty becomes ✓ The envelope was empty;
  • a label whose first non-marker character is an opening quote or bracket (" ' ( [ { and the typographic ones) is left exactly as it is — the quoted literal is the producer's content, and re-casing it would corrupt a locator or an identifier;
  • a label whose first word is a camelCase identifier (graphqlErrorMessages reads…, iPhone…) is left alone for the same reason, and is not counted as a violation;
  • a step with a keyword is never touched: the rendered line already starts with the capitalised keyword (Given the mock is armed) and the author's casing after it is meaningful;
  • culture-invariant, Unicode-aware (éÉ, łŁ), idempotent.

Titles too. ReportConfigurationOptions.CapitaliseTitles (default on; the same --no-capitalise turns it off) applies the same helper to every feature, rule and scenario title — and to an outline's template title, so its members still group — so a Gherkin Scenario: the overview renders is shown as The overview renders in every view, including the living documentation. Quoted, bracketed, numeric and symbolic titles are left alone; example display names are never touched. DiagnosticKind.TitlesNotStartingWithCapital counts what is left. Because a scenario's stableId is computed from the displayed title, a title the rule changes gets a new stableId (titles that already start with a capital are unaffected).

stableId changed in 3.0.47. A scenario's example values are now part of the hash. Without them every row of a scenario outline sharing a display name hashed identically — measured on the checked-in ReqNRoll example: 6 scenarios, 4 distinct ids, with three rows of one outline colliding. Since the field is documented as the key for matching a test across runs, and per-row matching is exactly the case that matters, they now differ. Parameterised scenarios therefore get new ids once; anything storing them historically sees a single discontinuity. Non-parameterised scenarios are unaffected.

The rule is applied once, over the finished model, so the HTML, JSON, XML and YAML views of a step cannot disagree — and the diagram's step bars and ✓/✗ notes go through the same helper (Kronikol.Reports.StepText), so the picture and the step list read alike. What is left over — the quoted literals it deliberately skips, and anything a producer slipped past it — is reported as StepsNotStartingWithCapital with the first five examples, so the gap is visible instead of merely present.

Diagnostics

IngestResult.Diagnostics is a read-only list of DiagnosticEntry(Kind, Message, ScenarioId?), printed by kronikol ingest and meant to be surfaced by a host (a dashboard, a CI summary):

Kind
MalformedLine A capture line was skipped (file, line number, first 80 characters).
RenderFailure A diagram could not be produced or rendered; that scenario shows the placeholder note.
OutputFailure One report output failed; the others were still written.
StepsNotStartingWithCapital Labels that still do not read as sentences, with examples.
UnattributedInteractions How many records window attribution claimed, and how many are still unattributed.
DroppedUnattributed How many records DropUnattributed discarded.
DroppedOutsideRunWindow How many records lay before the run began or after it ended (--run-window), with the window.
TitlesNotStartingWithCapital Feature/rule/scenario titles that still start with a lower-case letter, with examples.
AttachmentFailure An artefact could not be copied or deleted.
CaptureDegraded A capture component — a tap, a sink — lost or skipped data (a decoder that gave up on a connection, an oversize payload streamed past, export payloads dropped or refused, a failed forward); forwarding was never affected. Raised by the host through HostDiagnostics / --diagnostic, never by the ingest itself.
Other Anything without a dedicated kind yet — e.g. how many records took their phase from a step.

Empty is the happy path. Report generation is diagnostics, never a reason for a run to fail: nothing here throws.

Host diagnostics

The ingest can only report what it sees in the files. The host that ran the capture usually knows more — a TcpTap whose decoder gave up on a connection (every arrow after that moment is missing, and the file looks healthy), an OtlpTap that dropped export payloads, a ProxyTap that answered 502. IngestRequest.HostDiagnostics (IReadOnlyList<DiagnosticEntry>, default empty) carries that knowledge into the report verbatim: the entries come first in IngestResult.Diagnostics, appear in the collapsed "Report diagnostics" block of TestRunReport.html (kind badge, message, scenario id) and in the top-level diagnostics array of TestRunReport.json ({ kind, message, scenarioId }, described by TestRunReport.schema.json). Every tap exposes the same surface — TcpTap.Diagnostics(), ProxyTap.Diagnostics(), OtlpTap.Diagnostics() — one CaptureDegraded entry per non-zero problem counter, worded for a report reader, empty while healthy:

HostDiagnostics = [.. redisTap.Diagnostics(), .. otlpTap.Diagnostics(), .. proxyTaps.SelectMany(t => t.Diagnostics())],

On the command line the same thing is --diagnostic "<kind>:<message>" (repeatable). With an empty list nothing changes — no HTML block, an empty diagnostics array, the result exactly as before. Details and the rendering rules: Diagnostics-and-DebuggingHost diagnostics.


Design notes

  • Correlation is Scenario.Id == testId, byte-for-byte. testName is cosmetic.
  • Order is enqueue order. The sequence diagram never sorts by timestamp itself; the pipeline sorts for you.
  • Multiple hops of one call chain (web→graphql, graphql→myDotnetService) are separate pairs with the same testId — they all render in the same scenario, in time order.
  • Parity. The same schema is the contract for the Java (Kronikol4J) and Node (@kronikol/*) ports — see JAVA_PORT_PLAN.md / NODE_PORT_PLAN.md in the repo.

Home


Demo


Getting Started

Common Tasks

Integration Guides

Uninstrumentable / polyglot backends

Extensions

Configuration

Features

Reference

Clone this wiki locally