Panduan Lengkap Structured Logging di Go Menggunakan log/slog

Panduan lengkap implementasi structured logging di Go menggunakan log/slog: arsitektur observabilitas, pipeline k8s, konfigurasi JSON handler, context tracing, masking data dengan LogValuer, dan HTTP middleware.

Share
Panduan Lengkap Structured Logging di Go Menggunakan log/slog
Arsitektur Structured Logging di Go Menggunakan log/slog

Pernahkah Anda terbangun tengah malam karena ada laporan transaksi gagal di server production, lalu saat membuka log dashboard (seperti Grafana Loki, Datadog, atau Elasticsearch), yang Anda temukan hanyalah ribuan baris teks acak seperti log.Printf("User %d gagal checkout: %v", userID, err)? Mencari akar masalah dari teks bebas seperti itu rasanya seperti mencari jarum di tumpukan jerami—kita terpaksa meracik regex rumit yang lambat dan rawan meleset.

Masalah inilah yang diselesaikan oleh Structured Logging—format pencatatan log berbasis pasangan key-value (umumnya berformat JSON) yang dirancang agar ramah dibaca mesin (machine-readable). Dengan format terstruktur, log aggregator bisa langsung mengindeks, memfilter, dan mengagregasi ribuan request per detik tanpa perlu parsing teks manual.

Kabar baiknya, sejak Go 1.21, tim Go resmi merilis package log/slog langsung ke dalam standard library. Kita tidak lagi diwajibkan bergantung pada pustaka pihak ketiga hanya untuk memproduksi log JSON yang rapi.

Di panduan ini, kita akan membedah log/slog secara mendalam dari kacamata arsitektur backend: mulai dari konsep dasar Logger vs Handler, otomatisasi Trace ID dengan context, perlindungan data sensitif via LogValuer, kustomisasi atribut, hingga implementasi HTTP logging middleware siap pakai.

Arsitektur Structured Logging di Go Menggunakan log/slog
Arsitektur Structured Logging di Go Menggunakan log/slog

Daftar Isi

Mengapa Memilih log/slog Dibandingkan Pustaka Pihak Ketiga?

Sebelum kehadiran Go 1.21, komunitas backend Go terbelah dalam memilih pustaka logging:

  1. Logrus: Sangat populer pada masanya karena simpel, namun kini resmi berstatus maintenance mode dan alokasi memorinya tergolong boros untuk beban kerja tinggi.
  2. Uber Zap & Zerolog: Menawarkan performa luar biasa dan alokasi memori mendekati nol, namun keduanya adalah pustaka pihak ketiga. Setiap modul atau library open source yang kita pakai kerap membawa preferensi logging yang berbeda-beda, membuat integrasi jadi berantakan.

Kehadiran package log/slog menjadi titik balik bagi ekosistem Go karena memberikan standardisasi resmi:

  • Bawaan Standard Library (Zero External Dependencies): Kita tidak perlu menambah dependency eksternal di go.mod. Keamanannya terjamin dan didukung penuh oleh garansi backward compatibility Go 1.
  • Standar Baru untuk Ekosistem Open Source: Pembuat library (misalnya client database, message broker, atau HTTP router) kini cukup menerima parameter *slog.Logger sebagai interface logging standar mereka.
  • Keseimbangan Antara Ergonomi & Performa: Menawarkan sintaks santai untuk kebutuhan sehari-hari (slog.Info), sekaligus menyediakan API zero-allocation (slog.Attr, LogAttrs) untuk jalur eksekusi berkecepatan tinggi (hot path).

Arsitektur Inti: Logger vs Handler

Agar tidak bingung saat mengonfigurasinya, kita perlu memahami bahwa log/slog memisahkan tanggung jawabnya menjadi dua komponen utama:

[Aplikasi Go] ---> [slog.Logger] (Frontend API: Info, Warn, Error)
                          |
                          v (meneruskan Record)
                   [slog.Handler] (Backend Formatter & Writer)
                          |
             +------------+------------+
             |                         |
     [TextHandler]             [JSONHandler]
     (Terminal/Dev)           (Production/Stdout)
  1. slog.Logger: Lapisan antarmuka (frontend) tempat developer menulis kode logging (slog.Info(), slog.Error(), dll). Tugas utamanya adalah membungkus pesan dan atribut menjadi struktur data slog.Record.
  2. slog.Handler: Mesin di balik layar (backend) yang memutuskan apakah level log tersebut layak dicetak, bagaimana datanya diformat (teks biasa atau JSON), dan ke target mana data dialirkan (io.Writer).

TextHandler vs JSONHandler

Go menyediakan dua handler bawaan yang siap pakai langsung dari kotak:

  • slog.NewTextHandler(w, opts): Memformat log menjadi pasangan key=value berbasis teks yang rapi dan nyaman dibaca saat kita menjalankan aplikasi secara lokal di terminal laptop.
  • slog.NewJSONHandler(w, opts): Menghasilkan log berformat JSON satu baris (newline-delimited JSON). Format ini adalah standar wajib untuk container di production karena siap diserap langsung oleh log shipper.

Mari kita lihat perbandingannya dalam kode berikut:

package main

