Observabilidade é a capacidade de entender o estado interno de um sistema a partir de seus outputs externos. Em sistemas distribuídos modernos, onde múltiplos serviços interagem, a observabilidade se torna essencial para diagnosticar problemas, otimizar performance e garantir confiabilidade. Nesta aula, vamos explorar os três pilares da observabilidade — métricas, tracing distribuído e logs — e como o OpenTelemetry unifica a coleta desses sinais em aplicações Go.

Vamos começar com uma visão geral de cada pilar, depois mergulhar em exemplos práticos de instrumentação em Go, e finalizar com uma introdução ao OpenTelemetry, o padrão atual para observabilidade.

Métricas

Métricas são medições numéricas coletadas ao longo do tempo, como contadores, gauges e histogramas. Elas fornecem uma visão agregada do comportamento do sistema, permitindo monitorar tendências, detectar anomalias e alertar sobre condições anormais. Em Go, a biblioteca padrão expvar e a biblioteca prometheus/client_golang são as mais comuns para expor métricas.

O expvar é uma solução simples que publica variáveis via HTTP em formato JSON. Já o Prometheus oferece um modelo de dados mais rico e integração com o ecossistema de monitoramento. Vamos ver um exemplo com expvar primeiro:

package main

import (
    "expvar"
    "net/http"
)

var (
    requests = expvar.NewInt("requests_total")
    errors   = expvar.NewInt("errors_total")
)

func handler(w http.ResponseWriter, r *http.Request) {
    requests.Add(1)
    // ... lógica do handler
    if r.URL.Path == "/error" {
        errors.Add(1)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    w.Write([]byte("OK"))
}

func main() {
    http.HandleFunc("/", handler)
    http.Handle("/debug/vars", expvar.Handler())
    http.ListenAndServe(":8080", nil)
}

Com o Prometheus, definimos métricas com tipos específicos e registramos em um registry. O servidor HTTP expõe as métricas no formato de texto do Prometheus. Exemplo:

package main

import (
    "net/http"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    httpRequests = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total number of HTTP requests.",
        },
        []string{"method", "path"},
    )
)

func init() {
    prometheus.MustRegister(httpRequests)
}

func handler(w http.ResponseWriter, r *http.Request) {
    httpRequests.WithLabelValues(r.Method, r.URL.Path).Inc()
    w.Write([]byte("OK"))
}

func main() {
    http.HandleFunc("/", handler)
    http.Handle("/metrics", promhttp.Handler())
    http.ListenAndServe(":8080", nil)
}

Métricas são fundamentais para monitoramento em tempo real e alertas. Elas ajudam a responder perguntas como "quantas requisições por segundo?", "qual é a latência média?" e "quantos erros ocorreram?".

Tracing distribuído (visão geral)

Tracing distribuído rastreia o fluxo de uma requisição à medida que ela atravessa múltiplos serviços. Ele captura informações como tempo gasto em cada serviço, operações executadas e erros encontrados, permitindo reconstruir o caminho completo de uma chamada. Isso é crucial para identificar gargalos de performance e falhas em arquiteturas de microserviços.

Cada trace é composto por spans, que representam uma unidade de trabalho. Um span contém um ID, um trace ID (que agrupa todos os spans de uma requisição), um nome, timestamps e atributos. Em Go, bibliotecas como go.opentelemetry.io/otel e github.com/opentracing/opentracing-go são usadas para criar spans. Vamos ver um exemplo com OpenTelemetry:

package main

import (
    "context"
    "fmt"
    "log"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/trace"
)

func main() {
    // Configurar um tracer provider simples (apenas para exemplo)
    tp := otel.TracerProvider()
    otel.SetTracerProvider(tp)

    tracer := tp.Tracer("example")
    ctx, span := tracer.Start(context.Background(), "main-operation")
    defer span.End()

    // Simular trabalho
    doWork(ctx)
    fmt.Println("Done")
}

