The question metrics cannot answer
Your p99 checkout latency went from 300ms to 2.4s. Metrics tell you that, and they tell you which service's own latency rose. What they cannot tell you is the shape of a single slow request: that it spent 40ms in the API gateway, 30ms in auth, then 2.1 seconds waiting on a connection to the inventory service because a connection pool of 10 was serving 400 concurrent requests.
That is what tracing is for. A trace is the complete causal record of one request as it moves through your system, assembled from spans emitted independently by each service.
| Concept | Meaning |
|---|---|
| Trace | One request end to end, identified by a 16-byte trace ID |
| Span | One unit of work within the trace: an HTTP handler, a DB query, a queue publish |
| Parent span ID | The span that caused this one, which is what builds the tree |
| Span context | trace ID + span ID + flags, propagated across process boundaries in headers |
| Attributes | Key-value pairs on a span. High cardinality is fine and expected here |
| Events | Timestamped points within a span — an exception, a retry, a cache miss |
| Links | References to other traces — how you connect a producer to a batched consumer |
OpenTelemetry is the part worth committing to
Vendor-specific tracing agents were a lock-in trap for a decade. OpenTelemetry is the CNCF standard that ends it: one set of APIs and SDKs, one wire protocol (OTLP), and a collector that can fan out to any backend. Instrument once, change backends by editing a config file.
Tracer provider setup
// Go: minimal but complete OTel setup
package main
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func initTracing(ctx context.Context) (func(context.Context) error, error) {
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("alloy:4317"),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
// Resource attributes identify the SERVICE and are attached to
// every span. service.name is mandatory - without it your spans
// land in a bucket called "unknown_service".
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("checkout-api"),
semconv.ServiceVersion(buildVersion),
semconv.DeploymentEnvironment("production"),
),
resource.WithHost(),
resource.WithProcessRuntimeDescription(),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
// Head sampling decision - see the sampling section below
sdktrace.WithSampler(
sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1)),
),
)
otel.SetTracerProvider(tp)
// Without this, context does not cross process boundaries and
// you get thousands of one-span traces instead of one real trace.
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return tp.Shutdown, nil
}
Setting the propagator is the step people forget. Without it each service starts a fresh trace, and you get a flood of single-span traces that look like tracing is working while telling you nothing.
Instrumenting: auto first, manual where it matters
Auto-instrumentation libraries cover HTTP servers and clients, gRPC, SQL drivers, and most message brokers. Start there — it gets you the service map and the call-graph latency breakdown for almost no effort. Add manual spans only around business logic that auto-instrumentation cannot see.
Auto plus manual
// Auto-instrumentation covers the transport layer
import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
handler := otelhttp.NewHandler(mux, "checkout-api")
client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
// Manual spans for the interesting internal work
func (s *Service) ApplyPromotion(ctx context.Context, order *Order) error {
ctx, span := s.tracer.Start(ctx, "ApplyPromotion",
trace.WithAttributes(
// High cardinality is FINE here - this is the whole point
attribute.String("order.id", order.ID),
attribute.String("user.id", order.UserID),
attribute.String("promo.code", order.PromoCode),
attribute.Int("order.item_count", len(order.Items)),
attribute.Float64("order.total", order.Total),
))
defer span.End()
rules, err := s.rules.Load(ctx, order.PromoCode)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to load promotion rules")
return err
}
span.AddEvent("rules loaded", trace.WithAttributes(
attribute.Int("rules.count", len(rules)),
))
// ctx carries the span - passing it downstream is what builds the tree
return s.apply(ctx, order, rules)
}
Attributes on spans are cheap in exactly the way metric labels are expensive. Order IDs, user IDs, SQL statements, cache keys, feature flag values — all of it belongs here. This is where the high-cardinality data you were told to keep out of metrics in Part 7 actually goes.
Follow the semantic conventions
OpenTelemetry defines standard attribute names: http.request.method,
server.address, db.system, messaging.destination.name. Using them
rather than inventing your own is what makes generic dashboards, automatic service maps, and vendor
tooling work without per-service configuration.
Sampling: the decision that determines your bill
Tracing every request at scale is usually unaffordable and rarely useful, because 99% of traces describe requests that worked fine. Sampling is unavoidable; the question is when the decision is made.
| Approach | When decided | Pros | Cons |
|---|---|---|---|
| Head sampling | At the root, before the request runs | Trivial, cheap, no buffering, consistent across services | Decides before knowing if the request was interesting; drops most errors and slow requests |
| Tail sampling | After the trace completes, in the collector | Keeps all errors and slow traces; sample only the boring ones | Collector must buffer whole traces; needs memory and careful routing |
| Probabilistic + rules | Head, with overrides | Cheap with a safety valve for known-important paths | Still blind to unexpected failures |
The configuration most teams settle on is a low head-sampling rate combined with tail sampling in the collector that keeps 100% of errors and slow traces plus a small random sample of successes.
otel-collector-config.yaml
# OpenTelemetry Collector - tail sampling
processors:
tail_sampling:
decision_wait: 10s # how long to buffer a trace before deciding
num_traces: 100000 # in-memory trace buffer
expected_new_traces_per_sec: 2000
policies:
# Always keep anything that failed
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
# Always keep anything slow
- name: slow
type: latency
latency:
threshold_ms: 1000
# Always keep traces for a service under investigation
- name: under-investigation
type: string_attribute
string_attribute:
key: service.name
values: [checkout-api, payments-api]
# Keep 1% of everything else, for baseline comparison
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 1
Tail sampling requires all spans of a trace to reach the same collector instance. With multiple collector replicas you need a two-tier setup: a load-balancing exporter that routes by trace ID into a second tier that does the sampling. Getting this wrong produces silently truncated traces.
Span metrics: percentiles for free from your traces
The collector's span metrics connector generates RED metrics from span data. You get consistent request rate, error rate, and latency histograms for every instrumented service without touching application code — especially valuable for services whose owners never got around to adding Prometheus metrics.
Span metrics connector
connectors:
spanmetrics:
histogram:
explicit:
buckets: [5ms, 10ms, 25ms, 50ms, 100ms, 200ms, 400ms, 800ms, 1500ms, 3s, 5s, 10s]
dimensions:
# Keep these BOUNDED - these become Prometheus labels,
# and Part 7 applies in full force here.
- name: http.request.method
- name: http.response.status_code
- name: server.address
exemplars:
enabled: true # links the histogram back to real traces
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, batch]
exporters: [otlp/tempo, spanmetrics]
metrics/spanmetrics:
receivers: [spanmetrics]
exporters: [prometheusremotewrite]
Do not put unbounded span attributes in dimensions. Span attributes are cheap; the Prometheus metrics generated from them are not. This connector is the most common way teams accidentally create a cardinality explosion after reading Part 7 and thinking they were safe.
Storing traces in Tempo
Tempo makes the same bet as Loki: no index beyond trace ID and a small set of searchable fields, everything in object storage. Retrieval by trace ID is very cheap, which pairs perfectly with exemplars and derived log fields — the two ways you will actually arrive at a trace in practice.
tempo.yaml
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
max_block_duration: 5m
compactor:
compaction:
block_retention: 336h # 14 days
metrics_generator:
registry:
external_labels:
source: tempo
storage:
path: /var/tempo/generator/wal
remote_write:
- url: http://mimir:9009/api/v1/push
send_exemplars: true
traces_storage:
path: /var/tempo/generator/traces
processor:
service_graphs:
dimensions: [http.method]
span_metrics:
dimensions: [http.method, http.status_code]
storage:
trace:
backend: s3
s3:
endpoint: minio:9000
bucket: tempo
access_key: ${S3_ACCESS_KEY}
secret_key: ${S3_SECRET_KEY}
insecure: true
wal:
path: /var/tempo/wal
overrides:
defaults:
metrics_generator:
processors: [service-graphs, span-metrics, local-blocks]
Tempo's metrics generator produces service graphs directly from trace data, giving you an automatic, always-current dependency map. That map is worth the setup cost on its own — it is invariably more accurate than the architecture diagram in the wiki.
TraceQL: querying traces
TraceQL
# All traces touching a service
{ resource.service.name = "checkout-api" }
# Slow traces only
{ resource.service.name = "checkout-api" && duration > 2s }
# Errors on a specific route
{ resource.service.name = "checkout-api"
&& span.http.route = "/api/v1/checkout"
&& status = error }
# Structural: traces where checkout called inventory AND that call was slow
{ resource.service.name = "checkout-api" }
>> { resource.service.name = "inventory-api" && duration > 500ms }
# Aggregate across matching spans
{ resource.service.name = "checkout-api" } | avg(duration) > 1s
# Find a specific customer's failed orders - the high-cardinality
# question that would be impossible as a metric
{ span.order.id = "ord_8f3a91c2" }
Hands-on: labs/09-tracing in the companion repo runs a four-service demo app with injectable latency and errors, full OTel instrumentation, tail sampling, span metrics, and exemplar links from Grafana panels straight into Tempo.
Key takeaways
- Metrics say something got slower; traces say where the time went.
- Commit to OpenTelemetry. Instrument once, swap backends in config.
- Set the propagator, or you get thousands of single-span traces that look like working tracing.
- Auto-instrument the transport layer first; add manual spans around business logic.
- Span attributes are the correct home for high-cardinality data. Use them freely.
- Head sampling is cheap and blind. Tail sampling keeps every error and slow trace at the cost of collector memory.
- The span metrics connector gives RED metrics for free - but its dimensions become Prometheus labels, so keep them bounded.
- Tempo indexes almost nothing, which is fine because you arrive at traces via exemplars and log links, not search.
Quick answers
What sampling rate should I start with?
Start at 100% in development and on low-traffic services, since the volume is trivial and complete traces are worth far more while you are learning. In production, a common configuration is 1 to 10% head sampling plus tail sampling that retains 100% of errors and traces slower than your SLO threshold.
Why do my traces show only one service?
Context is not propagating. Check that you set a text map propagator using W3C traceparent, that your HTTP clients are instrumented and not just your servers, and that no proxy or load balancer is stripping the traceparent header. Async boundaries such as queues need the context injected into the message explicitly.
Do I still need metrics if I have traces?
Yes. Traces are sampled, expensive to retain, and unsuitable for alerting because you cannot compute a reliable rate from a sample. Metrics tell you something is wrong and are what you page on; traces tell you why. The span metrics connector narrows the instrumentation effort but does not remove the need for both.
Where should high-cardinality data live - spans or logs?
Spans, when it describes the request being traced, because you get it in the context of the full call tree. Logs remain valuable for events outside a request scope such as startup, background jobs, and scheduled tasks. Include the trace ID in logs so the two link together.