Skip to content

Integration ProxyTap Extension

aryehcitron@gmail.com edited this page Aug 21, 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.

Two 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. Both produce the same RequestResponseLog entries and the same reports.


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 ──► data-insights :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.
Log / Name null Diagnostics callback and display name.

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