-
Notifications
You must be signed in to change notification settings - Fork 1
Integration Otlp Extension
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 sameRequestResponseLogentries and the same reports, and B and C combine: see Merging the wire and the span views.
dotnet add package Kronikol.Extensions.OtlpNo 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.
┌──────────────► real collector (optional ForwardBaseUri)
services ──OTLP──► tap :4319
└── bounded queue ──► SpanToInteractionMapper ──► IRequestResponseSink
(store and/or NDJSON)
For every export the tap:
-
Authenticates it against
ExpectedHeaders(a per-start shared secret). Anything missing or different is answered401and counted — never forwarded, never mapped. -
Forwards or acknowledges, first. With
ForwardBaseUriset 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 answers200locally with an emptyExportTraceServiceResponse(which is zero bytes in protobuf,{}in JSON). - 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.
-
Maps each span it recognises to one Kronikol arrow: request at
startTimeUnixNano, response atendTimeUnixNano(so call-tree ordering and durations work),TestId= the trace id,ActivityTraceId/ActivitySpanIdset for cross-linking to Tempo/Jaeger, andcapturedBy: spanstamped on both records.
Spans that are not calls — internal spans, server spans (by default), families excluded by CaptureKinds — are counted as ignored and dropped.
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/tracesservices.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>.
o.ForwardBaseUri = new Uri("http://localhost:4318"); // the tap becomes a tee, not a leafNothing downstream changes: Grafana/Tempo see exactly what they saw before.
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 |
-
Contenton the request is the statement/query, capped atContentCapBytes— null 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. -
StatusCodeis the HTTP status when there is one, elseOK, else500with the span's status message as the response body whenstatus.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 = truefor a stack with no taps.
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.
| 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.
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.
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.
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);-
Traces only.
/v1/metricsand/v1/logsare not mapped (they are still forwarded whenForwardBaseUriis set; without it they are answered404). -
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.
- Integration ProxyTap Extension — the wire-side tee (topology B).
-
Ingesting External Captures — the NDJSON format,
kronikol ingest, and the wire/span merge. - Integration OpenTelemetry Extension — the other direction: exporting Kronikol's own in-process tracking as OTel spans.
- Capture-Time Redaction — keeping secrets out of captured content.
Getting Started
Common Tasks
Integration Guides
- Integration xUnit3
- Integration xUnit2
- Integration NUnit
- Integration MSTest
- Integration TUnit
- Integration BDDfy xUnit3
- Integration LightBDD xUnit2
- Integration LightBDD xUnit3
- Integration LightBDD TUnit
- Integration ReqNRoll xUnit2
- Integration ReqNRoll xUnit3
- Integration ReqNRoll TUnit
- Integration Playwright
Uninstrumentable / polyglot backends
- Integration ProxyTap Extension
- Integration TcpTap Extension
- Integration Otlp Extension
- Ingesting External Captures
- Integration Cucumber Messages
- Capture-Time Redaction
Extensions
- Integration AtlasDataApi Extension
- Integration BigQuery Extension
- Integration Bigtable Extension
- Integration BlobStorage Extension
- Integration ClickHouse Extension
- Integration CloudStorage Extension
- Integration CosmosDB Extension
- Integration Dapper Extension
- Integration DynamoDB Extension
- Integration EF Core Relational Extension
- Integration Elasticsearch Extension
- Integration EventBridge Extension
- Integration EventHubs Extension
- Integration Grpc Extension
- Integration Kafka Extension
- Integration MassTransit Extension
- Integration MongoDB Extension
- Integration MySqlConnector Extension
- Integration Npgsql Extension
- Integration Oracle Extension
- Integration PubSub Extension
- Integration Redis Extension
- Integration S3 Extension
- Integration ServiceBus Extension
- Integration SNS Extension
- Integration Spanner Extension
- Integration SqlClient Extension
- Integration Sqlite Extension
- Integration SQS Extension
- Integration StorageQueues Extension
- Integration OpenTelemetry Extension
- Integration DispatchProxy Extension
- Integration MediatR Extension
- Integration PlantUML IKVM
Configuration
- Tracking Dependencies
- Tracking Custom Dependencies
- HTTP Tracking Setup
- Report Configuration
- Diagram Customisation
- Phase-Aware Tracking
- Content Formatting
- PlantUML Server Configuration
Features
- Generated Reports
- Search Syntax
- Component Diagrams
- PlantUML Browser Rendering
- Inline SVG Rendering
- Internal Flow Tracking
- Tags and Attributes
- Excluding Requests
- Excluded Headers
- Multi-Host Test Architectures
- Event-Driven Architecture Testing
- Service Bus Tracking Patterns
- Background Thread Correlation
- Parallel-Safe Background Correlation
- Event & Message Tracking
- Assertion Tracking
- Step Tracking
- Tabular Attributes
- Large Response and Diagram Handling
- Querying Reports
- Diagnostics and Debugging
- CI Summary Integration
- CI Artifact Upload
- Merging Parallel Reports
Reference