DEV Community

Gophernment
Gophernment

Posted on

log ใน microservice — ควร log อะไร เมื่อไหร่ และอะไรที่ไม่ควร log

log ใน microservice — ควร log อะไร เมื่อไหร่ และอะไรที่ไม่ควร log

เคยเปิด log ใน production แล้วเจอแบบนี้ไหม:

INFO  request received
INFO  processing...
INFO  done
Enter fullscreen mode Exit fullscreen mode

สามบรรทัดนี้ — บอกอะไรเราไม่ได้เลย — request ไหน? ใช้เวลากี่ ms? สำเร็จหรือพัง?

หรืออีกแบบ — log ทุกอย่าง:

DEBUG user_id=42 name=สมชาย email=somchai@email.com 
       credit_card=4111-xxxx-xxxx-1234 amount=1500
Enter fullscreen mode Exit fullscreen mode

PII เต็ม log — GDPR ปรับเป็นล้าน — และ storage ก็ไม่ฟรี

การ log ใน microservice ไม่ใช่แค่ "ใส่ print เยอะ ๆ" — มันคือการตัดสินใจว่า อะไรควรเก็บ อะไรไม่ควรเก็บ — และบทความนี้จะว่ากันทีละเรื่อง


ข้อมูลพื้นฐานที่ทุก log ควรมี

ไม่ว่าจะเป็น service อะไร — log ทุกบรรทัดควรมี 5 สิ่งนี้:

ts=2026-07-09T03:15:30Z level=INFO 
  msg="order created" trace_id=abc123 span_id=def456
  service=order-svc user_id=42 duration_ms=120
Enter fullscreen mode Exit fullscreen mode
ต้องมี ตัวอย่าง ทำไม
timestamp ts=2026-07-09T03:15:30Z รู้ว่าเกิดเมื่อไหร่ — ISO 8601, UTC เสมอ
level level=INFO กรองได้ — DEBUG/INFO/WARN/ERROR
message msg="order created" คนอ่านรู้ว่าเกิดอะไรขึ้น — สั้น กระชับ
trace_id trace_id=abc123 ตาม request นี้ข้าม service ได้
service service=order-svc รู้ว่า log มาจาก service ไหน

trace_id — สำคัญที่สุด

ถ้าจะเลือกทำแค่ 1 อย่าง — ทำ trace_id — เพราะมันเชื่อม log ของทุก service ที่ request นี้ผ่าน

อย่า reinvent เอง — ใช้ W3C Trace Context (traceparent header):

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
              │  │                                      │              │
              ver trace_id                               span_id        flags
Enter fullscreen mode Exit fullscreen mode

นี่คือมาตรฐานที่ Jaeger, Datadog, Tempo, Grafana รองรับทั้งหมด — ไม่ต้อง generate เอง ไม่ต้อง invent header เอง

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/propagation"
)

// middleware — extract trace_id จาก W3C traceparent header
// suboptimal: อ่าน trace_id ได้ถูก แต่ไม่ได้สร้าง span (ไม่มี duration/status)
// production ควรใช้ otelhttp.NewHandler(mux, "order-service") จาก otelcontrib
func TracingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        propagator := propagation.TraceContext{}
        ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// ใน handler — trace_id ได้จาก span โดยตรง ไม่ต้อง context.WithValue
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    span := trace.SpanFromContext(r.Context())
    traceID := span.SpanContext().TraceID().String()

    slog.InfoContext(r.Context(), "order fetched",
        slog.String("trace_id", traceID),  // ← ได้ฟรีจาก OTel
        slog.String("order_id", orderID),
    )
}
Enter fullscreen mode Exit fullscreen mode

ข้อดีของวิธีนี้: ไม่มี custom header, ไม่มี context.WithValue string key, trace_id ตามข้าม service ได้อัตโนมัติผ่าน HTTP propagation — ทุก tool ใน ecosystem รองรับ


log เมื่อไหร่ — 3 จุดที่ควร log + 1 จุดที่ต้องคิดให้ดี

