Skip to content

Integration Otlp Extension

aryehcitron@gmail.com edited this page Aug 26, 2026 · 2 revisions

The Kronikol.Extensions.Otlp package turns OpenTelemetry spans into Kronikol interactions. An OtlpTap is an OTLP/HTTP receiver-tee: it accepts POST /v1/traces exactly as a collector does (protobuf and JSON, gzip), optionally forwards the export byte-for-byte to a real collector, and maps the database, HTTP, messaging and RPC client spans it recognises to request/response pairs on an IRequestResponseSink.

Use it when a hop is already traced but cannot be proxied — an in-process driver that never crosses a socket you control, a compressed or binary protocol, a cloud SDK — and when you want exact test attribution: a span carries the W3C trace id, and a browser-driven suite mints the trace id as the test id, so no time-window guessing is needed. The W3C ids this extension produces land on every interaction in TestRunReport.json and are followable with kronikol query trace <report> <trace-id> — see Querying-Reports.

Three capture topologies. A: instrument the system under test (handlers, middleware, the Kronikol.Extensions.* client extensions). B: Integration ProxyTap Extension — a transparent HTTP tee on each hop, nothing inside the services changes. C: this package — the telemetry the services already export is teed and mapped. All three produce the same RequestResponseLog entries and the same reports, and B and C combine: see Merging the wire and the span views.


Install

dotnet add package Kronikol.Extensions.Otlp

No ASP.NET Core host and no protobuf dependency: the receiver speaks HTTP/1.1 over a plain socket and decodes the OTLP trace schema directly. See Listening interface for why it is not built on HttpListener.


How it works

                    ┌──────────────► real collector (optional ForwardBaseUri)
services ──OTLP──►  tap :4319
                    └── bounded queue ──► SpanToInteractionMapper ──► IRequestResponseSink
                                                                       (store and/or NDJSON)

For every export the tap:

  1. Authenticates it against ExpectedHeaders (a per-start shared secret). Anything missing or different is answered 401 and counted — never forwarded, never mapped.
  2. Forwards or acknowledges, first. With ForwardBaseUri set the request goes upstream verbatim — same method, path, headers and body, still gzipped — and the upstream status and body are relayed to the caller. Without it the tap answers 200 locally with an empty ExportTraceServiceResponse (which is zero bytes in protobuf, {} in JSON).
  3. Queues the payload on a bounded channel and returns. Decoding and mapping happen on a background worker, so a slow or blocked sink can never slow the exporter down; when the queue is full the newest payload is dropped and counted.
  4. Maps each span it recognises to one Kronikol arrow: request at startTimeUnixNano, response at endTimeUnixNano (so call-tree ordering and durations work), TestId = the trace id, ActivityTraceId/ActivitySpanId set for cross-linking to Tempo/Jaeger, and capturedBy: span stamped on both records.

Spans that are not calls — internal spans, server spans (by default), families excluded by CaptureKinds — are counted as ignored and dropped.


Quick start

using Kronikol.Extensions.Otlp;

await using var tap = new OtlpTap(new OtlpTapOptions
{
    ListenPort = 4319,
    // ServiceNameMap turns OTel names and peer addresses into diagram participants
    ServiceNameMap = { ["localhost:27099"] = "mongo", ["localhost:6399"] = "redis" },
});
await tap.StartAsync();

// Point an exporter at it:
//   OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4319/v1/traces

With dependency injection

services.AddOtlpTapTestTracking(o =>
{
    o.Name = "tap-otlp";
    o.ListenPort = 4319;
    o.ExpectedHeaders["x-kronikol-tap"] = sharedSecret;   // required off loopback
    o.CaptureKinds.Clear();
    o.CaptureKinds.Add(OtlpCaptureKinds.Db);              // HTTP hops already have proxy taps
    o.ServiceNameMap["localhost:27099"] = "mongo";
    o.Sink = new NdjsonInteractionWriter(".logs/taps/spans.ndjson");
});

One IHostedService per tap starts it with the host and stops it on shutdown; the taps are resolvable as IEnumerable<OtlpTap>.

In front of a real collector

o.ForwardBaseUri = new Uri("http://localhost:4318");   // the tap becomes a tee, not a leaf

Nothing downstream changes: Grafana/Tempo see exactly what they saw before.


What a span becomes

SpanToInteractionMapper is public and pure — you can map a captured export offline, and it is unit-tested against golden payloads from the Java agent, the Node auto-instrumentations and the .NET SDK.

Both the deprecated and the stable semantic conventions are accepted, stable first:

Stable Deprecated
db.system.name db.system
db.query.text db.statement
db.operation.name db.operation
db.collection.name db.mongodb.collection
db.namespace db.name, db.redis.database_index
server.address / server.port net.peer.name / net.peer.port, peer.service
http.request.method http.method
url.full http.url
http.response.status_code http.status_code
Span Arrow label (Method) Uri DependencyCategory
Redis / Valkey the command, e.g. GET ((Hit)/(Miss) only when the producer reported a result) redis://db{n}/{key} Redis
MongoDB Find ← Trial, Insert → Trial, FindAndModify ↔ Trial — the same directional arrows Integration MongoDB Extension draws mongodb:///{db}/{collection} MongoDB
Any other db.system db.operation.name, else the first SQL verb {system}://{server}/{namespace} BigQuery, PostgreSQL, MySQL, SqlServer, … else Database
HTTP client the HTTP method url.full none (plain HTTP service)
Messaging (opt-in) Publish {destination} {system}://{broker}/{destination} MessageQueue
RPC (opt-in) {rpc.service}/{rpc.method} {system}://{server}/{service}/{method} gRPC
  • Content on the request is the statement/query, capped at ContentCapBytesnull when the producer does not capture command text, which is common (several vendor Mongo instrumentations expose no switch for it). That is the fidelity gap the wire tap fills.
  • StatusCode is the HTTP status when there is one, else OK, else 500 with the span's status message as the response body when status.code = ERROR.
  • The (×N) document count the MongoDB extension shows is not derivable from a span and is therefore never shown.
  • Server spans are ignored by default — in a tapped stack the inbound hop is already captured with bodies, and mapping it too would draw the arrow twice. Set IncludeServerSpans = true for a stack with no taps.

