Skip to content

Integration ProxyTap Extension

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

The Kronikol.Extensions.ProxyTap package is Kronikol's first out-of-process capture component. A ProxyTap is a transparent HTTP tee: it listens on a port, forwards every request byte-for-byte to the real service, and records a copy of each exchange as a Kronikol request/response pair attributed to the running test. Nothing inside the services needs to change.

Use it when the system under test is polyglot, third-party or legacy — services you cannot add TestTrackingMessageHandler / TestTrackingContextMiddleware to — and you still want per-test sequence diagrams of every hop.

Three capture topologies. Kronikol's classic model (topology A) instruments the system under test: handlers and middleware inside the backend record the calls. The proxy-tap model (topology B) leaves the backend untouched: a browser or test fixture stamps identity headers, they ride the real request through the services, and a tap on each hop is the sink. The span model (topology C, Integration Otlp Extension) tees the OTLP traces the services already export and maps their client spans. All three produce the same RequestResponseLog entries and the same reports.

Tapping a database hop? ProxyTap speaks HTTP. For the Redis and MongoDB hops of the same stack use Integration TcpTap Extension — the same tee shape and the same options, over a byte-for-byte TCP tee with RESP and OP_MSG decoders, producing arrows identical to the in-process Integration Redis Extension / Integration MongoDB Extension.

When a hop cannot be proxied — a binary or compressed protocol, an in-process driver, a cloud SDK — use Integration Otlp Extension instead, or as well: it attributes by W3C trace id rather than by header, and kronikol ingest --merge-duplicates folds the two views of a call that both taps saw into one arrow (see Ingesting External Captures).


Install

dotnet add package Kronikol.Extensions.ProxyTap

No ASP.NET Core host is required — the tap is built on HttpListener and binds localhost without URL ACLs.


How It Works

browser/test ──► tap :8082 ──► graphql :8081 ──► tap :9192 ──► myDotnetService :9091 ──► ...
                  │ records                        │ records
                  ▼                                ▼
            RequestResponseLogger  /  NDJSON file  (IRequestResponseSink)

For every exchange the tap:

  1. Resolves the test identity from the inbound request — test-tracking-current-test-name / test-tracking-current-test-id (the same headers TestTrackingContextMiddleware reads), then any configured fallback headers, then (by default) the W3C traceparent trace id. The traceparent fallback is what makes browser-driven suites work: the test mints the trace, every browser request carries it, and downstream hops that drop the custom headers still carry traceparent.
  2. Re-injects the four correlation headers (test-tracking-current-test-name, -current-test-id, -caller-name, -trace-id) on the forwarded request when missing, so attribution survives a hop that would otherwise drop them.
  3. Forwards the original bytes (hop-by-hop headers handled; Host rewritten to the target). When an ActivityListener is attached to the Kronikol.ProxyTap source it emits a server span (parented on the inbound traceparent) and a client span, and re-parents the forwarded traceparent on the client span — the downstream service then nests under the tap in your distributed trace. Without a listener it is fully transparent.
  4. Responds first, records second — capture decodes (gzip/deflate/br) and caps a copy of both bodies after the response has been written back, so it never sits on the request path.
  5. Redacts secrets at captureauthorization, proxy-authorization, cookie, set-cookie, x-api-key, … are replaced with [REDACTED] (or dropped) before the entry reaches any sink. This is the security boundary: the secret never enters the in-memory store, TestRunReport.json, or an NDJSON file. (ExcludedHeaders only hides headers in the diagram — see Capture-Time-Redaction.)
  6. Logs a Request and a Response entry (shared TraceId/RequestResponseId, ServiceName/CallerName from the options, status, timestamps, ActivityTraceId/ActivitySpanId) to the configured sink.

Requests with no resolvable identity (health probes, warm-ups) are forwarded but not captured unless CaptureUnattributedRequests is set.


Quick Start

using Kronikol.Extensions.ProxyTap;

await using var tap = new ProxyTap(new ProxyTapOptions
{
    ListenPort = 8082,
    ForwardBaseUri = new Uri("http://localhost:8081"),
    CallerName = "web",        // the participant that dials the tap
    ServiceName = "graphql",   // the participant the tap forwards to
});
await tap.StartAsync();

// Point the caller at :8082 instead of :8081 — it sees identical traffic.

Then generate reports exactly as you would for in-process tracking (ReportGenerator.CreateStandardReportsWithDiagrams(features, start, end, options)); every tapped call renders as an arrow between web and graphql in the scenario whose Id equals the resolved test id.

With dependency injection

services.AddProxyTapTestTracking(o =>
{
    o.ListenPort = 8082;
    o.ForwardBaseUri = new Uri("http://localhost:8081");
    o.CallerName = "web";
    o.ServiceName = "graphql";
});

One IHostedService per tap starts it with the host and stops it on shutdown. Call it once per hop (a ProxyTap per listen → forward pair); the taps are resolvable as IEnumerable<ProxyTap>.

Stamping identity from the test

  • .NET + Playwright — use Integration Playwright (browser.NewTrackedContextAsync(identity)): the four headers plus a traceparent land on every browser request.
  • Any other client — send the headers named in Kronikol.Constants.TestTrackingHttpHeaders, or just a W3C traceparent whose trace id you use as the scenario id.
  • In-process HttpClientTestTrackingMessageHandler already stamps them.

ProxyTapOptions (the topology schema)