import (
	"log/slog"
	"os"
)

func main() {
	// 1. TextHandler untuk lingkungan Development lokal
	textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelDebug,
	})
	devLogger := slog.New(textHandler)
	devLogger.Info("Aplikasi berjalan di mode development", "env", "local", "port", 8080)

	// 2. JSONHandler untuk lingkungan Production
	jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	})
	prodLogger := slog.New(jsonHandler)
	prodLogger.Info("Aplikasi berjalan di mode production", "env", "production", "port", 8080)
}

Bila kode di atas dijalankan, output di terminal akan tampak seperti ini:

time=2026-03-14T08:30:00.000+07:00 level=INFO msg="Aplikasi berjalan di mode development" env=local port=8080
{"time":"2026-03-14T08:30:00.000+07:00","level":"INFO","msg":"Aplikasi berjalan di mode production","env":"production","port":8080}

Leveling Log dan Dynamic LevelVar

log/slog membagi prioritas log ke dalam empat tingkatan level default:

Level Nilai Integer Kapan Sebaiknya Digunakan
LevelDebug -4 Informasi diagnostik mendalam, payload request, atau tracing internal saat debugging
LevelInfo 0 Catatan aktivitas normal sistem (server menyala, transaksi order berhasil dibuat)
LevelWarn 4 Kejadian tak terduga yang tidak mematikan sistem (retry koneksi database, disk 85%)
LevelError 8 Kegagalan fatal pada alur kerja (koneksi payment gateway timeout, database down)

Di lingkungan produksi, kita biasanya menetapkan level di LevelInfo untuk menghemat kuota penyimpanan storage log. Namun, apa yang terjadi jika mendadak ada bug aneh yang hanya muncul di server tertentu? Apakah kita harus mengedit file konfigurasi lalu me-restart container? Tentu tidak bijak.

Di sinilah peran penting slog.LevelVar. Fitur ini memungkinkan kita mengubah level log aplikasi secara dinamis saat runtime, misalnya melalui endpoint internal admin atau sinyal sistem:

package main

import (
	"log/slog"
	"os"
)

func main() {
	// Buat LevelVar dinamis (default: Info)
	programLevel := new(slog.LevelVar)
	programLevel.Set(slog.LevelInfo)

	handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: programLevel,
	})
	logger := slog.New(handler)

	// Log level Debug ini tidak akan tercetak karena level aktif masih INFO
	logger.Debug("Pesan diagnostik tersembunyi")

	// Naikkan level log ke DEBUG saat runtime (misal dipicu oleh admin HTTP endpoint)
	programLevel.Set(slog.LevelDebug)

	// Sekarang log Debug akan langsung tercetak tanpa perlu restart server!
	logger.Debug("Pesan diagnostik muncul seketika setelah level diubah!")
}

Bekerja dengan Structured Attributes

Loosely-Typed vs Strongly-Typed (Zero-Allocation)

Saat menyisipkan metadata ke dalam log, slog memberikan dua opsi gaya penulisan:

1. Pendekatan Loosely-Typed (Alternating Key-Value)

Pendekatan ini paling santai dan cepat ditulis. Kita cukup memasukkan parameter berpasangan antara nama key (string) dan nilainya (any):

slog.Info("Pembayaran berhasil", "order_id", "ORD-1234", "amount", 150000, "is_guest", false)

2. Pendekatan Strongly-Typed (slog.Attr)

Untuk fungsi yang dipanggil ratusan ribu hingga jutaan kali per detik (high-throughput hot path), passing parameter bertipe any dapat menyebabkan alokasi memori ekstra ke heap (escape analysis). Untuk skenario performa tinggi ini, Go menyediakan konstruktor strongly-typed seperti slog.String(), slog.Int(), slog.Duration(), serta method logger.LogAttrs():

logger.LogAttrs(ctx, slog.LevelInfo, "Pembayaran berhasil",
    slog.String("order_id", "ORD-1234"),
    slog.Int("amount", 150000),
    slog.Bool("is_guest", false),
    slog.Duration("processing_time", 42*time.Millisecond),
)
Tips Praktis: Untuk 95% alur bisnis umum di aplikasi Anda, gunakan saja gaya santai slog.Info("...", "key", val). Keterbacaan kode jauh lebih berharga. Beralihlah ke slog.Attr dan LogAttrs() hanya pada jalur pemrosesan data super padat jika hasil profiling memori memang menunjukkan adanya bottleneck alokasi di logging.

Grouping dan Scoping dengan With()

Sub-Logger dengan logger.With()

Pernahkah Anda merasa lelah karena harus mengetik atribut yang sama berulang kali di setiap baris log (misalnya nama service, hostname, atau versi aplikasi)? Kita bisa membuat sub-logger dengan menyematkan atribut permanen menggunakan With():

orderLogger := logger.With(
    slog.String("service", "order-service"),
    slog.String("version", "v2.1.0"),
)

// Setiap log dari orderLogger akan otomatis membawa service="order-service" dan version="v2.1.0"
orderLogger.Info("Worker pemrosesan order berhasil diinisialisasi")

Pengelompokan Data Bersarang dengan slog.Group

