Panduan Lengkap Structured Logging di Go Menggunakan log/slog

Panduan praktis memakai log/slog di backend Go: JSON handler, level dinamis, trace ID, masking data sensitif, dan middleware HTTP.

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

Saat menulis kode Go, salah satu cara paling umum untuk mencatat informasi proses adalah memakai log.Println atau log.Printf. Output-nya sederhana: sebaris teks biasa, misalnya User 123 gagal checkout: saldo tidak cukup.

Untuk kebutuhan pengembangan, format seperti ini sudah enak dibaca. Masalahnya mulai terasa ketika aplikasi berjalan di production dan menghasilkan ribuan baris log. Sistem hanya melihatnya sebagai teks; ia tidak tahu mana ID pengguna, mana jenis transaksi, dan mana pesan error.

Akibatnya, mencari kasus tertentu jadi lebih merepotkan. Misalnya, saat ingin melihat semua transaksi gagal untuk User 123, kita harus mengandalkan pencarian teks dan berharap format pesannya selalu sama.

Structured logging menyelesaikan masalah tersebut dengan mencatat data sebagai pasangan key-value, biasanya dalam bentuk JSON. Jadi, informasi seperti user_id, event, dan error mempunyai label sendiri. Log lebih mudah difilter, dikelompokkan, dan dipakai untuk mencari masalah.

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

Daftar Isi

Mengapa Memilih log/slog?

Sebelum adanya Go 1.21, saat kita ingin membuat log terstruktur di Go, pilihannya biasanya hanya ke beberapa library populer berikut ini:

  • Logrus: Dulu sangat populer karena mudah dipakai. Saat ini proyeknya berstatus maintenance mode. Jika performa logging menjadi pertimbangan utama, sebaiknya bandingkan lewat benchmark yang mendekati beban aplikasi sendiri.
  • Zap & Zerolog: Dirancang untuk performa tinggi dan penggunaan alokasi yang rendah. Keduanya tetap library pihak ketiga. Saat memakai beragam modul open-source, masing-masing modul juga bisa membawa dependensi logger yang berbeda sehingga format log menjadi tidak seragam.

Kehadiran log/slog menjadi alternatif pilihan standar resmi untuk ekosistem Go:

  • Bawaan standard library: Tidak menambah dependensi eksternal di go.mod dan mengikuti Go 1 compatibility promise yang berlaku untuk API standard library, dengan pengecualian umum dalam kebijakan kompatibilitas Go.
  • Interface Bersama: Pembuat library (client database, message broker, HTTP router) bisa menerima *slog.Logger sebagai kontrak standar tanpa memaksa pengguna menginstal pustaka logging tertentu.
  • Pilihan Performa: Menyediakan API info standar (slog.Info) untuk alur bisnis umum, sekaligus API strongly-typed (slog.Attr, LogAttrs) saat bekerja di jalur kritis (hot path) yang membutuhkan minim alokasi heap.

Arsitektur Inti: Logger vs Handler

log/slog memisahkan tanggung jawab logging 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: Adalah fungsi/method yang dipanggil di kode aplikasi (slog.Info(), slog.Error(), dll). Tugasnya mengemas pesan dan atribut ke dalam struktur data slog.Record.
  • slog.Handler: Bagian pemrosesan (backend). Handler menentukan apakah level log diizinkan, bagaimana data diformat (teks biasa atau JSON), dan ke mana log ditulis (io.Writer).

TextHandler vs JSONHandler

Go menyediakan dua handler bawaan yang bisa langsung kita gunakan:

  • slog.NewTextHandler: Menghasilkan format teks key=value. Format ini lebih nyaman dibaca langsung di terminal pada saat pengembangan.
  • slog.NewJSONHandler: Menghasilkan baris log dalam format JSON. Ini adalah format standar yang biasanya digunakan di zona production agar sistem agregasi log bisa memproses datanya secara otomatis.

Contoh perbandingannya:

package main

import (
	"log/slog"
	"os"
)

func main() {
	// TextHandler untuk 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)

	// JSONHandler untuk 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)
}

Output di terminal:

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 default:

Level Nilai Integer Kapan Digunakan
LevelDebug -4 Diagnostik mendalam, payload detail, atau tracing internal saat debugging
LevelInfo 0 Aktivitas normal sistem (service start, proses transaksi selesai)
LevelWarn 4 Kejadian tak terduga yang tidak menghentikan sistem (retry koneksi, disk mendekati batas)
LevelError 8 Kegagalan proses (koneksi payment timeout, query database gagal total)

Pada zona production, level log biasanya diatur pada LevelInfo agar kapasitas penyimpanan tidak cepat habis oleh pesan-pesan diagnostik. Masalahnya, saat muncul kendala atau bug tertentu di server, kita sering kali butuh memunculkan log Debug tanpa harus mematikan atau me-restart aplikasi.