Option Default Meaning
ListenPort / ListenHost — / localhost Where the tap listens.
ForwardBaseUri The real service (scheme + host + port).
CallerName / ServiceName The two participants this hop joins.
DependencyCategory / CallerDependencyCategory null A DependencyCategories value for shape/colour (e.g. BigQuery, AI).
CaptureBodies / BodyCapBytes true / 65536 Capture bodies; truncate decoded text beyond the cap (…truncated (N chars total)).
HeaderPolicy AllExceptSecrets All, AllExceptSecrets, Whitelist (+ HeaderWhitelist), None.
SecretDenylist / DropSecretHeaders / RedactedValue CaptureRedaction.DefaultSecretHeaders / false / [REDACTED] Capture-time secret handling.
ReinjectCorrelation true Re-stamp missing test-tracking-* headers on the forwarded request.
IdentityFromTraceparent true Use the inbound traceparent trace id as the test id when no id header is present.
TestNameHeaderFallbacks / TestIdHeaderFallbacks empty Extra headers to read name/id from (legacy header names).
IdentityResolver null Custom (headers, traceparent) → (name, id)? override.
FallbackTestName Unknown Name when only an id is known (the tests file / kronikol ingest normalises it).
CaptureUnattributedRequests false Capture requests with no identity (under FallbackTestName).
FallbackTestId null With CaptureUnattributedRequests, use this fixed id for all unattributed traffic (one "outside any test" scenario) instead of a fresh id per request.
Sink RequestResponseLoggerSink.Instance Where entries go; combine with NdjsonInteractionWriter via CompositeRequestResponseSink.
Phase Unknown TestPhase stamped on entries.
EmitActivities / SynthesizeTraceparent true / true Spans on ProxyTap.ActivitySource; mint a traceparent when the caller sent none.
ForwardTimeout / ConnectTimeout 200 s / 5 s Upstream deadlines.
InFlightRegistry null Publish the identity of every request in flight to ServiceName, so a capturer that cannot read headers can attribute what it sees — see In-flight identity.
Log / Name null Diagnostics callback and display name.

In-flight identity: lending a database tap an identity

Added in v3.0.45

A tap on an HTTP hop reads the test identity straight off the request headers. A tap on a database connection cannot: the Redis and MongoDB wire protocols have nowhere to put a test-tracking-current-test-id, and the connection is pooled besides. The only honest source left is which test was the service handling when the query left it — and the HTTP tap in front of that service knows.

InFlightIdentityRegistry is that channel. Point one or more taps at it and each registers every request it forwards, for as long as it is in flight:

var inFlight = new InFlightIdentityRegistry();          // or InFlightIdentityRegistry.Shared

services.AddProxyTap(o =>
{
    o.CallerName = "graphql";
    o.ServiceName = "myDotnetService";
    o.InFlightRegistry = inFlight;                      // publish who myDotnetService is working for
    // …
});

// The database capturer asks, per captured command:
var identity = inFlight.MostRecentFor("myDotnetService");
var testId = identity?.Id ?? fallbackTestId;
Member
Register(serviceName, testName, testId) Returns a handle; dispose it when the request completes. The tap does this in a using covering every exit, so a failed forward cannot leave a stale identity behind.
MostRecentFor(serviceName) The identity of the most recently started request still in flight, or null when the service is idle. Most recent, not first: a call a service makes belongs to the request it is currently handling, and nested work always starts after its cause.
CountFor(serviceName) / ActiveServices Diagnostics.
Clear() Forget everything (tests, restarts).
Shared A process-wide instance for hosts that would rather not thread one through.

Service names match case-insensitively, and the whole thing is thread-safe.

Prefer ingest-time window attribution. With one worker MostRecentFor is exact; with several it is a best guess, and it couples the database tap to an HTTP tap in the same process. kronikol ingest --attribute-by-window reconstructs the same answer deterministically from the tests NDJSON's timeline, works for any capturer in any process, and is documented in Ingesting External Captures. The registry is the live option for suites that genuinely run tests in parallel; nothing is published unless InFlightRegistry is set.


Same process or different process?

RequestResponseLogger is a process-wide static store, so a tap running in the same process as report generation (a test host, an orchestrator) can log directly — the default sink. A tap running in another process (a sidecar, a polyglot harness) cannot reach that store: give it an NdjsonInteractionWriter sink and replay the file with kronikol ingest (see Ingesting External Captures). Use both at once to get the live in-process report and a replayable artifact:

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

Legible diagrams for chatty hops

Polling and retry traffic produces long runs of identical arrows. Enable collapsing on the report: ReportConfigurationOptions.CollapseConsecutiveIdenticalCalls = true (+ CollapseThreshold, MaxArrowsPerDiagram) — see Report Configuration. A scenario whose id matched no tapped call shows an explicit No interactions captured marker rather than an empty section.


Limitations

  • HTTP/1.1 only (the HttpListener front end); HTTP/2 or gRPC-over-h2c hops need a different tee.
  • The tap is a participant in the trace: latencies you see include the tap's own (small) overhead.
  • The tap cannot repair a downstream service that resets trace context on its own (e.g. a Java agent starting a fresh root for an outbound call); such legs need the service to forward the id — see the generic alternative (header re-injection) before patching anything.

Home


Demo


Getting Started

Common Tasks

Integration Guides

Uninstrumentable / polyglot backends

Extensions

Configuration

Features

Reference

Clone this wiki locally