Jika log aggregator Anda mendukung struktur objek JSON bertingkat (nested JSON), kita bisa merapikan data menggunakan slog.Group:

logger.Info("Aktivitas login terdeteksi",
    slog.Group("user",
        slog.Int("id", 101),
        slog.String("email", "[email protected]"),
        slog.String("role", "admin"),
    ),
    slog.Group("network",
        slog.String("ip", "203.0.113.195"),
        slog.String("user_agent", "Mozilla/5.0"),
    ),
)

Hasil JSON di terminal akan terorganisir rapi dalam objek bersarang:

{
  "time": "2026-03-14T08:35:10.123Z",
  "level": "INFO",
  "msg": "Aktivitas login terdeteksi",
  "user": {
    "id": 101,
    "email": "[email protected]",
    "role": "admin"
  },
  "network": {
    "ip": "203.0.113.195",
    "user_agent": "Mozilla/5.0"
  }
}

Context dan Distributed Tracing (Trace ID / Request ID)

Mengapa Wajib Menggunakan Context?

Di sistem microservices modern, satu klik pengguna di aplikasi frontend bisa memicu panggilan berantai ke puluhan service backend (mulai dari API Gateway -> Order Service -> Payment Service -> Notification Service). Tanpa adanya pengenal unik bersama, mencari alur transaksi yang tersendat di antara ratusan container nyaris mustahil.

Kita memerlukan Correlation ID / Trace ID yang menempel di setiap baris log. Go sendiri sudah menyediakan varian fungsi log berbasis context:

  • slog.InfoContext(ctx, "pesan", ...)
  • slog.WarnContext(ctx, "pesan", ...)
  • slog.ErrorContext(ctx, "pesan", ...)

Tetapi, jika setiap engineer harus mengetik slog.String("trace_id", tid) secara manual di ratusan titik kode, pasti ada yang terlewat. Kuncinya adalah otomatisasi.

Membangun Custom ContextHandler Otomatis

Pendekatan paling bersih secara arsitektur adalah membuat sebuah wrapper handler kustom. Handler ini akan otomatis memeriksa isi context.Context, mengambil nilai trace_id atau request_id, lalu menyuntikkannya langsung ke dalam slog.Record sebelum diformat ke JSON:

package main

import (
	"context"
	"log/slog"
	"os"
)

type contextKey string

const (
	TraceIDKey   contextKey = "trace_id"
	RequestIDKey contextKey = "request_id"
)

// ContextHandler membungkus handler bawaan dan menginjeksi metadata dari context
type ContextHandler struct {
	slog.Handler
}

func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error {
	if ctx != nil {
		// Ekstrak trace_id secara otomatis jika ada di context
		if traceID, ok := ctx.Value(TraceIDKey).(string); ok && traceID != "" {
			r.AddAttrs(slog.String("trace_id", traceID))
		}
		// Ekstrak request_id jika ada
		if reqID, ok := ctx.Value(RequestIDKey).(string); ok && reqID != "" {
			r.AddAttrs(slog.String("request_id", reqID))
		}
	}
	return h.Handler.Handle(ctx, r)
}

Cara pakainya di kode aplikasi sangat bersih. Tim developer cukup memanggil InfoContext tanpa perlu memikirkan Trace ID lagi:

func main() {
	baseJSONHandler := slog.NewJSONHandler(os.Stdout, nil)
	customHandler := ContextHandler{Handler: baseJSONHandler}
	logger := slog.New(customHandler)

	// Simulasikan context HTTP yang membawa Trace ID dari OpenTelemetry atau Gateway
	ctx := context.WithValue(context.Background(), TraceIDKey, "c4b9-4f81-9b7e-9023")

	// Trace ID langsung tersemat otomatis ke dalam log JSON!
	logger.InfoContext(ctx, "Query ke database pembayaran selesai", "duration_ms", 12)
}

Output log JSON yang dihasilkan:

{
  "time": "2026-03-14T08:40:00.000Z",
  "level": "INFO",
  "msg": "Query ke database pembayaran selesai",
  "duration_ms": 12,
  "trace_id": "c4b9-4f81-9b7e-9023"
}

💡 Baca Juga: Dalam arsitektur microservices dan sistem backend berskala besar, context.Context dan Trace ID menjadi sangat krusial saat menangani banyak goroutine bersamaan (seperti background worker atau pipeline asinkron). Simak panduan mendalam seputar concurrency berikut:

Panduan Lengkap Concurrency di Golang: Goroutine, Channel, dan Best Practice
Pelajari konsep concurrency di Golang secara mendalam: goroutine, channel, sync.WaitGroup (termasuk WaitGroup.Go), worker pool pattern, hingga cara deteksi race condition.

Keamanan Data: Masking Sensitif dengan LogValuer

Bencana keamanan data sering kali berawal dari hal sepele: developer mencetak seluruh objek user atau payload API ke log untuk keperluan debugging, tanpa sadar bahwa di dalamnya ada password plain-text, API token, CVV kartu kredit, atau NIK pengguna. Sekali data tersebut terkirim ke log aggregator eksternal, kebocoran data sudah resmi terjadi.

Untungnya, log/slog punya solusi elegan bawaan lewat interface slog.LogValuer:

Alur Masking Data Sensitif dengan slog.LogValuer
Alur Masking Data Sensitif dengan slog.LogValuer
type LogValuer interface {
    LogValue() slog.Value
}

Setiap kali sebuah tipe data mengimplementasikan method LogValue(), handler slog akan mengabaikan nilai asli struct dan menggantinya dengan nilai kembalian dari method tersebut.

Mari kita buat simulasi struct profil pengguna yang aman:

package main

import (
	"log/slog"
	"os"
)

type UserProfile struct {
	ID       int
	Username string
	Password string
	APIKey   string
}

// LogValue menjamin password dan API key tidak akan pernah bocor ke log
func (u UserProfile) LogValue() slog.Value {
	return slog.GroupValue(
		slog.Int("id", u.ID),
		slog.String("username", u.Username),
		slog.String("password", "[REDACTED]"),
		slog.String("api_key", maskSecret(u.APIKey)),
	)
}

func maskSecret(s string) string {
	if len(s) <= 4 {
		return "****"
	}
	return s[:2] + "****" + s[len(s)-2:]
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	user := UserProfile{
		ID:       42,
		Username: "budi_santoso",
		Password: "RahasiaSuperAman123!",
		APIKey:   "sk_live_9981273918239018",
	}

	// Walaupun kita passing objek user utuh, nilainya otomatis tersanitasi
	logger.Info("Pendaftaran akun pengguna baru berhasil", slog.Any("user", user))
}

Output log JSON yang aman dari kebocoran:

{
  "time": "2026-03-14T08:42:00.000Z",
  "level": "INFO",
  "msg": "Pendaftaran akun pengguna baru berhasil",
  "user": {
    "id": 42,
    "username": "budi_santoso",
    "password": "[REDACTED]",
    "api_key": "sk****18"
  }
}

Kustomisasi Handler Tingkat Lanjut

Untuk menyelaraskan format log dengan standar platform perusahaan Anda, struct slog.HandlerOptions menyediakan opsi penyesuaian yang sangat fleksibel:

type HandlerOptions struct {
    AddSource   bool
    Level       Leveler
    ReplaceAttr func(groups []string, a Attr) Attr
}

Menambahkan Caller Source (AddSource)

Dengan mengaktifkan AddSource: true, logger akan otomatis menyertakan nama file, baris kode, dan fungsi tempat log dipanggil:

opts := &slog.HandlerOptions{
    AddSource: true,
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger.Error("Koneksi ke database PostgreSQL terputus")

Output:

{
  "time": "2026-03-14T08:45:00.000Z",
  "level": "ERROR",
  "source": {
    "function": "main.main",
    "file": "/app/cmd/server/main.go",
    "line": 48
  },
  "msg": "Koneksi ke database PostgreSQL terputus"
}

Transformasi Atribut dengan ReplaceAttr

Fungsi ReplaceAttr dipanggil pada setiap atribut sebelum dicetak. Fitur ini sangat berguna untuk tiga hal:

  1. Menyeragamkan Skema Nama Key: Misalnya mengubah msg menjadi message (sesuai standar Datadog atau Elasticsearch ECS), atau level menjadi severity (sesuai Google Cloud Logging).
  2. Memformat Timestamp: Memastikan waktu selalu berada dalam format UTC RFC 3339.
  3. Membersihkan File Path: Memotong path file absolut komputer host agar log lebih ringkas dan tidak membocorkan struktur direktori server.

Berikut konfigurasi logger produksi yang ideal:

package main

import (
	"log/slog"
	"os"
	"path/filepath"
	"time"
)

func setupProductionLogger() *slog.Logger {
	opts := &slog.HandlerOptions{
		Level:     slog.LevelInfo,
		AddSource: true,
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			// 1. Seragamkan format waktu menjadi UTC RFC 3339
			if a.Key == slog.TimeKey {
				return slog.String("timestamp", a.Value.Time().UTC().Format(time.RFC3339))
			}

			// 2. Sesuaikan key "msg" menjadi "message" (standar Datadog / Elastic)
			if a.Key == slog.MessageKey {
				return slog.Attr{Key: "message", Value: a.Value}
			}

			// 3. Ubah "level" menjadi "severity" (standar Google Cloud Logging)
			if a.Key == slog.LevelKey {
				return slog.Attr{Key: "severity", Value: a.Value}
			}

			// 4. Ringkas path file agar hanya menampilkan nama berkas (misal: main.go:25)
			if a.Key == slog.SourceKey {
				source, ok := a.Value.Any().(*slog.Source)
				if ok && source != nil {
					source.File = filepath.Base(source.File)
				}
			}

			return a
		},
	}

	return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}

Output log JSON yang dihasilkan menjadi sangat bersih dan siap diserap log parser:

{
  "timestamp": "2026-03-14T01:45:00Z",
  "severity": "INFO",
  "source": {
    "function": "main.main",
    "file": "main.go",
    "line": 25
  },
  "message": "Worker pool diinisialisasi"
}

Implementasi Nyata: HTTP Logging Middleware

Sekarang, mari kita satukan seluruh teknik di atas ke dalam skenario nyata: membangun HTTP Logging Middleware yang dapat dipasang di router standar net/http, Chi, Gin, atau framework Go favorit Anda.

