Using Jaeger with OpenTelemetry in Go: A Step-by-Step Guide

In the world of microservices, monitoring and tracing are essential to understand the interactions between services and diagnose issues effectively. OpenTelemetry and Jaeger are popular tools that help in achieving this. This blog post will guide you through setting up Jaeger with OpenTelemetry in a Go application using Docker Compose, along with example implementations for service-a, service-b, and service-c.

Prerequisites

Before we begin, ensure you have the following installed:

  • Docker

  • Docker Compose

  • Go

Setting Up the Environment

We'll use Docker Compose to set up our environment. The provided docker-compose.yml file includes configurations for three services (service-a, service-b, and service-c), Jaeger, and Elasticsearch (for storing traces).

Here's a breakdown of the docker-compose.yml file:

services:
  service-a:
    build:
      context: .
      dockerfile: Dockerfile
    networks:
      - service-jaeger
    ports:
      - "8081:8081"
    environment:
      - OUTBOUND_B_HOST_PORT=service-b:8082
      - OUTBOUND_C_HOST_PORT=service-c:8083
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger-collector:4317
      - OTEL_EXPORTER_OTLP_INSECURE=true
      - OTEL_TRACES_EXPORTER=jaeger

  service-b:
    build:
      context: .
      dockerfile: Dockerfile
    networks:
      - service-jaeger
    environment:
      - OUTBOUND_C_HOST_PORT=service-c:8083
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger-collector:4317
      - OTEL_EXPORTER_OTLP_INSECURE=true
    depends_on:
      - jaeger-collector

  service-c:
    build:
      context: .
      dockerfile: Dockerfile
    networks:
      - service-jaeger
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger-collector:4317
      - OTEL_EXPORTER_OTLP_INSECURE=true
    depends_on:
      - jaeger-collector

  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:6.3.1
    networks:
      - service-jaeger
    ports:
      - "127.0.0.1:9200:9200"
      - "127.0.0.1:9300:9300"
    restart: on-failure
    environment:
      - cluster.name=jaeger-cluster
      - discovery.type=single-node
      - http.host=0.0.0.0
      - transport.host=127.0.0.1
      - ES_JAVA_OPTS=-Xms512m -Xmx512m
      - xpack.security.enabled=false
    volumes:
      - esdata:/usr/share/elasticsearch/data

  jaeger-collector:
    image: jaegertracing/jaeger-collector:1.57
    ports:
      - "14269:14269"
      - "14268:14268"
      - "14267:14267"
      - "9411:9411"
    networks:
      - service-jaeger
    restart: on-failure
    environment:
      - SPAN_STORAGE_TYPE=elasticsearch
    command: [
      "--es.server-urls=http://elasticsearch:9200",
      "--es.num-shards=1",
      "--es.num-replicas=0",
      "--log-level=error"
    ]
    depends_on:
      - elasticsearch

  jaeger-query:
    image: jaegertracing/jaeger-query:1.57
    environment:
      - SPAN_STORAGE_TYPE=elasticsearch
      - no_proxy=localhost
    ports:
      - "16686:16686"
      - "16687:16687"
    networks:
      - service-jaeger
    restart: on-failure
    command: [
      "--es.server-urls=http://elasticsearch:9200",
      "--span-storage.type=elasticsearch",
      "--log-level=debug",
    ]

volumes:
  esdata:
    driver: local

networks:
  service-jaeger:
    driver: bridge

Setting Up OpenTelemetry in Go

To instrument your Go application with OpenTelemetry, follow these steps:

1. Install Dependencies:

go get go.opentelemetry.io/otel
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
go get go.opentelemetry.io/otel/sdk/trace
go get go.opentelemetry.io/otel/sdk/resource
go get go.opentelemetry.io/otel/semconv/v1.4.0

2. Create service-a.go: Here is the code for service-a:

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"

    "github.com/albertteoh/jaeger-go-example/lib/ping"
    "github.com/albertteoh/jaeger-go-example/lib/tracing"
)

const thisServiceName = "service-a"

func main() {
    ctx := context.Background()
    log.Println("pre-init")
    tracer := tracing.Init(ctx, thisServiceName)
    log.Println("post-init")

    outboundBHostPort, ok := os.LookupEnv("OUTBOUND_B_HOST_PORT")
    if !ok {
        log.Println("using default host and port service-b")
        outboundBHostPort = "localhost:8082"
    }

    outboundCHostPort, ok := os.LookupEnv("OUTBOUND_C_HOST_PORT")
    if !ok {
        log.Println("using default host and port service-c")
        outboundCHostPort = "localhost:8083"
    }

    http.HandleFunc("/ping", func(writer http.ResponseWriter, r *http.Request) {
        log.Printf("handling request: %+v", tracer)
        ctx, span := tracer.Start(r.Context(), "/ping-both")
        defer span.End()

        responseB, err := ping.Ping(ctx, outboundBHostPort, tracer)
        if err != nil {
            log.Fatalf("Error occurred on ping: %s", err)
        }
        if _, err = writer.Write([]byte(fmt.Sprintf("%s -> %s", thisServiceName, responseB))); err != nil {
            log.Fatalf("Error occurred on write: %s", err)
        }

        responseC, err := ping.Ping(ctx, outboundCHostPort, tracer)
        if err != nil {
            log.Fatalf("Error occurred on ping: %s", err)
        }
        if _, err = writer.Write([]byte(fmt.Sprintf("%s -> %s", thisServiceName, responseC))); err != nil {
            log.Fatalf("Error occurred on write: %s", err)
        }
    })

    http.HandleFunc("/ping-next", func(writer http.ResponseWriter, r *http.Request) {
        log.Printf("handling request: %+v", tracer)
        ctx, span := tracer.Start(r.Context(), "/ping-next")
        defer span.End()

        responseB, err := ping.PingNext(ctx, outboundBHostPort, tracer)
        if err != nil {
            log.Fatalf("Error occurred on ping: %s", err)
        }
        if _, err = writer.Write([]byte(fmt.Sprintf("%s -> %s", thisServiceName, responseB))); err != nil {
            log.Fatalf("Error occurred on write: %s", err)
        }
    })
    log.Printf("Listening on localhost:8081")
    log.Fatal(http.ListenAndServe(":8081", nil))
}

3. Create Dockerfile: Use this Dockerfile for service-a, service-b, and service-c:

ARG port=8081
ARG app=service-a

FROM golang as builder
ARG app
WORKDIR /app

COPY go.mod .
COPY go.sum .

RUN go mod download

COPY ${app}/ ${app}
COPY lib/ lib

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/${app} ./${app}

# final stage
FROM public.ecr.aws/docker/library/golang:1.22-alpine
ARG app
ARG port
COPY --from=builder /out/${app} /app/

EXPOSE ${port}
ENTRYPOINT ["/app/service-a"]

4. Build and Run the Services: Ensure you have Docker running and execute the following command in the directory containing your docker-compose.yml file:

docker-compose up --build

Accessing Jaeger UI

After starting the services, you can access the Jaeger UI at http://localhost:16686. Here, you can visualize traces collected from your services.

Conclusion

In this blog post, we covered how to set up Jaeger with OpenTelemetry in a Go application using Docker Compose. By following these steps, you can easily trace and monitor your microservices, making it easier to diagnose and resolve issues.

Previous
Previous

Function Parameters Simplified: Option Structs vs. Variadic Parameters

Next
Next

Understanding the Difference Between Update and Save in GORM