1. Error — log ทุกครั้งเสมอ

ไม่มีข้อแม้ — error ต้องมี log:

if err != nil {
    slog.ErrorContext(ctx, "database query failed",
        slog.String("query", "INSERT INTO orders"),
        slog.String("error", err.Error()),
        slog.String("user_id", userID),
    )
}
Enter fullscreen mode Exit fullscreen mode

ไม่ควร log แบบนี้:

slog.Error("error", err)  // ❌ ไม่มี context — อ่านไม่รู้เรื่อง
Enter fullscreen mode Exit fullscreen mode

2. Call external service

ทุกครั้งที่เรียก service อื่น หรือ external API:

func (s *Service) notifyWarehouse(ctx context.Context, order *Order) error {
    start := time.Now()
    err := s.warehouseClient.Notify(ctx, order)

    slog.InfoContext(ctx, "warehouse notification",
        slog.String("target", "warehouse-svc"),
        slog.String("order_id", order.ID),
        slog.Int64("duration_ms", time.Since(start).Milliseconds()),
        slog.Bool("success", err == nil),
    )
    return err
}
Enter fullscreen mode Exit fullscreen mode

3. Business event

สิ่งที่เกิดขึ้นแล้วมีผลทางธุรกิจ — log ทุกครั้ง:

slog.InfoContext(ctx, "order paid",
    slog.String("event", "payment_completed"),
    slog.String("order_id", order.ID),
    slog.Float64("amount", order.Amount),
)
Enter fullscreen mode Exit fullscreen mode

4. Request/Response — ไม่จำเป็นต้อง log ทุก service

ข้อนี้ต้องแยกให้ดี — log ทุก request ในทุก service = storage พุ่งแน่

ตัวอย่าง — 20 services, 1,000 req/s, แต่ละ request ผ่าน 5 services:

ถ้า log ทุก request ในทุก service:
  INFO request → 5 services × 1,000 req/s = 5,000 log lines/วินาที
  ต่อวัน = 432 ล้านบรรทัด
  ต่อเดือน = 13 พันล้านบรรทัด → storage เป็น TB
Enter fullscreen mode Exit fullscreen mode

minimum requirement ที่พอดี:

✅ log ที่ API Gateway (entry point) เท่านั้น:
   INFO request method=POST path=/orders trace_id=abc123

❌ log ทุก service ที่ request ผ่าน:
   INFO order-svc: request received   ← ไม่จำเป็น
   INFO payment-svc: request received ← ไม่จำเป็น
   INFO inventory-svc: request received ← ไม่จำเป็น

✅ ใช้ tracing แทน:
   trace_id=abc123 → ตามดูทุก service ได้ โดยไม่ต้อง log
Enter fullscreen mode Exit fullscreen mode

ถ้าอยากรู้ว่า request ผ่าน service ไหนบ้าง — tracing คือคำตอบ — ถูกกว่าและข้อมูลมากกว่า:

// tracing — ถูกกว่า log ตรงที่:
// 1. sampling ได้ (10% แทน 100%)
// 2. ข้อมูลดีกว่า — เห็น parent-child relationship
// 3. storage มักถูกกว่า (compressed binary แทน text)

func CreateOrder(ctx context.Context) {
    ctx, span := tracer.Start(ctx, "CreateOrder")
    defer span.End()
    // ... business logic
}
Enter fullscreen mode Exit fullscreen mode

สรุปเรื่อง request/response:

ที่ไหน log request? เพราะ
API Gateway ✅ ควร รู้ว่ามี request อะไรเข้ามา — แค่นี้ก็พอ
Internal service ❌ ไม่ควร ใช้ tracing แทน — ถูกกว่า
Error ✅ ทุกที่ error ต้อง log เสมอ
Business event ✅ ทุกที่ นี่คือสิ่งที่ stakeholders สนใจ

อย่าสับสน — บทความนี้ไม่ได้บอกว่า "อย่า log"

