Golang context propagation stands at the center of effective distributed tracing and observability. When I first encountered complex microservice architectures, tracking requests across service boundaries seemed nearly impossible. However, Go’s context package transformed this challenge into a manageable task. Let me share practical patterns that have proven invaluable in production environments.
Understanding Context in Go
The Go context package provides a clean way to carry request-scoped values, cancellation signals, and deadlines across API boundaries. For distributed tracing, context serves as the carrier for trace information.
import (
"context"
"time"
)
func processingWithTimeout() {
// Create a context that cancels after 100ms
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() // Always call cancel to release resources
// Use the context for operation that should respect the deadline
result, err := performOperation(ctx)
}
The context flows through your application, carrying critical metadata that connects disparate operations into coherent traces.
Pattern 1: Trace Context Injection
Trace context injection forms the foundation of distributed tracing. This pattern ensures that trace information persists across service boundaries.
func handleRequest(w http.ResponseWriter, r *http.Request) {
// Extract trace context from incoming request
ctx := r.Context()
// Create a new span for this handler
ctx, span := tracer.Start(ctx, "handle-request")
defer span.End()
// Add attributes to the span
span.SetAttributes(attribute.String("http.method", r.Method))
// Call downstream services with the context
data, err := fetchDataFromService(ctx, "service-id")
if err != nil {
span.RecordError(err)
http.Error(w, "Internal error", 500)
return
}
w.Write(data)
}
This pattern ensures each service contributes to the same distributed trace, creating a comprehensive view of request execution.
Pattern 2: HTTP Header Propagation
HTTP header propagation maintains trace context across network boundaries. This pattern ensures trace continuity between separate services.
func callDownstreamService(ctx context.Context, url string) ([]byte, error) {
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
// Inject trace context into HTTP headers
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
// Make the HTTP request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return ioutil.ReadAll(resp.Body)
}
func handleIncomingRequest(w http.ResponseWriter, r *http.Request) {
// Extract trace context from incoming headers
ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header))
// Create a span in the existing trace
ctx, span := tracer.Start(ctx, "handle-api-request")
defer span.End()
// Process request with the traced context
// ...
}
When implemented correctly, this pattern creates seamless traces that flow naturally across service boundaries.
Pattern 3: Database Query Tracing
Database operations often consume significant time in request processing. Tracing these operations provides valuable performance insights.
func queryUserData(ctx context.Context, userID string) (*User, error) {
// Create child span for database operation
ctx, span := tracer.Start(ctx, "db.query.user")
defer span.End()
// Add relevant attributes
span.SetAttributes(
attribute.String("db.system", "postgresql"),
attribute.String("db.operation", "select"),
attribute.String("db.user_id", userID),
)
// Execute database query with traced context
var user User
err := db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", userID).Scan(
&user.ID, &user.Name, &user.Email,
)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "database query failed")
return nil, err
}
return &user, nil
}
This pattern reveals how database interactions affect overall request performance, quickly identifying slow queries.
Pattern 4: Middleware Automation
Middleware automation reduces boilerplate code when handling traced HTTP requests. This pattern centralizes trace context extraction and span creation.
func TracingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract context from request headers
ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header))
// Create a span for this request
path := r.URL.Path
ctx, span := tracer.Start(
ctx,
fmt.Sprintf("HTTP %s %s", r.Method, path),
trace.WithSpanKind(trace.SpanKindServer),
)
defer span.End()
// Add HTTP request details to span
span.SetAttributes(
attribute.String("http.method", r.Method),
attribute.String("http.path", path),
attribute.String("http.user_agent", r.UserAgent()),
attribute.String("http.remote_addr", r.RemoteAddr),
)
// Create HTTP response writer wrapper to capture status code
wrappedWriter := middleware.NewResponseWriter(w)
// Execute handler with traced context
next.ServeHTTP(wrappedWriter, r.WithContext(ctx))
// Record response details
span.SetAttributes(
attribute.Int("http.status_code", wrappedWriter.StatusCode()),
attribute.Int("http.response_size", wrappedWriter.ResponseSize()),
)
})
}
// Usage:
func main() {
router := http.NewServeMux()
router.HandleFunc("/api/users", handleUsers)
// Wrap with tracing middleware
tracedRouter := TracingMiddleware(router)
http.ListenAndServe(":8080", tracedRouter)
}
This pattern ensures consistent tracing across all endpoints with minimal code repetition.
Pattern 5: Asynchronous Work Tracking
Asynchronous operations present unique challenges for tracing. This pattern maintains trace context across goroutines.
func processOrder(ctx context.Context, orderID string) error {
ctx, span := tracer.Start(ctx, "process-order")
defer span.End()
// Start background task for order processing
orderCtx := ctx // Preserve trace context
// Create result channel
resultCh := make(chan error, 1)
go func() {
// Create child span for async work
asyncCtx, asyncSpan := tracer.Start(orderCtx, "async-order-processing")
defer asyncSpan.End()
// Simulate async processing
err := performLongRunningTask(asyncCtx, orderID)
// Record result and send to channel
if err != nil {
asyncSpan.RecordError(err)
asyncSpan.SetStatus(codes.Error, err.Error())
}
resultCh <- err
}()
// Continue with other processing
err := updateOrderStatus(ctx, orderID, "processing")
if err != nil {
span.RecordError(err)
return err
}
// Wait for async result or timeout
select {
case err := <-resultCh:
return err
case <-time.After(5 * time.Second):
return fmt.Errorf("order processing timeout")
}
}
This pattern ensures asynchronous work appears correctly in traces, even when operations complete after the initial request returns.
Pattern 6: Baggage Propagation
Baggage propagation carries application-specific values across service boundaries. This adds valuable context to traces.
func processWithBaggage(ctx context.Context) {
// Create context with baggage values
ctx = baggage.ContextWithValues(ctx,
attribute.String("user.id", "12345"),
attribute.String("tenant.id", "acme-corp"),
)
// Create span that will inherit baggage values
ctx, span := tracer.Start(ctx, "process-request")
defer span.End()
// Access baggage values in downstream code
userID := baggage.Value(ctx, "user.id")
span.SetAttributes(attribute.String("applied.user.id", userID))
// Call downstream service that will receive baggage automatically
callService(ctx)
}
func callService(ctx context.Context) {
// Create child span
_, span := tracer.Start(ctx, "downstream-operation")
defer span.End()
// Access baggage values
tenantID := baggage.Value(ctx, "tenant.id")
span.SetAttributes(attribute.String("tenant.id", tenantID))
// Use tenant ID for business logic
data := fetchTenantData(tenantID)
// Process data...
}
This pattern enriches traces with business context, making them more meaningful for debugging and analysis.
Pattern 7: Error Context Enrichment
Error context enrichment adds detailed failure information to traces. This simplifies troubleshooting in distributed systems.
func processDocumentWithErrorContext(ctx context.Context, docID string) error {
ctx, span := tracer.Start(ctx, "process-document")
defer span.End()
// Add initial context to span
span.SetAttributes(attribute.String("document.id", docID))
// Attempt to fetch document
doc, err := fetchDocument(ctx, docID)
if err != nil {
// Enrich error with context
detailedErr := fmt.Errorf("document fetch failed: %w", err)
// Record error with context
span.RecordError(err, trace.WithAttributes(
attribute.String("error.type", "document_fetch_error"),
attribute.String("document.source", "primary_storage"),
))
span.SetStatus(codes.Error, detailedErr.Error())
return detailedErr
}
// Process document
err = validateDocument(ctx, doc)
if err != nil {
// Record validation errors with detailed context
span.RecordError(err, trace.WithAttributes(
attribute.String("error.type", "validation_error"),
attribute.String("document.format", doc.Format),
attribute.Int("document.size", doc.Size),
))
span.SetStatus(codes.Error, "document validation failed")
return fmt.Errorf("document validation failed: %w", err)
}
return nil
}
This pattern provides rich error context that dramatically reduces mean time to resolution in production outages.
Pattern 8: Custom Context Propagators
Sometimes standard propagation mechanisms aren’t sufficient. Custom propagators handle specialized context needs.
// Define custom propagator
type CustomPropagator struct{}
func (p CustomPropagator) Inject(ctx context.Context, carrier propagation.TextMapCarrier) {
// Inject standard trace context
otel.GetTextMapPropagator().Inject(ctx, carrier)
// Add custom values
if span := trace.SpanFromContext(ctx); span.IsRecording() {
carrier.Set("x-custom-request-priority", getPriorityFromContext(ctx))
carrier.Set("x-custom-request-source", getSourceFromContext(ctx))
}
}
func (p CustomPropagator) Extract(ctx context.Context, carrier propagation.TextMapCarrier) context.Context {
// Extract standard context first
ctx = otel.GetTextMapPropagator().Extract(ctx, carrier)
// Extract custom values
if priority := carrier.Get("x-custom-request-priority"); priority != "" {
ctx = context.WithValue(ctx, priorityKey, priority)
}
if source := carrier.Get("x-custom-request-source"); source != "" {
ctx = context.WithValue(ctx, sourceKey, source)
}
return ctx
}
func (p CustomPropagator) Fields() []string {
// Return the keys this propagator manages
fields := otel.GetTextMapPropagator().Fields()
return append(fields, "x-custom-request-priority", "x-custom-request-source")
}
// Install custom propagator
func initTracing() {
// Initialize provider, etc.
// ...
// Set global propagator with custom implementation
otel.SetTextMapPropagator(CustomPropagator{})
}
This pattern extends standard propagation with domain-specific context that enhances traceability in specialized environments.
Real-World Implementation Considerations
When implementing these patterns, several practical considerations emerge. First, consider performance implications. Tracing adds overhead, particularly when collecting high-cardinality attributes. Be selective about what you trace.
Second, consider security implications. Trace data may contain sensitive information. Implement proper filtering to avoid leaking secrets or personally identifiable information.
Third, ensure proper trace sampling. In high-volume systems, sampling reduces overhead while maintaining observability. Implement head-based or tail-based sampling based on your requirements.
Finally, consider readability. Well-structured traces tell a story. Use consistent naming conventions for spans and organize them hierarchically to create readable, intuitive traces.
Conclusion
Go’s context propagation capabilities provide powerful mechanisms for implementing distributed tracing. The patterns described create a foundation for comprehensive observability across microservice architectures.
I’ve used these patterns to solve complex debugging challenges that would have been nearly impossible with traditional logging alone. The ability to follow requests across service boundaries, correlate errors, and understand performance bottlenecks has transformed how I approach distributed systems debugging.
By implementing these patterns, you create a powerful observability foundation that grows with your system. Start with basic trace context propagation and gradually add more sophisticated patterns as your needs evolve. The investment pays off through reduced debugging time and improved system understanding.