TIL: OpenTelemetry has a separate 'Baggage' concept
I’ve been setting span attributes for everything. Tenant ID, user ID, feature flags, A/B variants — all going on the active span as attributes. Today I learned this isn’t quite right.
Span attributes are metadata on a specific span. They don’t propagate to child spans automatically. Each service sets its own.
Baggage is key-value data that propagates across service boundaries automatically, carried in the baggage HTTP header alongside traceparent.
The distinction matters for things like “what tenant is this request for?” If I set it as a span attribute in the gateway, it’s on the gateway’s span but not on the downstream service’s span. If I set it as baggage, every service downstream can read it and tag its own spans with it.
import "go.opentelemetry.io/otel/baggage"
// in the gateway, set the baggage
m, _ := baggage.NewMember("tenant.id", tenantID)
bag, _ := baggage.New(m)
ctx = baggage.ContextWithBaggage(ctx, bag)
// in a downstream service, read it
bag := baggage.FromContext(ctx)
tenantID := bag.Member("tenant.id").Value()
Caveat: baggage is sent with every request. Don’t stuff large values in there. And some infrastructure (proxies, API gateways) strips unknown headers by default — check that your baggage header is preserved end-to-end before relying on it.
For production: use baggage for a small, bounded set of cross-cutting concerns (tenant, feature flag set, env). Use span attributes for per-service detail. Don’t double up.