Untuk kebutuhan ini, kita bisa memanfaatkan slog.LevelVar. Fitur ini memungkinkan kita mengubah level log aplikasi secara dinamis saat sistem sedang berjalan (runtime), contohnya pada endpoint HTTP khusus admin:

package main

import (
	"log/slog"
	"os"
)

func main() {
	// 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 Debug ini diabaikan karena level aktif masih INFO
	logger.Debug("Pesan diagnostik tersembunyi")

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

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

Bekerja dengan Structured Attributes

Loosely-Typed vs Strongly-Typed (Minim Alokasi)

Saat menambahkan atribut ke log, slog menyediakan dua pendekatan:

1. Loosely-Typed (Key-Value Bergantian) Pendekatan ini paling sederhana. Kita tinggal memasukkan nama key (string) dan nilainya (any) secara bergantian:

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

2. Strongly-Typed (`slog.Attr`) Pada fungsi yang dieksekusi ratusan ribu kali per detik (hot path), passing tipe any dapat menyebabkan alokasi memori ke heap (escape analysis). Untuk skenario dimana performa harus diutamakan, maka pendekatan loosely ini menjadi kurang tepat. Go menyediakan konstruktor strongly-typed seperti slog.String(), slog.Int(), slog.Duration(), yang dipanggil melalui 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),
)
Menurut saya, untuk sebagian besar kode bisnis umum, gaya loosely-typed slog.Info("...", "key", val) sudah sangat memadai. Keterbacaan kode lebih berharga ketimbang optimasi dini. Gunakan slog.Attr dan LogAttrs() jika profiling memori memang menunjukkan alokasi logging menjadi bottleneck.

Grouping dan Scoping Atribut

Sub-Logger dengan logger.With() . Jika ada atribut yang harus selalu muncul di setiap baris log pada suatu modul (misalnya nama service atau versi), maka kita bisa menambahkan sub-logger dengan With():

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

// Log otomatis menyertakan service="order-service" dan version="v2.1.0"
orderLogger.Info("Worker pemrosesan order berhasil diinisialisasi")

Pengelompokan dengan slog.Group . Bila log aggregator mendukung format JSON bertingkat (nested JSON), atribut terkait bisa dikelompokkan:

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-nya akan lebih terstruktur rapi:

{
  "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"
  }
}

Membawa Trace ID lewat context

Pada backend yang menangani banyak request bersamaan atau terdiri dari beberapa service, menelusuri alur satu request biasanya membutuhkan penanda khusus yang unique , biasanya disebut _trace_id_ atau _request_id_.

Go sudah menyediakan fungsi log berbasis context tersebut, seperti slog.InfoContext(ctx, ...) dan slog.ErrorContext(ctx, ...). Masalahnya, jika developer harus menyisipkan trace_id secara manual di setiap baris kode pencatatan log, maka cara ini sangat mungkin akan terlewat.

Solusi yang lebih rapi adalah dengan membuat wrapper custom pada slog.Handler. Handler ini bertugas memeriksa context.Context, mengekstrak nilai _trace_id_, lalu memasukkannya secara otomatis ke baris log sebelum dicetak:

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 {
		if traceID, ok := ctx.Value(TraceIDKey).(string); ok && traceID != "" {
			r.AddAttrs(slog.String("trace_id", traceID))
		}
		if reqID, ok := ctx.Value(RequestIDKey).(string); ok && reqID != "" {
			r.AddAttrs(slog.String("request_id", reqID))
		}
	}
	return h.Handler.Handle(ctx, r)
}

Dengan cara ini, maka pemakaian di kode aplikasi menjadi lebih praktis. Kita cukup memanggil InfoContext dengan context yang aktif:

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

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

	// Trace ID otomatis tersemat di output log
	logger.InfoContext(ctx, "Query database selesai", "duration_ms", 12)
}

Output log JSON yang dihasilkan:

{
  "time": "2026-03-14T08:40:00.000Z",
  "level": "INFO",
  "msg": "Query database selesai",
  "duration_ms": 12,
  "trace_id": "c4b9-4f81-9b7e-9023"
}
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

Kebocoran data rahasia sering kali terjadi karena hal sederhana: saat mencari penyebab masalah, kita mencetak seluruh struct data pengguna ke log, sehingga data seperti password plaintext, nomor token, atau API key ikut terkirim ke sistem pencatat log.

log/slog telah mengantisipasi masalah ini melalui fitur interface slog.LogValuer:

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

Ketika sebuah struct mengimplementasikan method LogValue(), handler slog tidak akan mencetak nilai asli dari struct tersebut, melainkan mencetak data yang sudah kita amankan melalui method itu.

Contoh implementasinya:

