254. OpenTelemetry di Go - Trace & Metrik
Memasang instrumentasi trace dan metrik pada aplikasi Go dengan OpenTelemetry, menyebarkan konteks, dan menghubungkannya ke log slog.
Menyiapkan Penyedia Trace
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func siapkanOTel(ctx context.Context) (func(context.Context) error, error) {
eksportir, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("otel-collector:4317"),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("layanan-pesanan"),
semconv.ServiceVersion("2.4.0"),
semconv.DeploymentEnvironment("produksi"),
),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(eksportir),
sdktrace.WithResource(res),
sdktrace.WithSampler(
sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.05)),
),
)
otel.SetTracerProvider(tp)
// WAJIB: tanpa ini, konteks trace tidak menyeberang antar layanan
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return tp.Shutdown, nil
}
1. Lupa memasang propagator. Tanpa SetTextMapPropagator, header traceparent tidak akan pernah ditulis maupun dibaca. Tiap layanan akan memulai trace baru dan Anda kehilangan seluruh gambaran lintas layanan.
2. Lupa memanggil Shutdown. Span dikirim secara batch. Bila proses berhenti tanpa tp.Shutdown(ctx), span terakhir — yang sering justru berisi error penyebab crash — tidak pernah terkirim.
ParentBased menghormati keputusan sampling dari layanan hulu. Tanpanya, layanan A bisa memutuskan merekam sementara layanan B memutuskan membuang, dan Anda mendapat trace yang bolong-bolong. Keputusan sampling harus diambil sekali di tepi sistem lalu dipatuhi seluruh layanan.
Instrumentasi Otomatis
import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
)
// Server HTTP
mux := http.NewServeMux()
mux.HandleFunc("/pesanan", tanganiPesanan)
http.ListenAndServe(":8080", otelhttp.NewHandler(mux, "server-http"))
// Klien HTTP -- span otomatis dibuat & header disisipkan
klien := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
// Server & klien gRPC
s := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
conn, _ := grpc.NewClient(alamat, grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
// Database
db, _ := otelsql.Open("postgres", dsn,
otelsql.WithAttributes(semconv.DBSystemPostgreSQL))
Span Manual untuk Logika Bisnis
var tracer = otel.Tracer("github.com/org/proyek/layanan/pesanan")
func (s *Layanan) BuatPesanan(ctx context.Context, req Request) (*Pesanan, error) {
ctx, span := tracer.Start(ctx, "Layanan.BuatPesanan",
trace.WithAttributes(
attribute.Int64("pengguna.id", req.PenggunaID),
attribute.Int("pesanan.jumlah_item", len(req.Item)),
),
)
defer span.End()
if err := s.periksaStok(ctx, req.Item); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "stok tidak mencukupi")
return nil, err
}
span.AddEvent("stok terverifikasi")
p, err := s.repo.Simpan(ctx, req) // ctx membawa span induk
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "gagal menyimpan")
return nil, err
}
span.SetAttributes(attribute.String("pesanan.id", p.ID))
return p, nil
}
Email, nomor telepon, nomor kartu, alamat, dan token tidak boleh masuk ke atribut span — data itu akan tersimpan di backend tracing dan bisa dibaca siapa pun yang punya akses dasbor. Simpan pengenal yang tidak bermakna sendiri seperti ID pengguna, dan lakukan penyaringan tambahan di OpenTelemetry Collector sebagai jaring pengaman.
Metrik
var meter = otel.Meter("github.com/org/proyek/layanan/pesanan")
var (
pesananDibuat metric.Int64Counter
durasiProses metric.Float64Histogram
pesananAktif metric.Int64UpDownCounter
)
func init() {
pesananDibuat, _ = meter.Int64Counter("pesanan.dibuat",
metric.WithDescription("jumlah pesanan yang berhasil dibuat"),
metric.WithUnit("{pesanan}"))
durasiProses, _ = meter.Float64Histogram("pesanan.durasi_proses",
metric.WithDescription("lama pemrosesan pesanan"),
metric.WithUnit("s"))
pesananAktif, _ = meter.Int64UpDownCounter("pesanan.aktif")
}
func (s *Layanan) BuatPesanan(ctx context.Context, req Request) (*Pesanan, error) {
mulai := time.Now()
pesananAktif.Add(ctx, 1)
defer pesananAktif.Add(ctx, -1)
p, err := s.buat(ctx, req)
// Label harus BERKARDINALITAS RENDAH
atribut := metric.WithAttributes(
attribute.String("status", statusDari(err)),
attribute.String("saluran", req.Saluran),
)
pesananDibuat.Add(ctx, 1, atribut)
durasiProses.Record(ctx, time.Since(mulai).Seconds(), atribut)
return p, err
}
Jangan pernah menjadikan ID pesanan, ID pengguna, atau URL lengkap sebagai atribut metrik. Setiap kombinasi nilai unik membuat satu time series baru; sejuta ID pesanan berarti sejuta time series yang akan menghabiskan memori Prometheus. Atribut metrik harus berupa nilai berhingga yang sedikit: status, metode, saluran, wilayah. Informasi per-permintaan tempatnya di trace dan log.
Menghubungkan Log dengan Trace
// Handler slog yang otomatis menyisipkan trace_id & span_id
type HandlerOTel struct{ slog.Handler }
func (h HandlerOTel) Handle(ctx context.Context, r slog.Record) error {
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
r.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}
func main() {
dasar := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
slog.SetDefault(slog.New(HandlerOTel{dasar}))
}
// Selalu pakai varian ...Context agar trace ikut tercatat
slog.InfoContext(ctx, "pesanan dibuat", "pesanan_id", p.ID)
Dengan trace_id di setiap baris log, alurnya menjadi: metrik menunjukkan tingkat error naik → buka satu trace yang gagal → lompat ke seluruh log dari permintaan itu di semua layanan sekaligus. Yang tadinya butuh berjam-jam menggabungkan log manual dari belasan layanan menjadi hitungan menit.
Fungsi yang menerima ctx tetapi tidak meneruskannya ke pemanggilan berikutnya akan memutus rantai trace di titik itu. Aturan praktis di Go: context.Context selalu menjadi parameter pertama, dan diteruskan ke setiap pemanggilan yang mungkin memblokir atau melakukan I/O. Jangan pernah menyimpan context di dalam struct.
Contoh program
package main
import (
"context"
"fmt"
"time"
)
// Simulasi span & konteks trace
type kunciSpan struct{}
type Span struct {
Nama string
TraceID string
SpanID string
Induk string
Mulai time.Time
Atribut map[string]any
}
var terkumpul []*Span
var penghitung int
func Mulai(ctx context.Context, nama string) (context.Context, *Span) {
penghitung++
s := &Span{Nama: nama, TraceID: "4bf92f35", SpanID: fmt.Sprintf("span-%02d", penghitung), Mulai: time.Now(), Atribut: map[string]any{}}
if induk, ok := ctx.Value(kunciSpan{}).(*Span); ok {
s.Induk = induk.SpanID
}
terkumpul = append(terkumpul, s)
return context.WithValue(ctx, kunciSpan{}, s), s
}
func main() {
ctx := context.Background()
ctx, akar := Mulai(ctx, "POST /pesanan")
akar.Atribut["pengguna.id"] = 55
func(ctx context.Context) {
ctx, s := Mulai(ctx, "periksaStok")
s.Atribut["item.jumlah"] = 2
func(ctx context.Context) {
_, db := Mulai(ctx, "SELECT stok")
db.Atribut["db.system"] = "postgresql"
}(ctx)
}(ctx)
func(ctx context.Context) {
_, s := Mulai(ctx, "simpanPesanan")
s.Atribut["db.operation"] = "INSERT"
}(ctx)
fmt.Println("Trace ID:", akar.TraceID)
for _, s := range terkumpul {
indent := ""
if s.Induk != "" {
indent = " └─ "
}
fmt.Printf("%s%-16s [%s] induk=%-8s %v\n", indent, s.Nama, s.SpanID, s.Induk, s.Atribut)
}
}
255. Structured Logging Produksi dengan slog
Merancang log yang bisa dikueri: handler kustom, atribut kontekstual, penyaringan data sensitif, dan pemilihan level yang tepat.
Kenapa Log Terstruktur
// Log kalimat bebas -- tidak bisa dikueri
log.Printf("pesanan %s gagal untuk user %d: %v", id, uid, err)
// "pesanan p-1001 gagal untuk user 55: koneksi ditolak"
// Untuk mencari semua kegagalan user 55, Anda terpaksa memakai regex.
// Log terstruktur -- bisa dikueri seperti basis data
slog.ErrorContext(ctx, "pesanan gagal",
"pesanan_id", id,
"pengguna_id", uid,
"error", err)
// {"level":"ERROR","msg":"pesanan gagal","pesanan_id":"p-1001",
// "pengguna_id":55,"error":"koneksi ditolak"}
// Kueri: {pengguna_id=55, level="ERROR"}
Menyiapkan Logger
func siapkanLogger(lingkungan string) *slog.Logger {
opsi := &slog.HandlerOptions{
Level: tingkatDariEnv(),
AddSource: lingkungan != "produksi", // nomor baris mahal
ReplaceAttr: gantiAtribut,
}
var h slog.Handler
if lingkungan == "produksi" {
h = slog.NewJSONHandler(os.Stdout, opsi) // mesin yang membaca
} else {
h = slog.NewTextHandler(os.Stdout, opsi) // manusia yang membaca
}
return slog.New(h).With(
"layanan", "layanan-pesanan",
"versi", versiBuild,
)
}
// Ubah level saat runtime tanpa restart
var tingkat = new(slog.LevelVar)
func UbahTingkat(t slog.Level) { tingkat.Set(t) }
Saat terjadi insiden, Anda ingin menaikkan verbositas ke DEBUG untuk melihat detail — tetapi me-restart layanan justru menghilangkan keadaan yang sedang Anda selidiki. Dengan slog.LevelVar yang diekspos lewat endpoint admin, Anda bisa mengubahnya seketika lalu menurunkannya kembali setelah selesai.
Menyaring Data Sensitif
var fieldSensitif = map[string]bool{
"password": true, "kata_sandi": true, "token": true,
"authorization": true, "kartu_kredit": true, "cvv": true,
"api_key": true, "secret": true, "nik": true,
}
func gantiAtribut(gugus []string, a slog.Attr) slog.Attr {
if fieldSensitif[strings.ToLower(a.Key)] {
return slog.String(a.Key, "[DISUNTING]")
}
// Persingkat jalur berkas agar log tidak membengkak
if a.Key == slog.SourceKey {
if src, ok := a.Value.Any().(*slog.Source); ok {
src.File = filepath.Base(src.File)
}
}
return a
}
type Pengguna struct {
ID int64
Email string
Password string
}
// Menentukan sendiri bagaimana tipe ini muncul di log
func (p Pengguna) LogValue() slog.Value {
return slog.GroupValue(
slog.Int64("id", p.ID),
slog.String("email", samarkanEmail(p.Email)),
// Password sengaja TIDAK disertakan sama sekali
)
}
func samarkanEmail(e string) string {
bagian := strings.SplitN(e, "@", 2)
if len(bagian) != 2 || len(bagian[0]) < 2 {
return "***"
}
return bagian[0][:2] + "***@" + bagian[1]
}
// Sekarang aman dipakai di mana pun:
slog.Info("pengguna masuk", "pengguna", p)
// {"pengguna":{"id":55,"email":"bu***@contoh.com"}}
ReplaceAttr hanya menangkap field yang namanya sudah Anda daftarkan. LogValuer menempel pada tipenya, sehingga struct itu selalu tersamarkan dengan benar di mana pun ia dicatat — bahkan ketika programmer lain memakainya tanpa mengetahui aturan penyamarannya.
Membawa Konteks Sepanjang Permintaan
type kunciLogger struct{}
func DariKonteks(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(kunciLogger{}).(*slog.Logger); ok {
return l
}
return slog.Default()
}
func MiddlewareLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idPermintaan := r.Header.Get("X-Request-Id")
if idPermintaan == "" {
idPermintaan = uuid.NewString()
}
l := slog.With(
"request_id", idPermintaan,
"method", r.Method,
"path", r.URL.Path,
)
ctx := context.WithValue(r.Context(), kunciLogger{}, l)
mulai := time.Now()
rw := &penulisTerbungkus{ResponseWriter: w, kode: 200}
next.ServeHTTP(rw, r.WithContext(ctx))
l.InfoContext(ctx, "permintaan selesai",
"status", rw.kode,
"durasi_ms", time.Since(mulai).Milliseconds())
})
}
// Di lapisan mana pun, konteksnya ikut terbawa:
func (s *Layanan) Proses(ctx context.Context) {
l := DariKonteks(ctx)
l.InfoContext(ctx, "memulai pemrosesan") // request_id ikut tercatat
}
Memilih Level dengan Benar
| Level | Dipakai untuk | Perlu tindakan? |
|---|---|---|
DEBUG | Detail untuk penelusuran; mati di produksi | Tidak |
INFO | Peristiwa bisnis penting: pesanan dibuat, pembayaran lunas | Tidak |
WARN | Hal ganjil yang berhasil ditangani: retry berhasil, memakai nilai bawaan | Perlu diperhatikan |
ERROR | Operasi gagal dan pengguna terdampak | Ya |
Mencatat ERROR untuk hal yang sebenarnya berhasil ditangani — retry yang akhirnya sukses, validasi masukan pengguna yang ditolak — membuat dasbor error dipenuhi derau. Tim lalu terbiasa mengabaikannya, dan error yang benar-benar penting ikut terlewat. ERROR berarti seseorang perlu bertindak. Kegagalan validasi masukan adalah kejadian normal: catat sebagai INFO atau WARN.
Kesalahan Umum
// ❌ Mencatat error DAN mengembalikannya -- tercatat berkali-kali
if err != nil {
slog.Error("gagal menyimpan", "error", err)
return err // pemanggil mencatat lagi, dan pemanggilnya lagi...
}
// ✅ Bungkus dengan konteks, catat SEKALI di lapisan paling atas
if err != nil {
return fmt.Errorf("menyimpan pesanan %s: %w", id, err)
}
// ❌ Mencatat di dalam perulangan panas
for _, item := range sejutaItem {
slog.Debug("memproses", "item", item) // membanjiri sistem log
}
// ✅ Catat ringkasannya
slog.Info("pemrosesan selesai", "jumlah", len(sejutaItem),
"durasi_ms", time.Since(mulai).Milliseconds())
// ❌ String yang dirangkai -- kembali ke masalah semula
slog.Info("pesanan " + id + " selesai")
// ✅ Atribut terpisah yang bisa dikueri
slog.Info("pesanan selesai", "pesanan_id", id)
Di lingkungan berkontainer, aplikasi seharusnya menulis log ke stdout/stderr saja dan menyerahkan pengumpulan serta rotasinya kepada platform. Menulis langsung ke berkas di dalam kontainer menciptakan masalah baru: berkas hilang saat pod dihapus, disk bisa penuh, dan rotasi log menjadi tanggung jawab aplikasi yang seharusnya tidak ia urus.
Contoh program
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
type Pengguna struct {
ID int64
Email string
Password string
}
func samarkanEmail(e string) string {
bagian := strings.SplitN(e, "@", 2)
if len(bagian) != 2 || len(bagian[0]) < 2 {
return "***"
}
return bagian[0][:2] + "***@" + bagian[1]
}
// Meniru slog.LogValuer
func (p Pengguna) NilaiLog() map[string]any {
return map[string]any{
"id": p.ID,
"email": samarkanEmail(p.Email),
// Password sengaja tidak disertakan
}
}
func catat(level, pesan string, kv ...any) {
rec := map[string]any{
"waktu": time.Now().UTC().Format(time.RFC3339),
"level": level,
"msg": pesan,
}
sensitif := map[string]bool{"password": true, "token": true, "cvv": true}
for i := 0; i+1 < len(kv); i += 2 {
k := fmt.Sprint(kv[i])
v := kv[i+1]
if sensitif[strings.ToLower(k)] {
v = "[DISUNTING]"
}
if p, ok := v.(Pengguna); ok {
v = p.NilaiLog()
}
rec[k] = v
}
enc := json.NewEncoder(os.Stdout)
enc.Encode(rec)
}
func main() {
p := Pengguna{ID: 55, Email: "[email protected]", Password: "rahasia123"}
catat("INFO", "pengguna masuk", "pengguna", p, "request_id", "req-abc123")
catat("INFO", "pesanan dibuat", "pesanan_id", "p-1001", "total", 250000)
catat("ERROR", "pembayaran gagal", "pesanan_id", "p-1001", "token", "tok_live_xyz", "error", "kartu ditolak")
}