Belajar Golang dari Nol #18: Logging dan Error Handling Rapi
Di Belajar Golang dari Nol #17 kita memasang context di seluruh alur handler, service, dan repository. Sekarang setiap request punya batas waktu dan bisa dibatalkan. Bagus. Tapi ada satu hal yang belum kita urus: apa yang terjadi kalau alur itu gagal di server, jam dua pagi, saat tidak ada yang menonton.
Bagian ini soal itu. Kita rapikan cara aplikasi bercerita tentang dirinya sendiri lewat log, dan cara error dibawa naik dari lapisan bawah tanpa kehilangan konteks.
Masalahnya: log adalah satu-satunya mata kita
Saat aplikasi jalan di laptop, kita bisa pasang breakpoint, cetak variabel, ulangi request sesuka hati. Di server tidak begitu. Aplikasi berjalan di balik systemd, request datang dari pengguna asli, dan kejadiannya sudah lewat saat kita baru membuka journalctl.
Yang tersisa cuma log. Kalau log kita begini:
2026/07/26 02:14:33 error
2026/07/26 02:14:35 error
2026/07/26 02:17:02 gagal
maka kita tidak punya apa-apa. Error apa? Request yang mana? Pengguna mana? Endpoint mana? Baris seperti itu hanya memberi tahu bahwa ada masalah, dan itu sudah kita tahu dari keluhan pelanggan.
Ada satu kebiasaan lain yang lebih berbahaya: error yang ditelan diam-diam.
data, _ := json.Marshal(produk)
w.Write(data)
Underscore itu berarti “saya tidak peduli”. Kalau suatu hari marshal gagal, aplikasi tetap jalan, pengguna dapat balasan kosong, dan tidak ada satu baris pun di log. Bug seperti ini bisa duduk diam berbulan-bulan sampai akhirnya meledak di waktu yang paling tidak enak.
Aturan sederhananya: setiap error diperiksa, dan setiap error yang tidak ditangani harus dicatat.
Error wrapping dengan %w
Di bagian 3 kita sudah kenal pola if err != nil. Masalahnya, kalau error hanya diteruskan apa adanya, kita kehilangan jejak. Pesan sql: no rows in result set muncul di handler tanpa memberi tahu tabel apa, id berapa, dan fungsi mana yang memanggilnya.
Solusinya adalah membungkus error. Gunakan fmt.Errorf dengan kata kunci %w. Kata kunci ini menambah konteks di depan, tapi menyimpan error aslinya di dalam.
Mulai dari repository:
func (r *ProdukRepo) Ambil(ctx context.Context, id int64) (Produk, error) {
var p Produk
query := "SELECT id, nama, harga FROM produk WHERE id = ?"
err := r.db.QueryRowContext(ctx, query, id).Scan(&p.ID, &p.Nama, &p.Harga)
if err != nil {
return Produk{}, fmt.Errorf("repo ambil produk id %d: %w", id, err)
}
return p, nil
}
Naik ke service:
func (s *ProdukService) Detail(ctx context.Context, id int64) (Produk, error) {
p, err := s.repo.Ambil(ctx, id)
if err != nil {
return Produk{}, fmt.Errorf("service detail produk: %w", err)
}
return p, nil
}
Saat handler mencatat error itu, isinya jadi seperti ini:
service detail produk: repo ambil produk id 42: sql: no rows in result set
Satu baris, tiga lapis cerita. Kita tahu alurnya lewat mana, id yang dicari berapa, dan akar masalahnya apa. Itulah yang kita cari saat jam dua pagi.
Dua hal yang perlu dijaga. Pertama, jangan menulis kata “error” atau “gagal” berulang di setiap lapis, nanti pesannya jadi “gagal: gagal: gagal”. Sebut nama operasinya saja. Kedua, cukup bungkus sekali per lapis. Membungkus dua kali di fungsi yang sama hanya menambah panjang tanpa menambah informasi.
errors.Is untuk mengenali error tertentu
Error yang dibungkus %w tetap bisa dikenali. Di sinilah errors.Is berguna. Fungsi ini membongkar lapisan pembungkus sampai menemukan error yang kita cari.
Ingat sql.ErrNoRows dari bagian 12? Itu bukan kerusakan sistem, itu cuma data tidak ada. Balasan yang benar adalah 404, bukan 500.
if errors.Is(err, sql.ErrNoRows) {
balasJSON(w, http.StatusNotFound, map[string]string{
"pesan": "Produk tidak ditemukan",
})
return
}
Perhatikan bahwa ini tetap bekerja walau error sudah dibungkus dua kali. Kalau kita pakai err == sql.ErrNoRows, perbandingan itu gagal begitu error dibungkus. Jadi biasakan pakai errors.Is.
Pola yang sama berlaku untuk context.DeadlineExceeded dari bagian 17. Request yang kehabisan waktu layak dibalas 504, bukan 500 generik.
errors.As untuk mengambil tipe error custom
errors.Is membandingkan nilai. Kalau kita butuh isinya, gunakan errors.As. Fungsi ini mencari error dengan tipe tertentu di dalam rantai, lalu menyalinnya ke variabel kita supaya field-nya bisa dibaca.
Error custom sederhana
Di bagian 9 kita belajar interface. error sendiri sebenarnya interface biasa dengan satu method:
type error interface {
Error() string
}
Artinya struct apa pun bisa jadi error asal punya method Error() string. Ini berguna saat error perlu membawa data, bukan cuma kalimat.
type ErrValidasi struct {
Field string
Pesan string
}
func (e *ErrValidasi) Error() string {
return fmt.Sprintf("validasi gagal pada field %s: %s", e.Field, e.Pesan)
}
Service memakainya begini:
func (s *ProdukService) Simpan(ctx context.Context, p Produk) error {
if p.Harga <= 0 {
return &ErrValidasi{Field: "harga", Pesan: "Harga harus lebih dari nol"}
}
if err := s.repo.Simpan(ctx, p); err != nil {
return fmt.Errorf("service simpan produk: %w", err)
}
return nil
}
Handler mengambilnya kembali dengan errors.As:
var errValidasi *ErrValidasi
if errors.As(err, &errValidasi) {
balasJSON(w, http.StatusBadRequest, map[string]string{
"field": errValidasi.Field,
"pesan": errValidasi.Pesan,
})
return
}
Kapan ini berguna? Saat handler perlu memutuskan sesuatu berdasarkan isi error, misalnya menentukan status code atau memberi tahu field mana yang salah di form. Kalau error cuma perlu dicatat lalu dibalas 500, tipe custom itu berlebihan. Cukup bungkus dengan fmt.Errorf.
Pisahkan pesan untuk pengguna dan detail untuk developer
Ini kesalahan yang sering saya temukan saat mengaudit backend orang lain: pesan error mentah dikirim langsung ke browser.
// jangan begini
http.Error(w, err.Error(), http.StatusInternalServerError)
Pengguna jadi melihat kalimat seperti dial tcp 10.0.0.5:3306: connect: connection refused. Bagi pengguna itu tidak berguna. Bagi penyerang itu hadiah: nama tabel, IP database, port, versi driver, semuanya bocor gratis.
Aturannya dua arah. Pengguna dapat kalimat sopan dan umum. Log menyimpan detail teknis lengkap.
log.Error("gagal mengambil produk",
"produk_id", id,
"error", err.Error(),
)
balasJSON(w, http.StatusInternalServerError, map[string]string{
"pesan": "Terjadi kesalahan di server, coba beberapa saat lagi",
})
Kalau ingin pengguna bisa melapor dengan jelas, sisipkan request id ke balasan. Detailnya tetap di log, tapi pengguna punya nomor tiket untuk disebut ke tim support.
log/slog: logging terstruktur dari standard library
Sejak Go 1.21, standard library punya log/slog. Dibanding log biasa, bedanya jauh.
- Log punya level: Debug, Info, Warn, Error. Kita bisa menyaring saat produksi.
- Data ditulis sebagai pasangan key value, bukan kalimat panjang yang harus diurai ulang.
- Bisa keluar sebagai JSON, jadi mudah dibaca mesin dan dicari lewat tool log.
Siapkan logger sekali di main.go, lalu pasang jadi default:
func siapkanLogger(env string) *slog.Logger {
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
var handler slog.Handler
if env == "production" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
opts.Level = slog.LevelDebug
handler = slog.NewTextHandler(os.Stdout, opts)
}
logger := slog.New(handler)
slog.SetDefault(logger)
return logger
}
Di lokal, text handler lebih enak dibaca mata. Di server, JSON handler lebih enak dibaca alat. Level Debug menyala saat ngoding, dan mati saat produksi supaya log tidak penuh sampah.
Cara pakainya:
slog.Info("server berjalan", "port", 8080, "env", env)
slog.Error("koneksi database putus", "host", cfg.DBHost, "error", err.Error())
Perhatikan polanya: pesan pendek dulu, lalu data menyusul sebagai pasangan key value. Jangan menempel nilai ke dalam pesan pakai fmt.Sprintf, karena nanti sulit dicari dan difilter.
Menyisipkan request id lewat middleware dan context
Satu request bisa menghasilkan lima baris log. Kalau ada seratus request per menit, semua baris itu bercampur. Tanpa penanda, kita tidak tahu baris mana milik request mana.
Solusinya request id. Dibuat di middleware (nyambung bagian 13), disimpan di context (nyambung bagian 17), lalu diambil lagi saat logging. Ini justru contoh sah pemakaian context.WithValue: data lintas lapisan yang sifatnya metadata request, bukan parameter bisnis.
type kunciCtx string
const kunciRequestID kunciCtx = "request_id"
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = acakID()
}
ctx := context.WithValue(r.Context(), kunciRequestID, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func acakID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "id-gagal"
}
return hex.EncodeToString(b)
}
Lalu dua helper kecil untuk mengambilnya kembali:
func RequestIDDari(ctx context.Context) string {
if id, ok := ctx.Value(kunciRequestID).(string); ok {
return id
}
return "tanpa-id"
}
func LogDari(ctx context.Context) *slog.Logger {
return slog.Default().With("request_id", RequestIDDari(ctx))
}
Sekarang semua lapisan tinggal panggil LogDari(ctx) dan setiap baris otomatis membawa request id yang sama. Saat pelanggan lapor, kita minta nilai header X-Request-ID, lalu cari di server dengan cara yang sudah kita pelajari di bagian 15:
journalctl -u aplikasi.service | grep 9f2c1ab7d3e5f004
Yang wajib dan yang jangan dicatat
Wajib ada di setiap baris log request:
- waktu kejadian
- level (info, warn, error)
- request id
- method dan path
- durasi proses
- pesan error yang sudah berkonteks, bukan cuma kata “error”
Jangan pernah masuk log:
- password, walau sudah salah ketik
- token, session, API key, isi header Authorization
- nomor kartu, CVV, data pembayaran
- NIK, alamat lengkap, dan data pribadi yang tidak dibutuhkan untuk debug
- seluruh body request mentah, karena isinya sering mengandung semua hal di atas
Log itu disimpan lama, dikirim ke layanan pihak ketiga, dan sering bisa dibaca lebih banyak orang daripada database. Perlakukan seperti dokumen publik internal.
Panic dan recover
Panic berbeda dengan error. Error itu keadaan yang kita perkirakan. Panic itu keadaan yang tidak kita perkirakan, misalnya nil pointer atau index di luar batas.
Masalahnya, panic di satu goroutine handler bisa menjatuhkan seluruh proses. Satu request bermasalah membuat semua pengguna lain ikut kena. Karena itu kita pasang middleware recovery.
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic tertangkap",
"request_id", RequestIDDari(r.Context()),
"path", r.URL.Path,
"panic", fmt.Sprint(rec),
"stack", string(debug.Stack()),
)
balasJSON(w, http.StatusInternalServerError, map[string]string{
"pesan": "Terjadi kesalahan di server",
})
}
}()
next.ServeHTTP(w, r)
})
}
Pasang paling luar, sebelum middleware lain, supaya panic dari mana pun tertangkap. Stack trace masuk log, pengguna dapat 500 yang sopan, server tetap hidup.
Recover bukan alasan untuk malas menangani error. Anggap setiap baris “panic tertangkap” di log sebagai bug yang wajib diperbaiki minggu itu juga.
Latihan: rapikan satu endpoint sampai tuntas
Gabungkan semuanya di handler detail produk.
func (h *ProdukHandler) Detail(w http.ResponseWriter, r *http.Request) {
mulai := time.Now()
ctx := r.Context()
log := LogDari(ctx).With("method", r.Method, "path", r.URL.Path)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
log.Warn("id tidak valid", "nilai", r.PathValue("id"))
balasJSON(w, http.StatusBadRequest, map[string]string{
"pesan": "ID produk tidak valid",
})
return
}
produk, err := h.service.Detail(ctx, id)
if err != nil {
var errValidasi *ErrValidasi
switch {
case errors.Is(err, sql.ErrNoRows):
log.Info("produk tidak ditemukan", "produk_id", id)
balasJSON(w, http.StatusNotFound, map[string]string{
"pesan": "Produk tidak ditemukan",
})
case errors.As(err, &errValidasi):
log.Warn("validasi gagal", "field", errValidasi.Field)
balasJSON(w, http.StatusBadRequest, map[string]string{
"pesan": errValidasi.Pesan,
})
default:
log.Error("gagal mengambil produk",
"produk_id", id,
"error", err.Error(),
"durasi_ms", time.Since(mulai).Milliseconds(),
)
balasJSON(w, http.StatusInternalServerError, map[string]string{
"pesan": "Terjadi kesalahan di server",
})
}
return
}
log.Info("produk terkirim",
"produk_id", id,
"durasi_ms", time.Since(mulai).Milliseconds(),
)
balasJSON(w, http.StatusOK, produk)
}
Rangkai middleware-nya di main.go, recovery di lapis paling luar:
mux := http.NewServeMux()
mux.HandleFunc("GET /produk/{id}", produkHandler.Detail)
handler := Recovery(RequestID(mux))
slog.Info("server berjalan", "port", 8080)
http.ListenAndServe(":8080", handler)
Saat request berhasil, log produksi terlihat seperti ini:
{"time":"2026-07-26T02:14:33.481+07:00","level":"INFO","msg":"produk terkirim","request_id":"9f2c1ab7d3e5f004","method":"GET","path":"/produk/42","produk_id":42,"durasi_ms":6}
Saat database mati, log yang sama berubah jadi:
{"time":"2026-07-26T02:14:35.902+07:00","level":"ERROR","msg":"gagal mengambil produk","request_id":"a71b0c94ee3d2185","method":"GET","path":"/produk/42","produk_id":42,"error":"service detail produk: repo ambil produk id 42: dial tcp 10.0.0.5:3306: connect: connection refused","durasi_ms":2041}
Bandingkan dengan baris “error” polos di awal artikel. Sekarang kita tahu endpoint mana, id berapa, akar masalahnya di koneksi database, dan proses menggantung dua detik sebelum menyerah. Pengguna sendiri hanya melihat kalimat sopan tanpa satu pun detail internal.
Rangkuman
- Bungkus error dengan
%wdi setiap lapis supaya konteksnya menumpuk. - Pakai
errors.Isuntuk mengenali error tertentu,errors.Asuntuk mengambil isinya. - Buat error custom hanya saat error perlu membawa data.
- Pengguna dapat kalimat sopan, log dapat detail teknis.
- Pakai
log/slogdengan atribut key value, JSON di server, text di lokal. - Sisipkan request id lewat middleware dan context supaya satu request bisa dirunut.
- Pasang middleware recovery, dan perlakukan setiap panic sebagai bug yang harus diperbaiki.
Di bagian 19 kita bahas Worker dan Job Terjadwal di Go, untuk pekerjaan yang jalan di belakang layar tanpa menunggu request.
Kalau tim Anda butuh bantuan membangun backend yang rapi sejak awal, silakan lihat layanan sistem aplikasi dari Arrazy Inovasi.
Artikel Lainnya di Kategori Golang
Golang 29 Juli 2026
Belajar Golang dari Nol #3: Function, Multiple Return, dan Error
Lanjutan seri Golang pemula: belajar function, multiple return, konvensi error, pola if err != nil, dan variadic lewat latihan kalkulator diskon.
Baca Artikel
Golang 5 Agustus 2026
Belajar Golang dari Nol #10: Goroutine dan Channel
Kenalan dengan concurrency di Go: goroutine, sync.WaitGroup, channel, buffered channel, dan select, ditutup latihan cek harga tiga supplier sekaligus.
Baca Artikel
Golang 10 Agustus 2026
Belajar Golang dari Nol #15: Deploy API Go ke Server Production
Bawa API Go dari laptop ke server: cross compile binary, environment variable, systemd service, reverse proxy Nginx, sampai HTTPS gratis certbot.
Baca ArtikelIngin Membaca Artikel Lainnya?
Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.
Lihat Semua Artikel