traceid

traceid captures a request-scoped trace ID at the service boundary, validates it against header injection, and propagates it downstream without a tracing framework.

Part of nurago, a collection of independent Go packages for backend services.

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

Read an ID off an inbound request, carry it in the context, write it onto outbound requests. That is the whole loop, and it works without adopting OpenTelemetry or any other tracing framework.

// inbound, in middleware
id := traceid.FromHTTPRequestHeader(r, traceid.DefaultHeader, traceid.DefaultValue)
ctx := traceid.NewContext(r.Context(), id)

// outbound, before calling a downstream service
traceid.SetHTTPRequestHeaderFromContext(ctx, req, traceid.DefaultHeader, traceid.DefaultValue)

The header is untrusted input, and this package treats it that way

Validation happens at ingest.

An inbound trace ID arrives from outside. It ends up in log lines, in outbound headers, and often in a database column. A caller controls it completely.

FromHTTPRequestHeader validates before accepting: 1 to MaxIDLen characters drawn from [0-9A-Za-z._-]. Anything else, including an absent or empty header, is replaced with the caller-supplied default.

Each exclusion in that character set blocks something specific. CR and LF would let a crafted header inject extra headers into an outbound request, or forge additional lines in a log file. Quotes, spaces and control characters would break the structure of a JSON log record. The length bound stops a client from writing a megabyte into every log line the request produces.

Log forging is the attack people forget. An ID of abc" , "level":"info","msg":"all fine inside a JSON log line is a plausible-looking fabricated record, and a naive handler will emit it. Rejecting the character set is the fix, and it has to happen at ingest.

Valid is exported so you can apply the same check to an ID arriving from somewhere other than an HTTP header.

Note the asymmetry the package documents: what you put into the context with NewContext is not validated. The validation belongs at the boundary. If you ingest an ID from an untrusted origin by some other route, call Valid yourself before storing it.

NewContext and ForceContext

Two setters, because there are two different intentions.

NewContext does nothing if an ID is already present. That makes it safe to call at every layer: a handler, a service method and a repository can all call it without any of them clobbering the one the middleware established.

ForceContext overwrites unconditionally. It exists for the moment you have just determined the authoritative ID and the context has to agree with what will be propagated downstream.

Defaulting to non-destructive is the right way round. The failure mode of the opposite default is a trace that silently splits into two IDs somewhere in the middle of a request, which is close to impossible to notice and removes most of the value of tracing at all.

Collision-proof context key

The key is an unexported struct type. That is the idiomatic Go answer and it matters: a string key can collide with any other package storing under the same string, and the compiler will not tell you. An unexported type cannot be constructed outside this package, so no collision is possible.

Shared defaults

DefaultHeader is X-Request-ID, DefaultValue is the empty string, DefaultLogKey is traceid. They are exported so that every service in a fleet can agree without each one hardcoding the strings, which is how the header ends up as X-Request-Id in one service and X-Request-ID in another.

Cost

Functionns/opB/opallocs/op
Valid16.700
FromHTTPRequestHeader75.9161

Measured with go test -bench on Go 1.27.1, linux/amd64, Intel Core i7-1260P. Run go test -bench=. ./pkg/traceid/ to reproduce them on your own hardware; the absolute values will differ, the ratios much less so.

Validation is allocation-free. The single allocation in the header path is the returned string. At 76 ns once per request this is not a cost worth thinking about.

Scope of the propagation

The package carries one opaque ID and nothing else: no spans, no timing, no sampling, no parent-child relationship. The ID stays constant for the lifetime of a request and identical in every service that handles it, so log aggregation can reconstruct the request by filtering on a single field.

DefaultLogKey is the hook for that. Pairing it with logutil puts the same key on every record:

logger = logger.With(slog.String(traceid.DefaultLogKey, traceid.FromContext(ctx, traceid.DefaultValue)))

Span-based tracing with an exporter and a backend lives in opentel, and the two run side by side: an X-Request-ID header and W3C traceparent propagation carry different things and do not interfere.

Example

ctx := traceid.NewContext(context.Background(), "abc123")

fmt.Println(traceid.FromContext(ctx, traceid.DefaultValue))

// Output:
// abc123

Full source is in example_traceid_test.go. More runnable examples are on pkg.go.dev.

Dependencies

This package reaches no external module: it uses only the Go standard library.