กำลังบอกว่า อย่า log request ในทุก service — เพราะมันเปลือง storage โดยไม่จำเป็น — ใช้ tracing แทน

แต่ ERROR, business event, call external — log ได้เลย ไม่ต้องคิดมาก


Structured Logging — log แบบให้เครื่องอ่านรู้เรื่อง

ใช้ slog (Go 1.21+) — text format สำหรับ dev, JSON format สำหรับ production:

// development — text format (อ่านง่าย)
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
// ts=2026-07-09T03:15:30Z level=INFO msg="order created" order_id=ORD-001

// production — JSON format (เอาเข้า Grafana/Loki/Datadog)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// {"ts":"2026-07-09T03:15:30Z","level":"INFO","msg":"order created","order_id":"ORD-001"}
Enter fullscreen mode Exit fullscreen mode

ข้อดีของ structured log:

  • search ด้วย field ได้ — level=ERROR AND service=payment
  • aggregate ได้ — นับ orders/ชม
  • alert ได้ — ERROR > 10 ใน 5 นาที → pager

Business Log — log เพื่อธุรกิจ ไม่ใช่เพื่อ debug

นอกจาก log ทางเทคนิค — ควรมี log ที่ตอบคำถามทางธุรกิจ:

คำถาม log แบบไหน
"วันนี้ขายได้เท่าไหร่?" event=order_paid amount=1500
"feature ใหม่มีคนใช้ไหม?" event=feature_used feature=new_search
"ลูกค้าหลุดตอนขั้นตอนไหน?" event=checkout_abandoned step=payment
"ของใกล้หมดไหม?" event=stock_low sku=BKK-001 remaining=3

เทคนิค: ใส่ event= prefix — ทำให้ search ใน log ง่าย:

slog.InfoContext(ctx, "user action",
    slog.String("event", "checkout_completed"),  // ← search ด้วยคำนี้
    slog.String("user_id", userID),
    slog.Float64("total", total),
    slog.Int("items", len(cart.Items)),
)
Enter fullscreen mode Exit fullscreen mode

PII — สิ่งที่ห้าม log เด็ดขาด

PII (Personally Identifiable Information) คือข้อมูลที่ระบุตัวบุคคลได้ — และมันมีกฏหมายควบคุม (GDPR, PDPA)

❌ ห้าม log ✅ log แบบนี้แทน
email=somchai@email.com ❌ อย่า log — ใช้ user_id=42 แทน การ hash email แบบ plain SHA256 ไม่ปลอดภัย — email space มันจำกัด โดน rainbow table / brute force ง่าย — ต้องใช้ HMAC-SHA256 with secret key เท่านั้น ซึ่งยุ่งยากเกินเหตุ — user_id ก็พอแล้ว
credit_card=4111-xxxx payment_method=credit_card last_four=1234
password=secret123 ❌ ไม่มีเหตุผลที่ต้อง log เลย
ssn=123-45-6789 ❌ ไม่มีเหตุผล — ใช้ user_id แทน
phone=0812345678 ❌ อย่า log — hash plain SHA256 ก็ไม่ปลอดภัยด้วยเหตุผลเดียวกับ email — ไม่เก็บเลยดีที่สุด
address=123 ถนนสุขุมวิท province=กรุงเทพ (เอาแค่ระดับจังหวัด)
ip=192.168.1.1 ❌ อย่า log — แม้ mask เหลือ 192.168.*.* ก็ยังเป็น PII ภายใต้ GDPR — ถ้าไม่ได้ใช้ทำ security/geo-block จริงจัง ไม่ต้องเก็บ
full_name=สมชาย ใจดี user_id=42

วิธีป้องกัน — ทำที่ middleware

func SanitizeLog(record slog.Record) slog.Record {
    // strip PII fields ก่อน log
    record.Attrs(func(a slog.Attr) bool {
        if a.Key == "email" || a.Key == "password" || 
           a.Key == "credit_card" || a.Key == "ssn" {
            return false  // ไม่ log field นี้
        }
        return true
    })
    return record
}
Enter fullscreen mode Exit fullscreen mode

