Kodeloka
  1. Kodeloka
  2. Kelas Golang
  3. Materi

Domain 25 · 2 bab

Observability Go

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
}
⚠️ Dua kesalahan yang membuat trace tidak berguna

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.

📌 Kenapa ParentBased sampler

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
}
🚫 Jangan letakkan data pribadi di atribut span

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
}
🚫 Kardinalitas metrik yang meledak

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)
💡 Inilah yang mengubah investigasi insiden

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.

⚠️ Selalu teruskan context

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

Praktik dan kuis bab ini di kelas interaktif →

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) }
📌 Level yang bisa diubah saat runtime sangat berharga

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
}
Kontrol lebih baik: implementasikan LogValuer
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"}}
💡 LogValuer lebih andal daripada ReplaceAttr

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

LevelDipakai untukPerlu tindakan?
DEBUGDetail untuk penelusuran; mati di produksiTidak
INFOPeristiwa bisnis penting: pesanan dibuat, pembayaran lunasTidak
WARNHal ganjil yang berhasil ditangani: retry berhasil, memakai nilai bawaanPerlu diperhatikan
ERROROperasi gagal dan pengguna terdampakYa
🚫 Level ERROR yang terlalu berisik menghancurkan kegunaannya

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)
⚠️ Tulis ke stdout, jangan ke berkas

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

Praktik dan kuis bab ini di kelas interaktif →