Repository navigation
Make OpenTelemetry tracing usable: sampling, span names, per-router spans - #9
Merged
Merged
Conversation
NEEDLE_SAMPLING_FRACTION sampled nothing: the root sampler only kept spans
named needle*, and otelhttp names the server span "GET /". A trusted
traceparent was honoured, but the trace held that one span and nothing
about the work inside the request.
Sampling is now ParentBased(TraceIDRatioBased(fraction)) with a root
sampler that only samples the request's server span, so background DHT
and address book work is still never traced. A request's traceparent is
trusted with NEEDLE_TRACING_AUTH, as before, or with the new
NEEDLE_TRACING_TRUST_TRACEPARENT for a needle behind a proxy that
replaces client traceparent headers; any other request starts its own
trace and keeps the caller's as a link.
The request span is named after the endpoint
(needle GET /routing/v1/providers/{cid}). Each router asked gets a child
span, needle router <op> <router>, carrying the routerTrace measurements
(records, exclusive records, first record, how it finished) plus errors
and the cached address book's counts; each /dnsaddr query gets a span.
The DHT library's per-peer query spans are kept out of request traces.
OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES now override needle's
resource values, service.instance.id defaults to host:port, and an OTLP
endpoint alone selects the OTLP exporter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Value.Emit is deprecated, which staticcheck (SA1019) fails go-check on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
needle's OpenTelemetry tracing is unusable in v1.0.0. Running the v1.0.0 image against a local collector showed two bugs:
GET /(method plus route, and needle sets no route). The root sampler only samples spans whose name starts withneedle, soNEEDLE_SAMPLING_FRACTIONsamples nothing at any value.NEEDLE_TRACING_AUTHand a sampledtraceparent, needle exports a singleGET /span. Nothing shows which router answered, how long each took, or what a routing cutoff would cost.This is the needle side of ipni/infra#163 (tracing via Alloy and Tempo). It also makes ipni/workers#18 useful: route-origin's Envoy head-samples 5%, strips the client's
traceparent/tracestate, and passes its owntraceparentto needle from arouter needle egressspan. With this PR, needle can join that trace.What changes
Sampling (
tracing.go). The sampler isParentBased(requestSampler{TraceIDRatioBased(NEEDLE_SAMPLING_FRACTION)}). The root sampler applies the fraction only to the request's HTTP server span. Any other root span is dropped, so background DHT and address-book work stays out of traces, as before. A request continues its caller's trace only when needle trusts the caller'straceparent:NEEDLE_TRACING_TRUST_TRACEPARENT=true(new, default off). needle trusts every request'straceparent. This is for a needle that is reachable only through a proxy that replaces client headers, which is what Envoy does in Trace 5% of route-origin's requests workers#18. needle then samples exactly what Envoy sampled: a sampledtraceparentjoins Envoy's trace, and an unsampled one is dropped.NEEDLE_TRACING_AUTH(kept). A request whoseAuthorizationheader matches still continues itstraceparent. This is on-demand tracing of a single request by someone who can reach needle directly. Behind Envoy, Envoy has already replaced that header, so it no longer does anything there.traceparentas a span link (otelhttp.WithPublicEndpointFn). v1.0.0 deleted the header instead. An untrusted client therefore cannot force sampling, and so cannot control how much needle exports.Span names (
tracing.go). The request span is namedneedle <method> <route>using boxo's route templates, for exampleneedle GET /routing/v1/providers/{cid}, and getshttp.route. Names never include the CID, peer ID or IPNS name, and unknown methods becomeHTTP. Status comes from otelhttp: ERROR on 5xx, which covers "every router failed". A lookup that finds no records is not an error.Router spans (
server_routers.go). Every router a request asks gets a child spanneedle router <op> <router>, for exampleneedle router providers delegated:cid.contactorneedle router providers dht. Span metrics built perspan_nametherefore give per-router latency directly. The spans reuse theNEEDLE_ROUTER_TRACEmeasurements (routerTrace) instead of measuring twice.routerTraceends each span at that router's own finish time, once the request is done, because exclusive records are only known then. Attributes on each router span:needle.router.recordsneedle.router.exclusive_records: what the request would have lost without this router.needle.router.first_record_ms, plus afirst recordevent.needle.router.errorsneedle.router.outcome: the metrics'cancelledreason is split intotimeout(routing timeout),client_cancelledandclosed(records limit reached, or another router already answered). The other values areexhausted,cut(DHT tail budget) anderror.needle.router.found(IPNS only).ERROR is set on a failed call or on error results. Not found and cancellation are not errors.
Paths that skip
manyIterget the same span:traceSingle/tracedIter);tracedRouter). Withautoendpoints that covers/peers,/ipnsand the closest-peers endpoint;The request span also gets
needle.routing.outcome,needle.routing.routers,needle.routing.last_routerandneedle.routing.tail_ms. One limit: on a routing timeout or a client disconnect, the response ends without waiting for the routers, so the request span can close before these are set. The router spans still record what happened.DHT library spans (
server_routers.go,tracing.go). kad-dht starts a span for every peer a lookup queries: in a local run, about 230 spans per provider lookup.libp2pRouternow calls the DHT under a context detached from the trace, so those spans become roots and the sampler drops them. The DHT router span covers the lookup as a whole. Without this, the 1 s tail-sampling rule in ipni/infra#163 would keep most DHT-backed traces with hundreds of spans each. If anyone wants the DHT internals back, a flag would be a small follow-up.Cached address book and
/dnsaddr(server_cached_router.go,dnsaddr.go).needle.addr_book.hits,misses,unused,lookups_dispatched, and, for/peers,negative_hitsandlookups_joined. The counts are passed through the context by a smallspanCountershelper.needle dnsaddr lookupspan:dns.question.name, result, address count, and whether the query was shared with another request's in-flight lookup. At most 16 such spans per request.OTEL_*variables (tracing.go).resource.New(..., WithAttributes(needle's), WithFromEnv()), soOTEL_SERVICE_NAMEandOTEL_RESOURCE_ATTRIBUTESnow win. v1.0.0 mergedservice.name=needleover them.service.instance.iddefaults to<hostname>:<API port>, which tells apart instances that share a host network.host.nameis added too.OTEL_EXPORTER_OTLP_ENDPOINT(or..._TRACES_ENDPOINT) alone now selects the OTLP exporter. boxo'sOTEL_TRACES_EXPORTERdefaults to empty, nototlpas in the spec.Other files.
main.go,server.go: the new flag, andwithTracingreplaceswithTracingAndDebug.docs/tracing.md: rewritten around sampling and proxies, plus a reference for the span names and attributes.docs/environment-variables.md: documentsNEEDLE_SAMPLING_FRACTION,NEEDLE_TRACING_TRUST_TRACEPARENT,NEEDLE_TRACING_AUTHand theOTEL_*variables.CHANGELOG.md: entries under Unreleased.Tests
tracing_test.gousestracetest. It covers:traceparent→ own trace plus link; proxy trust;NEEDLE_TRACING_AUTHwith matching and wrong values.timeoutoutcome, the all-routers-failed 500, single-router and DHT-only spans,untraced, address-book counters,OTEL_SERVICE_NAME/OTEL_RESOURCE_ATTRIBUTESprecedence, and endpoint-only exporter selection.TestCombineRoutersnow expects the DHT-only branch to be wrapped intracedRouter.go test -race ./...,go vet ./...andgofmtare clean.End-to-end check
I ran the built binary against a local
otel/opentelemetry-collector-contribwith a file exporter and read the exported spans.Instance A: as behind Envoy.
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318(HTTP),NEEDLE_TRACING_TRUST_TRACEPARENT=true,NEEDLE_SAMPLING_FRACTION=0.traceparenton/providersjoins that trace, and the request span's parent is the remote span. The trace has 6 spans: the request, cid.contact (17 records, 16 exclusive, first record at 221 ms,exhausted), the DHT (136 records, 121 exclusive,timeoutafter the 25 s routing timeout, with address-book counts) and 3/dnsaddrlookups. Before the DHT detach it had 232.traceparent, and an unsampledtraceparent: nothing exported./peers,/ipnsand/dht/closest/peerswith a sampledtraceparent: request span plusneedle router peers dht,needle router ipns_get dht(found=false) andneedle router closest dht.Instance B: direct.
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317withOTEL_EXPORTER_OTLP_PROTOCOL=grpc,OTEL_SERVICE_NAME=needle-b,NEEDLE_SAMPLING_FRACTION=1,NEEDLE_TRACING_AUTHset.traceparent: sampled as its own root, withneedle.routing.outcome=exhaustedandlast_router=dht.traceparent: a new trace ID, with a link to the sent one.Authorization: joins the sent trace.curl --max-time 1): DHT spanoutcome=client_cancelled, cid.contactexhausted.service.name=needle-b, and distinctservice.instance.ids for the two instances.Rollout
Nothing changes in production until needle is released and ipni/workers sets needle's OTEL environment:
OTEL_EXPORTER_OTLP_ENDPOINTat the local Alloy collector,NEEDLE_TRACING_TRUST_TRACEPARENT=true, and a smallNEEDLE_SAMPLING_FRACTIONfor requests that arrive without Envoy's header. That is a separate ipni/workers change after a release, and this PR does not make it.Not in this PR
manyIter.Nexthas returned false,manyIter.Closereturns early, so on a timeout it neither waits for the routers nor closes the underlying iterators. That is why the request-levelneedle.routing.*attributes can be missing on timeouts. ChangingCloseaffects the streaming contract, so it is worth its own change.🤖 Generated with Claude Code