- Published on
Observability Series ตอนที่ 3 — Tracing เชื่อม Logger ด้วย OpenTelemetry + Tempo
- Authors
- Name
- Somprasong Damyos
- @somprasongd
ตอนนี้เราจะเริ่ม เก็บ Trace ให้เห็นใน Grafana โดยใช้ Tempo
สิ่งที่จะได้เรียนรู้ในตอนนี้
- เพิ่ม 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_id
promtail-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
กับtempo
docker-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 ได้ทันที
ตอนถัดไป
- ตอนที่ 4 — เก็บ Metrics ด้วย OpenTelemetry + OTLP Metric (gRPC) ทำ Metrics ต่อให้ครบ 3 เสาหลัก Logs + Traces + Metrics!