- Published on
Observability Series ตอนที่ 2 — ส่ง Log เข้า Grafana ด้วย Loki
- Authors
- Name
- Somprasong Damyos
- @somprasongd
ในตอนที่แล้ว เรารู้จัก Observability และ 3 เสาหลัก: Logs, Metrics, Traces
ตอนนี้เราจะเริ่ม เก็บ Log ให้เห็นใน Grafana โดยใช้ Loki
สิ่งที่จะได้เรียนรู้ในตอนนี้
- สร้าง API ด้วย Go + Fiber (Layered Architecture)
- ใช้ Middleware ใส่
Request ID
และLogger
ลงในcontext.Context
- ทุก Layer (Handler, Service, Repo) ดึง
Logger
จากcontext
- ใช้ Zap เป็น Logger
- ต่อ Log ออก stdout → เก็บด้วย Promtail → ส่งเข้า Loki
- ดู Log ใน Grafana Dashboard
ตัวอย่าง Architecture
ในโปรเจ็กต์นี้ เราจะใช้ Layered Architecture
[Fiber Middleware] -> [Fiber Handler] -> [Service Layer] -> [Repository Layer]
และเราจะ Log ทุกชั้น ด้วย Logger เดียวกัน
เพื่อเชื่อม Log ทั้งหมดเข้ากับ Request ID เดียว
โครงสร้างโปรเจ็กต์
โค้ดตั้งต้น: https://github.com/somprasongd/observability-demo-go
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
│
├── go.mod
└── go.sum
ขั้นตอน
1. สร้าง Logger (Zap)
/pkg/ctxkey/ctxkey.go
package ctxkey
type Logger struct{}
/pkg/logger/logger.go
package logger
import (
"context"
"demo/pkg/ctxkey"
"go.uber.org/zap"
)
var baseLogger *zap.Logger
func Init() {
l, _ := zap.NewProduction()
baseLogger = l.With(zap.String("app_name", "demo-app"))
}
func Default() *zap.Logger {
return baseLogger
}
// FromContext extracts logger from context
func FromContext(ctx context.Context) *zap.Logger {
logger, ok := ctx.Value(ctxkey.Logger{}).(*zap.Logger)
if !ok {
return baseLogger
}
return logger
}
2. Middleware สร้าง Logger ต่อ Request
/pkg/middleware/obervability_middleware.go
package middleware
import (
"context"
"demo/pkg/ctxkey"
"fmt"
"runtime/debug"
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"go.uber.org/zap"
)
func NewObservabilityMiddleware(baseLogger *zap.Logger) fiber.Handler {
return func(c *fiber.Ctx) error {
start := time.Now()
method := c.Method()
path := c.Path()
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("request_id", requestID),
)
// สร้าง Context ใหม่
ctx := context.WithValue(c.Context(), ctxkey.Logger{}, reqLogger)
// แทน Context เดิม
c.SetUserContext(ctx)
err := c.Next()
duration := time.Since(start).Seconds()
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.Float64("duration_sec", duration),
)
return err
}
}
3. Handler Layer
เรียกใช้ logger จาก context
/internal/handler/user_handler.go
package handler
import (
"demo/internal/service"
"demo/pkg/logger"
"github.com/gofiber/fiber/v2"
"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 จาก context
logger := logger.FromContext(ctx)
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)
}
4. Service Layer
เรียกใช้ logger จาก context
/internal/service/user_service.go
package service
import (
"context"
"demo/internal/repository"
"demo/pkg/logger"
"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)
logger.Info("Service: GetUser called", zap.String("id", id))
return s.repo.FindUser(ctx, id)
}
5. Repository Layer
/internal/repository/user_repo.go
package repository
import (
"context"
"demo/pkg/logger"
"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)
logger.Info("Repository: FindUser called", zap.String("id", id))
// Mock DB
user := map[string]string{
"id": id,
"name": "John Doe",
}
return user, nil
}
6. Main
/cmd/main.go
package main
import (
"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"
)
func main() {
// Init Logger
logger.Init()
// Init Fiber
app := fiber.New()
// Middlewares
app.Use(middleware.NewObservabilityMiddleware(logger.Default()))
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")
}
ให้ ObservabilityMiddleware อยู่ก่อนเพราะ
- เก็บ log ทุก request ไม่ว่าผ่านหรือไม่ผ่าน CORS
- ถ้าเกิด panic คืน HTTP 500 response → แล้ว Observability ก็เก็บ status 500 ได้พอดี
7. ส่ง Log ไปหา Loki
วิธีที่ง่ายสุด:
รัน Grafana Loki + Promtail
ตั้งค่าให้ Promtail อ่าน Log จาก
stdout
,stderr
ของ Containerpromtail-config.yml
# ตัวอย่าง 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: - labels: app_name: level: request_id:
pipeline_stages
= ขั้นตอนการประมวลผลdocker: {}
→ ดึงlog
จาก Docker log JSONjson
→ Parselog
เป็น JSON อีกชั้น และหา Field ตามที่expressions
กำหนดlabels
→ สร้าง Loki label
ใช้ docker-compose หรือ k8s ตามสะดวก แต่ในบทความจะใช้ docker-compose
Dockerfile
FROM golang:1.24 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app ./cmd/main.go FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY /app/app . EXPOSE 8080 CMD ["./app"]
docker-compose.yml
services: app: build: . container_name: backend-app ports: - '8080:8080' 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 volumes: - loki_data:/loki command: -config.file=/etc/loki/local-config.yaml # 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 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 volumes: loki_data: grafana-data:
7. ดู Log ใน 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 → Loki → URL: http://loki:3100 → Save & test
- Explore → Filter ด้วย Label
app=demo-app
→ คุณจะเห็น Log จากทุก Layer ใน Request เดียวกัน (request_id
เดียวกัน)
จุดเด่นแนวทางนี้
- ใช้ Middleware สร้าง Logger ต่อ Request พร้อม
Request ID
(Logger ถูกสร้าง 1 ครั้งต่อ Request) - เก็บใน
context.Context
→ ดึงจากไหนก็ได้ - แต่ละ Layer ไม่ต้องส่ง Logger เป็น argument
- Logs จะโยงกันได้หมดใน Grafana Loki
- ใช้ Zap ร่วมกับ OTel ได้ถ้าจะต่อ Tracing ในตอนต่อไป