Progress belajar
Modul 35 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Logging Strategy yang Observable
Backend yang Bisa Ditelusuri
Di chapter ini kita merakit logging Go yang mudah dicari, aman dari kebocoran rahasia, dan benar-benar berguna saat checkout skincare bermasalah di staging atau production.
Kenapa Logging Harus Observable?
Log bukan catatan yang asal tampil, log adalah alat investigasi ketika sistem hidup.
Developer React membaca error dari browser console, developer Laravel membuka storage/logs/laravel.log, tetapi backend Go di production butuh log yang bisa ditelusuri lintas request, lintas service, dan lintas jam.
Pada online shop skincare, masalah yang paling mahal jarang muncul sebagai crash yang jelas. Checkout gagal karena stok berubah di detik terakhir, webhook pembayaran datang dua kali, atau customer service menelepon menanyakan order yang statusnya tertahan di pending sejak pagi. Tanpa log yang rapi, kita cuma punya dugaan dan tangkapan layar buram dari pelanggan.
Logging observable berarti setiap event penting ditulis dengan format konsisten (key-value), membawa konteks yang cukup untuk menjawab “request mana, user mana, order mana, dan berapa lama”, lalu bisa dicari dengan presisi di sistem log seperti CloudWatch Logs Insights, bukan cuma dibaca berurutan dari atas ke bawah.
console.log enak untuk debugging lokal, dan Laravel punya Monolog yang bisa diarahkan ke banyak channel lewat config/logging.php. Padanannya di Go adalah log/slog dari standard library: bedanya, kita langsung menulis log JSON sejak awal supaya mesin bisa memfilter field seperti request_id, user_id, dan duration_ms, bukan sekadar membaca string panjang. Tidak ada package eksternal yang perlu dipasang.
Tujuan chapter ini bukan membuat log sebanyak-banyaknya. Tujuannya membuat log yang menjawab pertanyaan production dengan satu query: request mana yang gagal, user mana yang terdampak, order mana yang bermasalah, di tahap apa kegagalannya, dan berapa lama operasi itu berjalan. Itulah arti “men-debug masalah production lebih mudah”.
- cmd/
- api/
- main.go pasang logger global, RequestID, AccessLog
- internal/
- logging/
- logger.go setup slog JSON handler + level dinamis
- context.go simpan/ambil logger lewat context.Context
- redact.go tipe Secret pakai slog.LogValuer
- httpmw/
- request_id.go buat/teruskan X-Request-ID ke context
- access_log.go satu log ringkas per HTTP request
- order/
- service.go contoh business log untuk checkout
String Log vs Structured Log
Bedanya bukan soal tampilan, tetapi soal bagaimana log dicari oleh manusia dan mesin.
String log mudah ditulis tetapi sulit dicari dengan akurat. Structured log menulis event sebagai pasangan key-value, sehingga sistem log bisa memfilter level, request_id, user_id, atau order_id tanpa regex yang rapuh.
Bayangkan tiga ribu baris log dari ratusan checkout bercampur dalam satu menit di production. Dengan string log, kamu menelusuri dengan grep dan berharap format pesannya konsisten. Dengan structured log, kamu menulis satu query order_id = 1001 AND level = "ERROR" dan langsung mendapat semua kegagalan untuk order itu, terurut waktu.
checkout failed for user 42 order 1001enak dibaca manusia, tetapi sulit difilter tanpa regex.- Format berubah antar developer (kadang “user 42”, kadang “uid=42”).
- Field penting tenggelam di dalam teks pesan, tidak bisa di-query.
- Agregasi (berapa banyak checkout gagal per jam) hampir mustahil.
msg,level,user_id,order_id, danerrorpunya field masing-masing.- Query log mencari
order_id=1001dengan presisi, bukan kira-kira. - Format identik di API, worker, dan webhook processor.
- Bisa diagregasi: hitung, kelompokkan, dan beri alert otomatis.
String log: mudah ditulis, sulit di-query2026/06/06 10:15:43 checkout failed for user 42 order 1001 payment timeout
Structured log: setiap konteks jadi field{ "time": "2026-06-06T10:15:43.512Z", "level": "ERROR", "msg": "checkout failed", "service": "skincare-api", "env": "production", "request_id": "req_8f2c1a4d", "user_id": 42, "order_id": 1001, "stage": "authorize_payment", "error": "payment gateway timeout" }
Di React kamu mungkin menulis console.log("checkout failed", { userId, orderId }) dan browser DevTools menampilkannya sebagai objek yang bisa di-expand. slog membawa ide yang sama ke server: pesan tetap satu string pendek, dan konteksnya ditulis sebagai attribute terstruktur. Bedanya, output JSON-nya dirancang untuk dibaca mesin di production, bukan mata manusia di DevTools.
slog punya TextHandler (format key=value) dan JSONHandler. Teks lebih enak dibaca di terminal lokal, JSON lebih mudah di-parse mesin. Di Roadmap 8 kita akan menjalankan API di container yang menulis ke stdout, dan CloudWatch Logs Insights mem-parse JSON secara native. Karena itu kita pakai JSON di mana pun kecuali saat sengaja ingin output ramah-mata di laptop sendiri.
Posisi Logging di Modular Monolith
Logging adalah cross-cutting concern, ia menembus semua layer tetapi punya aturan main.
Di Chapter 1 sampai 4 kamu sudah menata handler, service, repository, config, dan error. Logging adalah lapis yang berbeda sifatnya: ia menembus semuanya. Justru karena itu, ia butuh aturan agar tidak berubah jadi kebisingan.
Aturan mainnya sederhana: middleware HTTP mencatat satu access log per request, service mencatat event bisnis dan kegagalan di boundary yang punya konteks, repository umumnya tidak melog (ia mengembalikan error ke atas), dan error yang sama tidak dilog dua kali di layer berbeda.
flowchart TD
FE["React Storefront"] -->|HTTP JSON| MW["httpmw: RequestID + AccessLog"]
MW -->|"context: request_id + logger"| H["chi Handler"]
H -->|"FromContext(ctx)"| S["Order Service"]
S --> R["Repository (pgx)"]
R --> DB[("PostgreSQL")]
MW -. "1 access log / request" .-> OUT[("stdout JSON")]
S -. "business log + error log" .-> OUT
R -. "tidak melog, kembalikan error" .-> SGambar 1. Logging menembus semua layer, tetapi dengan pembagian kerja: middleware menulis access log, service menulis business log dan error log, repository hanya mengembalikan error ke atas. Semua mengalir ke stdout sebagai JSON.
Middleware
Menulis tepat satu access log per request: method, path, status, duration_ms, plus request_id. Ia juga yang menanam logger ber-konteks ke context.Context.
Service
Menulis business log (order dibuat, webhook diterima) dan error log di boundary, lengkap dengan stage dan field bisnis. Inilah lapis yang paling banyak melog.
Repository
Umumnya tidak melog. Ia mengembalikan error domain ke service (lihat Chapter 4), dan biarkan service yang punya konteks bisnis memutuskan apa yang dilog.
Di Laravel, request logging sering dipasang sebagai middleware di app/Http/Middleware atau lewat Log::channel(...). Pola di Go nyaris sama: logging request adalah middleware yang membungkus handler. Bedanya, di Go logger ber-konteks diteruskan eksplisit lewat context.Context, bukan diambil dari facade global Log::. Lebih verbose, tetapi setiap log line tahu persis request mana yang memilikinya.
Godaan terbesar pemula adalah melog error di repository, lalu melog lagi di service, lalu melog lagi di handler. Hasilnya: satu kegagalan checkout muncul tiga kali dengan pesan berbeda, dan investigasi malah membingungkan. Pilih satu boundary (biasanya service atau handler) sebagai tempat melog, dan layer di bawahnya cukup mengembalikan error yang sudah dibungkus konteks (fmt.Errorf("...: %w", err)).
Setup slog dengan JSON Handler
Go sudah punya structured logging di standard library sejak Go 1.21, dan ia matang di seri 1.26.
Paket log/slog adalah pilihan default yang aman untuk memulai: structured logging dengan level, message, dan attribute, tanpa membawa satu pun dependency eksternal.
Paket resmi log/slog menyediakan tiga lapis konsep: Logger (API yang kamu panggil), Handler (yang memutuskan format dan tujuan output), dan Attr (pasangan key-value). Untuk production kita pakai slog.NewJSONHandler yang menulis JSON ke os.Stdout. Di container dan AWS, pola standarnya adalah aplikasi menulis ke stdout, lalu platform (ECS, CloudWatch) yang mengumpulkan dan menyimpan log itu.
internal/logging/logger.gopackage logging import ( "log/slog" "os" "strings" ) // Config diisi dari Chapter 3 (Configuration Management). type Config struct { Env string // "local", "staging", "production" LogLevel string // "debug", "info", "warn", "error" } // NewLogger membangun satu logger proses, dipasang sekali saat startup. func NewLogger(cfg Config) *slog.Logger { // LevelVar bisa diubah saat runtime tanpa membuat handler baru. level := new(slog.LevelVar) level.Set(parseLevel(cfg.LogLevel)) var handler slog.Handler opts := &slog.HandlerOptions{ Level: level, // AddSource menambah file:line. Berguna di lokal, mahal di production. AddSource: cfg.Env != "production", } if cfg.Env == "local" { // Lokal: teks ramah-mata di terminal. handler = slog.NewTextHandler(os.Stdout, opts) } else { // Staging/production: JSON untuk mesin (CloudWatch). handler = slog.NewJSONHandler(os.Stdout, opts) } // With menanam field tetap yang ikut di SETIAP log line proses ini. logger := slog.New(handler).With( slog.String("service", "skincare-api"), slog.String("env", cfg.Env), ) // SetDefault agar slog.Info(...) global juga memakai handler ini, // termasuk log dari library yang memakai slog.Default(). slog.SetDefault(logger) return logger } func parseLevel(s string) slog.Level { switch strings.ToLower(s) { case "debug": return slog.LevelDebug case "warn": return slog.LevelWarn case "error": return slog.LevelError default: return slog.LevelInfo } }
JSON handler membuat setiap attribute jadi field eksplisit yang stabil untuk query. Mencari status >= 500 di JSON itu satu klausa; mencari potongan string yang sama di log teks butuh regex yang patah begitu ada developer mengubah kalimat pesan. Pisahkan format berdasarkan environment: teks ramah-mata di lokal, JSON di staging dan production.
Buat logger sekali saat startup, lalu teruskan lewat dependency injection atau context. Logger boleh diperkaya dengan field tambahan via With (itu murah, ia menyalin pointer dan menyimpan attribute), tetapi membangun NewJSONHandler baru untuk tiap request memboroskan alokasi dan kehilangan field tetap proses.
Go 1.26 (rilis 10 Februari 2026) menambahkan slog.NewMultiHandler ke standard library, sehingga satu logger bisa menulis ke beberapa tujuan sekaligus tanpa library pihak ketiga, misalnya JSON ke stdout untuk CloudWatch plus teks ke file lokal saat debugging. Untuk modul ini satu handler sudah cukup, tetapi simpan ini saat nanti butuh fan-out di Roadmap 8.
Log Level: Disiplin Bersama
Level menjawab seberapa penting, field menjawab konteksnya apa.
Structured log baru berguna kalau seluruh tim sepakat kapan memakai Debug, Info, Warn, dan Error. Tanpa kesepakatan, level jadi acak dan filter level = "ERROR" kehilangan arti.
slog punya empat level bawaan dengan nilai numerik (Debug = -4, Info = 0, Warn = 4, Error = 8). Handler hanya menulis log yang levelnya sama atau lebih tinggi dari Level yang dipasang. Karena kita memakai slog.LevelVar, ambang ini bisa kamu naik-turunkan tanpa restart, misalnya menurunkan ke Debug sementara saat sedang menyelidiki insiden, lalu menaikkan lagi ke Info.
Debug
Detail teknis untuk investigasi di lokal atau staging: payload yang sudah disanitasi, keputusan branching internal, hasil antara. Biasanya dimatikan di production.
Info
Event bisnis normal yang penting: order dibuat, webhook payment diterima, worker selesai memproses job. Inilah denyut nadi sistem yang sehat.
Warn
Kondisi tidak ideal tetapi sistem masih jalan: stok hampir habis, signature webhook gagal karena request buruk, retry ke provider. Layak dipantau, belum perlu dibangunkan tengah malam.
Error
Operasi gagal dan butuh investigasi: checkout gagal, query database error, provider payment timeout. Inilah yang memicu alert di Roadmap 8.
Field yang konsisten lebih penting daripada pesan yang puitis. Untuk API skincare, kosakata field minimum yang sering dipakai adalah request_id, method, path, status, duration_ms, user_id, order_id, variant_id, stage, dan error. Sepakati nama-nama ini sekali, lalu pakai persis sama di mana pun.
internal/order/service.go// Event normal: pakai Info. Field bisnis sebagai Attr. logger.InfoContext(ctx, "checkout completed", slog.Int64("user_id", userID), slog.Int64("order_id", order.ID), slog.Int64("total_rupiah", order.TotalRupiah), slog.Int("item_count", len(items)), ) // Kondisi tidak ideal tapi belum gagal: pakai Warn. logger.WarnContext(ctx, "inventory is low", slog.Int64("variant_id", variantID), slog.Int("remaining_stock", remainingStock), ) // Operasi gagal: pakai Error, sertakan stage dan error. logger.ErrorContext(ctx, "checkout failed", slog.Int64("user_id", userID), slog.String("stage", "reserve_inventory"), slog.String("error", err.Error()), )
Di Laravel kamu menulis Log::info('checkout completed', ['order_id' => $id]) dengan array context. Di slog, context ditulis sebagai attribute bertipe seperti slog.Int64("order_id", id). Bedanya yang penting: slog meminta kamu menyebut tipe (Int64, String, Duration), sehingga order_id selalu angka di JSON, bukan kadang string kadang angka. Konsistensi tipe ini yang membuat query agregasi tidak patah.
Selalu pilih varian InfoContext/ErrorContext agar ctx ikut (penting untuk cancellation dan, nanti, tracing). Untuk path yang sangat sering dipanggil, logger.LogAttrs(ctx, slog.LevelInfo, msg, attrs...) sedikit lebih cepat karena menerima []slog.Attr langsung tanpa konversi ...any.
Request ID lewat Middleware chi
Satu request checkout melewati handler, service, repository, dan payment client. Request ID adalah benang merahnya.
Tanpa request ID, log dari ratusan pelanggan bercampur jadi satu. Dengan request ID, kamu bisa menarik semua event satu request checkout: dari POST /v1/orders, validasi cart, pengurangan stok, otorisasi pembayaran, sampai response akhir, lalu membacanya berurutan.
Kita membuat middleware chi yang membaca header X-Request-ID dari client (kalau ada, misalnya dari API gateway), atau membangkitkan satu yang baru, lalu menyimpannya di context.Context dan menempelkannya di response. chi v5 punya middleware.RequestID bawaan, tetapi membuat sendiri membuat kamu paham persis apa yang terjadi dan bebas memilih format ID-nya.
sequenceDiagram participant FE as React Client participant MW as RequestID Middleware participant H as Order Handler participant S as Checkout Service participant DB as PostgreSQL FE->>MW: POST /v1/orders MW->>MW: baca atau buat X-Request-ID MW->>H: ctx + request_id H->>S: Checkout(ctx, userID) S->>DB: reserve stock, insert order S-->>H: order result H-->>FE: 201 Created (header X-Request-ID)
Gambar 2. request_id lahir di middleware, ikut mengalir lewat context.Context ke setiap layer, dan dipantulkan kembali ke client lewat header response.
internal/httpmw/request_id.gopackage httpmw import ( "context" "crypto/rand" "encoding/hex" "net/http" ) const requestIDHeader = "X-Request-ID" // contextKey unexported agar tidak bentrok dengan key package lain. type requestIDContextKey struct{} // RequestID membaca atau membangkitkan request_id, lalu menaruhnya di context. func RequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestID := r.Header.Get(requestIDHeader) if requestID == "" { requestID = newRequestID() } // Pantulkan ke client agar frontend bisa menyimpan/menampilkannya. w.Header().Set(requestIDHeader, requestID) ctx := context.WithValue(r.Context(), requestIDContextKey{}, requestID) next.ServeHTTP(w, r.WithContext(ctx)) }) } // RequestIDFromContext mengambil request_id; aman dipanggil di layer mana pun. func RequestIDFromContext(ctx context.Context) string { requestID, ok := ctx.Value(requestIDContextKey{}).(string) if !ok { return "" } return requestID } func newRequestID() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return "req_unknown" } return "req_" + hex.EncodeToString(b[:]) }
Di Node, untuk membawa request ID lintas fungsi tanpa mengoper argumen, kamu mungkin pakai AsyncLocalStorage. Go tidak punya penyimpanan implisit per-request semacam itu, dan itu disengaja: nilai per-request dibawa eksplisit lewat context.Context yang dioper sebagai parameter pertama. Lebih banyak ketikan, tetapi tidak ada keadaan tersembunyi yang sulit dilacak saat dua request berjalan bersamaan.
context.WithValue(ctx, "request_id", id) dengan key string berbahaya: package lain bisa memakai string yang sama dan saling menimpa nilai. Idiom Go adalah tipe unexported type requestIDContextKey struct{} sebagai key, sehingga key-nya unik secara tipe dan tidak mungkin bentrok dengan package lain.
Logger di Context: User ID Otomatis
Daripada mengoper request_id dan user_id ke setiap pemanggilan log, tanam logger ber-konteks sekali.
Kita bisa mengoper request_id ke setiap logger.Info(...), tetapi itu berulang dan mudah lupa. Cara yang lebih bersih: simpan satu *slog.Logger yang sudah membawa field request di dalam context.Context, lalu setiap layer mengambilnya dengan FromContext(ctx).
Logger yang sudah diperkaya dengan With(slog.String("request_id", ...)) akan menyertakan field itu di setiap log line, otomatis, tanpa pemanggil perlu mengingatnya. Saat auth middleware (dari Roadmap 2 / Roadmap 7) sudah menaruh user_id di context, kita perkaya logger dengan user_id juga, sehingga seluruh log untuk request itu langsung membawa siapa pelakunya.
internal/logging/context.gopackage logging import ( "context" "log/slog" ) type loggerContextKey struct{} // IntoContext menyimpan logger (yang sudah diperkaya) ke dalam context. func IntoContext(ctx context.Context, logger *slog.Logger) context.Context { return context.WithValue(ctx, loggerContextKey{}, logger) } // FromContext mengambil logger ber-konteks; jatuh ke slog.Default() bila tak ada, // sehingga aman dipanggil di mana pun tanpa takut nil pointer. func FromContext(ctx context.Context) *slog.Logger { logger, ok := ctx.Value(loggerContextKey{}).(*slog.Logger) if !ok || logger == nil { return slog.Default() } return logger }
Field user_id diperkaya di middleware autentikasi, setelah token diverifikasi. Polanya: ambil logger dari context, tambahkan user_id, lalu simpan kembali logger yang sudah diperkaya itu ke context untuk layer berikutnya.
internal/httpmw/enrich_user.gopackage httpmw import ( "log/slog" "net/http" "github.com/kamu/skincare-backend/internal/logging" ) // EnrichUser dipasang SETELAH middleware auth menaruh user_id di context. // Ia memperkaya logger ber-konteks dengan user_id agar ikut di semua log line. // // UserIDFromContext disediakan oleh middleware autentikasi (Roadmap 7) dan // tinggal di package httpmw yang sama, jadi forward-reference ini akan tersedia // begitu modul autentikasi terpasang. func EnrichUser(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, ok := UserIDFromContext(ctx) if ok { logger := logging.FromContext(ctx).With(slog.Int64("user_id", userID)) ctx = logging.IntoContext(ctx, logger) } next.ServeHTTP(w, r.WithContext(ctx)) }) }
Laravel 8+ punya Log::withContext(['user_id' => $id]) yang menyisipkan field ke semua log berikutnya dalam request itu. slog mencapai hasil yang sama dengan logger.With(...) lalu menyimpannya di context.Context. Bedanya, di Go konteks itu eksplisit terbawa lewat ctx, jadi log dari goroutine yang kamu jalankan untuk request lain tidak akan keliru mengambil user_id orang lain.
Begitu request_id dan user_id sudah menempel di logger dalam context, service cukup memanggil logging.FromContext(ctx).InfoContext(ctx, "checkout completed", ...) dan kedua field itu ikut tanpa diketik ulang. Field bisnis spesifik (order_id, stage) ditambahkan di titik pemanggilan, field request mengalir otomatis.
Access Log Middleware
Setiap request HTTP perlu satu log ringkas yang konsisten: siapa, ke mana, hasilnya apa, berapa lama.
Access log adalah satu baris per request yang mencatat metode, path, status, dan durasi. Ia juga tempat yang tepat untuk menanam logger ber-konteks (dengan request_id) ke context.Context, sehingga layer di bawahnya tinggal mengambilnya.
Di Go, middleware adalah fungsi yang membungkus http.Handler. Karena kita perlu tahu status code yang akhirnya ditulis handler, kita bungkus http.ResponseWriter dengan recorder kecil yang mengintip WriteHeader. Setelah handler selesai, kita hitung durasi dan tulis satu log dengan level yang sesuai status: Error untuk 5xx, Warn untuk 4xx, Info untuk sisanya.
internal/httpmw/access_log.gopackage httpmw import ( "log/slog" "net/http" "time" "github.com/kamu/skincare-backend/internal/logging" ) // statusRecorder mengintip status code agar bisa dicatat di access log. type statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(status int) { r.status = status r.ResponseWriter.WriteHeader(status) } // AccessLog menanam logger ber-konteks, menjalankan handler, lalu menulis 1 log. func AccessLog(baseLogger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { startedAt := time.Now() requestID := RequestIDFromContext(r.Context()) // Logger yang membawa request_id, method, path untuk SELURUH request. logger := baseLogger.With( slog.String("request_id", requestID), slog.String("method", r.Method), slog.String("path", r.URL.Path), ) // Tanam ke context agar handler dan service mengambilnya. ctx := logging.IntoContext(r.Context(), logger) recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(recorder, r.WithContext(ctx)) attrs := []slog.Attr{ slog.Int("status", recorder.status), slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()), } switch { case recorder.status >= http.StatusInternalServerError: logger.LogAttrs(ctx, slog.LevelError, "http request failed", attrs...) case recorder.status >= http.StatusBadRequest: logger.LogAttrs(ctx, slog.LevelWarn, "http request client error", attrs...) default: logger.LogAttrs(ctx, slog.LevelInfo, "http request completed", attrs...) } }) } }
Urutan pemasangan middleware menentukan urutan eksekusi. RequestID harus jalan lebih dulu agar AccessLog bisa membaca request ID dari context. Wiring-nya disusun di cmd/api/main.go, menyatu dengan dependency injection dari Chapter sebelumnya.
cmd/api/main.gopackage main import ( "net/http" "os" "time" "github.com/go-chi/chi/v5" "github.com/kamu/skincare-backend/internal/httpmw" "github.com/kamu/skincare-backend/internal/logging" ) func main() { // Config datang dari Chapter 3 (di sini disederhanakan). logger := logging.NewLogger(logging.Config{ Env: os.Getenv("APP_ENV"), LogLevel: os.Getenv("LOG_LEVEL"), }) r := chi.NewRouter() r.Use(httpmw.RequestID) // 1. buat request_id dulu r.Use(httpmw.AccessLog(logger)) // 2. baru access log bisa membacanya r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) logger.Info("api server starting", slog.String("addr", ":8080")) srv := &http.Server{ Addr: ":8080", Handler: r, ReadHeaderTimeout: 5 * time.Second, } if err := srv.ListenAndServe(); err != nil { logger.Error("api server stopped", slog.String("error", err.Error())) os.Exit(1) } }
RequestID wajib dipasang sebelum AccessLog, karena access logger membaca request_id dari context yang baru diisi oleh RequestID. Pasang juga EnrichUser setelah middleware autentikasi tetapi sebelum route domain, supaya user_id sudah menempel di logger saat handler mulai bekerja. Salah urutan, fieldnya kosong tanpa error apa pun.
Cuplikan di atas memakai slog.String. Tambahkan "log/slog" ke blok import main saat kamu menyalinnya. Di proyek nyata, gofmt dan goimports akan merapikan import ini otomatis.
Error Context yang Cukup
Log error yang baik memberi tahu operasi apa yang gagal dan input bisnis mana yang relevan.
Pesan error mentah jarang cukup. payment timeout tidak memberi tahu order mana, user mana, atau di tahap apa kegagalan terjadi. Error context yang baik menjawab semua itu dalam satu log line.
Ingat dari Chapter 4: di Go, error adalah nilai. Repository mengembalikan error domain, service membungkusnya dengan fmt.Errorf("...: %w", err) untuk menambah jejak, dan logging dilakukan di boundary yang punya konteks bisnis cukup, bukan di setiap fungsi kecil. stage adalah field paling berharga di sini: ia memberi tahu di langkah mana checkout patah.
logger.Error("failed", "error", err)tidak menjelaskan operasi, user, stage, atau order.- Investigasi terpaksa membaca kode dulu untuk menebak apa yang gagal.
- Tidak bisa diagregasi: berapa banyak gagal di tahap pembayaran vs tahap stok?
logger.ErrorContext(ctx, "checkout failed", slog.String("stage", "authorize_payment"), slog.Int64("order_id", id), slog.String("error", err.Error()))menjelaskan lokasi kegagalan.- Field bisnis membantu customer support dan engineer melihat pola.
- Bisa di-group by
stageuntuk menemukan tahap paling rapuh.
internal/order/service.gopackage order import ( "context" "fmt" "log/slog" "time" "github.com/kamu/skincare-backend/internal/logging" ) type Service struct { repo Repository pay PaymentGateway clock func() time.Time } // Checkout melog di boundary service: satu Info saat sukses, satu Error per tahap gagal. func (s *Service) Checkout(ctx context.Context, userID int64) (Order, error) { // Ambil logger ber-konteks; request_id dan user_id sudah menempel dari middleware. logger := logging.FromContext(ctx) cart, err := s.repo.GetActiveCart(ctx, userID) if err != nil { logger.ErrorContext(ctx, "checkout failed", slog.String("stage", "load_cart"), slog.String("error", err.Error()), ) return Order{}, fmt.Errorf("load active cart: %w", err) } order, err := s.repo.CreateOrderFromCart(ctx, cart.ID, s.clock()) if err != nil { logger.ErrorContext(ctx, "checkout failed", slog.String("stage", "create_order"), slog.Int64("cart_id", cart.ID), slog.String("error", err.Error()), ) return Order{}, fmt.Errorf("create order from cart: %w", err) } if err := s.pay.Authorize(ctx, order.OrderNumber, order.TotalRupiah); err != nil { logger.ErrorContext(ctx, "checkout failed", slog.String("stage", "authorize_payment"), slog.Int64("order_id", order.ID), slog.String("order_number", order.OrderNumber), slog.String("error", err.Error()), ) return Order{}, fmt.Errorf("authorize payment: %w", err) } logger.InfoContext(ctx, "checkout completed", slog.Int64("order_id", order.ID), slog.String("order_number", order.OrderNumber), slog.Int64("total_rupiah", order.TotalRupiah), ) return order, nil }
Di JavaScript, error membawa stack trace otomatis yang menunjukkan rantai pemanggilan. Go tidak menyertakan stack trace di error standar; sebagai gantinya, kamu membangun “jejak naratif” dengan fmt.Errorf("authorize payment: %w", err) di tiap layer. Saat dicetak, error menjadi authorize payment: create order from cart: connection refused, sebuah breadcrumb yang menunjukkan jalur kegagalan. Field stage di log melengkapi jejak ini dengan konteks bisnis.
Pilih satu tempat melog: handler, orchestration service, worker job, atau webhook processor, yaitu titik yang tahu konteks bisnis penuh. Layer di bawahnya cukup mengembalikan error yang dibungkus %w. Aturan praktisnya: kalau kamu sudah melog error lalu mengembalikannya juga, pemanggil di atas jangan melog lagi, cukup tangani atau teruskan.
Data Sensitif yang Tidak Boleh Dilog
Log production sering bertahan berbulan-bulan dan dibaca banyak orang serta banyak sistem.
Logging yang baik bukan hanya lengkap, tetapi juga aman. Sekali rahasia masuk ke log, ia tersimpan di banyak tempat (CloudWatch, backup, indeks pencarian) dan sangat sulit dicabut. Pencegahan jauh lebih murah daripada pembersihan.
Untuk online shop skincare, jangan pernah menulis password, hash password yang tidak perlu, access token, refresh token, header Authorization, session cookie, nomor kartu lengkap (PAN), CVV, alamat lengkap bila tidak perlu, atau payload webhook mentah yang berisi secret provider. Log cukup ID referensi, status, dan metadata aman untuk tracing.
Aman untuk dilog
request_id, user_id, order_id, order_number, provider, status, payment_status, duration_ms, error_code, dan empat digit terakhir kartu (card_last4).
Jangan pernah dilog
password, jwt, access_token, refresh_token, header Authorization, Cookie, nomor kartu penuh (PAN), cvv, dan secret provider payment.
Cara paling rapuh adalah mengandalkan ingatan setiap developer untuk tidak melog rahasia. Cara idiomatik di slog adalah membuat rahasia itu mustahil dilog dengan benar: bungkus nilai sensitif dalam tipe yang mengimplementasikan slog.LogValuer, sehingga ia selalu mencetak REDACTED apa pun yang terjadi.
internal/logging/redact.gopackage logging import "log/slog" // Secret membungkus string sensitif (token, password, secret provider). // Karena ia mengimplementasikan slog.LogValuer, slog TIDAK PERNAH // mencetak nilai aslinya, bahkan jika developer lupa dan melognya. type Secret string // LogValue dipanggil slog saat hendak melog nilai bertipe Secret. func (Secret) LogValue() slog.Value { return slog.StringValue("REDACTED") } // Card membungkus nomor kartu agar log hanya menampilkan 4 digit terakhir. type Card string func (c Card) LogValue() slog.Value { s := string(c) if len(s) < 4 { return slog.StringValue("****") } return slog.StringValue("****" + s[len(s)-4:]) }
Dengan tipe ini, sekali sebuah field dideklarasikan sebagai Secret, ia aman selamanya. Bahkan kalau developer yang sedang terburu-buru menulis logger.Info("debug", "token", token), output-nya tetap "token":"REDACTED".
contoh pemakaian Secret dan Cardtoken := logging.Secret(rawAccessToken) pan := logging.Card("4111111111111111") logger.InfoContext(ctx, "payment attempt", slog.Int64("order_id", order.ID), slog.String("provider", "midtrans"), slog.Any("access_token", token), // -> "access_token":"REDACTED" slog.Any("card", pan), // -> "card":"****1111" )
Untuk pertahanan lapis kedua, HandlerOptions.ReplaceAttr bisa menyensor field berdasarkan key di seluruh aplikasi, jaring pengaman terakhir kalau ada rahasia yang lolos dengan key yang sudah dikenal seperti authorization atau password.
internal/logging/logger.go (tambahan ReplaceAttr)var sensitiveKeys = map[string]struct{}{ "authorization": {}, "password": {}, "access_token": {}, "refresh_token": {}, "cookie": {}, "cvv": {}, } // redactSensitive dipasang sebagai HandlerOptions.ReplaceAttr. func redactSensitive(groups []string, a slog.Attr) slog.Attr { if _, ok := sensitiveKeys[strings.ToLower(a.Key)]; ok { return slog.String(a.Key, "REDACTED") } return a }
Laravel menyembunyikan field saat serialisasi model lewat protected $hidden = ['password']. slog.LogValuer adalah padanan yang lebih kuat untuk logging: alih-alih daftar nama yang harus diingat, kamu mengikat perilaku redaksi ke tipe data itu sendiri. Tipe Secret membawa aturan “jangan tampilkan aku” ke mana pun ia pergi, jadi keamanan tidak bergantung pada kedisiplinan pemanggil.
Saat memproses webhook payment, godaan untuk logger.Info("webhook", "body", string(rawBody)) sangat besar untuk debugging. Jangan. Body mentah berisi signature, secret, dan kadang data kartu. Log cukup event_id, provider, order_number, dan status setelah payload diparse dan diverifikasi. Saat butuh lebih untuk debugging, log field yang sudah dipilih satu per satu, bukan seluruh body.
Sebelum menggabungkan fitur baru, jalankan endpoint-nya di lokal lalu grep -iE 'password|bearer|token|cvv|4[0-9]{15}' pada output log. Kalau ada yang nyangkut, perbaiki sebelum kode itu sampai ke staging. Jadikan ini bagian dari review code, karena rahasia masuk log biasanya bukan karena niat jahat, tetapi karena developer ingin cepat melihat payload saat debugging.
Hands-on: Debug Satu Checkout
Kita rangkai request ID, access log, business log, dan redaksi pada satu endpoint checkout, lalu menelusurinya seperti di production.
Hands-on ini menyambungkan semua potongan: middleware, logger ber-konteks, business log, dan redaksi. Targetnya, satu request checkout menghasilkan jejak log yang bisa kamu tarik utuh hanya dengan satu request_id.
Buat logging.NewLogger, lalu pasang RequestID, AccessLog, dan (setelah auth) EnrichUser pada router sebelum route domain.
Pakai logging.FromContext(ctx) di Checkout, sehingga request_id, method, path, dan user_id ikut otomatis di setiap log line.
Sertakan stage, order_id, order_number, dan total_rupiah sesuai titik operasi, dan bungkus token/kartu dengan Secret/Card.
Set header X-Request-ID manual lewat curl agar kamu bisa mencari semua log dari request yang sama, persis seperti menelusuri insiden production.
Terminalcurl -i -X POST http://localhost:8080/v1/orders \ -H "Content-Type: application/json" \ -H "X-Request-ID: req_demo_checkout_001" \ -H "Authorization: Bearer dummy-access-token" \ -d '{"idempotency_key":"chk_2026_0606_42"}'
Di terminal, kamu akan melihat tepat dua baris log untuk request sukses: satu dari service (checkout completed) dan satu dari access log (http request completed). Keduanya membawa request_id yang sama. Perhatikan bahwa header Authorization tidak pernah muncul di log, karena ReplaceAttr dan tipe Secret menyensornya.
Log dari service: checkout completed{ "time": "2026-06-06T10:15:43.418Z", "level": "INFO", "msg": "checkout completed", "service": "skincare-api", "env": "staging", "request_id": "req_demo_checkout_001", "method": "POST", "path": "/v1/orders", "user_id": 42, "order_id": 1001, "order_number": "SKN-2026-0606-1001", "total_rupiah": 249000 }
Log dari access log middleware: 1 baris per request{ "time": "2026-06-06T10:15:43.421Z", "level": "INFO", "msg": "http request completed", "service": "skincare-api", "env": "staging", "request_id": "req_demo_checkout_001", "method": "POST", "path": "/v1/orders", "user_id": 42, "status": 201, "duration_ms": 37 }
Sekarang skenario yang sebenarnya kita latih: checkout gagal di tahap pembayaran. Karena setiap kegagalan membawa stage, kamu langsung tahu titik patahnya tanpa membaca kode. Inilah alur men-debug masalah production yang menjadi tujuan chapter ini.
flowchart LR A["Pelanggan lapor:<br/>checkout gagal"] --> B["Frontend kirim<br/>X-Request-ID ke support"] B --> C["Query CloudWatch:<br/>request_id = req_..."] C --> D["Lihat field stage<br/>pada level ERROR"] D --> E["stage = authorize_payment<br/>error = gateway timeout"] E --> F["Akar masalah ketemu<br/>tanpa membaca kode"]
Gambar 3. Alur investigasi nyata. request_id menghubungkan keluhan pelanggan ke log, stage menunjuk tahap yang patah, dan error memberi sebabnya. Tiga field, satu query, akar masalah ketemu.
Log saat checkout gagal di pembayaran{ "time": "2026-06-06T10:16:02.105Z", "level": "ERROR", "msg": "checkout failed", "service": "skincare-api", "env": "staging", "request_id": "req_demo_checkout_001", "method": "POST", "path": "/v1/orders", "user_id": 42, "stage": "authorize_payment", "order_id": 1001, "order_number": "SKN-2026-0606-1001", "error": "payment gateway timeout" }
Field JSON seperti request_id, order_id, dan stage akan dipakai langsung di CloudWatch Logs Insights, misalnya fields @timestamp, msg, stage | filter request_id = "req_demo_checkout_001" | sort @timestamp asc. Investasi format JSON yang kamu buat hari ini terbayar persis di sana.
/v1/orders Checkout: menghasilkan business log + access log dengan request_id yang sama /health Health check: tetap melewati access log, berguna memverifikasi pipeline log Ringkasan & Poin Penting
Logging yang observable membuat modular monolith skincare lebih mudah dirawat begitu masuk staging dan production: saat ada yang patah, kamu menelusuri, bukan menebak.
Yang Wajib Menempel
- Structured log mengalahkan string log di production karena field bisa di-query, difilter, dan diagregasi, bukan cuma dibaca berurutan.
log/slogadalah structured logging dari standard library Go, danslog.NewJSONHandlerkestdoutadalah format default untuk container dan CloudWatch.- Disiplinkan level bersama tim:
Debuguntuk detail teknis,Infountuk event bisnis normal,Warnuntuk kondisi mencurigakan,Erroruntuk kegagalan operasi. request_idlahir di middleware chi dan mengalir lewatcontext.Context;user_iddiperkaya setelah auth, sehingga setiap log line tahu request dan pelakunya.- Log error di satu boundary dengan konteks cukup (
stage,order_id,error), bukan berulang di tiap layer; pakaifmt.Errorf("...: %w", err)untuk jejak naratif. - Cegah kebocoran rahasia dengan tipe
slog.LogValuer(Secret,Card) plusReplaceAttrsebagai jaring pengaman; jangan pernah melog password, token, header Authorization, PAN, atau CVV. - Selaraskan kosakata field dengan skema kanonik proyek:
total_rupiah,order_number,variant_id, bukantotal_amountataupayment_ref.
Setelah chapter ini, Roadmap 4 makin siap untuk production: arsitektur sudah berlapis, config sudah rapi, error sudah punya bentuk, dan logging sudah bisa menjawab apa yang terjadi pada satu request tertentu. Langkah berikutnya di Chapter 6 adalah memisahkan validasi input dari aturan bisnis (stok, produk aktif, voucher, transisi status order), lalu Chapter 7 membahas idempotency agar retry jaringan dan webhook ganda tidak menggandakan order. Lebih jauh, di Roadmap 8 logging JSON ini bertemu CloudWatch Logs Insights untuk observability penuh saat backend skincare berjalan di AWS.
Progress disimpan lokal di browser ini.