Operations 20 min read

Mastering End-to-End Tracing in Go Microservices with OpenTracing and Zipkin

This article walks through the complete design and implementation of full‑stack distributed tracing for Go‑based microservices, explaining correlation IDs, OpenTracing concepts, component roles, client and server code, database and service call tracing, compatibility issues, and best‑practice design guidelines.

Architect
Architect
Architect
Mastering End-to-End Tracing in Go Microservices with OpenTracing and Zipkin

OpenTracing Overview

OpenTracing provides a vendor‑agnostic API for distributed tracing. A unique CorrelationID (trace ID) is generated at the start of a request and propagated across service boundaries, allowing the tracing back‑end (e.g., Zipkin or Jaeger) to be swapped without code changes.

Choosing a Tracing Library

Both Zipkin and Jaeger implement the OpenTracing API, so applications can depend on OpenTracing to abstract away the concrete library and avoid lock‑in when a different implementation becomes preferable.

Zipkin Architecture

Recorder – stores raw trace data.

Reporter (collecting agent) – forwards data to the UI.

Tracer – creates spans and trace IDs.

UI – visualises traces.

Zipkin component diagram
Zipkin component diagram

Client‑side Tracing Example (Go)

The following code creates a global OpenTracing tracer backed by Zipkin, configures an HTTP collector, and builds a gRPC client that automatically injects the trace context into outgoing calls.

const (
    endpoint_url = "http://localhost:9411/api/v1/spans"
    host_url = "localhost:5051"
    service_name_cache_client = "cache service client"
    service_name_call_get = "callGet"
)

func newTracer() (opentracing.Tracer, zipkintracer.Collector, error) {
    collector, err := openzipkin.NewHTTPCollector(endpoint_url)
    if err != nil { return nil, nil, err }
    recorder := openzipkin.NewRecorder(collector, true, host_url, service_name_cache_client)
    tracer, err := openzipkin.NewTracer(recorder, openzipkin.ClientServerSameSpan(true))
    if err != nil { return nil, nil, err }
    opentracing.SetGlobalTracer(tracer)
    return tracer, collector, nil
}

key := "123"
tracer, collector, err := newTracer()
if err != nil { panic(err) }
defer collector.Close()
conn, err := grpc.Dial(host_url, grpc.WithInsecure(), grpc.WithUnaryInterceptor(otgrpc.OpenTracingClientInterceptor(tracer, otgrpc.LogPayloads())))
if err != nil { panic(err) }
defer conn.Close()
client := pb.NewCacheServiceClient(conn)
value, err := callGet(key, client)

Server‑side Tracing Example (Go)

The server creates the same tracer, registers an OpenTracingServerInterceptor, and starts a gRPC server. The interceptor extracts the incoming trace context and creates a child span automatically.

listener, err := net.Listen("tcp", host_url)
if err != nil { panic(err) }
tracer, err := newTracer()
if err != nil { panic(err) }
opts := []grpc.ServerOption{grpc.UnaryInterceptor(otgrpc.OpenTracingServerInterceptor(tracer, otgrpc.LogPayloads()))}
srv := grpc.NewServer(opts...)
cs := initCache()
pb.RegisterCacheServiceServer(srv, cs)
if err = srv.Serve(listener); err != nil { panic(err) } else { fmt.Println("server listening on port 5051") }

Trace and Span Model

