II — Praktik
Artikel 8 dari 9

Observability: Tahu Kapan Harus Scale

Structured logging berformat JSON, metrics dengan Counter dan Histogram lewat Prometheus, jebakan cardinality explosion, dan menghubungkan angka metrics ke keputusan scaling yang nyata.

10 Juli 2026

Sepanjang seri ini, kita mengetahui ada masalah lewat cara yang cukup manual: menjalankan load_test.py, membaca output-nya, mengecek log server di terminal lain. Ini bagus untuk belajar dan eksperimen terkontrol, tapi di sistem production sungguhan, kita tidak bisa duduk menunggu dan menjalankan load test manual setiap saat. Kita butuh sistem yang terus-menerus memberi tahu kondisi aplikasi — inilah observability.

Tiga Pilar Observability (yang Relevan untuk Kita)

Observability biasanya dibahas lewat tiga pilar: logs (catatan kejadian), metrics (angka terukur dari waktu ke waktu), dan traces (jejak satu request melintasi banyak service). Untuk task-tracker-api yang skalanya masih sederhana, kita fokus ke dua yang pertama — logs dan metrics — karena traces baru benar-benar dibutuhkan begitu satu request melintasi banyak service berbeda (dibahas sebagai roadmap di Bagian 9).

Structured Logging — Log yang Bisa Diproses Mesin

Log biasa (print("User logged in")) enak dibaca manusia tapi susah diproses program. Structured logging menulis log dalam format yang bisa di-parse (kita pakai JSON), supaya bisa dicari, difilter, dan dianalisis oleh tooling.

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        extra_fields = getattr(record, "extra_fields", None)
        if extra_fields:
            payload.update(extra_fields)
        return json.dumps(payload)

Setiap request yang selesai diproses menghasilkan satu baris log seperti ini:

{"timestamp": "2026-07-10T13:41:56.695851+00:00", "level": "INFO", "logger": "app", "message": "request_completed", "request_id": "eec43850-6da2-4cd5-91a1-300a105438c8", "method": "POST", "path": "/api/tasks", "status_code": 201, "duration_ms": 72.09, "instance_id": "api-1"}

Bandingkan dengan log teks biasa "POST /api/tasks - 201 Created" — versi JSON di atas bisa langsung di-query: "tampilkan semua request dengan duration_ms > 500", atau "hitung berapa banyak status_code >= 500 di instance_id: api-2 dalam 5 menit terakhir". Ini yang membuatnya berguna begitu log dari banyak instance dikumpulkan di satu tempat (log aggregator seperti Loki, ELK stack, atau layanan cloud logging).

Perhatikan juga request_id — ID unik per request. Kalau user melaporkan error, kita bisa minta request_id-nya (bisa ditampilkan di response header, atau di pesan error yang dilihat user) dan langsung melompat ke log yang persis relevan, tanpa harus mencari di antara ribuan baris log lain yang terjadi di waktu yang sama.

Metrics — Angka yang Diringkas dari Waktu ke Waktu

Kalau log adalah catatan detail tiap kejadian, metrics adalah ringkasan angka dari waktu ke waktu — jumlah request, distribusi latency, tingkat error. Kita pakai prometheus-client, dengan dua jenis metric:

REQUEST_COUNT = Counter(
    "http_requests_total",
    "Total HTTP requests",
    ["method", "path", "status_code", "instance"],
)
REQUEST_LATENCY = Histogram(
    "http_request_duration_seconds",
    "HTTP request latency in seconds",
    ["method", "path", "instance"],
)
  • Counter — angka yang cuma naik, tidak pernah turun (kecuali restart). Cocok untuk "total request", "total error".
  • Histogram — mengelompokkan nilai (di sini, durasi request) ke dalam rentang ("bucket"), supaya kita bisa hitung persentil (p50, p95, p99) belakangan — persis metric yang kita pakai manual sepanjang seri ini lewat load_test.py, sekarang terekam otomatis dari trafik sungguhan, bukan cuma saat kita jalankan load test.
  • Middleware kita mencatat kedua metric ini di SETIAP request, lalu meng-expose-nya lewat endpoint /metrics:

    @app.middleware("http")
    async def observability_middleware(request: Request, call_next):
        start = time.perf_counter()
        response = await call_next(request)
        duration = time.perf_counter() - start
    
        route = request.scope.get("route")
        path_label = route.path if route is not None else request.url.path
    
        REQUEST_COUNT.labels(request.method, path_label, response.status_code, settings.instance_id).inc()
        REQUEST_LATENCY.labels(request.method, path_label, settings.instance_id).observe(duration)
        ...
    
    
    @app.get("/metrics")
    def metrics():
        return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
    
    curl localhost:8000/metrics
    
    http_requests_total{instance="api-1",method="GET",path="/health",status_code="200"} 1.0
    http_requests_total{instance="api-1",method="POST",path="/api/tasks",status_code="201"} 1.0
    http_requests_total{instance="api-1",method="GET",path="/api/tasks/{task_id}",status_code="404"} 1.0
    http_request_duration_seconds_bucket{instance="api-1",le="0.025",method="GET",path="/health"} 1.0
    http_request_duration_seconds_bucket{instance="api-1",le="0.05",method="GET",path="/health"} 1.0
    ...
    http_request_duration_seconds_sum{instance="api-1",method="GET",path="/health"} 0.0138
    

    Format ini (Prometheus exposition format) dirancang untuk di-scrape (diambil secara berkala) oleh server Prometheus, yang menyimpan riwayatnya dan memungkinkan visualisasi lewat Grafana. Kita tidak menjalankan Prometheus sungguhan di seri ini supaya tetap fokus, tapi endpoint /metrics kita sudah dalam format yang siap dihubungkan ke tools tersebut kapan saja.

    Jebakan Cardinality — Kenapa Kita Pakai Route Template, Bukan Raw Path

    Perhatikan baris ini di middleware:

    route = request.scope.get("route")
    path_label = route.path if route is not None else request.url.path
    

    Ini detail kecil yang gampang salah, dengan konsekuensi besar. Kalau kita pakai request.url.path mentah sebagai label metric, maka /api/tasks/1, /api/tasks/2, /api/tasks/3, dst. masing-masing dianggap label BERBEDA oleh Prometheus. Dengan ribuan task, itu jadi ribuan time series berbeda cuma untuk satu endpoint — ini disebut cardinality explosion, salah satu penyebab paling umum server metrics (Prometheus) kehabisan memory atau jadi sangat lambat di production sungguhan.

    Solusinya: pakai route.path, yang memberi TEMPLATE-nya (/api/tasks/{task_id}), bukan nilai aktualnya. Semua request ke task manapun terhitung sebagai SATU label yang sama — tetap bisa tahu endpoint mana yang lambat, tanpa meledakkan jumlah time series.

    Menghubungkan Metrics dengan Keputusan Scaling

    Sekarang kita tutup lingkaran balik ke Bagian 1: "kapan sebenarnya kita butuh scaling?" Dengan metrics yang sudah kita bangun, jawabannya tidak lagi tebakan. Beberapa sinyal konkret dari /metrics yang bisa dipakai untuk keputusan nyata:

  • http_requests_total dengan status_code 5xx naik tajam → ada yang rusak, butuh investigasi segera, bukan sekadar "tambah instance".
  • http_request_duration_seconds p95/p99 naik terus-menerus seiring waktu (bukan cuma sesekali lonjakan) → tanda nyata butuh scaling, baik vertical maupun horizontal.
  • Distribusi request antar instance di metrics timpang (satu instance jauh lebih sibuk dari yang lain) → load balancer atau salah satu instance bermasalah, bukan soal jumlah instance.
  • Throughput (rate(http_requests_total[5m])) mendekati batas yang pernah kita ukur di load test (Bagian 3, 4, 5, 7) → early warning sebelum user benar-benar merasakan dampaknya.
  • Inilah kenapa seluruh seri ini dimulai dari "ukur dulu" (Bagian 3) — angka baseline yang kita kumpulkan sepanjang jalan bukan cuma untuk artikel ini, tapi jadi patokan nyata untuk menilai metrics production: kalau p95 di dashboard sudah mendekati angka yang bikin sistem "retak" saat load test, itu sinyal untuk bertindak SEBELUM user mulai komplain — bukan sesudahnya.

    Rangkuman

  • Structured logging (JSON) membuat log bisa di-query dan dianalisis mesin, bukan cuma dibaca manusia.
  • request_id per request memudahkan menelusuri satu kejadian spesifik di antara jutaan baris log.
  • Metrics (Counter, Histogram) meringkas perilaku sistem dari waktu ke waktu, diekspos lewat /metrics dalam format Prometheus.
  • Hindari cardinality explosion — pakai route template, bukan raw path/ID, sebagai label metric.
  • Metrics yang terukur memungkinkan keputusan scaling berdasarkan data real-time, bukan tebakan atau laporan user yang sudah terlanjur kecewa.
  • Topik

    ScalabilityObservability