Skip to main content
This page assumes the sherlock.go and main.go from Setup, in either variant. Each section adds one thing to them. Every snippet below compiles against the pinned versions on that page.

Environment variables

OTEL_METRICS_EXEMPLAR_FILTER has no effect with the settings-in-the-file variant: it sets the filter in code, and code wins over the environment. The environment variant on the Setup page reads it.

Skip health checks

There is no default ignore list. A filtered request produces neither a span nor a data point, so a health checker that hits /healthz every few seconds does not show up anywhere.
Outbound calls made while handling a filtered request are still traced; they just have no parent.

Custom histograms

Record durations in seconds with unit s, and pass Sherlock’s boundaries so the chart matches the HTTP histograms. durationBoundariesS is the list in sherlock.go.
Every recording is a candidate exemplar; the reservoir keeps one per bucket per export. Unsampled requests get exemplars too, with trace_flags 00.
Every attribute you pass to Record becomes a metric label. A job id or a user id there creates one series per value. Put such values on the exemplar only, as shown next.

Exemplar-only attributes

To carry a high-cardinality value on the exemplar but not on the data point, record it as a normal attribute and drop it from the stream with an attribute filter. The SDK moves dropped attributes onto the exemplar. Add the case to sherlockView in sherlock.go; do not add a second view, because two views that match one instrument export it twice.
A typo in the filter key silently turns the value into a metric label. Check the series on the Metrics page after the first export.

Outbound HTTP

Wrap the transport. Each call gets a client span, W3C trace context headers for the callee, and http.client.request.duration with the same 20 boundaries.
Build requests with the incoming request’s context. A request built with context.Background() starts a new trace.

Manual spans

Pass ctx to everything the span covers, including Record calls and log lines, so they attach to it.

http.route on spans

otelhttp names the span after the mux pattern and puts http.route on the metric, but it does not set http.route on the span. If you filter traces by route, add this middleware inside otelhttp.NewHandler and outside the mux. It runs after routing, while the span is still open.
semconv is go.opentelemetry.io/otel/semconv/v1.37.0.

Sampling

The default samples every trace. To sample a fraction, set the sampler in the environment:
or in code, which overrides the environment:
ParentBased follows the caller’s decision when a request arrives with trace context, so a trace is sampled whole across services. Metrics count every request regardless. Unsampled requests still produce exemplars, marked trace_flags 00; those exemplars have no trace to open.

Kill switches

Go has no built-in way to turn a signal off at runtime. Two small wrappers give you one. Both keep a flag you set from wherever you like, an admin endpoint or a config watcher. Traces. A sampler wrapper. Wrapped in ParentBased, it stops new traces only; requests already being traced finish whole.
Metrics. An exporter wrapper that drops whole batches. With delta temporality a disabled window is a gap, never a double count.
metricdata is go.opentelemetry.io/otel/sdk/metric/metricdata.

Logs with trace ids

Neither setup sends logs on its own. Two ways get log lines next to their trace in Sherlock. Both need context-aware calls: slog.InfoContext(r.Context(), "work done") gets the ids, slog.Info("work done") does not.

Send logs over OTLP

Four more modules, all on the pre-stable logs line of the SDK:
Add this to Setup in sherlock.go, after the meter provider. It uses the same endpoint, headers, and res. The otelslog handler turns every slog record into an OTLP log record, and the SDK stamps trace_id, span_id, and trace_flags from the context.
Then flush and stop it with the others in the returned function:
sdklog is go.opentelemetry.io/otel/sdk/log. The attributes of a slog call become the log’s attributes as they are, so an error logged as slog.ErrorContext(ctx, "boom", "err", err) shows up on the Errors page when the source’s error rule reads LogAttributes['err']. Lines written through the standard log package after slog.SetDefault are exported too, without trace ids. To keep JSON on stdout as well, fan the record out to two handlers. Handle may modify the record, so give each handler its own r.Clone(), and forward WithAttrs and WithGroup so logger.With(...) keeps the fan-out.

Stamp the ids and ship with a collector

If a collector already reads your stdout or files, keep logging JSON and add the ids yourself. This slog.Handler wrapper needs only the trace package. The WithAttrs and WithGroup methods matter: without them the wrapper is lost on the first logger.With(...).
Send the lines with an OpenTelemetry Collector, as on OpenTelemetry SDKs and collectors. The trace_id field is the same lowercase hex Sherlock stores for the span, so the Logs page links each line to its trace.

Histogram boundaries

The settings-in-the-file variant of sherlock.go gives the two HTTP duration histograms Sherlock’s 20 boundaries in seconds. The environment variant keeps otelhttp’s 14, which skip the steps between 100 and 500 ms, so p95 is coarser there. To add the 20 to the environment variant, replace its traceFlagsOnExemplars with this version, which is the other variant’s sherlockView under another name:

Runtime metrics

The settings-in-the-file variant calls runtime.Start, which gives seven go.* metrics. With the environment variant, add go.opentelemetry.io/contrib/instrumentation/runtime@v0.71.0 to the modules and start it after the meter provider is set:
Scheduler latency, go.schedule.duration, needs a producer on the reader in either variant:

Baggage

The environment variant propagates trace context only. To forward baggage as well to the services you call:

Shutdown

The order in the main.go from Setup matters: stop the HTTP server first, so in-flight requests finish and record their spans and durations, then call the function Setup returned. It flushes both providers and shuts them down under the deadline you pass. Both exports land, including the last one.

Endpoint on a private network

To send through a proxy that needs a different Host header, WithHeaders does not work: Go’s HTTP client takes the authority from the request, not the header map. Give the exporter a client whose transport sets req.Host.
WithHTTPClient replaces the exporter’s own client, so WithTimeout, WithProxy, and WithTLSClientConfig no longer apply. Set them on your client.

Routers and nested muxes

The tested path is net/http with the Go 1.22 ServeMux, where otelhttp.NewHandler reads the route from the mux pattern. Muxes mounted with http.StripPrefix report the mount pattern, not the inner route. StripPrefix hands the inner mux a copy of the request, so the inner pattern never reaches the request otelhttp holds. A service with mux.Handle("/api/v1/auth/", http.StripPrefix("/api/v1/auth", authMux)) gets one span name and one http.route, POST /api/v1/auth/, for every route inside authMux. To keep the inner routes apart, register full patterns on one mux, or mount the inner mux without StripPrefix and register its patterns with the prefix. Other routers need their own contrib middleware to get a route: otelgin for Gin, otelecho for Echo, otelmux for gorilla/mux, and otelchi for chi. sherlockView applies to any histogram named http.server.request.duration, whichever middleware produces it. Check what your middleware emits on the Metrics page after the first export; span-only middlewares leave you without the request duration histogram.

Differences from Node.js

Exemplar counts, ignore lists, kill switches, and other traps.

Metrics

Chart a histogram, group by labels, click an exemplar.

Logs

Search log lines and open the trace behind one.

OpenTelemetry SDKs and collectors

Ship logs from files or stdout with a collector.