Skip to content

Exporting to OpenTelemetry

aryehcitron@gmail.com edited this page Aug 27, 2026 · 1 revision

The Kronikol.Extensions.Otlp package also works in the outbound direction: it exports Kronikol's captured interactions as OpenTelemetry spans over OTLP/HTTP, so the traffic only Kronikol can see — proxy-tap and TCP-tap hops from services that emit no telemetry, handler-captured calls with exact test attribution — appears in Tempo, Jaeger or any collector next to the application's real traces. It is the outbound twin of the same package's receiver-tee (Integration Otlp Extension).

Three layers, each useful alone:

Layer Type / verb When
Batch push OtlpExporter At the end of a run, from the in-process store or any list of logs — the primary mode; test suites are batch-shaped
Streaming sink OtlpExportSink : IRequestResponseSink Live tap topologies — compose onto a ProxyTap/TcpTap/OtlpTap Sink
CLI kronikol export Post-hoc, from NDJSON capture files, in any language's pipeline

What a call becomes

One request/response pair becomes one span: the request supplies the start time and request attributes, the response the end time and status.

Span field / attribute Source
resource service.name CallerName (one resourceSpans entry per caller)
scope Kronikol + the assembly version
span name the Kronikol method label (GET, Find ← Trial)
kind CLIENT, or PRODUCER when the pair is an event (MetaType: Event)
url.full Uri
http.request.method Method, when it is an HTTP verb
http.response.status_code the numeric StatusCode; span status ERROR when ≥ 400 (the client-span semconv rule) or when the status is the string form of a failure (Error, Failed, Timeout…)
db.system.name the reverse of the receiver's category mapping (Redisredis, MongoDBmongodb, generic Databaseother_sql); omitted for non-database calls
peer.service ServiceName
kronikol.test.id / kronikol.test.name TestId / TestName
kronikol.phase Phase, when not Unknown
kronikol.dependency.category DependencyCategory, when set
kronikol.captured.by CapturedBy, when set
kronikol.request.body / kronikol.response.body the bodies — only with IncludeBodies = true, capped at BodyAttributeCapBytes (default 8 KiB)
headers not exported (size + secret risk outweigh the value; an allow-list option can be added on demand)

The kronikol.* attribute names are the same vocabulary the taps' own Activities already emit, so everything Kronikol puts on a span reads consistently.

Timestamps are never a reason to drop a record: a request with no timestamp borrows its response's; a pair with none at all is stamped with the export time and marked kronikol.times.synthetic = true.

Trace identity — one test, one trace

  • A captured W3C trace id always wins (ActivityTraceId/ActivitySpanId): those spans land in the same distributed trace the system under test emitted, which is the whole point (invariant D4, below).
  • When no Activity id was captured, the default TraceIdStrategy.PerTest derives the trace id deterministically from TestId (the InteractionRecord.ToGuid recipe — a 32-hex browser-minted test id maps to itself), so one test renders as one trace in Tempo/Jaeger. Without this, capture paths that mint a fresh Guid per pair would flood the backend with thousands of single-span traces. TraceIdStrategy.PerPair keeps the raw per-pair id for users who want it (--per-pair-traces on the CLI).
  • The span id is the captured ActivitySpanId, else the first 16 hex of RequestResponseId — deterministic, so re-exporting produces identical spans.
  • Exported traces are flat: no parentSpanId is emitted, so a trace is a fan of sibling spans. That is by design, not a bug — inferring parent/child from caller-name chains and interval nesting is a possible future enhancement.

What is not exported

  • Echo suppression (default). Records with capturedBy: span or wire + span came from the backend's own telemetry via the OTLP tap — re-exporting them would duplicate spans the backend already stores. Opt in with IncludeSpanSourced / --include-span-sourced if you know the tap was a leaf and the collector never saw them.
  • Always skipped: diagram marker records (rendering control, not telemetry) and TrackingIgnore records.
  • A record whose other half never shows up is still exported — as a zero-duration orphan span marked kronikol.orphan = true (after PendingRequestTtl, default 30 s, in the streaming sink). A lone request that carries its own measured durationMs (the one-record NDJSON contract) is a complete span, not an orphan.

Batch export

using Kronikol.Extensions.Otlp;
using Kronikol.Tracking;

using var exporter = new OtlpExporter(new OtlpExportOptions
{
    Endpoint = new Uri("http://localhost:4318/v1/traces"),
    Headers = { ["authorization"] = $"Bearer {token}" },   // the tap's ExpectedHeaders, in reverse
    Gzip = true,
});