package main

import (
	"log/slog"
	"os"
)

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

// LogValue memastikan password dan API key tersanitasi sebelum dicetak
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 passing struct user utuh, nilainya otomatis tersanitasi
	logger.Info("Pendaftaran akun pengguna baru berhasil", slog.Any("user", user))
}

Output log JSON yang aman:

{
  "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"
  }
}

Merapikan Output dengan Handler

slog.HandlerOptions menyediakan opsi penyesuaian format output:

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

Menambahkan Caller Source (AddSource)

Dengan mengaktifkan AddSource: true, handler akan otomatis menyertakan fungsi, nama file, dan baris kode 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 dieksekusi pada setiap atribut sebelum dicetak. Fitur ini berguna untuk:

  • Menyeragamkan Key: Mengubah msg menjadi message (standar Elasticsearch ECS / Datadog), atau level menjadi severity (Google Cloud Logging).
  • Format Timestamp: Mengunci format waktu ke UTC RFC 3339.
  • Memotong File Path: Mengubah path absolut menjadi nama file relatif agar log lebih ringkas dan tidak mengekspos direktori mesin server.

Contoh konfigurasi logger production:

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 {
			// Format waktu menjadi UTC RFC 3339
			if a.Key == slog.TimeKey {
				return slog.String("timestamp", a.Value.Time().UTC().Format(time.RFC3339))
			}

			// Sesuaikan key "msg" menjadi "message"
			if a.Key == slog.MessageKey {
				return slog.Attr{Key: "message", Value: a.Value}
			}

			// Ubah "level" menjadi "severity"
			if a.Key == slog.LevelKey {
				return slog.Attr{Key: "severity", Value: a.Value}
			}

			// Potong path absolut agar hanya menampilkan nama file
			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 JSON menjadi rapi dan lebih mudah disesuaikan dengan skema ingestion yang dipakai:

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

Middleware HTTP untuk Mencatat Request

Berikut ini adalah contoh implementasi dari teori di atas, yaitu dalam bentuk HTTP Logging Middleware standar (net/http) yang bisa digunakan langsung:

  • Membaca X-Request-ID dari header request atau membuat ID baru jika kosong.
  • Menyimpan Request ID ke dalam context.
  • Menggunakan wrapper ResponseWriter untuk menangkap HTTP status code.
  • Menghitung latency pemrosesan.
  • Menyesuaikan level log berdasarkan status HTTP (5xx jadi Error, 4xx jadi Warn, lainnya Info).
package main

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

type contextKey string

const RequestIDKey contextKey = "request_id"

type responseWriterInterceptor struct {
	http.ResponseWriter
	statusCode int
}

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

func generateRequestID() string {
	bytes := make([]byte, 8)
	_, _ = rand.Read(bytes)
	return hex.EncodeToString(bytes)
}

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()

			reqID := r.Header.Get("X-Request-ID")
			if reqID == "" {
				reqID = generateRequestID()
			}

			ctx := context.WithValue(r.Context(), RequestIDKey, reqID)
			r = r.WithContext(ctx)

			interceptor := &responseWriterInterceptor{
				ResponseWriter: w,
				statusCode:     http.StatusOK,
			}

			next.ServeHTTP(interceptor, r)

			duration := time.Since(start)

			level := slog.LevelInfo
			if interceptor.statusCode >= 500 {
				level = slog.LevelError
			} else if interceptor.statusCode >= 400 {
				level = slog.LevelWarn
			}

			logger.LogAttrs(ctx, level, "HTTP Request Selesai",
				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"}`))
	})

	loggedMux := HTTPLoggingMiddleware(logger)(mux)

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

Ketika endpoint diakses, log JSON dengan latency dan status code tercatat:

{
  "time": "2026-03-14T08:50:15.821Z",
  "level": "INFO",
  "msg": "HTTP Request Selesai",
  "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"
}
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

Pada seluruh contoh konfigurasi di atas, handler selalu diarahkan ke os.Stdout:

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

Mungkin timbul pertanyaan praktis: mengapa log dicetak ke os.Stdout (layar terminal) dan bukan disimpan ke file log lokal di server? Pada deployment berbasis container seperti Docker atau Kubernetes, menyimpan file lokal sebagai satu-satunya sumber log biasanya membawa beberapa masalah:

  1. Pod Bersifat Ephemeral: Container bisa di-restart, dipindahkan antar-node, atau di-scale down kapan saja. Saat container musnah, file log lokal ikut hilang.
  2. Sulit Ditelusuri saat Scale Out: Jika aplikasi berjalan di 20 pod, memeriksa file lokal satu per satu di tiap pod jelas tidak praktis.
  3. Risiko Disk Penuh: Berkas log yang menumpuk di layer container bisa memenuhi disk node host (disk pressure).