กฏง่าย ๆ: ถ้าคิดว่า "log นี้จะถูกอ่านโดยคนนอกบริษัท" — ข้อมูลนั้นไม่ควรอยู่ใน log


log level — เมื่อไหร่ใช้อะไร

Level เมื่อไหร่ ตัวอย่าง
DEBUG ระหว่าง dev — production ปิด DEBUG query params: {...}
INFO business event, request flow ปกติ INFO order paid amount=1500
WARN สิ่งที่ไม่ควรเกิด แต่ไม่พัง WARN retry 3/5 connection refused
ERROR สิ่งที่พัง — ต้องมีคนดู ERROR payment failed order_id=001
FATAL service หยุดทำงาน FATAL cannot connect to database

💡 อย่าใช้ ERROR สำหรับสิ่งที่คาดเดาได้user not found = INFO (ไม่ใช่ ERROR ไม่ใช่ WARN) — เพราะมันคือ expected client error เกิดถี่มาก (login attempt ผิด, search ไม่เจอ) ถ้าเป็น WARN จะ pollute alert threshold — สงวน WARN ไว้กับ signal ที่ operator ต้องสนใจจริง (retry, degraded, near-limit) เท่านั้น


ตัวอย่าง — log ใน service จริง

func (s *OrderService) Checkout(ctx context.Context, req *CheckoutReq) (*Order, error) {
    // 1. business validation — INFO เพราะเป็น expected client error
    if req.Total <= 0 {
        slog.InfoContext(ctx, "invalid checkout amount",
            slog.Float64("total", req.Total),
        )
        return nil, ErrInvalidAmount
    }

    // 2. call external — log duration + success
    start := time.Now()
    paid, err := s.payment.Charge(ctx, req)
    slog.InfoContext(ctx, "payment processed",
        slog.Float64("amount", req.Total),
        slog.Int64("duration_ms", time.Since(start).Milliseconds()),
        slog.Bool("success", err == nil),
    )
    if err != nil {
        slog.ErrorContext(ctx, "payment failed",
            slog.String("error", err.Error()),
            slog.Float64("amount", req.Total),
        )
        return nil, err
    }

    // 3. business event — event= สำหรับ search
    slog.InfoContext(ctx, "order completed",
        slog.String("event", "checkout_completed"),
        slog.String("order_id", paid.OrderID),
        slog.Float64("amount", req.Total),
        slog.Int64("duration_ms", time.Since(start).Milliseconds()),
    )

    return paid.Order, nil
}
Enter fullscreen mode Exit fullscreen mode

จุดที่มักถูกมองข้าม

timestamp ต้องเป็น UTC เสมอ ไม่ใช่ local time

distributed system มี server หลาย timezone — ถ้า log A ใช้ +07:00 log B ใช้ +00:00 — sort log ข้าม service พังทันที

// ❌ local time — sort ข้าม service ไม่ได้
slog.Info("order created", "ts", time.Now())

// ✅ UTC เสมอ + มี NTP sync ทุกเครื่อง
slog.Info("order created", "ts", time.Now().UTC())
Enter fullscreen mode Exit fullscreen mode

ทุกเครื่องใน cluster ต้อง sync NTP — clock skew แค่ 5 วิ ก็ทำให้ trace ดูไม่ออกว่า A เกิดก่อน B หรือเปล่า

log ต้อง correlate กับ trace — ไม่ใช่แยกกัน

บทความพูดถึง slog กับ OTel แยกกัน — แต่ใน production ควร integrate ให้มันเป็นเรื่องเดียวกัน:

// ✅ log มี trace_id + span_id — คลิกจาก Grafana ไปดู log ได้
span := trace.SpanFromContext(ctx)
slog.InfoContext(ctx, "order paid",
    slog.String("trace_id", span.SpanContext().TraceID().String()),
    slog.String("span_id", span.SpanContext().SpanID().String()),
)
Enter fullscreen mode Exit fullscreen mode