var result = await exporter.ExportAsync(RequestResponseLogger.RequestAndResponseLogs);
// result.SpansExported / TraceCount / SkippedRecords / OrphanSpans / BatchesFailed

Batches are paged by BatchMaxSpans (default 512). A failed batch gets one immediate re-attempt, then is counted and logged — never thrown, and no retry loop that would hold a test process open; result.Success == false tells you the collector was down.

Redaction note: this path is already redacted — RequestResponseLogger.Redaction ran when each entry was logged (see Capture-Time Redaction).

Streaming sink

For live tap topologies, OtlpExportSink streams captures out as they happen. Compose it with the tap's normal sink:

var otlp = new OtlpExportSink(new OtlpExportOptions
{
    Endpoint = new Uri("http://localhost:4318/v1/traces"),
    Name = "otlp-export",
});

proxyTapOptions.Sink = new CompositeRequestResponseSink(
    new NdjsonInteractionWriter(".logs/taps/web.ndjson"),   // for kronikol ingest
    otlp);                                                  // and live to the collector

// ... run the tests ...

await otlp.DisposeAsync();   // drains and flushes, orphaning requests that never got a response

The same works on TcpTapOptions.Sink and OtlpTapOptions.Sink (mind the echo: spans an OtlpTap captured are suppressed by default, see above). The taps redact via their own hooks (SecretDenylist, Key/Value/DocumentRedaction) before the sink, so what streams out is what they would have stored.

The sink obeys D3. Log() is a TryWrite into a bounded channel (QueueCapacity, default 4096) — it never blocks and never throws. A background worker pairs, batches (BatchMaxSpans / FlushInterval, default 2 s) and POSTs. Drops and delivery failures are counted and surfaced as DiagnosticKind.CaptureDegraded entries from Diagnostics() — hand them to IngestRequest.HostDiagnostics (or --diagnostic) so a degraded export is a line in the report, not only in a log. FlushAsync() gives a deterministic flush point; DisposeAsync() waits at most ShutdownTimeout (default 5 s) before cancelling an in-flight POST, so a hung collector cannot hold the process open.

CLI: kronikol export

kronikol export <captures.ndjson>... --otlp <endpoint> [--header k=v]...
                [--include-bodies] [--body-cap N] [--include-span-sourced]
                [--per-pair-traces] [--no-redact] [--redact-header h]...
                [--gzip] [--dry-run [--out file.json]]

Reads the same NDJSON capture files kronikol ingest does and POSTs them as spans. --dry-run writes the encoded OTLP/JSON instead of POSTing (to --out, else stdout) — testable without a listener, and a handy debug view of exactly what would leave. Counts are printed (spans / traces / skipped / orphans); exit codes follow kronikol ingest (0 success, 1 runtime failure, 2 usage).

Redaction is per-path, and this is the path that needs it: batch export from the in-process store was redacted at capture, tap-fed sinks were redacted by the taps — but NDJSON files have had nothing run over them (RequestResponseLogger.Redaction applies only on ingest-replay). kronikol export therefore applies CaptureRedaction itself, default on, with --no-redact / --redact-header mirroring kronikol ingest exactly.


Non-interference

The exporter is a standalone HttpClient POSTing OTLP/JSON to a URL. It never touches the system under test's TracerProviderBuilder, never registers processors, never re-emits Activity objects and never flips Activity.Recorded. Exporting Kronikol's captures cannot change what the observed system emits, samples or reports — it only adds spans, under Kronikol's own scope name, to whatever collector you point it at.

Protocol note: the export encoding is OTLP/JSON (application/json on /v1/traces), which collectors accept alongside protobuf. A hand-rolled protobuf encoder is a possible follow-on if a JSON-rejecting endpoint ever shows up in practice.

The D-invariants

Two invariants this feature (and the taps) cite by name:

  • D3 — capture never blocks or degrades the observed system. Every Kronikol capture path hands work to a bounded queue and returns; when the queue is full the newest item is dropped and counted, never awaited. A slow report pipeline, a hung collector or a blocked sink can never slow the system under test. The counters surface as CaptureDegraded report diagnostics.
  • D4 — real W3C ids are preserved end-to-end. When a genuine trace/span id was observed (an incoming traceparent, a captured Activity, an OTLP span), Kronikol carries it through capture, storage, reports (activityTraceId/activitySpanId in TestRunReport.json) and now export — so Kronikol's view and the backend's view of the same call always cross-link.

See also

Home


Demo


Getting Started

Common Tasks

Integration Guides

Uninstrumentable / polyglot backends

Extensions

Configuration

Features

Reference

Clone this wiki locally