DEV Community

Somprasong Damyos
Somprasong Damyos

Posted on

Observability Series ตอนที่ 4 — เก็บ Metrics ด้วย OpenTelemetry + OTLP Metric (gRPC)

ตอนนี้เราจะเริ่ม เก็บ Metrics ให้เห็นใน Grafana โดยใช้ Prometheus


Originally published at https://somprasongd.work/blog/go/observability-4

สิ่งที่จะได้ในตอนนี้

  • เก็บ Metrics ด้วย OpenTelemetry Metrics API
  • ใช้ OTLP gRPC Exporter
  • ให้ OTel Collector รับ OTLP Metric → แปลงเป็น Prometheus Format
  • ใช้ Middleware วัด Request Count + Latency อัตโนมัติ
  • Prometheus Scrape จาก OTel Collector
  • ดู Metrics ใน Grafana

ทำไมใช้ OTLP ดีกว่า Prometheus Exporter ตรง ๆ ?

  • แอปไม่ต้องเปิดพอร์ต /metrics เอง
  • ใช้ Protocol มาตรฐานเดียวกับ Traces (OTLP gRPC)
  • OTel Collector ทำหน้าที่ Gateway รวม Traces, Metrics
  • เปลี่ยน Destination ได้ง่าย เช่น ส่งไป Prometheus, Mimir หรือ Cloud

Architecture

[Fiber App] | [OTLP Metric gRPC Exporter] | [OTel Collector] | [Prometheus] | [Grafana] 
Enter fullscreen mode Exit fullscreen mode

โครงสร้างโปรเจ็กต์

โค้ดตั้งต้น: https://github.com/somprasongd/observability-demo-go/tree/feat/trace

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 + Metric │ ├── go.mod └── go.sum 
Enter fullscreen mode Exit fullscreen mode

ขั้นตอน

1. ติดตั้ง Packages

go get go.opentelemetry.io/otel go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc go get go.opentelemetry.io/otel/sdk/metric go get go.opentelemetry.io/otel/metric go get go.opentelemetry.io/contrib/instrumentation/runtime 
Enter fullscreen mode Exit fullscreen mode

2. สร้าง Metrics Provider (OTLP gRPC)

/pkg/observability/observability.go

package observability import ( "context" "log" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "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 MeterProvider *sdkmetric.MeterProvider // เพิ่ม metric } 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) } if err := c.MeterProvider.Shutdown(ctx); err != nil { // เพิ่ม metric log.Println("failed to shutdown meter:", 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) // ----- Meter ----- metricExp, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithEndpoint(collectorAddr), otlpmetricgrpc.WithInsecure(), ) if err != nil { return nil, err } mp := sdkmetric.NewMeterProvider( sdkmetric.WithReader( sdkmetric.NewPeriodicReader(metricExp), ), sdkmetric.WithResource(res), ) otel.SetMeterProvider(mp) return &OTel{ TracerProvider: tp, MeterProvider: mp, }, nil } 
Enter fullscreen mode Exit fullscreen mode

3. Middleware วัด Metrics ทุก Request

ให้รับ meter เข้ามาเพื่อวัด Metrics ทุก Request

  • http_requests_total → Request Count
  • http_request_duration_seconds → Latency Histogram
  • http_requests_inflight → Inflight Count
  • http_request_size_bytes → Request Size Histogram
  • http_response_size_bytes → Response Size Histogram
  • http_requests_error_total → Errors Counter

/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/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" ) func NewObservabilityMiddleware( baseLogger *zap.Logger, tracer trace.Tracer, meter metric.Meter, ) fiber.Handler { // ----- OTel Instruments ----- requestCounter, _ := meter.Int64Counter("http_requests_total") requestDuration, _ := meter.Float64Histogram("http_request_duration_ms") inflightCounter, _ := meter.Int64UpDownCounter("http_requests_inflight") requestSize, _ := meter.Float64Histogram("http_request_size_bytes") responseSize, _ := meter.Float64Histogram("http_response_size_bytes") errorCounter, _ := meter.Int64Counter("http_requests_error_total") // Skip Paths ที่ไม่ต้องการ trace skipPaths := map[string]bool{ "/health": true, "/metrics": 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) // ----- Record Inflight ----- if !skip { inflightCounter.Add(ctx, 1) } err := c.Next() duration := time.Since(start).Milliseconds() status := c.Response().StatusCode() if !skip { labels := []attribute.KeyValue{ attribute.String("method", method), attribute.String("path", path), attribute.Int("status", status), } requestCounter.Add(ctx, 1, metric.WithAttributes(labels...)) requestDuration.Record(ctx, float64(duration), metric.WithAttributes(labels...)) inflightCounter.Add(ctx, -1) // Request Size (Header Content-Length) if reqSize := c.Request().Header.ContentLength(); reqSize > 0 { requestSize.Record(ctx, float64(reqSize), metric.WithAttributes(labels...)) } // Response Size (Body Length) if resSize := len(c.Response().Body()); resSize > 0 { responseSize.Record(ctx, float64(resSize), metric.WithAttributes(labels...)) } if status >= 400 { errorCounter.Add(ctx, 1, metric.WithAttributes(labels...)) } } // 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 } } 
Enter fullscreen mode Exit fullscreen mode

4. แก้ไข Main

/cmd/main.go

package main import ( "context" "os" "time" "go.opentelemetry.io/contrib/instrumentation/runtime" "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()) // เก็บ Process Metrics: สร้าง Runtime Instrument → ผูกกับ MeterProvider runtime.Start( runtime.WithMinimumReadMemStatsInterval(time.Second * 10), ) // Init Fiber app := fiber.New() // Middlewares app.Use(middleware.NewObservabilityMiddleware( logger.Default(), otel.TracerProvider.Tracer("demo-app"), otel.MeterProvider.Meter("demo-app"), // เพิ่มส่ง meter )) 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") } 
Enter fullscreen mode Exit fullscreen mode