A trace represents the entire request flow and is identified by a traceID. A trace consists of multiple spans , each covering a single operation and identified by a spanID. Spans form a directed acyclic graph (DAG) with parent‑child relationships as defined in the OpenTracing Semantic Specification (https://opentracing.io/specification/).

Database Tracing

To trace database calls, use a driver that wraps the standard database/sql interface and starts a child span before each query. Open‑source wrappers include instrumentedsql (https://github.com/ExpansiveWorlds/instrumentedsql), luna‑duclos/instrumentedsql, and ocsql (https://github.com/opencensus-integrations/ocsql/blob/master/driver.go).

Example of adding a child span inside a gRPC service method:

func (c *CacheService) Get(ctx context.Context, req *pb.GetReq) (*pb.GetResp, error) {
    if parent := opentracing.SpanFromContext(ctx); parent != nil {
        pctx := parent.Context()
        if tracer := opentracing.GlobalTracer(); tracer != nil {
            mysqlSpan := tracer.StartSpan("db query user", opentracing.ChildOf(pctx))
            defer mysqlSpan.Finish()
            // simulate DB work
            time.Sleep(10 * time.Millisecond)
        }
    }
    key := req.GetKey()
    value := c.storage[key]
    return &pb.GetResp{Value: value}, nil
}

Cross‑process Tracing

Trace context is propagated via HTTP headers (the “carrier”). The OpenTracingClientInterceptor extracts the current span from the Go context, injects its context into outgoing gRPC metadata, and the server interceptor extracts it back, creating a child span automatically. For pure HTTP services, developers must call Inject and Extract manually or use a compatible interceptor.

Key interceptor code (excerpt from otgrpc/client.go):

func OpenTracingClientInterceptor(tracer opentracing.Tracer, optFuncs ...Option) grpc.UnaryClientInterceptor {
    // ...
    ctx = injectSpanContext(ctx, tracer, clientSpan)
    // ...
}

func injectSpanContext(ctx context.Context, tracer opentracing.Tracer, clientSpan opentracing.Span) context.Context {
    md, ok := metadata.FromOutgoingContext(ctx)
    if !ok { md = metadata.New(nil) } else { md = md.Copy() }
    mdWriter := metadataReaderWriter{md}
    err := tracer.Inject(clientSpan.Context(), opentracing.HTTPHeaders, mdWriter)
    if err != nil { clientSpan.LogFields(log.String("event", "Tracer.Inject() failed"), log.Error(err)) }
    return metadata.NewOutgoingContext(ctx, md)
}

Compatibility Between Tracing Implementations

OpenTracing standardises the API but not the wire format of the trace context. Zipkin uses the X‑B3‑TraceId header, while Jaeger uses uber‑trace‑id. Consequently, a Zipkin‑based service and a Jaeger‑based service cannot interoperate out of the box. Jaeger offers a Zipkin‑compatibility mode that emits B3 headers, enabling cross‑library tracing (https://github.com/jaegertracing/jaeger-client-go/tree/master/zipkin).

Design Recommendations

Cross‑process tracing (service‑to‑service calls) – implement a small interceptor.

Database tracing – use a wrapper driver that automatically creates spans.

In‑process tracing – manually create and finish spans inside functions; this is the most labour‑intensive level.

Service meshes such as Istio or Linkerd can automate cross‑process tracing but still rely on the application to expose trace context for database and in‑process spans.

Trace‑ID Logging

OpenTracing does not expose the trace ID directly. Developers can cast the SpanContext to the concrete implementation (Zipkin or Jaeger) or convert it to a string and parse the ID. A future OpenTracing release will provide a native method for retrieving the trace ID (see https://github.com/opentracing/specification/blob/master/rfc/trace_identifiers.md).

OpenTracing vs. OpenCensus

OpenCensus is a collection of libraries that integrate with various tracing back‑ends, not a generic API like OpenTracing. The two projects are not directly compatible, but they are being merged into a single observability framework (https://medium.com/opentracing/merging-opentracing-and-opencensus-f0fe9c7ca6f0).

Conclusion

Full‑stack tracing in Go microservices consists of generating a correlation ID, propagating it across gRPC or HTTP boundaries, instrumenting database calls with wrapper drivers, and optionally adding manual spans for critical internal functions. Understanding the differences in trace‑context formats between Zipkin and Jaeger is essential for building interoperable observability solutions.

Source Code

Complete example repository: https://github.com/jfeng45/grpcservice

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

MicroservicesObservabilityGoOpenTracingDistributed Tracingtracingzipkin
Architect
Written by

Architect

Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.