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.
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.

Daftar Isi
- Mengapa Memilih log/slog Dibandingkan Pustaka Pihak Ketiga?
- Arsitektur Inti: Logger vs Handler
- TextHandler vs JSONHandler
- Leveling Log dan Dynamic LevelVar
- Bekerja dengan Structured Attributes
- Loosely-Typed vs Strongly-Typed (Zero-Allocation)
- Grouping dan Scoping dengan With()
- Context dan Distributed Tracing (Trace ID / Request ID)
- Mengapa Wajib Menggunakan Context?
- Membangun Custom ContextHandler Otomatis
- Keamanan Data: Masking Sensitif dengan LogValuer
- Kustomisasi Handler Tingkat Lanjut
- Menambahkan Caller Source (AddSource)
- Transformasi Atribut dengan ReplaceAttr
- Implementasi Nyata: HTTP Logging Middleware
- Dari os.Stdout ke Log Aggregator: Arsitektur saat Scaling Horizontal
- Interoperabilitas dan Komparasi Ekosistem
- Menjadikan slog Sebagai Default Logger
- Komparasi: log/slog vs Zap vs Zerolog
- Jebakan Umum (Common Pitfalls) & Solusinya
- Frequently Asked Questions (FAQ)
- Kesimpulan & Production Checklist
- Artikel Terkait Seputar Golang
Mengapa Memilih log/slog Dibandingkan Pustaka Pihak Ketiga?
Sebelum kehadiran Go 1.21, komunitas backend Go terbelah dalam memilih pustaka logging:
- Logrus: Sangat populer pada masanya karena simpel, namun kini resmi berstatus maintenance mode dan alokasi memorinya tergolong boros untuk beban kerja tinggi.
- 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.Loggersebagai 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)slog.Logger: Lapisan antarmuka (frontend) tempat developer menulis kode logging (slog.Info(),slog.Error(), dll). Tugas utamanya adalah membungkus pesan dan atribut menjadi struktur dataslog.Record.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 pasangankey=valueberbasis 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 santaislog.Info("...", "key", val). Keterbacaan kode jauh lebih berharga. Beralihlah keslog.AttrdanLogAttrs()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:

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:

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:
- Menyeragamkan Skema Nama Key: Misalnya mengubah
msgmenjadimessage(sesuai standar Datadog atau Elasticsearch ECS), ataulevelmenjadiseverity(sesuai Google Cloud Logging). - Memformat Timestamp: Memastikan waktu selalu berada dalam format UTC RFC 3339.
- 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-IDdari 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:

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:
- 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.
- 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.
- 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)
4 Lapisan Perjalanan Satu Baris Log dari Go hingga ke Dashboard
- Aplikasi Go (Log Producer): Aplikasi Anda memanggil
slog.InfoContext(ctx, "HTTP Request Selesai Diproses", ...).slogmembentuk baris JSON dan langsung menuliskannya keos.Stdout. Di level Go, eksekusi selesai dalam hitungan mikrodetik tanpa terbebani koneksi jaringan ke server log. - Container Runtime (Docker / containerd / CRI-O): Runtime container menangkap stream
stdoutdari proses aplikasi dan menyimpannya sementara ke berkas log standar di host mesin node fisik (misalnya di/var/log/pods/<namespace>_<pod-name>/). - 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.
- 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 >= 500Dengan 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:

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
ReplaceAttruntuk menyeragamkan format waktu UTC RFC 3339 dan skema nama key atribut. - [ ] Memasang wrapper
ContextHandleragartrace_iddanrequest_idtersemat secara otomatis. - [ ] Memastikan struct entitas sensitif (kredensial, token, data kartu) mengimplementasikan
slog.LogValueruntuk data masking. - [ ] Menggunakan
slog.LevelVaragar 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:




