ตอนนี้เราจะเริ่ม เก็บ Trace ให้เห็นใน Grafana โดยใช้ Tempo
Originally published at https://somprasongd.work/blog/go/observability-3
สิ่งที่จะได้เรียนรู้ในตอนนี้
- เพิ่ม Distributed Tracing ด้วย OpenTelemetry (OTel)
- เก็บ Trace เข้า Tempo
- ผูก Trace กับ Log เดียวกัน (ใช้
TraceIDเป็น Field ใน Log) - Logger ตัวเดียว (Zap) + Tracer ตัวเดียว ใช้ผ่าน Context
- เชื่อม Trace และ Log ใน Grafana → Click จาก Trace ไป Log ได้
Architecture เดิม + Tracing
ใช้โครงสร้างเดียวกับตอนที่ 2
[Fiber Middleware] -> [Fiber Handler] -> [Service] -> [Repo] เพิ่ม:
- Middleware สร้าง
Tracerใส่ใน Context - ใช้
otel.Tracerเปิด Span ทุก Layer - Logger ใส่
trace_idเพื่อเชื่อมโยง log กับ trace
โครงสร้างโปรเจ็กต์
โค้ดตั้งต้น: https://github.com/somprasongd/observability-demo-go/tree/feat/log
project-root/ │ ├── cmd/ │ └── main.go # จุดเริ่มโปรแกรม, bootstrap Fiber, DI, middleware │ ├── internal/ │ ├── handler/ │ │ └── user_handler.go # HTTP Handlers (Fiber routes) │ │ │ ├── service/ │ │ └── user_service.go # Business Logic Layer │ │ │ └── repository/ │ └── user_repo.go # Data Access Layer │ ├── pkg/ │ ├── ctxkey/ │ │ └── ctxkey.go # Shared Lib: เก็บ context key │ │ │ ├── logger/ │ │ └── logger.go # Shared Lib: Logger setup (Zap) │ │ │ ├── middleware/ # Shared Lib: Middleware │ │ └── observability_middleware.go │ │ │ └── observability/ │ └── observability.go # Shared Lib: Trace │ ├── go.mod └── go.sum ขั้นตอน
1. ติดตั้ง Package Tracing
go get go.opentelemetry.io/otel go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc go get go.opentelemetry.io/otel/sdk/trace go get go.opentelemetry.io/otel/trace 2. สร้าง Tracer Provider (OTLP gRPC)
/pkg/observability/observability.go
package observability import ( "context" "log" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.21.0" ) type OTel struct { TracerProvider *sdktrace.TracerProvider } func (c *OTel) Shutdown(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := c.TracerProvider.Shutdown(ctx); err != nil { log.Println("failed to shutdown tracer:", err) } } func NewOTel(ctx context.Context, collectorAddr, serviceName string) (*OTel, error) { // ----- Resource ----- res, err := resource.New(ctx, resource.WithAttributes( semconv.ServiceName(serviceName), ), ) if err != nil { return nil, err } // ----- Tracer ----- traceExp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithEndpoint(collectorAddr), otlptracegrpc.WithInsecure(), ) if err != nil { return nil, err } tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(traceExp), sdktrace.WithResource(res), ) otel.SetTracerProvider(tp) return &OTel{ TracerProvider: tp, }, nil } 3. ปรับ Middleware
ให้รับ trace เข้ามาเพื่อสร้าง span ใหม่ของแต่ละ request
/internal/middleware/obervability_middleware.go
package middleware import ( "context" "demo/pkg/ctxkey" "fmt" "runtime/debug" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/google/uuid" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" ) func NewObservabilityMiddleware( baseLogger *zap.Logger, tracer trace.Tracer, ) fiber.Handler { // Skip Paths ที่ไม่ต้องการ trace skipPaths := map[string]bool{ "/health": true, } // กรณีมีการ serve SPA staticPrefixes := []string{"/static", "/assets", "/public", "/favicon", "/robots.txt"} return func(c *fiber.Ctx) error { start := time.Now() method := c.Method() path := c.Path() // ตรวจสอบ path ที่เรียกมา skip := skipPaths[path] for _, prefix := range staticPrefixes { if strings.HasPrefix(path, prefix) { skip = true break } } var ( ctx context.Context span trace.Span traceID string ) if skip { ctx = c.Context() } else { ctx, span = tracer.Start(c.Context(), "HTTP "+c.Method()+" "+path) defer span.End() traceID = span.SpanContext().TraceID().String() } requestID := c.Get("X-Request-ID") if requestID == "" { requestID = uuid.New().String() } // Bind Request ID ลง Response Header c.Set("X-Request-ID", requestID) // สร้าง child logger reqLogger := baseLogger.With( zap.String("trace_id", traceID), // เพิ่ม trace_id เพื่อเชื่อมโยง log กับ trace zap.String("request_id", requestID), ) // สร้าง Context ใหม่ที่มี logger ctx = context.WithValue(ctx, ctxkey.Logger{}, reqLogger) // แทน Context เดิม c.SetUserContext(ctx) err := c.Next() duration := time.Since(start).Milliseconds() status := c.Response().StatusCode() // log unhandle error if err != nil { reqLogger.Error("an error occurred", zap.Any("error", err), zap.ByteString("stack", debug.Stack()), ) } msg := fmt.Sprintf("%d - %s %s", status, method, path) reqLogger.Info(msg, zap.Int("status", status), zap.Int64("duration_ms", duration), ) return err } } 4. แก้ไข Main
/cmd/main.go
package main import ( "context" "os" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/recover" "demo/internal/handler" "demo/internal/repository" "demo/internal/service" "demo/pkg/logger" "demo/pkg/middleware" "demo/pkg/observability" ) func main() { // Init Logger logger.Init() // Init Observability via Opentelmetry otel, err := observability.NewOTel( context.Background(), os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), "demo-app") if err != nil { logger.Default().Fatal(err.Error()) } defer otel.Shutdown(context.Background()) // Init Fiber app := fiber.New() // Middlewares app.Use(middleware.NewObservabilityMiddleware( logger.Default(), otel.TracerProvider.Tracer("demo-app"), // เพิ่มส่ง tracer )) app.Use(cors.New()) app.Use(recover.New()) // Init DI repo := repository.NewUserRepository() svc := service.NewUserService(repo) h := handler.NewUserHandler(svc) // Routes app.Get("/users/:id", h.GetUser) // Start app.Listen(":8080") } 5. Handler Layer → เปิด Span ต่อ
/internal/handler/user_handler.go
package handler import ( "demo/internal/service" "demo/pkg/logger" "github.com/gofiber/fiber/v2" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" ) type UserHandler struct { svc *service.UserService } func NewUserHandler(svc *service.UserService) *UserHandler { return &UserHandler{svc: svc} } func (h *UserHandler) GetUser(c *fiber.Ctx) error { ctx := c.UserContext() logger := logger.FromContext(ctx) tracer := trace.SpanFromContext(ctx).TracerProvider().Tracer("handler") ctx, span := tracer.Start(ctx, "Handler:GetUser") defer span.End() logger.Info("Handler: GetUser called") id := c.Params("id") user, err := h.svc.GetUser(ctx, id) if err != nil { logger.Error("Handler: Failed to get user", zap.Error(err)) return c.Status(500).SendString("Internal Server Error") } return c.JSON(user) } 6. Service Layer → Span + Logger จาก Context
/internal/service/user_service.go
package service import ( "context" "demo/internal/repository" "demo/pkg/logger" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" ) type UserService struct { repo *repository.UserRepository } func NewUserService(repo *repository.UserRepository) *UserService { return &UserService{repo: repo} } func (s *UserService) GetUser(ctx context.Context, id string) (map[string]string, error) { logger := logger.FromContext(ctx) tracer := trace.SpanFromContext(ctx).TracerProvider().Tracer("service") ctx, span := tracer.Start(ctx, "Service:GetUser") defer span.End() logger.Info("Service: GetUser called", zap.String("id", id)) return s.repo.FindUser(ctx, id) } 7. Repository Layer → Span + Logger จาก Context
/internal/repository/user_repo.go
package repository import ( "context" "demo/pkg/logger" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" ) type UserRepository struct{} func NewUserRepository() *UserRepository { return &UserRepository{} } func (r *UserRepository) FindUser(ctx context.Context, id string) (map[string]string, error) { logger := logger.FromContext(ctx) tracer := trace.SpanFromContext(ctx).TracerProvider().Tracer("repository") ctx, span := tracer.Start(ctx, "Repository:FindUser") defer span.End() logger.Info("Repository: FindUser called", zap.String("id", id)) // Mock DB user := map[string]string{ "id": id, "name": "John Doe", } return user, nil } 8. ส่ง Trace ไป Tempo
- สร้าง config ของ tempo
tempo.yaml****
auth_enabled: false stream_over_http_enabled: true server: http_listen_port: 3200 log_level: info distributor: receivers: otlp: protocols: grpc: endpoint: 'tempo:4317' ingester: trace_idle_period: 10s max_block_duration: 5m compactor: compaction: block_retention: 1h storage: trace: backend: local local: path: /tmp/tempo/blocks wal: path: /tmp/tempo/wal - สร้าง config ของ exporters
otel-collector-config.yaml
receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 exporters: otlp/tempo: endpoint: tempo:4317 tls: insecure: true processors: batch: service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [otlp/tempo] - แก้ให้ loki เพิ่ม label ชื่อ
trace_idpromtail-config.yml
server: http_listen_port: 9080 grpc_listen_port: 0 positions: filename: /tmp/positions.yaml clients: - url: http://loki:3100/loki/api/v1/push scrape_configs: - job_name: docker-logs docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s filters: - name: label values: ['logging=promtail'] relabel_configs: - source_labels: ['__meta_docker_container_name'] regex: '/(.*)' target_label: 'container' - source_labels: ['__meta_docker_container_log_stream'] target_label: 'logstream' - source_labels: ['__meta_docker_container_label_logging_jobname'] target_label: 'job' pipeline_stages: - docker: {} - json: expressions: app_name: level: msg: request_id: trace_id: - labels: app_name: level: request_id: trace_id: - เพิ่ม service
opentelemetry-collectorกับtempodocker-compose.yml
services: app: build: . container_name: backend-app ports: - '8080:8080' environment: - OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317 depends_on: - otel-collector logging: driver: 'json-file' options: max-size: '10m' max-file: '5' # เพิ่ม label สำหรับ filtering logs labels: logging: 'promtail' logging_jobname: 'containerlogs' loki: image: grafana/loki:latest container_name: loki command: -config.file=/etc/loki/local-config.yaml volumes: - loki_data:/loki # ports: # - "3100:3100" promtail: image: grafana/promtail:latest container_name: promtail volumes: - ./promtail-config.yml:/etc/promtail/promtail-config.yml - /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/run/docker.sock:/var/run/docker.sock:ro command: -config.file=/etc/promtail/promtail-config.yml depends_on: - loki otel-collector: image: otel/opentelemetry-collector:latest container_name: otel-collector volumes: - ./otel-collector-config.yaml:/etc/otelcol/config.yaml command: ['--config=/etc/otelcol/config.yaml'] ports: - '4317:4317' # gRPC - '4318:4318' # HTTP - '9464:9464' depends_on: - tempo tempo: image: grafana/tempo:latest container_name: tempo volumes: - ./tempo.yaml:/etc/tempo.yaml command: ['-config.file=/etc/tempo.yaml'] # ports: # - "3200" # tempo # - "4317" # otlp grpc grafana: image: grafana/grafana:latest container_name: grafana ports: - '3000:3000' environment: - GF_SECURITY_ADMIN_USER=admin - GF_SECURITY_ADMIN_PASSWORD=admin volumes: - grafana-data:/var/lib/grafana depends_on: - loki - tempo volumes: loki_data: grafana-data: 8. ดู Trace ใน Grafana
- รัน App ด้วยคำสั่ง:
docker compose up -d --build - ส่ง Request:
curl http://localhost:8080/users/1 - เปิด Grafana: http://localhost:3000 (User: admin, Password: admin)
- Data Source → Add data source → Tempo → URL: http://tempo:3200 → Trace to logs: เลือก Loki และเปิด filter by trace id → Save & test
- Explore → Trace → เลือก Query Type เป็น Search → คุณจะเห็น Trace ID และสามารถกดลิงค์ไปยัง log ผ่าน trace id ได้
จุดเด่นแนวทางนี้
-
TraceIDใส่ใน Log ทุกบรรทัด → เชื่อม Log + Trace ได้จริง - ใช้
otelแบบมาตรฐาน → ต่อเข้ากับ Tempo Collector หรือ Jaeger ได้หมด
สรุป
- คุณมี Distributed Tracing ครบ
- ทุก Layer มี Trace Span ของตัวเอง
- ทุก Log ผูกกับ TraceID → กดไปกลับ Log ↔ Trace ใน Grafana ได้ทันที
Top comments (0)