ถ้า trace_id ใน log ตรงกับ trace_id ใน tracing backend — คุณคลิกจาก dashboard ไปดู log ของ request นั้นได้ทันที

ป้องกัน log injection

user input มี \n หรือ ANSI escape — inject เข้า log ได้:

// ❌ user input เข้า log โดยตรง — attacker ใส่ \n → ปลอมแปลง log entry
slog.Info("user search", slog.String("q", r.URL.Query().Get("q")))

// q=hello\n2026-07-09T10:00:00Z INFO admin logged in  ← ปลอม!

// ✅ sanitize ก่อน log
import "strconv"
q := strconv.Quote(r.URL.Query().Get("q"))  // escape \n, \t, control chars
slog.Info("user search", slog.String("q", q))
Enter fullscreen mode Exit fullscreen mode

error log ที่จุดเดียว — จุดสูงสุดของ call chain

error bubble ผ่าน 3 layers แล้ว log ทุกชั้น = duplicate stack trace:

// ❌ log error ซ้ำทุก layer (pseudocode — ฟังก์ชันจริง syntax จะสมบูรณ์กว่านี้)
func repo.FindByID()  { log.Error(err); return err }
func svc.GetUser()    { log.Error(err); return err }
func handler.GetUser(){ log.Error(err); return err }
// → 3 log lines สำหรับ 1 error

// ✅ log ที่ top-level handler — จุดเดียว
func repo.FindByID()  { return fmt.Errorf("FindByID: %w", err) }
func svc.GetUser()    { return fmt.Errorf("GetUser: %w", err) }
func handler.GetUser(){ slog.Error("failed", "error", err) }
// → 1 log line — ครอบคลุมทั้ง chain (ผ่าน %w)
Enter fullscreen mode Exit fullscreen mode

business event = metric + log — ไม่ใช่แค่ log

order paid amount=1500 — เก็บเป็น log อย่างเดียวไม่พอ:

// ✅ counter (low-cardinality labels เท่านั้น) + histogram (สำหรับ value ต่อเนื่อง)
orderCounter.Add(ctx, 1, metric.WithAttributes(
    attribute.String("event", "payment_completed"),
    attribute.String("currency", "THB"),          // ← low-cardinality OK
    // ❌ ห้ามใช้ attribute.Float64("amount", amount) — cardinality ระเบิด!
    // แต่ละ order มี amount ไม่ซ้ำกัน → timeseries ใหม่ทุก order → Prometheus พัง
))
paymentAmountHistogram.Record(ctx, amount)        // ← ใช้ Histogram สำหรับ value ต่อเนื่อง
slog.InfoContext(ctx, "order paid", ...)
Enter fullscreen mode Exit fullscreen mode

⚠️ Metric cardinality คือ mistake ที่พบบ่อยที่สุด: ห้ามใช้ value ที่มี unique สูง (order_id, amount, email) เป็น label บน counter — ต้องใช้ Histogram/Gauge แทน มิฉะนั้น Prometheus/OTel backend จะ memory ระเบิดจาก timeseries เป็นล้าน ๆ series

rate-limit log — retry storm ต้องไม่ฆ่า storage

circuit breaker fail → WARN ทุก 100ms → 36,000 log lines/ชม:

import "golang.org/x/time/rate"

// keyed limiter — แต่ละ error type มี limiter ของตัวเอง
// ไม่งั้น db timeout จะบัง cache miss
var limiters sync.Map // map[string]*rate.Limiter

func warnWithLimit(ctx context.Context, msg string, args ...any) {
    lim, _ := limiters.LoadOrStore(msg, rate.NewLimiter(rate.Every(5*time.Second), 1))
    if lim.(*rate.Limiter).Allow() {
        slog.WarnContext(ctx, msg, args...)
    }
}
Enter fullscreen mode Exit fullscreen mode

dynamic log level — debug production โดยไม่ deploy