func doWork(ctx context.Context) {
    _, span := otel.Tracer("example").Start(ctx, "doWork-operation")
    defer span.End()
    // Simula uma chamada a um serviço externo
    log.Println("Working...")
}

Tracing distribuído é essencial para entender dependências entre serviços, medir latências por serviço e identificar erros em cascata. Ele complementa as métricas, fornecendo contexto detalhado sobre requisições individuais.

Logs

Logs são registros textuais de eventos que ocorrem no sistema. Eles são a forma mais tradicional de observabilidade e fornecem informações detalhadas sobre o que aconteceu em um determinado momento. Em Go, a biblioteca padrão log é básica, mas bibliotecas como logrus e zap oferecem logs estruturados, que são mais fáceis de analisar.

Logs estruturados incluem campos chave-valor, permitindo filtragem e agregação eficientes. Exemplo com logrus:

package main

import (
    "github.com/sirupsen/logrus"
)

func main() {
    log := logrus.New()
    log.SetFormatter(&logrus.JSONFormatter{})

    log.WithFields(logrus.Fields{
        "event": "user_login",
        "user_id": 12345,
        "ip": "192.168.1.1",
    }).Info("User logged in")
}

Boas práticas para logs incluem: usar níveis apropriados (debug, info, warn, error), incluir IDs de correlação (como trace IDs) para relacionar logs a traces, e evitar logs excessivos que podem impactar performance.

OpenTelemetry (visão geral)

OpenTelemetry é um conjunto de APIs, SDKs e ferramentas que padroniza a coleta de métricas, traces e logs. Ele permite que você instrumente seu código uma vez e exporte os dados para diferentes backends (Prometheus, Jaeger, Zipkin, etc.) sem alterar o código. Em Go, o OpenTelemetry é a escolha recomendada para observabilidade moderna.

Para usar OpenTelemetry em Go, você precisa adicionar as dependências e configurar um provider. Exemplo básico de configuração:

package main

import (
    "context"
    "fmt"
    "log"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
    "go.opentelemetry.io/otel/sdk/trace"
)

func main() {
    // Criar um exporter que imprime no stdout
    exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
    if err != nil {
        log.Fatal(err)
    }

    // Criar um tracer provider com o exporter
    tp := trace.NewTracerProvider(
        trace.WithBatcher(exporter),
    )
    defer tp.Shutdown(context.Background())

    otel.SetTracerProvider(tp)

    tracer := otel.Tracer("example")
    ctx, span := tracer.Start(context.Background(), "main")
    defer span.End()

    fmt.Println("Hello, OpenTelemetry!")
}

OpenTelemetry também fornece instrumentações automáticas para bibliotecas comuns (como HTTP, gRPC, bancos de dados), reduzindo o esforço manual. A integração com logs pode ser feita através de um context de correlação, onde o trace ID é incluído nos logs.

Boas práticas e observações finais

Para uma observabilidade eficaz, é importante combinar os três pilares: use métricas para monitoramento agregado e alertas, traces para investigação de requisições individuais, e logs para detalhes de eventos. Sempre que possível, use OpenTelemetry para unificar a coleta e evitar vendor lock-in. Além disso, estabeleça IDs de correlação (como trace IDs) e inclua-os nos logs para conectar os dados.

Lembre-se de que observabilidade é uma prática contínua: comece com instrumentação básica e evolua conforme necessário. Monitore a saúde do sistema, mas também investigue problemas com profundidade usando traces e logs.

Referências

