Part of: Distributed Tracing for Deduplication
Once a request enters a multi-hop call graph — API gateway to order service to payment service — the idempotency key needs to travel with it so every hop’s dedup.check span can be correlated under the same business operation, even though each hop’s traceparent changes at every boundary. This runbook shows how to carry the key in OpenTelemetry baggage across Go and Java services, why the key must never be stuffed into traceparent itself, and how to verify the propagation actually reached the last hop.
Problem statement and prerequisites
What you are implementing: an OTel Baggage entry carrying the idempotency key hash, propagated via HTTP headers across every service hop, read and re-attached as a span attribute at each hop.
Prerequisites:
- You understand the span model for a dedup decision and specifically the distinction between W3C trace context and the idempotency key.
- You have already instrumented the dedup check itself with a span in at least the first service in the chain.
- Your services communicate over HTTP or gRPC with an OTel propagator configured (
TraceContext+Baggagepropagators registered).
Step-by-step implementation
Step 1 — Extract incoming trace context and baggage (Go, API Gateway)
// Go: extract incoming context, including any propagated baggage
package gateway
import (
"net/http"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
)
var propagator = propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
func extractContext(r *http.Request) context.Context {
return propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
}
Step 2 — Inject the idempotency key into baggage, not traceparent
// Go: add the idempotency key hash as a baggage member on the first hop
import (
"context"
"crypto/sha256"
"encoding/hex"
"go.opentelemetry.io/otel/baggage"
)
func withIdempotencyBaggage(ctx context.Context, rawKey string) (context.Context, error) {
sum := sha256.Sum256([]byte(rawKey))
keyHash := hex.EncodeToString(sum[:])[:16]
member, err := baggage.NewMember("idempotency.key_hash", keyHash)
if err != nil {
return ctx, err
}
bag, err := baggage.New(member)
if err != nil {
return ctx, err
}
return baggage.ContextWithBaggage(ctx, bag), nil
}
Note what this deliberately does not do: it does not touch trace_id or span_id. Those remain whatever the OTel SDK generated for this request’s root span — a fresh, random 128-bit value that will be entirely different on the client’s next retry. The idempotency key hash rides alongside trace context as an independent, application-defined piece of state.
Step 3 — Propagate baggage across the next HTTP hop
// Go: outbound call to the order service, injecting both traceparent and baggage headers
func callOrderService(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
req, _ := http.NewRequestWithContext(ctx, "POST", url, nil)
propagator.Inject(ctx, propagation.HeaderCarrier(req.Header))
// req.Header now contains both "traceparent" and "baggage" headers
return client.Do(req)
}
Step 4 — Read baggage in the downstream service and attach it as a span attribute (Java, Payment Service)
// Java: extract baggage on the receiving side and copy it onto the active span
import io.opentelemetry.api.baggage.Baggage;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.propagation.TextMapGetter;
public void handleIncomingRequest(HttpServletRequest request) {
Context extractedContext = openTelemetry.getPropagators().getTextMapPropagator()
.extract(Context.current(), request, HttpServletRequestGetter.INSTANCE);
try (var scope = extractedContext.makeCurrent()) {
String keyHash = Baggage.fromContext(extractedContext).getEntryValue("idempotency.key_hash");
Span currentSpan = Span.current();
if (keyHash != null) {
currentSpan.setAttribute("idempotency.key_hash", keyHash);
}
processPayment(request);
}
}
Step 5 — Re-inject baggage on any further outbound calls
Baggage does not propagate itself past a service that does not explicitly re-inject it on outbound requests — each hop must both extract on the way in and inject on the way out.
// Java: propagate baggage onward if the payment service calls a fraud-check service
import io.opentelemetry.context.propagation.TextMapSetter;
public HttpResponse callFraudCheck(HttpRequest.Builder builder) {
openTelemetry.getPropagators().getTextMapPropagator()
.inject(Context.current(), builder, HttpRequestBuilderSetter.INSTANCE);
return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
The diagram below shows the full three-hop picture: trace_id stays constant across every hop (it is the same trace), span_id changes at each hop (each service creates its own child span), and the idempotency key travels unchanged in baggage — a completely different propagation rule from either trace context field.
Why NOT to overload traceparent
The W3C Trace Context specification defines traceparent as <version>-<trace-id>-<parent-id>-<trace-flags>, where trace-id is a 128-bit value and parent-id a 64-bit value, both required to be generated with effectively random entropy. Three concrete reasons never to derive either from your idempotency key:
- Format incompatibility. Any compliant vendor, collector, or load balancer that parses
traceparentassumes fixed hex-digit lengths. A key that doesn’t fit — or worse, one that does fit but collides with the randomness assumption — will either be rejected or silently corrupt trace sampling decisions made by intermediate systems. - Lifetime mismatch.
trace-idis regenerated on every new root span — every retry gets a new one by design. An idempotency key must survive across retries. Putting the key inside a field that resets on every attempt defeats the entire purpose of carrying it. - No structured place for metadata. Even if you wanted to encode more than one field (key hash plus, say, a tenant ID),
traceparenthas no extensibility —tracestateallows vendor-specific key-value pairs but is explicitly documented as unsuitable for arbitrary application data at volume. Baggage is the mechanism the specification actually provides for this.
Correlating across services once baggage lands
With idempotency.key_hash attached as a span attribute at every hop (Step 4), a query against your tracing backend for idempotency.key_hash=f8d2e1 returns every span in every service that touched this logical operation — not just the spans within one trace_id. This is what lets you answer, “did the fraud-check service actually see this operation, or did the payment service short-circuit before calling it?” without grepping logs across three codebases.
Verification and testing
# Send a request with an idempotency key through the gateway and confirm baggage reaches the last hop
curl -X POST http://localhost:8080/checkout \
-H "Idempotency-Key: 7c1e-order-8841" -d '{"amount":4200}'
# Query the tracing backend for the key_hash attribute across ALL services, not one trace
curl -s 'http://localhost:16686/api/traces?tags=%7B%22idempotency.key_hash%22%3A%22f8d2e1%22%7D' \
| jq '[.data[].processes | to_entries[] | .value.serviceName] | unique'
# Expect: ["api-gateway", "order-service", "payment-service"] — confirms the key reached every hop
# Confirm baggage headers are actually on the wire between hops, not just in-process
tcpdump -A -i lo0 'tcp port 8081' | grep -i "baggage:"
# Expect a line like: baggage: idempotency.key_hash=f8d2e1
# Confirm an intermediate proxy is not stripping the baggage header
curl -v -H "baggage: idempotency.key_hash=test123" http://localhost:8081/internal/echo-headers 2>&1 | grep -i baggage
# If the echoed response is missing the header, the proxy or gateway config needs an explicit allow-list entry
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
An intermediate reverse proxy or API gateway strips the baggage header because it is not in an explicit allow-list of forwarded headers |
Add baggage (and traceparent) to the proxy’s forwarded-header allow-list explicitly — most gateways default to stripping unrecognized headers; verify with a header-echo test at each hop |
idempotency.key_hash present on first-hop spans but absent on downstream-hop spans in the same trace; cross-service correlation query returns fewer services than expected |
| Baggage payload exceeds the SDK’s per-entry or total size limit (commonly 180 bytes per entry, 8192 bytes total) and is silently truncated or dropped | Keep the baggage value to the hashed key only (16 hex characters), never the raw key or additional metadata; if you need more context, add it as span attributes at each hop instead of growing the baggage payload | SDK debug logs showing baggage entry exceeds limit, dropped; baggage_propagation_incomplete_total counter from a validating middleware |
| A retry generates a new idempotency key baggage value because the client-side key generation logic runs per-attempt instead of being cached once per logical operation | Ensure the client (or the outermost gateway acting on the client’s behalf) generates the key once and reuses it verbatim on every retry — see idempotency key generation strategies for stable key derivation | Two independent traces sharing the same operation but showing different idempotency.key_hash values — investigate client-side key caching first |
| Double-encoding: a service framework URL-encodes the baggage header value on inject and the receiving service does not decode it before comparing, so hash values appear to mismatch across hops despite being logically identical | Standardize on the OTel SDK’s built-in Baggage propagator rather than hand-rolling header parsing; add a contract test that round-trips a known baggage value through the full gateway → downstream chain in CI |
key_hash_mismatch_total from a reconciliation job; spans showing percent-encoded characters (%3D) inside what should be a clean hex string |
SRE / observability checklist
- Cross-service key correlation query — a saved query in your tracing backend for
idempotency.key_hash=<value>should return spans from every service in the expected call graph; alert if a known-critical path (e.g. payment → fraud-check) is missing from the result set. baggage_propagation_incomplete_total— counter incremented by middleware whenever an expected baggage key is absent on an inbound request that should have carried it; alert if rate exceeds0.1%of requests.- Header allow-list drift detection — a periodic synthetic request through the full proxy chain with a test baggage value, asserting it survives to the last hop; alert immediately on any proxy config change that breaks this.
traceparentandbaggageboth present on 100% of inter-service calls — validate via a collector-side span processor that inspects incoming request headers (where available) or via synthetic canary requests.- Baggage payload size gauge — track
baggage_entry_bytesper key to catch accidental growth (e.g. someone appending raw JSON instead of a hash) before it hits the SDK’s silent-drop limit. - Trace-id-differs, key-hash-matches dashboard — a panel showing count of distinct
trace_idvalues grouped byidempotency.key_hash, which should be greater than 1 whenever retries occurred, confirming the correlation model described on the parent page is working end to end.
Related
- Distributed Tracing for Deduplication — parent page covering the span model, the W3C trace context vs idempotency key distinction, and sampling strategy
- Instrumenting Idempotency Flows with OpenTelemetry — adding the
dedup.checkspan this runbook’s baggage attribute gets attached to - Idempotency Key Generation Strategies — stable client-side key derivation, a prerequisite for baggage to carry a consistent value across retries
- Mitigating Thundering Herd During Retry Storms — what happens downstream when correlated retries pile up faster than the dedup layer can absorb them
- Monitoring Idempotency Metrics and Dashboards — aggregate dashboards that complement this per-request correlation technique