Middleware ini dirancang untuk:

  • Mengambil X-Request-ID dari header HTTP yang masuk (atau membuat ID acak baru jika belum ada).
  • Menghitung durasi pemrosesan request (latency).
  • Menangkap status response code melalui custom ResponseWriter.
  • Menyesuaikan level log secara pintar (Status 2xx/3xx -> INFO, 4xx -> WARN, 5xx -> ERROR).

Berikut kode lengkapnya:

package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"log/slog"
	"net/http"
	"os"
	"time"
)

type contextKey string

const RequestIDKey contextKey = "request_id"

// responseWriterInterceptor membungkus ResponseWriter standar untuk mencatat statusCode
type responseWriterInterceptor struct {
	http.ResponseWriter
	statusCode int
}

func (w *responseWriterInterceptor) WriteHeader(statusCode int) {
	w.statusCode = statusCode
	w.ResponseWriter.WriteHeader(statusCode)
}

// generateRequestID menghasilkan string heksadesimal acak sebagai ID unik request
func generateRequestID() string {
	bytes := make([]byte, 8)
	_, _ = rand.Read(bytes)
	return hex.EncodeToString(bytes)
}

// HTTPLoggingMiddleware mencatat aktivitas request HTTP secara terstruktur
func HTTPLoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()

			// Ambil X-Request-ID dari header atau generate baru jika kosong
			reqID := r.Header.Get("X-Request-ID")
			if reqID == "" {
				reqID = generateRequestID()
			}

			// Masukkan Request ID ke dalam context request
			ctx := context.WithValue(r.Context(), RequestIDKey, reqID)
			r = r.WithContext(ctx)

			// Bungkus response writer agar kita tahu status code yang dikirim
			interceptor := &responseWriterInterceptor{
				ResponseWriter: w,
				statusCode:     http.StatusOK, // default jika WriteHeader tidak dipanggil manual
			}

			// Jalankan handler berikutnya
			next.ServeHTTP(interceptor, r)

			duration := time.Since(start)

			// Tentukan tingkat keparahan log berdasarkan status HTTP
			level := slog.LevelInfo
			if interceptor.statusCode >= 500 {
				level = slog.LevelError
			} else if interceptor.statusCode >= 400 {
				level = slog.LevelWarn
			}

			// Catat log terstruktur dengan detail metrik
			logger.LogAttrs(ctx, level, "HTTP Request Selesai Diproses",
				slog.String("request_id", reqID),
				slog.String("method", r.Method),
				slog.String("path", r.URL.Path),
				slog.Int("status", interceptor.statusCode),
				slog.Duration("latency", duration),
				slog.String("ip", r.RemoteAddr),
				slog.String("user_agent", r.UserAgent()),
			)
		})
	}
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	mux := http.NewServeMux()
	mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte(`{"status":"UP"}`))
	})

	// Pasang middleware ke routing
	loggedMux := HTTPLoggingMiddleware(logger)(mux)

	logger.Info("Server API berjalan pada port :8080")
	_ = http.ListenAndServe(":8080", loggedMux)
}

Ketika endpoint /api/v1/health diakses, log JSON dengan informasi latency dan status code akan tercatat secara akurat:

{
  "time": "2026-03-14T08:50:15.821Z",
  "level": "INFO",
  "msg": "HTTP Request Selesai Diproses",
  "request_id": "8f3b20c91a457de1",
  "method": "GET",
  "path": "/api/v1/health",
  "status": 200,
  "latency": 45120,
  "ip": "127.0.0.1:52134",
  "user_agent": "curl/8.7.1"
}

💡 Baca Juga: Ketika menjalankan HTTP server di lingkungan produksi, logging middleware ini sangat disarankan dipadukan dengan implementasi graceful shutdown agar seluruh request in-flight selesai dicatat log-nya sebelum aplikasi dimatikan:

Cara Implementasi Graceful Shutdown di Golang
Tutorial praktis graceful shutdown di Go. Simak cara menangani sinyal, implementasi dengan berbagai framework, dan konfigurasi PM2 untuk zero-downtime deployment

Dari os.Stdout ke Log Aggregator: Arsitektur saat Scaling Horizontal

Sepanjang artikel ini, Anda mungkin memperhatikan bahwa seluruh konfigurasi handler yang kita bangun mengarahkan output-nya ke os.Stdout:

handler := slog.NewJSONHandler(os.Stdout, opts)

Pertanyaan kritis yang sering muncul di benak engineer adalah: "Lho, kalau log-nya cuma dibuang ke stdout, log tersebut sebenarnya disimpan ke mana? Lalu bagaimana nasibnya kalau aplikasi kita di-scale secara horizontal menjadi 20 atau 50 pod di Kubernetes?"

Mari kita bedah arsitektur operasional logging di sistem cloud-native modern.

Mengapa Menyimpan Log ke File Lokal Adalah Anti-Pattern di Era Cloud-Native?

Bagi yang terbiasa dengan arsitektur monolit tradisional di server tunggal, naluri pertama kita biasanya adalah menulis log ke berkas lokal dengan rotasi file:

// Pendekatan lama: menulis langsung ke file lokal container
file, _ := os.OpenFile("/var/log/app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)

Namun, begitu aplikasi Anda masuk ke ekosistem container (Docker/Kubernetes) dan di-scale secara horizontal, pendekatan berkas lokal ini langsung memicu tiga masalah fatal:

  1. Log Terfragmentasi (Siloed Logs): Bayangkan Anda memiliki 20 pod aplikasi yang menerima traffic dari satu Load Balancer. User melaporkan order mereka gagal dan request tersebut kebetulan diproses oleh Pod #14. Jika setiap pod menulis log ke file lokalnya masing-masing, engineer harus memeriksa satu per satu ke 20 pod untuk menemukan file log yang tepat. Ini sangat lambat, merepotkan, dan di lingkungan produksi yang ketat, akses SSH langsung ke dalam pod umumnya diblokir demi keamanan.
  2. Container Bersifat Ephemeral (Fana): Pod di Kubernetes bisa dimatikan, dipindah antar node (rescheduled), terkena OOMKilled, atau otomatis di-scale down dari 20 pod menjadi 3 pod saat malam hari. Saat pod dimatikan, layer filesystem lokal di dalam container tersebut musnah seketika bersama seluruh riwayat log-nya.
  3. Risiko Disk Node Jebol (Disk Pressure): Menulis berkas log terus-menerus ke dalam layer container akan menghabiskan ruang disk mesin host. Ketika kapasitas disk node menipis (biasanya di atas 85%), kubelet akan panik dan mulai melakukan pod eviction massal yang melumpuhkan layanan Anda.

Solusi Standar Industri: Twelve-Factor App & Log as Event Streams

Sesuai metodologi standar industri (The Twelve-Factor App: Factor XI - Logs), aplikasi backend sama sekali tidak boleh dibebani tanggung jawab memikirkan penyimpanan, perutean, atau rotasi berkas log.

Aplikasi Go kita cukup bertindak sebagai log producer: format log ke JSON terstruktur, lalu alirkan langsung ke os.Stdout secepat mungkin.

Lalu, siapa yang bertugas mengumpulkan, menyimpan, dan mengindeks log dari puluhan pod tersebut? Di sinilah Pipeline Observabilitas Terpusat bekerja:

[ Pod 1 (Go slog) ] --(stdout)--> \
[ Pod 2 (Go slog) ] --(stdout)-->  [ Container Runtime (containerd) ]
[ Pod 3 (Go slog) ] --(stdout)-->  Menulis stream ke /var/log/pods/ di Node Host
                                              |
                                              v
                                 [ Log Shipper: Fluent Bit / Promtail ]
                                 (Berjalan sebagai DaemonSet per Node)
                                 - Membaca berkas log host
                                 - Menginjeksi metadata k8s (pod, namespace)
                                              |
                                              v (Kirim batch via HTTP/gRPC)
                                 [ Centralized Log Aggregator ]
                                 (Grafana Loki / Elasticsearch / Datadog)
                                              |
                                 Disimpan di Object Storage Murah (AWS S3 / MinIO)
                                              |
                                              v
                                 [ Dashboard Observabilitas ]
                                 (Grafana / Kibana untuk pencarian satu pintu)
Animasi Alur Log Stream dari Pod Go ke Grafana Loki
Animasi Alur Log Stream dari Pod Go ke Grafana Loki

4 Lapisan Perjalanan Satu Baris Log dari Go hingga ke Dashboard

  1. Aplikasi Go (Log Producer): Aplikasi Anda memanggil slog.InfoContext(ctx, "HTTP Request Selesai Diproses", ...). slog membentuk baris JSON dan langsung menuliskannya ke os.Stdout. Di level Go, eksekusi selesai dalam hitungan mikrodetik tanpa terbebani koneksi jaringan ke server log.
  2. Container Runtime (Docker / containerd / CRI-O): Runtime container menangkap stream stdout dari proses aplikasi dan menyimpannya sementara ke berkas log standar di host mesin node fisik (misalnya di /var/log/pods/<namespace>_<pod-name>/).
  3. Log Shipper DaemonSet (Promtail / Fluent Bit / Vector): Alih-alih setiap pod mengirim log sendiri-sendiri, Kubernetes menjalankan satu agen pengumpul log per mesin host (DaemonSet). Agen ini membaca berkas log di node, menempelkan label metadata cluster (seperti nama namespace, pod name, container name, dan node IP), lalu mengirimkannya secara efisien dalam format batch terkompresi ke server aggregator.
  4. Log Aggregator & Storage (Grafana Loki / Elasticsearch): Server sentral yang mengumpulkan log dari semua node. Grafana Loki mengelompokkan log berdasarkan label metadata dan menyimpan datanya di Object Storage berbiaya murah (AWS S3, GCS, atau MinIO), sedangkan Elasticsearch mengindeks seluruh field JSON untuk kebutuhan pencarian teks penuh.

Menghubungkan log/slog dengan Kemudahan Query di Grafana Loki

Sekarang Anda bisa melihat benang merahnya: mengapa di bab-bab sebelumnya kita bersusah payah merancang format JSON, ContextHandler untuk `trace_id`, dan structured attributes di Go?

Jawabannya adalah agar saat sistem Anda di-scale menjadi puluhan pod, tim SRE atau backend engineer bisa mencari akar masalah hanya dalam satu baris query di Grafana Explore:

{app="order-service", env="production"} | json | trace_id = "c4b9-4f81-9b7e-9023"

Atau mencari semua request yang lambat di seluruh replica pod secara serentak:

{app="order-service"} | json | latency > 500ms and status >= 500

Dengan mengalirkan JSON log/slog ke os.Stdout, aplikasi Anda sepenuhnya stateless, siap di-scale hingga ratusan pod kapan saja tanpa takut kehilangan data log, dan siap diintegrasikan dengan platform observabilitas modern.

Interoperabilitas dan Komparasi Ekosistem

Menjadikan slog Sebagai Default Logger

Jika codebase Anda sudah terlanjur banyak memanggil fungsi global slog.Info(...) tanpa menyuntikkan instance logger secara eksplisit, Anda bisa menetapkan logger kustom Anda sebagai default global di fungsi main():

slog.SetDefault(logger)

Bagaimana dengan pustaka eksternal pihak ketiga yang masih memakai package log lama bawaan Go? Anda dapat menjembataninya (bridge) menggunakan slog.NewLogLogger, sehingga semua pesan dari pustaka lama otomatis dialirkan ke format JSON slog:

// Buat bridge standard logger yang dialirkan langsung ke slog handler
legacyLogger := slog.NewLogLogger(logger.Handler(), slog.LevelWarn)

// Masukkan ke konfigurasi server HTTP bawaan yang membutuhkan *log.Logger
httpServer := &http.Server{
    Addr:     ":8080",
    ErrorLog: legacyLogger,
}

Komparasi: log/slog vs Zap vs Zerolog

Fitur / Karakteristik `log/slog` (Standard Library) `uber-go/zap` `rs/zerolog`
Status Dependensi Resmi Standard Library (Go 1.21+) Pustaka Eksternal (go get) Pustaka Eksternal (go get)
Jaminan Stabilitas Garansi Kompatibilitas Go 1 Bergantung maintainer open source Bergantung maintainer open source
Kecepatan & Alokasi Sangat Cepat (Mumpuni untuk 99% sistem) Ekstrem (Fokus zero-allocation) Ekstrem (Fokus zero-allocation)
Fitur Rotasi Berkas Tidak Ada (Gunakan Lumberjack / Logrotate) Tidak Ada Tidak Ada
Integrasi Context Sangat Rapi (*Context + Custom Handler) Butuh modul wrapper zapctx Butuh context helper terpisah
Data Masking Bawaan (slog.LogValuer) Kustom Encoder Kustom Hook
Rekomendasi Pemakaian Pilihan Utama Layanan Modern Sistem HFT / Ultra-low Latency Sistem HFT / Embedded CLI
Catatan Lapangan: Pada sebagian besar aplikasi backend microservices, performa log/slog sudah jauh melampaui batas kecepatan I/O jaringan server kita. Keuntungan tidak menambah dependensi eksternal pada binary aplikasi jauh lebih bernilai dibanding selisih microsecond yang sering kali tidak terasa di produksi.

Jebakan Umum (Common Pitfalls) & Solusinya

1. Evaluasi Dini (Eager Evaluation) pada Parameter Log

Semua parameter fungsi di Go selalu dievaluasi saat baris fungsi tersebut dipanggil (eager evaluation). Perhatikan kode berikut:

// JEBAKAN: serializePayload() akan SELALU dieksekusi walaupun log level saat ini adalah INFO!
logger.Debug("Detail payload request", "data", serializePayload(massivePayload))

Jika fungsi serializePayload memproses serialisasi JSON berukuran besar, siklus CPU server Anda akan terbuang percuma hanya untuk menyiapkan parameter yang pada akhirnya langsung dibuang oleh logger.

Solusinya: Lakukan pengecekan aktif atau tidaknya level log dengan logger.Enabled(), atau serahkan evaluasinya ke slog.LogValuer:

if logger.Enabled(ctx, slog.LevelDebug) {
    logger.Debug("Detail payload request", "data", serializePayload(massivePayload))
}

2. Kehilangan Informasi Baris Kode (Caller Loss) Saat Membuat Helper Function

Ketika kita membuat fungsi pembungkus (helper function) untuk menyederhanakan pemanggilan log:

// JEBAKAN: AddSource akan selalu mencatat nama file helper.go, bukan letak baris kode pemanggil aslinya!
func LogError(logger *slog.Logger, msg string, err error) {
    logger.Error(msg, "error", err)
}

Jika opsi AddSource: true aktif, logger akan selalu mencatat file helper.go sebagai sumber log, sehingga kita kehilangan informasi di mana error sebenarnya terjadi.

Solusinya: Jika memang harus membuat custom wrapper, gunakan runtime.Callers dan bangun slog.NewRecord secara manual untuk menentukan kedalaman call stack yang tepat.

Frequently Asked Questions (FAQ)

Apakah log/slog mendukung fitur rotasi file log otomatis?

Tidak secara langsung. Sesuai prinsip Twelve-Factor App dan filosofi Unix, log/slog hanya fokus pada tugas memformat dan menulis log ke target io.Writer. Jika aplikasi Anda harus menulis langsung ke file lokal dengan aturan rotasi harian atau ukuran maksimal, pasangkan slog dengan pustaka rotasi seperti lumberjack.v2:

fileWriter := &lumberjack.Logger{
    Filename:   "/var/log/myapp/app.log",
    MaxSize:    100, // MB
    MaxBackups: 5,
    MaxAge:     30,  // hari
}
logger := slog.New(slog.NewJSONHandler(fileWriter, nil))

💡 Baca Juga: Bagi Anda yang menjalankan service Go di server VPS atau Bare-Metal dan ingin solusi rotasi log otomatis, clustering, serta process management tanpa mengubah kode Go, simak panduan deploy Go dengan PM2:

Deploy Aplikasi Go dengan PM2: Panduan Lengkap
Pelajari cara mudah deploy aplikasi Go menggunakan PM2. Ikuti panduan lengkap dengan contoh kode, konfigurasi log rotasi, clustering, dan zero‑downtime reload

Kapan sebaiknya saya memilih TextHandler dibanding JSONHandler?

Gunakan TextHandler hanya saat Anda bekerja secara lokal di laptop development agar log enak dibaca di terminal. Untuk lingkungan staging dan production, selalu gunakan JSONHandler agar log dapat langsung diparsing dengan cepat oleh log ingestion agent (seperti Promtail, Fluent Bit, atau Vector).

Bagaimana cara menyertakan Error Stack Trace di log/slog?

Secara default, memasukkan err ke dalam slog.Any("error", err) hanya akan memanggil method err.Error(). Untuk mencetak stack trace lengkap, Anda bisa mengombinasikannya dengan library error tracing (seperti github.com/pkg/errors atau error wrapper kustom), lalu mengekstrak frame stack tersebut di dalam fungsi ReplaceAttr atau implementasi slog.LogValuer.

Mengapa log aplikasi di container sebaiknya dialirkan ke os.Stdout bukan ke file lokal?

Dalam standar container (Docker & Kubernetes), container dirancang bersifat stateless dan ephemeral (bisa dimatikan dan diganti kapan saja). Menyimpan berkas log di dalam container akan membuat ukuran storage membengkak dan menyulitkan agregasi. Mengalirkan log JSON langsung ke os.Stdout memungkinkan container engine (containerd/CRI-O) dan daemon logging node mengumpulkan log secara otomatis dan terpusat.

Kesimpulan & Production Checklist

Kehadiran log/slog telah membawa angin segar dan standardisasi resmi yang sudah lama dinantikan oleh komunitas Go. Dengan arsitektur yang modular, integrasi context yang mulus, serta performa bawaan yang tinggi, kita kini bisa membangun sistem logging level enterprise tanpa perlu terbebani dependensi eksternal.

Checklist Implementasi di Production:

  • [ ] Menggunakan slog.NewJSONHandler(os.Stdout, ...) untuk container production.
  • [ ] Mengonfigurasi ReplaceAttr untuk menyeragamkan format waktu UTC RFC 3339 dan skema nama key atribut.
  • [ ] Memasang wrapper ContextHandler agar trace_id dan request_id tersemat secara otomatis.
  • [ ] Memastikan struct entitas sensitif (kredensial, token, data kartu) mengimplementasikan slog.LogValuer untuk data masking.
  • [ ] Menggunakan slog.LevelVar agar level log dapat dinaikkan ke Debug saat investigasi insiden tanpa perlu me-restart service.
  • [ ] Memasang HTTP/gRPC logging middleware untuk memantau durasi eksekusi dan kode status response secara real-time.

Semoga panduan ini membantu Anda merapikan sistem observabilitas di backend Go. Jika Anda memiliki pertanyaan atau pendekatan unik lainnya seputar implementasi log/slog, jangan ragu untuk berbagi di kolom komentar!


Artikel Terkait Seputar Golang

Tingkatkan keahlian backend engineering dan perdalam penguasaan ekosistem Go Anda melalui rangkaian panduan pilihan berikut:

Panduan Lengkap Concurrency di Golang: Goroutine, Channel, dan Best Practice
Pelajari konsep concurrency di Golang secara mendalam: goroutine, channel, sync.WaitGroup (termasuk WaitGroup.Go), worker pool pattern, hingga cara deteksi race condition.
Cara Implementasi Graceful Shutdown di Golang
Tutorial praktis graceful shutdown di Go. Simak cara menangani sinyal, implementasi dengan berbagai framework, dan konfigurasi PM2 untuk zero-downtime deployment
Deploy Aplikasi Go dengan PM2: Panduan Lengkap
Pelajari cara mudah deploy aplikasi Go menggunakan PM2. Ikuti panduan lengkap dengan contoh kode, konfigurasi log rotasi, clustering, dan zero‑downtime reload
Panduan Lengkap Generics di Golang: Sejarah, Konsep, dan Contoh Implementasi
Panduan mendalam implementasi Generics di Golang. Pahami cara menulis kode reusable dan aman (type-safe), pola constructor, serta update Go 1.27