Participant names

ServiceNameMap is consulted (case-insensitively) for the caller — the span's service.name — and for the receiving side, in this order: peer.service, {server.address}:{server.port}, server.address, the system name (mongodb, redis, …). Unmapped names are used verbatim, so a map is optional but usually worth setting: localhost:27099 reads far worse than mongo.


OtlpTapOptions

Option Default Meaning
ListenPort / ListenHost — / localhost Where the receiver listens. 0 binds a free port (read OtlpTap.BoundPort).
ForwardBaseUri null Real collector to tee to; null answers 200 locally.
ExpectedHeaders empty Header name → exact value every request must carry; otherwise 401.
TracesPath /v1/traces The path that is mapped.
MaxRequestBytes 32 MiB Bigger exports are answered 413 (and drained, not buffered).
QueueCapacity 256 Export payloads that may wait to be mapped; the newest is dropped when full.
Sink RequestResponseLoggerSink.Instance Where mapped interactions go.
Phase Unknown TestPhase stamped on entries.
ServiceNameMap empty OTel name / peer address → diagram participant.
CaptureKinds db, http Span families to map (OtlpCaptureKinds: db, http, messaging, rpc).
IncludeServerSpans false Also map SERVER spans.
AttributeByTraceId true testId = traceId — the exact-attribution path.
KnownTestIds null Func<string,bool>; return false to send a trace to FallbackTestId.
FallbackTestId / FallbackTestName null / Unknown Where non-test traffic lands (pair with kronikol ingest --fold-unknown).
ContentCapBytes 65536 Statement/query truncation.
DefaultCallerName unknown-service Used when a span has no service.name.
Log / Name null Diagnostics callback and display name.

Counters on the tap: RequestsReceived, SpansReceived, SpansMapped, SpansIgnored, PayloadsDropped, UnauthenticatedRequests, ForwardFailures.


Listening interface and exposure

A containerised exporter reaching the host through host.docker.internal cannot use a loopback-only listener, so the tap supports binding every interface: ListenHost = "+" (also *, any, 0.0.0.0).

That is why this tap is not built on HttpListener like Integration ProxyTap Extension. On Windows, http.sys refuses a non-loopback prefix to a non-elevated process unless a URL ACL exists — http://+:{port}/ fails with "Access is denied" (the fix would be netsh http add urlacl url=http://+:{port}/ user=…, run as administrator) and http://0.0.0.0:{port}/ is not even a valid prefix ("The request is not supported"). A plain socket binds 0.0.0.0 with no privileges and registers nothing with http.sys, so a tap can never outlive its process either.

A non-loopback bind is an open port. Always pair it with a shared secret:

o.ListenHost = "+";
o.ExpectedHeaders["x-kronikol-tap"] = secret;   // exporter: OTEL_EXPORTER_OTLP_HEADERS=x-kronikol-tap=<secret>

MaxRequestBytes and QueueCapacity bound the rest. The first non-loopback bind may raise a one-time Windows Defender firewall prompt for dotnet.exe.

With the default ListenHost = "localhost" the tap binds both loopbacks (127.0.0.1 and ::1): an exporter dialling http://localhost:{port} resolves ::1 first on Windows, and without the IPv6 socket every single export would pay a connect-refused round trip first.


Merging the wire and the span views

Run a proxy/TCP tap and an OTLP tap over the same hop and each call is captured twice: the wire capture has the payloads and the status but had to guess which test the call belongs to; the span capture has the exact trace id but usually no payload. IngestRequest.MergeDuplicateInteractions (CLI: kronikol ingest --merge-duplicates) folds them into one arrow — the span's identity, the wire's fidelity, and an x-kronikol-captured-by: wire + span note. See Ingesting External Captures for the rule and the knobs.


Out of process?

RequestResponseLogger is a process-wide static store. A tap in the same process as report generation can log straight to it (the default sink). A tap in another process needs an NdjsonInteractionWriter sink and a replay with kronikol ingest — see Ingesting External Captures. Use both at once for a live report and a replayable artifact:

using var file = new NdjsonInteractionWriter(".logs/taps/spans.ndjson");
o.Sink = new CompositeRequestResponseSink(RequestResponseLoggerSink.Instance, file);

Limitations

  • Traces only. /v1/metrics and /v1/logs are not mapped (they are still forwarded when ForwardBaseUri is set; without it they are answered 404).
  • HTTP/1.1 only. OTLP/gRPC exporters need to be pointed at an OTLP/HTTP endpoint (OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf), or at a collector that fans out to this tap over HTTP.
  • Fidelity is whatever the producer exported. No payloads, no (×N) counts, no hit/miss unless an attribute says so. Pair it with a wire tap when the note matters.
  • The mapper reads the OTLP trace schema directly rather than through generated protobuf classes; unknown fields are skipped, and a malformed payload yields the spans that could be read rather than an exception.

See also

Home


Demo


Getting Started

Common Tasks

Integration Guides

Uninstrumentable / polyglot backends

Extensions

Configuration

Features

Reference

Clone this wiki locally