runtime.WithMinimumReadMemStatsInterval() ใช้สำหรับเก็บ runtime metrics (Goroutines Count, GC Pauses, Heap Usage)


5. ส่ง Metric ไป Prometheus

ส่งผ่าน OTel Pipeline → Collector → Prometheus

  • สร้าง config ของ Prometheus prometheus.yml ****
 global: scrape_interval: 5s scrape_configs: - job_name: 'otel-collector' static_configs: - targets: ['otel-collector:9464'] 
Enter fullscreen mode Exit fullscreen mode
  • แก้ config ของ exporters เพิ่ม Prometheus 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 prometheus: endpoint: '0.0.0.0:9464' processors: batch: service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [otlp/tempo] metrics: receivers: [otlp] processors: [batch] exporters: [prometheus] 
Enter fullscreen mode Exit fullscreen mode
  • เพิ่ม service prometheus 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 prometheus: image: prom/prometheus:latest container_name: prometheus command: ['--config.file=/etc/prometheus/prometheus.yml'] volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus # ports: # - "9090:9090" 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 - prometheus volumes: loki_data: grafana-data: prometheus-data: 
Enter fullscreen mode Exit fullscreen mode

6. ดู Metric ใน Grafana

  1. รัน App ด้วยคำสั่ง: docker compose up -d --build
  2. ส่ง Request: curl http://localhost:8080/users/1
  3. เปิด Grafana: http://localhost:3000 (User: admin, Password: admin)
  4. Data Source → Add data source → Prometheus → URL: http://prometheus:9090 → Save & test
  5. Explore → Prometheus → Metric: http_request_total → คุณจะเห็น Graph
  6. หรือดูที่ Drilldiwn → Metrics

ผลลัพธ์

  • ทุก Request มี Metric Count + Duration และอื่นๆ
  • มีแสดง Go Process Metrics จาก Runtime
  • ใช้ Protocol มาตรฐาน OTLP → ต่อเข้ากับ OTel Collector
  • Prometheus Scrape ผ่าน Collector → ไม่ต้อง expose /metrics เองที่ฝั่ง App
  • ต่อยอดรวม Traces, Logs, Metrics ผ่าน Collector จุดเดียว

จุดเด่นแนวทางนี้

  • ง่ายต่อการจัดการ: Export ผ่าน OTLP Protocol เดียว
  • ยืดหยุ่น: Collector เปลี่ยน Destination ได้ง่าย
  • มาตรฐานเดียวกับ Tracing → ระบบเดียวกันจัดการหมด

สรุป

  • Middleware เก็บ Request Metrics
  • Rumtime เก็บ Process Metrics
  • Export ผ่าน OTLP gRPC → Collector → Prometheus

Top comments (0)