79 lines
3.1 KiB
Markdown
79 lines
3.1 KiB
Markdown
# Context Values & Cross-Service Tracing
|
|
|
|
## Using context values correctly
|
|
|
|
Context values carry request-scoped metadata that crosses API boundaries — not function parameters, configuration, or optional arguments. Good candidates: trace IDs, span IDs, request IDs, authenticated user info, correlation IDs.
|
|
|
|
Always use an unexported type as the key to prevent collisions between packages:
|
|
|
|
```go
|
|
// ✓ Good — unexported key type prevents collisions
|
|
type contextKey string
|
|
|
|
const (
|
|
traceIDKey contextKey = "trace_id"
|
|
requestIDKey contextKey = "request_id"
|
|
)
|
|
|
|
func WithTraceID(ctx context.Context, traceID string) context.Context {
|
|
return context.WithValue(ctx, traceIDKey, traceID)
|
|
}
|
|
|
|
func TraceIDFromContext(ctx context.Context) (string, bool) {
|
|
traceID, ok := ctx.Value(traceIDKey).(string)
|
|
return traceID, ok
|
|
}
|
|
```
|
|
|
|
```go
|
|
// ✗ Bad — string keys collide across packages
|
|
ctx = context.WithValue(ctx, "trace_id", traceID) // another package could use the same key
|
|
```
|
|
|
|
## What belongs in context values vs function parameters
|
|
|
|
| Data | Context value? | Why |
|
|
| --- | --- | --- |
|
|
| trace_id, span_id, request_id | Yes | Request-scoped metadata for observability |
|
|
| Authenticated user/tenant | Yes | Request-scoped, crosses API boundaries |
|
|
| Database connection | No | Infrastructure dependency, pass explicitly |
|
|
| Feature flags | No | Configuration, pass explicitly or inject |
|
|
| Function arguments (user ID, order data) | No | Business logic parameters, pass as arguments |
|
|
| Logger | Depends | OK if enriched with request-scoped fields (trace_id); otherwise pass explicitly |
|
|
|
|
## Trace propagation between services
|
|
|
|
In a microservices architecture, `context.Context` is the vehicle for trace propagation. When Service A calls Service B, the trace_id and span_id travel through context values and are injected into outgoing HTTP headers (typically via OpenTelemetry). This creates a connected trace across the entire request path.
|
|
|
|
```go
|
|
// Middleware injects trace_id from incoming request headers into context
|
|
func TracingMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
traceID := r.Header.Get("X-Trace-ID")
|
|
if traceID == "" {
|
|
traceID = generateTraceID()
|
|
}
|
|
|
|
ctx := WithTraceID(r.Context(), traceID)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// When making outbound HTTP calls, inject trace_id from context into headers
|
|
func (c *HTTPClient) Do(ctx context.Context, method, url string, body io.Reader) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
|
|
// Propagate trace_id to downstream service
|
|
if traceID, ok := TraceIDFromContext(ctx); ok {
|
|
req.Header.Set("X-Trace-ID", traceID)
|
|
}
|
|
|
|
return c.client.Do(req)
|
|
}
|
|
```
|
|
|
|
With OpenTelemetry, this propagation is handled automatically through the `otel` SDK and `propagation.TraceContext`, but the mechanism is the same: context carries the trace state, and it must be propagated through every layer.
|