เปลี่ยน log level เป็น DEBUG ตอน runtime:

var globalLevel = new(slog.LevelVar)  // ← mutable
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: globalLevel,
}))

// HTTP endpoint สำหรับเปลี่ยน level — ต้องอยู่หลัง internal-only network/mTLS/auth middleware
// ❌ ห้ามเปิด public — DEBUG level จะ leak sensitive data + เป็น DoS vector
mux := http.NewServeMux()
mux.HandleFunc("/debug/loglevel", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
    level := r.URL.Query().Get("level")  // debug, info, warn, error
    globalLevel.Set(parseLevel(level))
}))
Enter fullscreen mode Exit fullscreen mode
# debug production incident — เปลี่ยน level ชั่วคราว
curl -X POST "http://payment-svc:8080/debug/loglevel?level=debug"
# แก้เสร็จ → กลับมา info
curl -X POST "http://payment-svc:8080/debug/loglevel?level=info"
Enter fullscreen mode Exit fullscreen mode

retention tiering — hot → cold → delete

hot (7 วัน, SSD): Loki/Grafana Cloud → fast search
warm (30 วัน, HDD): S3 Standard → search ได้แต่ช้า
cold (90 วัน, Glacier): S3 Glacier → ต้อง restore ก่อน
delete (>90 วัน): ไม่เก็บ
Enter fullscreen mode Exit fullscreen mode

policy นี้สำคัญกว่าแค่รู้ว่า "storage แพง" — ต้องมี tier จริง

JSON log กับ multiline — ต้อง escape

stack trace มี \n — JSON parser พังถ้าไม่ escape:

// ❌ JSON log — stack trace ทำ JSON แตก
{"msg": "panic: runtime error\n\ngoroutine 1 [running]:\n..."}

// ✅ ใช้ json.Marshal หรือ slog.Attr
slog.Error("panic", slog.String("stack", string(debug.Stack())))
// slog จัดการ escape \n → \\n ให้อัตโนมัติ
Enter fullscreen mode Exit fullscreen mode

access log ≠ application log

"log เฉพาะที่ API Gateway" — หมายถึง application log (ที่ dev เขียนเอง) — แต่ access log เป็นคนละเรื่อง:

Access Log Application Log
ใครเขียน infra (nginx, envoy, load balancer) dev (ในโค้ด)
เก็บอะไร method, path, status, latency, IP business event, error, debug
volume มหาศาล (ทุก request) น้อยกว่า (เฉพาะที่สำคัญ)
cost ต่ำ — infra-level, compressed binary ปานกลาง — JSON text
ทำไมต้องมี security audit, traffic analysis debug, business analytics

หลาย org เก็บ access log ทุก ingress — แต่นั่นเป็น infra-layer ไม่ใช่ application code — และ cost structure ต่างกันกับที่บทความคำนวณ


สรุป — checklist

✅ ทุก log มี: timestamp (UTC), level, message, trace_id + span_id
✅ log: error (ที่ top-level), call external, business event
✅ structured log (JSON) ใน production
✅ business event = metric (Prometheus) + log — ไม่ใช่แค่ log
✅ PII — ไม่ต้อง hash เพราะ hash plain SHA256 ไม่ปลอดภัย — ไม่ log เลย
✅ ใช้ log level ให้ถูก — user not found = INFO ไม่ใช่ WARN
✅ rate-limit WARN/ERROR — retry storm ต้องไม่ระเบิด storage
✅ dynamic log level — เปลี่ยน runtime โดยไม่ deploy
✅ sanitize user input ก่อน log — กัน log injection (\n, ANSI escape)
✅ retention: hot 7d → warm 30d → cold 90d → delete
✅ access log (infra) ≠ application log — cost structure ต่างกัน
✅ NTP sync ทุกเครื่อง — clock skew >5 วิ = sort log ข้าม service พัง
Enter fullscreen mode Exit fullscreen mode

📚 อ่านต่อ:

Top comments (0)