Exercícios

  1. Exercício 1: Crie um programa Go que exponha uma métrica do tipo contador usando expvar para contar o número de vezes que uma função é chamada, e exponha em /debug/vars.

    ✓ Resposta:
    package main
    
    import (
        "expvar"
        "fmt"
        "net/http"
    )
    
    var calls = expvar.NewInt("function_calls_total")
    
    func myFunction() {
        calls.Add(1)
        fmt.Println("Function called")
    }
    
    func main() {
        http.HandleFunc("/call", func(w http.ResponseWriter, r *http.Request) {
            myFunction()
            w.Write([]byte("OK"))
        })
        http.Handle("/debug/vars", expvar.Handler())
        http.ListenAndServe(":8080", nil)
    }
  2. Exercício 2: Usando a biblioteca prometheus/client_golang, registre um histograma para medir a duração de uma operação e exponha em /metrics.

    ✓ Resposta:
    package main
    
    import (
        "net/http"
        "time"
        "github.com/prometheus/client_golang/prometheus"
        "github.com/prometheus/client_golang/prometheus/promhttp"
    )
    
    var (
        operationDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
            Name:    "operation_duration_seconds",
            Help:    "Duration of operation in seconds.",
            Buckets: prometheus.DefBuckets,
        })
    )
    
    func init() {
        prometheus.MustRegister(operationDuration)
    }
    
    func doOperation() {
        start := time.Now()
        defer func() {
            operationDuration.Observe(time.Since(start).Seconds())
        }()
        // Simula trabalho
        time.Sleep(100 * time.Millisecond)
    }
    
    func main() {
        http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
            doOperation()
            w.Write([]byte("OK"))
        })
        http.Handle("/metrics", promhttp.Handler())
        http.ListenAndServe(":8080", nil)
    }
  3. Exercício 3: Escreva um programa Go que use OpenTelemetry para criar um trace com dois spans (pai e filho) e exporte para o console.

    ✓ Resposta:
    package main
    
    import (
        "context"
        "fmt"
        "log"
    
        "go.opentelemetry.io/otel"
        "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
        "go.opentelemetry.io/otel/sdk/trace"
    )
    
    func main() {
        exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
        if err != nil {
            log.Fatal(err)
        }
        tp := trace.NewTracerProvider(trace.WithBatcher(exporter))
        defer tp.Shutdown(context.Background())
        otel.SetTracerProvider(tp)
    
        tracer := otel.Tracer("example")
        ctx, parent := tracer.Start(context.Background(), "parent")
        defer parent.End()
    
        _, child := tracer.Start(ctx, "child")
        defer child.End()
    
        fmt.Println("Trace created")
    }
  4. Exercício 4: Implemente um logger estruturado usando logrus que registre um evento de login com campos como usuário e IP, em formato JSON.

    ✓ Resposta:
    package main
    
    import (
        "github.com/sirupsen/logrus"
    )
    
    func main() {
        log := logrus.New()
        log.SetFormatter(&logrus.JSONFormatter{})
    
        log.WithFields(logrus.Fields{
            "event":  "user_login",
            "user_id": 12345,
            "ip":     "192.168.1.1",
        }).Info("User logged in")
    }
  5. Exercício 5: Explique como você correlacionaria logs com traces usando OpenTelemetry. Descreva os passos e mostre um exemplo de código que adiciona o trace ID a um log.

    ✓ Resposta: Para correlacionar logs com traces, você deve extrair o trace ID do contexto e incluí-lo nos campos do log. Com OpenTelemetry, você pode obter o span atual do contexto e usar SpanContext() para pegar o trace ID. Exemplo:
    package main
    
    import (
        "context"
        "github.com/sirupsen/logrus"
        "go.opentelemetry.io/otel"
        "go.opentelemetry.io/otel/trace"
    )
    
    func main() {
        log := logrus.New()
        log.SetFormatter(&logrus.JSONFormatter{})
    
        tracer := otel.Tracer("example")
        ctx, span := tracer.Start(context.Background(), "operation")
        defer span.End()
    
        // Extrair trace ID do contexto
        spanCtx := trace.SpanContextFromContext(ctx)
        traceID := spanCtx.TraceID().String()
    
        log.WithFields(logrus.Fields{
            "trace_id": traceID,
            "event":    "operation_start",
        }).Info("Operation started")
    }