Observing Outbound HTTP in Go Without Disturbing It

How the nurago httpclient package instruments outbound requests while trying not to change them: a cloned request, a timeout timer tied to the response body, three-source trace IDs, redacted logs, and bounded debug dumps.


Instrumentation has an observer problem. The moment you wrap an HTTP client to log requests, propagate trace IDs, and dump payloads for debugging, you have added code that can mutate the caller’s request, leak timers, buffer streams that were meant to flow, and write secrets to disk. The wrapper meant to explain production behaviour becomes part of it.

The httpclient package in nurago wraps net/http with trace ID propagation and structured request/response logging, and most of its design is about exactly this tension. Each section below is one place where observing a request could disturb it, and what the code does there instead.

import "github.com/tecnickcom/nurago/pkg/httpclient"

client := httpclient.New(
    httpclient.WithTimeout(15*time.Second),
    httpclient.WithComponent("payments"),
)

resp, err := client.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()

The request you observe is not the caller’s

Do needs to write a trace ID header, and at debug level it dumps the request. Doing either on the caller’s *http.Request would mutate an object the caller may reuse or inspect afterwards. So the first thing Do does is operate on a private clone: the caller’s request keeps its original headers and context, and a test pins that it is not mutated. One boundary is stated rather than papered over: the clone shares the body with the original, so the request remains single-use, exactly as with the standard client.

The same care applies to malformed input. A nil request or a nil URL returns ErrNilRequest or ErrNilRequestURL instead of panicking, mirroring the standard client’s error-on-malformed-request behaviour.


A timer that must outlive Do

The client applies its per-request deadline (default one minute) through the request context rather than http.Client.Timeout. The reasons are practical: a context deadline also reaches custom round-trippers and dialers, and setting both would arm two overlapping timers per request. A zero or negative timeout disables the deadline entirely, the net/http convention.

That choice creates a lifetime puzzle. The timeout must be able to interrupt a slow body read after Do returns, so the context cannot be cancelled when Do exits; but a context whose cancel function is never called leaks its timer until the deadline fires. The resolution is to tie the context’s lifetime to the response body:

// cancelReadCloser wraps a response body so that closing it also releases the
// per-request timeout context (canceling its timer promptly) while still
// allowing the timeout to interrupt long reads that happen before Close.
type cancelReadCloser struct {
	io.ReadCloser

	cancel context.CancelFunc
}

Closing the body cancels the context and releases the timer; leaving it open preserves the timeout’s power over long reads. On every path where no body reaches the caller (an error, a nil response, even a panic inside a user-supplied round-tripper) a deferred guard releases the timer instead, and the tests exercise the close, error, and panic paths. The familiar advice still applies with one addition: forgetting resp.Body.Close() leaks the connection as it does with the standard client, and here it also holds the timeout timer until the deadline elapses.


One identity, three sources

Distributed tracing only works if the outbound request carries the same identifier the rest of the call chain uses, so the trace ID is resolved in priority order: a valid ID already in the context wins, then a valid ID already set on the request header (an explicit caller choice is honoured rather than clobbered), and only when neither is present is a fresh UUIDv7 (universally unique identifier, version 7) generated. The validation matters as much as the priority: an invalid value in either place is replaced rather than propagated, and the random generator is only consulted when nothing valid exists.

Whichever source wins, the resolved ID is then forced onto the request context, so the header a downstream service sees and the context value the caller’s code sees agree. Tests pin each branch: context reuse, caller-header honouring, and invalid-value replacement.


What may be written down

Every Do produces one structured log entry under the constant message outbound, carrying the component tag, trace ID, method, host, path, timing, and the response status. The interesting decisions are about what is not written.

Query strings are redacted before logging at every level, not just debug, since secrets ride in query parameters (api_key, token) far more often than anyone intends. A failed request logs a *url.Error whose message embeds the full URL, so the logged copy has its query string and userinfo redacted too; the error returned to the caller is left intact, and the redaction happens on a copy. The redaction function defaults to the redact package’s shared redactor and is swappable with WithRedactFn.

Two gaps are documented rather than hidden: the request path is not redacted (avoid designs like /reset/{token} that place secrets in the path), and an error produced by a custom round-tripper that does not wrap a *url.Error is logged as-is, so such round-trippers need to redact their own messages.


The heavy instrument: payload dumps

At debug level the client dumps full requests and responses into the log entry, and this is where observation can disturb the most. Dumping means buffering, and buffering a body meant to stream changes the caller’s timing; buffering an unbounded body changes the process’s memory profile. So dumps are bounded by WithMaxDumpSize (default 1 MiB), and the dump path splits by what is known about the body.

For requests: a body of unknown length (a streaming io.Pipe, say) is dumped headers-only, because httputil.DumpRequestOut with the body enabled would block until the body reaches end-of-file, which for an unsent streaming request is a deadlock. A body whose known length exceeds the cap is also dumped without its payload, via a body-stripped copy, because the standard dumper with the body disabled would still write Content-Length filler bytes, defeating the point of the cap.

For responses, the awkward case is a body of unknown length (chunked or streaming). The dump peeks at most one byte past the cap, marks the dump as truncated when the body turns out larger, and then restores a body that replays the peeked prefix followed by the unread remainder:

// Restore a body that replays the peeked prefix followed by the remainder.
resp.Body = &replayBody{Reader: io.MultiReader(bytes.NewReader(buf), original), closer: original}

The caller still receives the complete stream; the tests pin this for chunked responses. The cost is stated plainly in the package documentation: because the peek happens before Do returns, debug logging can add up to a cap’s worth of buffering latency, so it is a poor fit for genuinely streaming endpoints such as Server-Sent Events. It also shifts what response_duration measures: time to response headers at normal levels, headers plus buffered body transfer at debug level. Durations are measured on the monotonic clock, so a wall-clock adjustment mid-request does not distort them.


The stand the instrument sits on

Each New builds a private transport by cloning http.DefaultTransport, so per-client options such as a custom dialer mutate only that clone, and one client’s configuration does not reach into http.DefaultTransport and change behaviour for every other consumer in the process. The clone raises MaxIdleConnsPerHost from net/http’s default of 2 to 100: this client targets service-to-service traffic that concentrates many concurrent calls on a few downstream hosts, and a two-connection idle pool per host throttles reuse in exactly that pattern. Construct one client and reuse it; a client per request defeats the pool.

The options that touch the transport compose in a specific order, and the documentation is explicit about it: WithTransport installs the base, WithDialContext and WithTLSClientConfig mutate that base while it is still an *http.Transport, and WithRoundTripper wraps it, after which the mutating options can no longer find the underlying transport and silently do nothing. Options are applied in the order given, so the safe order is transport first, mutators next, wrapper last.


None of these decisions is visible in a demo, which is rather the point. An instrumented client earns its keep during an incident, and it is precisely then that a mutated request, a leaked timer, or a token in a log line would hurt the most. The design aims to make the observation itself as close to weightless as it can manage, and to document the residual weight where it cannot.