Oleh karena itu, praktik yang umum digunakan (mengikuti prinsip Twelve-Factor App) adalah membiarkan aplikasi Go mencetak stream log ke os.Stdout secepat mungkin. Urusan mengumpulkan, menyaring, dan menyimpan log diserahkan ke agen pengumpul log (log shipper):

[ 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 (S3 / MinIO)
                                              |
                                              v
                                 [ Dashboard Observabilitas ]
                                 (Grafana / Kibana untuk query terpusat)
Animasi Alur Log Stream dari Pod Go ke Grafana Loki
Animasi Alur Log Stream dari Pod Go ke Grafana Loki

Aplikasi Go kita cukup fokus memformat log ke JSON dan mengalirkannya ke stdout. Agen log shipper (seperti Promtail atau Fluent Bit yang berjalan sebagai DaemonSet pada tiap node server) akan membaca berkas log runtime, menambahkan metadata (seperti nama pod dan namespace), lalu mengirimkannya secara efisien ke sistem agregasi sentral (seperti Grafana Loki atau Elasticsearch).

Karena log sudah berformat JSON dan memiliki label yang jelas, pencarian masalah di dashboard menjadi sangat mudah. Contohnya pada Grafana Loki menggunakan query LogQL:

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

Atau memfilter request yang lambat dan error:

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

Berbagi Logger dengan Library Lain

Menjadikan slog Sebagai Default Logger

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

slog.SetDefault(logger)

Sementara untuk library eksternal yang masih memakai package log standar bawaan Go, kita bisa menjembataninya (bridge) menggunakan slog.NewLogLogger agar output-nya tetap seragam:

legacyLogger := slog.NewLogLogger(logger.Handler(), slog.LevelWarn)

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 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
Alokasi Memori Rendah (dengan LogAttrs) Sangat Rendah (Zero-allocation) Sangat Rendah (Zero-allocation)
Integrasi Context Bawaan (*Context + Custom Handler) Memerlukan wrapper zapctx Memerlukan helper terpisah
Data Masking Bawaan (slog.LogValuer) Kustom Encoder Kustom Hook
Rotasi Berkas Tidak Ada (Gunakan logrotate / lumberjack) Tidak Ada Tidak Ada
Menurut pengalaman saya di backend service biasa (REST/gRPC API), performa log/slog sudah lebih dari cukup untuk menangani throughput produksi. Keuntungan tidak menambah dependensi eksternal di go.mod sering kali lebih bernilai praktis dibanding selisih mikrodetik alokasi, kecuali pada sistem ultra-low latency atau HFT yang memang menuntut alokasi memori nol secara ketat.

Tiga Jebakan yang Sering Terlewat

1. Evaluasi Dini (Eager Evaluation) pada Parameter Log

Parameter fungsi di Go selalu dievaluasi saat baris fungsi dipanggil (eager evaluation):

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

Jika serializePayload melakukan serialisasi berat, resource CPU terbuang percuma untuk menyiapkan parameter yang pada akhirnya langsung dibuang oleh logger.

Solusi: Cek apakah log level aktif terlebih dahulu dengan logger.Enabled():

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

2. Caller Loss Saat Membuat Helper Function

Jika kita membuat fungsi pembungkus (helper function):

// JEBAKAN: AddSource akan mencatat helper.go sebagai sumber baris kode, bukan lokasi pemanggil sebenarnya!
func LogError(logger *slog.Logger, msg string, err error) {
    logger.Error(msg, "error", err)
}

Dengan AddSource: true, logger akan selalu mencatat file helper.go. Informasi baris kode asli tempat error terjadi pun hilang.

Solusi: Jika perlu membuat wrapper logging sendiri, gunakan runtime.Callers dan konstruksi slog.NewRecord secara manual agar kedalaman call stack terhitung dengan benar.

3. Error Stack Trace

Secara default, memasukkan err ke slog.Any("error", err) hanya akan mencetak pesan string dari method err.Error(). Jika membutuhkan stack trace lengkap, kita bisa membungkus error dengan library tracing error (atau wrapper kustom), lalu mengekstrak frame stack-nya melalui ReplaceAttr atau implementasi slog.LogValuer.

Penutup

Catatan ini saya tulis sebagai pengingat saat mengerjakan backend Go dan mulai berhadapan dengan log yang semakin ramai. log/slog sendiri punya cukup banyak bagian untuk dipelajari, dan mungkin ada pendekatan lain yang lebih cocok untuk kebutuhan proyek masing-masing.

Semoga contoh dan catatan di atas bisa menjadi titik awal kalau sedang mencoba merapikan logging di aplikasi Go. Terima kasih sudah membaca, selamat mencoba, dan semoga catatan kecil ini bermanfaat.

Bacaan Lanjutan

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