Lompat ke konten
·27 menit membaca

Observability di Node.js: Logs, Metrics, dan Traces Tanpa Kerumitan

Structured logging dengan Pino, metrics dengan Prometheus, distributed tracing dengan OpenTelemetry. Stack observability yang saya gunakan untuk aplikasi Node.js produksi, dari nol hingga visibilitas penuh.

Bagikan:X / TwitterLinkedIn

Dulu saya mengira observability berarti "tambahkan beberapa console.log dan periksa saat ada yang rusak." Itu berhasil sampai tidak lagi. Titik baliknya adalah insiden production di mana API kami mengembalikan 200 tapi datanya basi. Tidak ada error di log. Tidak ada exception. Hanya respons yang salah secara diam-diam karena cache downstream sudah basi dan tidak ada yang menyadarinya selama empat jam.

Saat itulah saya belajar perbedaan antara monitoring dan observability. Monitoring memberi tahu bahwa ada yang salah. Observability memberi tahu mengapa itu salah. Dan jarak di antara keduanya adalah tempat insiden production hidup.

Ini adalah stack observability yang sudah saya tetapkan untuk layanan Node.js setelah mencoba sebagian besar alternatifnya. Ini bukan setup paling canggih di dunia, tapi ia menangkap masalah sebelum pengguna menyadarinya, dan saat ada yang lolos, saya bisa mendiagnosisnya dalam hitungan menit, bukan jam.

Tiga Pilar, dan Mengapa Kamu Butuh Semuanya#

Semua orang membicarakan "tiga pilar observability" — logs, metrics, dan traces. Yang tidak dikatakan siapa pun adalah bahwa setiap pilar menjawab pertanyaan yang secara fundamental berbeda, dan kamu butuh ketiganya karena tidak ada satu pilar pun yang bisa menjawab setiap pertanyaan.

Logs menjawab: Apa yang terjadi?

Sebuah baris log mengatakan "pada 14:23:07, user 4821 meminta /api/orders dan mendapat 500 karena koneksi database timeout." Itu adalah narasi. Ia menceritakan kisah dari satu event spesifik.

Metrics menjawab: Seberapa banyak yang terjadi?

Sebuah metrik mengatakan "dalam 5 menit terakhir, response time p99 adalah 2.3 detik dan error rate adalah 4.7%." Ini data agregat. Ia memberi tahu tentang kesehatan sistem secara keseluruhan, bukan tentang request individual apa pun.

Traces menjawab: Di mana waktunya habis?

Sebuah trace mengatakan "request ini memakan waktu 450ms total: 12ms di validasi auth, 15ms di query database, 400ms menunggu Stripe API, dan 23ms merender respons." Ini adalah timeline — ia menunjukkan jalur yang tepat yang diambil request melalui sistemmu.

Saat API kamu lambat:

  • Logs memberi tahu request apa yang lambat
  • Metrics memberi tahu seberapa banyak request yang lambat
  • Traces memberi tahu mengapa mereka lambat

Kamu bisa menjalankan layanan hanya dengan log. Banyak yang melakukannya. Tapi kamu akan menghabiskan waktu menghitung hal-hal secara manual yang seharusnya bisa diberitahu metrics, dan menebak bottleneck yang bisa ditunjukkan traces secara tepat. Investasi ini terbayar saat insiden pertama terjadi.

Structured Logging dengan Pino#

console.log baik-baik saja untuk debugging. Ini buruk untuk production. Outputnya tidak terstruktur (string biasa tidak bisa di-parse secara programatis), ia tidak memiliki level, dan ia secara mengejutkan lambat — console.log melakukan I/O sinkron di Node.js, yang berarti ia memblokir event loop saat menulis ke stdout.

Pino adalah logger yang saya gunakan. Ia menghasilkan JSON, ia cepat (5-10x lebih cepat dari Winston dalam benchmark), dan ia dirancang untuk pipeline log production.

Setup Dasar#

typescript
// src/lib/logger.ts
import pino from "pino";
 
export const logger = pino({
  level: process.env.LOG_LEVEL || "info",
 
  // Redact field sensitif
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "body.password",
      "body.token",
      "body.creditCard",
    ],
    censor: "[REDACTED]",
  },
 
  // Tambahkan metadata berguna ke setiap baris log
  base: {
    service: process.env.SERVICE_NAME || "api",
    version: process.env.APP_VERSION || "unknown",
    env: process.env.NODE_ENV || "development",
  },
 
  // Timestamp sebagai ISO string (default epoch ms kurang bisa dibaca manusia)
  timestamp: pino.stdTimeFunctions.isoTime,
 
  // Formatting yang lebih bagus di development
  ...(process.env.NODE_ENV !== "production" && {
    transport: {
      target: "pino-pretty",
      options: {
        colorize: true,
        translateTime: "HH:MM:ss",
        ignore: "pid,hostname",
      },
    },
  }),
});

Di production, ini menghasilkan:

json
{"level":30,"time":"2026-03-15T14:23:07.123Z","service":"api","version":"1.2.3","env":"production","msg":"Request selesai","statusCode":200,"duration":45,"path":"/api/products"}

Setiap baris log adalah JSON yang valid. Ini berarti kamu bisa mem-pipe-nya ke Loki, Elasticsearch, CloudWatch, atau alat agregasi log apa pun dan melakukan query terstruktur. "Tampilkan semua log di mana statusCode >= 500 dan duration > 1000" menjadi query database, bukan grep regex.

Mengapa Bukan Winston?#

Winston adalah logger Node.js yang paling populer. Saya dulunya menggunakannya. Saya berhenti karena tiga alasan:

  1. Performa: Pino 5-10x lebih cepat. Dalam hot path (middleware logging request), ini penting.
  2. Kesederhanaan: Transport Winston bisa menjadi rumit. Pino mengikuti filosofi Unix — output JSON ke stdout, biarkan proses terpisah merutekannya.
  3. Async by default: Pino menulis ke stdout secara asinkron. Winston bisa sinkron tergantung konfigurasi transport.

Jika kamu sudah menggunakan Winston dan ia bekerja dengan baik, jangan migrasi hanya demi migrasi. Tapi untuk proyek baru, Pino adalah pilihan default saya.

Child Logger untuk Konteks Request#

Kekuatan nyata dari structured logging datang dari menambahkan konteks ke setiap baris log. Child logger mewarisi field dari parent dan menambahkan field baru:

typescript
// src/middleware/request-logger.ts
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import { logger } from "../lib/logger";
 
export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
  const startTime = performance.now();
 
  // Buat child logger dengan konteks request
  req.log = logger.child({
    requestId,
    method: req.method,
    url: req.originalUrl,
    userAgent: req.headers["user-agent"],
    ip: req.headers["x-forwarded-for"]?.toString().split(",").pop()?.trim()
        || req.socket.remoteAddress,
  });
 
  // Set header request ID di response untuk korelasi
  res.setHeader("x-request-id", requestId);
 
  req.log.info("Request diterima");
 
  res.on("finish", () => {
    const duration = Math.round(performance.now() - startTime);
    const logMethod = res.statusCode >= 500 ? "error"
                    : res.statusCode >= 400 ? "warn"
                    : "info";
 
    req.log[logMethod]({
      statusCode: res.statusCode,
      duration,
      contentLength: res.getHeader("content-length"),
    }, "Request selesai");
  });
 
  next();
}

AsyncLocalStorage untuk Propagasi Konteks Otomatis#

Pendekatan child logger bekerja, tapi memerlukan kamu meneruskan req.log melalui setiap pemanggilan fungsi. Itu membosankan. AsyncLocalStorage menyelesaikan ini — ia menyediakan context store yang mengikuti alur eksekusi async tanpa penerusan eksplisit.

typescript
// src/lib/async-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { logger } from "./logger";
import type { Logger } from "pino";
 
interface RequestContext {
  requestId: string;
  logger: Logger;
  userId?: string;
  startTime: number;
}
 
export const asyncContext = new AsyncLocalStorage<RequestContext>();
 
// Dapatkan logger kontekstual dari mana saja di call stack
export function getLogger(): Logger {
  const store = asyncContext.getStore();
  return store?.logger || logger;
}
 
export function getRequestId(): string | undefined {
  const store = asyncContext.getStore();
  return store?.requestId;
}
typescript
// src/middleware/async-context-middleware.ts
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import { asyncContext } from "../lib/async-context";
import { logger } from "../lib/logger";
 
export function asyncContextMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
 
  const requestLogger = logger.child({ requestId });
 
  asyncContext.run(
    {
      requestId,
      logger: requestLogger,
      startTime: performance.now(),
    },
    () => next()
  );
}

Sekarang di mana saja dalam kode aplikasimu, kamu bisa menulis:

typescript
import { getLogger } from "../lib/async-context";
 
export async function processOrder(orderId: string) {
  const log = getLogger(); // Otomatis memiliki requestId terlampir
 
  log.info({ orderId }, "Memproses order");
 
  const items = await fetchOrderItems(orderId);
  log.info({ itemCount: items.length }, "Item diambil");
 
  // Tidak perlu meneruskan logger — ia mengikuti konteks async
}

Setiap baris log dari handler request ini secara otomatis menyertakan requestId — tanpa meneruskan logger secara manual melalui 5 level pemanggilan fungsi. Saat melakukan debug insiden production, kamu bisa memfilter semua log berdasarkan requestId untuk melihat timeline lengkap dari satu request.

Level Log dan Kapan Menggunakannya#

typescript
// FATAL: Proses tidak bisa dilanjutkan. Diikuti oleh process.exit().
log.fatal({ err }, "Konfigurasi database tidak valid - proses keluar");
 
// ERROR: Operasi gagal. Memerlukan perhatian.
log.error({ err, orderId }, "Pemrosesan pembayaran gagal");
 
// WARN: Sesuatu yang tidak terduga tapi tidak kritis.
log.warn({ retryCount: 3, service: "stripe" }, "Percobaan ulang panggilan API eksternal");
 
// INFO: Event bisnis yang signifikan.
log.info({ orderId, total: 49.99 }, "Order berhasil diproses");
 
// DEBUG: Detail teknis berguna untuk pengembangan.
log.debug({ query, params }, "Query database dieksekusi");
 
// TRACE: Detail yang sangat verbose. Jarang digunakan di production.
log.trace({ headers: req.headers }, "Header request mentah");

Aturan praktis saya: Production dijalankan di info. Jika saya menyelidiki masalah, saya sementara menurunkan ke debug untuk layanan atau path kode tertentu. Saya tidak pernah menjalankan trace di production — terlalu berisik.

Pengiriman Log: Stdout vs File Langsung#

Pino mengirimkan log ke stdout. Itu sengaja. Dalam deployment kontainer, stdout sudah ditangkap oleh runtime container (Docker, Kubernetes). Di VPS, kamu menggunakan process manager seperti PM2 yang menangkap stdout ke file.

bash
# PM2 sudah merotasi log
pm2 start ecosystem.config.js
# Log ada di ~/.pm2/logs/
 
# Atau kirim ke file secara eksplisit
node server.js | pino-pretty > app.log  # development
node server.js >> /var/log/app/api.log   # production (dengan logrotate)

Jangan menulis log langsung ke file dari dalam aplikasi Node.js. Itu memperkenalkan I/O di hot path. Biarkan OS atau process manager menanganinya.

Metrics dengan Prometheus#

Log memberi narasi. Metrics memberi angka. "Berapa request rate kita?" "Berapa persentase request yang gagal?" "Berapa response time p99?" Ini adalah pertanyaan yang log tidak bisa jawab secara efisien — kamu harus mengagregasi ribuan baris log untuk mendapatkan satu angka.

Setup Prometheus Client#

typescript
// src/lib/metrics.ts
import client from "prom-client";
 
// Buat registry (jangan gunakan registry default global — ia mengumpulkan
// metrics dari library yang mungkin tidak kamu inginkan)
export const metricsRegistry = new client.Registry();
 
// Tambahkan metrics default Node.js (heap size, event loop lag, GC, dll.)
client.collectDefaultMetrics({
  register: metricsRegistry,
  prefix: "nodeapp_",
});
 
// --- Metrics Kustom ---
 
// Counter: Hanya naik. Bagus untuk request total, error total, dll.
export const httpRequestsTotal = new client.Counter({
  name: "nodeapp_http_requests_total",
  help: "Total HTTP requests",
  labelNames: ["method", "route", "status_code"] as const,
  registers: [metricsRegistry],
});
 
// Histogram: Distribusi durasi. Memberi p50, p95, p99.
export const httpRequestDuration = new client.Histogram({
  name: "nodeapp_http_request_duration_seconds",
  help: "HTTP request duration in seconds",
  labelNames: ["method", "route", "status_code"] as const,
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
  registers: [metricsRegistry],
});
 
// Gauge: Naik dan turun. Bagus untuk nilai saat ini (koneksi aktif, ukuran antrian).
export const activeConnections = new client.Gauge({
  name: "nodeapp_active_connections",
  help: "Koneksi aktif saat ini",
  registers: [metricsRegistry],
});
 
// Histogram untuk durasi query DB
export const dbQueryDuration = new client.Histogram({
  name: "nodeapp_db_query_duration_seconds",
  help: "Database query duration in seconds",
  labelNames: ["operation", "table"] as const,
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1],
  registers: [metricsRegistry],
});
 
// Counter dan Histogram untuk panggilan API eksternal
export const externalApiDuration = new client.Histogram({
  name: "nodeapp_external_api_duration_seconds",
  help: "External API call duration in seconds",
  labelNames: ["service", "endpoint", "status"] as const,
  buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
  registers: [metricsRegistry],
});
 
export const ordersProcessed = new client.Counter({
  name: "nodeapp_orders_processed_total",
  help: "Total orders diproses",
  labelNames: ["status"] as const,
  registers: [metricsRegistry],
});

Middleware Metrics#

typescript
// src/middleware/metrics-middleware.ts
import type { Request, Response, NextFunction } from "express";
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
 
export function metricsMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Lewati endpoint metrics sendiri untuk menghindari rekursi
  if (req.path === "/metrics") return next();
 
  const end = httpRequestDuration.startTimer();
 
  res.on("finish", () => {
    const route = req.route?.path || req.path;
    const labels = {
      method: req.method,
      route,
      status_code: res.statusCode.toString(),
    };
 
    httpRequestsTotal.inc(labels);
    end(labels);
  });
 
  next();
}

Endpoint Metrics#

Prometheus mengambil metrics dengan mem-pull dari endpoint HTTP. Ekspos di /metrics:

typescript
// src/routes/metrics.ts
import { Router } from "express";
import { metricsRegistry } from "../lib/metrics";
import { timingSafeEqual } from "node:crypto";
 
const router = Router();
 
// Proteksi basic auth — endpoint metrics tidak boleh publik
router.get("/metrics", async (req, res) => {
  const authHeader = req.headers.authorization;
  const expected = `Basic ${Buffer.from(
    `${process.env.METRICS_USER}:${process.env.METRICS_PASSWORD}`
  ).toString("base64")}`;
 
  if (
    !authHeader ||
    !timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected))
  ) {
    res.status(401).set("WWW-Authenticate", "Basic").send("Unauthorized");
    return;
  }
 
  try {
    const metrics = await metricsRegistry.metrics();
    res.set("Content-Type", metricsRegistry.contentType);
    res.send(metrics);
  } catch (err) {
    res.status(500).send("Error mengumpulkan metrics");
  }
});
 
export default router;

Metrics Bisnis Kustom Adalah Kekuatan Sesungguhnya#

Metrics default Node.js (ukuran heap, event loop lag, durasi GC) adalah standar minimum. Mereka memberi tahu tentang kesehatan runtime. Tapi metrics bisnis memberi tahu tentang kesehatan aplikasi.

typescript
// Di order service
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
 
export async function processOrder(order: Order) {
  try {
    // Ukur waktu panggilan payment provider
    const paymentTimer = externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    });
 
    const charge = await stripe.charges.create({
      amount: order.total,
      currency: "usd",
      source: order.paymentToken,
    });
 
    paymentTimer({ status: "success" });
 
    ordersProcessed.inc({ status: "success" });
    return charge;
  } catch (err) {
    ordersProcessed.inc({ status: "failed" });
 
    externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    })({ status: "error" });
 
    throw err;
  }
}

Lonjakan di ordersProcessed{status="failed"} memberi tahu sesuatu yang tidak pernah bisa disampaikan oleh metrics CPU apa pun.

Kardinalitas Label: Pembunuh Diam-Diam#

Satu kata peringatan. Setiap kombinasi unik dari nilai label membuat time series baru. Jika kamu menambahkan label userId ke counter HTTP request, dan kamu memiliki 100.000 pengguna, kamu baru saja membuat 100.000+ time series. Prometheus akan macet.

Aturan untuk label:

  • Kardinalitas rendah saja: HTTP method (7 nilai), status code (5 kategori), route (puluhan, bukan ribuan)
  • Jangan pernah gunakan user ID, request ID, alamat IP, atau timestamp sebagai nilai label
  • Jika kamu tidak yakin, jangan tambahkan label. Kamu selalu bisa menambahkannya nanti, tapi menghapusnya memerlukan perubahan dashboard dan alert

Dashboard Grafana#

Prometheus menyimpan datanya. Grafana memvisualisasikannya. Berikut panel yang saya taruh di setiap dashboard layanan Node.js.

Dashboard Esensial#

1. Request Rate (requests/detik)

promql
rate(nodeapp_http_requests_total[5m])

Menunjukkan pola traffic. Berguna untuk mendeteksi lonjakan atau penurunan mendadak.

2. Error Rate (%)

promql
100 * (
  sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
  /
  sum(rate(nodeapp_http_requests_total[5m]))
)

Angka paling penting. Jika ini di atas 1%, ada yang salah.

3. Latensi p50 / p95 / p99

promql
histogram_quantile(0.99,
  sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
)

p50 memberi tahu pengalaman tipikal. p99 memberi tahu pengalaman terburuk. Jika p99 10x lipat p50, kamu punya masalah tail latency.

4. Event Loop Lag

promql
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}

Jika ini di atas 100ms, event loop-mu terblokir. Kemungkinan operasi sinkron di jalur async.

5. Penggunaan Heap

promql
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100

Perhatikan tren naik yang stabil — itu memory leak. Lonjakan selama GC itu normal.

6. Active Handles

promql
nodeapp_nodejs_active_handles_total

File descriptor terbuka, socket, timer. Angka yang terus tumbuh berarti kamu membocorkan handle — kemungkinan tidak menutup koneksi database atau respons HTTP.

Dashboard Grafana sebagai Kode#

Kamu bisa meng-version-control dashboard menggunakan fitur provisioning Grafana:

yaml
# /etc/grafana/provisioning/dashboards/dashboards.yml
apiVersion: 1
providers:
  - name: "Node.js Services"
    orgId: 1
    folder: "Services"
    type: file
    disableDeletion: false
    editable: true
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: true

Ekspor JSON dashboard-mu dari Grafana, commit ke repo, dan dashboard-mu bertahan saat reinstall Grafana. Ini bukan opsional untuk production — prinsipnya sama dengan infrastructure as code.

Distributed Tracing dengan OpenTelemetry#

Tracing adalah pilar yang kebanyakan tim adopsi terakhir, dan yang paling mereka harap diadopsi pertama. Saat kamu memiliki beberapa layanan yang saling berkomunikasi (bahkan jika hanya "server API + database + Redis + API eksternal"), tracing menunjukkan gambaran lengkap perjalanan sebuah request.

Apa Itu Trace?#

Trace adalah pohon dari span. Setiap span merepresentasikan unit kerja — sebuah HTTP request, query database, pemanggilan fungsi. Span memiliki waktu mulai, waktu selesai, status, dan atribut. Mereka terhubung satu sama lain oleh trace ID yang dipropagasikan melewati batas layanan.

Trace: abc-123
├── [API Gateway] POST /api/orders (250ms)
│   ├── [Auth Service] validate-token (12ms)
│   ├── [Order Service] create-order (230ms)
│   │   ├── [PostgreSQL] INSERT INTO orders (15ms)
│   │   ├── [Redis] SET order:cache (2ms)
│   │   └── [Payment Service] charge (200ms)
│   │       ├── [Stripe API] POST /v1/charges (180ms)
│   │       └── [PostgreSQL] UPDATE orders SET status (8ms)
│   └── [Email Service] send-confirmation (async, 45ms)

Sekali lihat memberitahu: request 250ms menghabiskan 180ms menunggu Stripe. Di situlah kamu mengoptimasi.

Setup OpenTelemetry#

OpenTelemetry (OTel) adalah standarnya. Ia menggantikan lanskap terfragmentasi dari klien Jaeger, klien Zipkin, dan SDK vendor-spesifik dengan satu API vendor-neutral yang tunggal.

typescript
// src/instrumentation.ts
// File ini HARUS dimuat sebelum import lainnya.
// Di Node.js, gunakan flag --require atau --import.
 
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
 
const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || "node-api",
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "0.0.0",
    "deployment.environment": process.env.NODE_ENV || "development",
  }),
 
  // Kirim traces ke collector (Jaeger, Tempo, dll.)
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
  }),
 
  // Opsional kirim metrics melalui OTel juga
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
    }),
    exportIntervalMillis: 15000,
  }),
 
  // Auto-instrumentation: otomatis membuat span untuk
  // HTTP request, route Express, query PostgreSQL, perintah Redis,
  // DNS lookup, dan lainnya
  instrumentations: [
    getNodeAutoInstrumentations({
      // Nonaktifkan instrumentasi yang berisik
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // Konfigurasi yang spesifik
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
      },
      "@opentelemetry/instrumentation-express": {
        ignoreLayersType: ["middleware"],
      },
    }),
  ],
});
 
sdk.start();
 
// Graceful shutdown
process.on("SIGTERM", () => {
  sdk.shutdown().then(
    () => console.log("OTel SDK berhasil dimatikan"),
    (err) => console.error("Error mematikan OTel SDK", err)
  );
});

Mulai aplikasimu dengan:

bash
node --import ./src/instrumentation.ts ./src/server.ts

Itu saja. Tanpa perubahan apa pun pada kode aplikasimu, sekarang kamu mendapat traces untuk setiap HTTP request, setiap query database, setiap perintah Redis.

Pembuatan Span Manual#

Auto-instrumentation mencakup panggilan infrastruktur, tapi terkadang kamu ingin meng-trace logika bisnis:

typescript
// src/services/order-service.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
 
const tracer = trace.getTracer("order-service");
 
export async function processOrder(orderId: string): Promise<Order> {
  return tracer.startActiveSpan("processOrder", async (span) => {
    try {
      span.setAttribute("order.id", orderId);
 
      // Span ini menjadi parent dari query DB atau panggilan HTTP
      // yang di-auto-instrumentasi di dalam fungsi-fungsi ini
      const order = await fetchOrder(orderId);
      span.setAttribute("order.total", order.total);
      span.setAttribute("order.item_count", order.items.length);
 
      const validationResult = await tracer.startActiveSpan(
        "validateOrder",
        async (validationSpan) => {
          const result = await validateInventory(order);
          validationSpan.setAttribute("validation.passed", result.valid);
          if (!result.valid) {
            validationSpan.setStatus({
              code: SpanStatusCode.ERROR,
              message: `Validasi gagal: ${result.reason}`,
            });
          }
          validationSpan.end();
          return result;
        }
      );
 
      if (!validationResult.valid) {
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: "Validasi order gagal",
        });
        throw new Error(validationResult.reason);
      }
 
      const payment = await processPayment(order);
      span.setAttribute("payment.id", payment.id);
 
      span.setStatus({ code: SpanStatusCode.OK });
      return order;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: (err as Error).message,
      });
      throw err;
    } finally {
      span.end();
    }
  });
}

Propagasi Trace Context#

Keajaiban distributed tracing adalah trace ID mengikuti request melewati layanan. Saat Service A memanggil Service B, trace context otomatis disuntikkan ke HTTP header (header traceparent sesuai standar W3C Trace Context).

Auto-instrumentation menangani ini untuk panggilan HTTP keluar. Tapi jika kamu menggunakan message queue, kamu perlu mempropagasikan secara manual:

typescript
import { context, propagation } from "@opentelemetry/api";
 
// Saat mempublikasikan pesan
function publishEvent(queue: string, payload: object) {
  const carrier: Record<string, string> = {};
 
  // Suntikkan trace context saat ini ke carrier
  propagation.inject(context.active(), carrier);
 
  // Kirim baik payload maupun trace context
  messageQueue.publish(queue, {
    payload,
    traceContext: carrier,
  });
}
 
// Saat mengonsumsi pesan
function consumeEvent(message: QueueMessage) {
  // Ekstrak trace context dari pesan
  const parentContext = propagation.extract(
    context.active(),
    message.traceContext
  );
 
  // Jalankan handler dalam konteks yang diekstrak
  // Sekarang span apa pun yang dibuat di sini akan menjadi children dari trace asli
  context.with(parentContext, () => {
    tracer.startActiveSpan("processEvent", (span) => {
      span.setAttribute("queue.message_id", message.id);
      handleEvent(message.payload);
      span.end();
    });
  });
}

Ke Mana Mengirim Traces#

Jaeger — Opsi open-source klasik. UI bagus, mudah dijalankan secara lokal dengan Docker. Penyimpanan jangka panjang terbatas.

Grafana Tempo — Jika kamu sudah menggunakan Grafana dan Loki, Tempo adalah pilihan alami untuk traces. Menggunakan object storage (S3/GCS) untuk retensi jangka panjang yang hemat biaya.

Grafana Cloud / Datadog / Honeycomb — Jika kamu tidak mau menjalankan infrastruktur. Lebih mahal, overhead operasional lebih sedikit.

Untuk pengembangan lokal, Jaeger di Docker sempurna:

yaml
# docker-compose.otel.yml
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"   # UI Jaeger
      - "4318:4318"     # Receiver OTLP HTTP
    environment:
      - COLLECTOR_OTLP_ENABLED=true

Endpoint Health Check#

Health check adalah bentuk observability paling sederhana dan hal pertama yang harus kamu implementasikan. Mereka menjawab satu pertanyaan: "Apakah layanan ini mampu melayani request saat ini?"

Tiga Jenis Health Check#

/health — Kesehatan umum. Apakah prosesnya berjalan dan responsif?

/ready — Kesiapan. Bisakah layanan ini menangani traffic? (Sudahkah ia terhubung ke database? Sudahkah ia memuat konfigurasinya? Sudahkah ia menghangatkan cache-nya?)

/live — Keaktifan. Apakah prosesnya hidup dan tidak deadlock? (Bisakah ia merespons request sederhana dalam batas timeout?)

Perbedaannya penting untuk Kubernetes, di mana liveness probe me-restart container yang macet dan readiness probe menghapus container dari load balancer selama startup atau kegagalan dependency.

typescript
// src/routes/health.ts
import { Router } from "express";
import { Pool } from "pg";
import Redis from "ioredis";
 
const router = Router();
 
interface HealthCheckResult {
  status: "ok" | "degraded" | "error";
  checks: Record<
    string,
    {
      status: "ok" | "error";
      latency?: number;
      message?: string;
    }
  >;
  uptime: number;
  timestamp: string;
  version: string;
}
 
async function checkDatabase(pool: Pool): Promise<{ ok: boolean; latency: number }> {
  const start = performance.now();
  try {
    await pool.query("SELECT 1");
    return { ok: true, latency: Math.round(performance.now() - start) };
  } catch {
    return { ok: false, latency: Math.round(performance.now() - start) };
  }
}
 
async function checkRedis(redis: Redis): Promise<{ ok: boolean; latency: number }> {
  const start = performance.now();
  try {
    await redis.ping();
    return { ok: true, latency: Math.round(performance.now() - start) };
  } catch {
    return { ok: false, latency: Math.round(performance.now() - start) };
  }
}
 
export function createHealthRoutes(pool: Pool, redis: Redis) {
  // Liveness — cukup periksa apakah proses bisa merespons
  router.get("/live", (_req, res) => {
    res.status(200).json({ status: "ok" });
  });
 
  // Readiness — periksa semua dependency
  router.get("/ready", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const allOk = db.ok && cache.ok;
 
    res.status(allOk ? 200 : 503).json({
      status: allOk ? "ok" : "not_ready",
      checks: {
        database: db,
        redis: cache,
      },
    });
  });
 
  // Full health — status detail untuk dashboard dan debugging
  router.get("/health", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const anyError = !db.ok || !cache.ok;
    const allError = !db.ok && !cache.ok;
 
    const result: HealthCheckResult = {
      status: allError ? "error" : anyError ? "degraded" : "ok",
      checks: {
        database: {
          status: db.ok ? "ok" : "error",
          latency: db.latency,
          ...(!db.ok && { message: "Koneksi gagal" }),
        },
        redis: {
          status: cache.ok ? "ok" : "error",
          latency: cache.latency,
          ...(!cache.ok && { message: "Koneksi gagal" }),
        },
      },
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
      version: process.env.APP_VERSION || "unknown",
    };
 
    // Return 200 untuk ok/degraded (layanan masih bisa menangani sebagian traffic)
    // Return 503 untuk error (layanan harus dihapus dari rotasi)
    res.status(result.status === "error" ? 503 : 200).json(result);
  });
 
  return router;
}

Konfigurasi Probe Kubernetes#

yaml
# k8s/deployment.yml
spec:
  containers:
    - name: api
      livenessProbe:
        httpGet:
          path: /live
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 15
        timeoutSeconds: 5
        failureThreshold: 3    # Restart setelah 3 kegagalan berturut-turut (45 detik)
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 5
        failureThreshold: 2    # Hapus dari LB setelah 2 kegagalan (20 detik)
      startupProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 0
        periodSeconds: 5
        failureThreshold: 30   # Beri waktu sampai 150 detik untuk startup

Kesalahan umum: membuat liveness probe terlalu agresif. Jika liveness probe-mu memeriksa database, dan database sedang down sementara, Kubernetes akan me-restart container-mu. Tapi restart tidak akan memperbaiki database. Sekarang kamu punya crash loop di atas gangguan database. Jaga liveness probe tetap sederhana — mereka hanya harus mendeteksi proses yang deadlock atau macet.

Error Tracking dengan Sentry#

Log menangkap error yang kamu harapkan. Sentry menangkap yang tidak kamu harapkan.

Perbedaannya penting. Kamu menambahkan blok try/catch di sekitar kode yang kamu tahu mungkin gagal. Tapi bug yang paling penting adalah yang ada di kode yang kamu pikir aman. Unhandled promise rejection, type error dari respons API tak terduga, null pointer access pada optional chain yang ternyata tidak cukup opsional.

Setup Sentry untuk Node.js#

typescript
// src/lib/sentry.ts
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
 
export function initSentry() {
  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    environment: process.env.NODE_ENV || "development",
    release: process.env.APP_VERSION || "unknown",
 
    // Sample 10% transaksi untuk monitoring performa
    // (100% di development)
    tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
 
    // Profile 100% transaksi yang di-sample
    profilesSampleRate: 1.0,
 
    integrations: [
      nodeProfilingIntegration(),
      // Filter error yang berisik
      Sentry.rewriteFramesIntegration({
        root: process.cwd(),
      }),
    ],
 
    // Jangan kirim error dari development
    enabled: process.env.NODE_ENV === "production",
 
    // Filter masalah yang sudah diketahui bukan bug
    ignoreErrors: [
      // Pemutusan koneksi client bukan bug
      "ECONNRESET",
      "ECONNABORTED",
      "EPIPE",
      // Bot mengirim sampah
      "SyntaxError: Unexpected token",
    ],
 
    // Hapus PII sebelum mengirim
    beforeSend(event) {
      // Hapus alamat IP
      if (event.request) {
        delete event.request.headers?.["x-forwarded-for"];
        delete event.request.headers?.["x-real-ip"];
        delete event.request.cookies;
      }
 
      // Hapus query param sensitif
      if (event.request?.query_string) {
        const params = new URLSearchParams(event.request.query_string);
        params.delete("token");
        params.delete("api_key");
        event.request.query_string = params.toString();
      }
 
      return event;
    },
  });
}

Error Handler Express dengan Sentry#

typescript
// src/middleware/error-handler.ts
import * as Sentry from "@sentry/node";
import { getLogger } from "../lib/async-context";
import type { Request, Response, NextFunction } from "express";
 
// Request handler Sentry harus lebih dulu
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
 
// Tracing handler Sentry
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
 
// Error handler kustommu datang terakhir
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  const log = getLogger();
 
  // Tambahkan konteks kustom ke event Sentry
  Sentry.withScope((scope) => {
    scope.setTag("route", req.route?.path || req.path);
    scope.setTag("method", req.method);
 
    if (req.user) {
      scope.setUser({
        id: req.user.id,
        // Jangan kirim email atau username ke Sentry
      });
    }
 
    // Tambahkan breadcrumb untuk debugging
    scope.addBreadcrumb({
      category: "request",
      message: `${req.method} ${req.path}`,
      level: "info",
      data: {
        query: req.query,
        statusCode: res.statusCode,
      },
    });
 
    Sentry.captureException(err);
  });
 
  // Log error dengan konteks lengkap
  log.error(
    {
      err,
      statusCode: 500,
      route: req.route?.path || req.path,
      method: req.method,
    },
    "Error tidak tertangani di request handler"
  );
 
  // Kirim respons error generik
  // Jangan pernah ekspos detail error ke client di production
  res.status(500).json({
    error: "Internal Server Error",
    ...(process.env.NODE_ENV !== "production" && {
      message: err.message,
      stack: err.stack,
    }),
  });
}

Source Maps#

Tanpa source maps, Sentry menampilkan stack trace yang sudah di-minify/transpile. Tidak berguna. Upload source maps selama build:

bash
# Di pipeline CI/CD
npx @sentry/cli sourcemaps upload \
  --org your-org \
  --project your-project \
  --release $APP_VERSION \
  ./dist

Atau konfigurasikan di bundler-mu:

typescript
// vite.config.ts (atau yang setara)
import { sentryVitePlugin } from "@sentry/vite-plugin";
 
export default defineConfig({
  build: {
    sourcemap: true, // Diperlukan untuk Sentry
  },
  plugins: [
    sentryVitePlugin({
      org: process.env.SENTRY_ORG,
      project: process.env.SENTRY_PROJECT,
      authToken: process.env.SENTRY_AUTH_TOKEN,
    }),
  ],
});

Biaya Unhandled Promise Rejection#

Sejak Node.js 15, unhandled promise rejection meng-crash proses secara default. Ini bagus — memaksa kamu menangani error. Tapi kamu butuh jaring pengaman:

typescript
// src/server.ts — di dekat awal entry point
process.on("unhandledRejection", (reason, promise) => {
  logger.fatal({ reason, promise }, "Unhandled promise rejection — crashing");
  Sentry.captureException(reason);
 
  // Flush event Sentry sebelum crash
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});
 
process.on("uncaughtException", (error) => {
  logger.fatal({ err: error }, "Uncaught exception — crashing");
  Sentry.captureException(error);
 
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});

Bagian pentingnya: Sentry.flush() sebelum process.exit(). Tanpanya, event error mungkin tidak sampai ke Sentry sebelum proses mati.

Alerting: Alert yang Benar-Benar Penting#

Memiliki 200 metrics Prometheus dan nol alert hanyalah vanity monitoring. Memiliki 50 alert yang menyala setiap hari adalah alert fatigue — kamu akan mulai mengabaikannya, dan kemudian melewatkan yang penting.

Tujuannya adalah sejumlah kecil alert sinyal tinggi yang berarti "ada yang benar-benar salah dan manusia perlu melihatnya."

Konfigurasi Prometheus AlertManager#

yaml
# alertmanager.yml
global:
  resolve_timeout: 5m
  slack_api_url: $SLACK_WEBHOOK_URL
 
route:
  receiver: "slack-warnings"
  group_by: ["alertname", "service"]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - match:
        severity: critical
      receiver: "pagerduty-critical"
      repeat_interval: 15m
    - match:
        severity: warning
      receiver: "slack-warnings"
 
receivers:
  - name: "pagerduty-critical"
    pagerduty_configs:
      - routing_key: $PAGERDUTY_ROUTING_KEY
        severity: critical
  - name: "slack-warnings"
    slack_configs:
      - channel: "#alerts"
        title: '{{ template "slack.title" . }}'
        text: '{{ template "slack.text" . }}'

Alert yang Benar-Benar Membangunkan Saya#

yaml
# prometheus/rules/node-api.yml
groups:
  - name: node-api-critical
    rules:
      # Error rate tinggi — ada yang rusak
      - alert: HighErrorRate
        expr: |
          (
            sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
            /
            sum(rate(nodeapp_http_requests_total[5m]))
          ) > 0.01
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Error rate di atas 1% selama 5 menit"
          description: "{{ $value | humanizePercentage }} request mengembalikan 5xx"
 
      # Response lambat — pengguna menderita
      - alert: HighP99Latency
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
          ) > 1
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Latensi p99 di atas 1 detik selama 5 menit"
          description: "Latensi p99 adalah {{ $value | humanizeDuration }}"
 
      # Memory leak — akan OOM segera
      - alert: HighHeapUsage
        expr: |
          (
            nodeapp_nodejs_heap_size_used_bytes
            /
            nodeapp_nodejs_heap_size_total_bytes
          ) > 0.80
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "Penggunaan heap di atas 80% selama 10 menit"
          description: "Penggunaan heap di {{ $value | humanizePercentage }}"
 
      # Proses down
      - alert: ServiceDown
        expr: up{job="node-api"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Node.js API sedang down"
 
  - name: node-api-warnings
    rules:
      # Event loop mulai lambat
      - alert: HighEventLoopLag
        expr: |
          nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"} > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Event loop lag di atas 100ms"
          description: "Event loop lag p99 adalah {{ $value | humanizeDuration }}"
 
      # Traffic turun signifikan — kemungkinan masalah routing
      - alert: TrafficDrop
        expr: |
          sum(rate(nodeapp_http_requests_total[5m]))
          < (sum(rate(nodeapp_http_requests_total[5m] offset 1h)) * 0.5)
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Traffic turun lebih dari 50% dibanding 1 jam lalu"
 
      # Query database mulai lambat
      - alert: SlowDatabaseQueries
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_db_query_duration_seconds_bucket[5m])) by (le, operation)
          ) > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Waktu query database p99 di atas 500ms"
          description: "Query {{ $labels.operation }} lambat: {{ $value | humanizeDuration }}"
 
      # API eksternal gagal
      - alert: ExternalAPIFailures
        expr: |
          (
            sum(rate(nodeapp_external_api_duration_seconds_count{status="error"}[5m])) by (service)
            /
            sum(rate(nodeapp_external_api_duration_seconds_count[5m])) by (service)
          ) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "API eksternal {{ $labels.service }} gagal >10%"

Perhatikan klausa for di setiap alert. Tanpanya, satu lonjakan akan men-trigger alert. for 5 menit berarti kondisi harus benar selama 5 menit terus-menerus. Ini mengeliminasi noise dari blip sesaat.

Kebersihan Alert#

Setiap alert harus lolos tes ini:

  1. Apakah bisa ditindaklanjuti? Jika tidak ada yang bisa dilakukan, jangan buat alert. Log saja, tampilkan di dashboard, tapi jangan bangunkan seseorang.
  2. Apakah memerlukan intervensi manusia? Jika masalah pulih sendiri (seperti blip jaringan singkat), klausa for seharusnya memfilternya.
  3. Apakah pernah menyala dalam 30 hari terakhir? Jika tidak, mungkin salah konfigurasi atau threshold-nya salah. Tinjau.
  4. Saat menyala, apakah orang peduli? Jika tim secara rutin mengabaikannya, hapus atau perbaiki threshold-nya.

Saya mengaudit alert saya setiap kuartal. Setiap alert mendapat salah satu dari tiga hasil: pertahankan, sesuaikan threshold, atau hapus.

Menyatukan Semuanya: Aplikasi Express#

Berikut bagaimana semua potongan cocok bersama dalam aplikasi nyata:

typescript
// src/server.ts
import { initSentry } from "./lib/sentry";
 
// Inisialisasi Sentry pertama — sebelum import lainnya
initSentry();
 
import express from "express";
import * as Sentry from "@sentry/node";
import { Pool } from "pg";
import Redis from "ioredis";
import { logger } from "./lib/logger";
import { asyncContextMiddleware } from "./middleware/async-context-middleware";
import { metricsMiddleware } from "./middleware/metrics-middleware";
import { requestLogger } from "./middleware/request-logger";
import {
  sentryRequestHandler,
  sentryTracingHandler,
  errorHandler,
} from "./middleware/error-handler";
import { createHealthRoutes } from "./routes/health";
import metricsRouter from "./routes/metrics";
import apiRouter from "./routes/api";
 
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);
 
// --- Urutan Middleware Penting ---
 
// 1. Request handler Sentry (harus pertama)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
 
// 2. Async context (membuat konteks per-request)
app.use(asyncContextMiddleware);
 
// 3. Request logging
app.use(requestLogger);
 
// 4. Pengumpulan metrics
app.use(metricsMiddleware);
 
// 5. Body parsing
app.use(express.json({ limit: "1mb" }));
 
// --- Routes ---
 
// Health check (tidak perlu auth)
app.use(createHealthRoutes(pool, redis));
 
// Metrics (dilindungi basic auth)
app.use(metricsRouter);
 
// Route API
app.use("/api", apiRouter);
 
// --- Error Handling ---
 
// Error handler Sentry (harus sebelum error handler kustom)
app.use(Sentry.Handlers.errorHandler());
 
// Error handler kustom (harus terakhir)
app.use(errorHandler);
 
// --- Mulai ---
 
const port = parseInt(process.env.PORT || "3000", 10);
 
app.listen(port, () => {
  logger.info(
    {
      port,
      nodeEnv: process.env.NODE_ENV,
      version: process.env.APP_VERSION,
    },
    "Server dimulai"
  );
});
 
// Graceful shutdown
async function shutdown(signal: string) {
  logger.info({ signal }, "Sinyal shutdown diterima");
 
  // Berhenti menerima koneksi baru
  // Proses request yang sedang berjalan (Express melakukan ini otomatis)
 
  // Tutup pool database
  await pool.end().catch((err) => {
    logger.error({ err }, "Error menutup pool database");
  });
 
  // Tutup koneksi Redis
  await redis.quit().catch((err) => {
    logger.error({ err }, "Error menutup koneksi Redis");
  });
 
  // Flush Sentry
  await Sentry.close(2000);
 
  logger.info("Shutdown selesai");
  process.exit(0);
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Stack Minimum yang Layak#

Semua di atas adalah stack "lengkap". Kamu tidak membutuhkan semuanya di hari pertama. Berikut cara men-scale observability seiring pertumbuhan proyekmu.

Tahap 1: Side Project / Developer Solo#

Kamu butuh tiga hal:

  1. Structured console log — Gunakan Pino, output JSON ke stdout. Bahkan jika kamu hanya membacanya dengan pm2 logs, log JSON bisa dicari dan di-parse.

  2. Endpoint /health — Butuh 5 menit untuk implementasi, menyelamatkanmu saat debugging "apakah ini bahkan berjalan?"

  3. Sentry free tier — Menangkap error yang tidak kamu antisipasi. Free tier memberi 5.000 event/bulan, yang cukup untuk side project.

typescript
// Ini adalah setup minimal. Di bawah 50 baris. Tidak ada alasan.
import pino from "pino";
import express from "express";
import * as Sentry from "@sentry/node";
 
const logger = pino({ level: "info" });
const app = express();
 
Sentry.init({ dsn: process.env.SENTRY_DSN });
app.use(Sentry.Handlers.requestHandler());
 
app.get("/health", (_req, res) => {
  res.json({ status: "ok", uptime: process.uptime() });
});
 
app.use("/api", apiRoutes);
 
app.use(Sentry.Handlers.errorHandler());
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  logger.error({ err }, "Error tidak tertangani");
  res.status(500).json({ error: "Internal Server Error" });
});
 
app.listen(3000, () => logger.info("Server dimulai di port 3000"));

Tahap 2: Proyek Berkembang / Tim Kecil#

Tambahkan:

  1. Prometheus metrics + Grafana — Saat "terasa lambat" tidak cukup dan kamu butuh data. Mulai dengan request rate, error rate, dan latensi p99.

  2. Agregasi log — Saat ssh ke server dan grep melalui file berhenti berskala. Loki + Promtail jika kamu sudah menggunakan Grafana.

  3. Alert dasar — Error rate > 1%, p99 > 1 detik, layanan down. Tiga alert. Itu saja.

Tahap 3: Layanan Production / Multiple Services#

Tambahkan:

  1. Distributed tracing dengan OpenTelemetry — Saat "API-nya lambat" menjadi "dari 5 layanan yang dipanggilnya, mana yang lambat?" Auto-instrumentation OTel memberi 80% nilainya tanpa perubahan kode.

  2. Dashboard sebagai kode — Version-control dashboard Grafana-mu. Kamu akan berterima kasih pada diri sendiri saat perlu membuatnya ulang.

  3. Alerting terstruktur — AlertManager dengan routing yang tepat, eskalasi, dan aturan silence.

  4. Metrics bisnis — Order/detik, conversion rate, kedalaman antrian. Metrics yang dipedulikan tim produk.

Apa yang Perlu Dilewati#

  • Vendor APM dengan harga per-host — Di skala besar, biayanya gila. Open source (Prometheus + Grafana + Tempo + Loki) memberi 95% fungsionalitasnya.
  • Level log di bawah INFO di production — Kamu akan menghasilkan terabyte log DEBUG dan membayar penyimpanannya. Gunakan DEBUG hanya saat secara aktif menyelidiki masalah, lalu matikan.
  • Metrics kustom untuk semuanya — Mulai dengan metode RED (Rate, Errors, Duration) untuk setiap layanan. Tambahkan metrics kustom hanya saat kamu punya pertanyaan spesifik untuk dijawab.
  • Sampling trace yang kompleks — Mulai dengan sample rate sederhana (10% di production). Adaptive sampling adalah optimasi prematur untuk kebanyakan tim.

Pemikiran Akhir#

Observability bukan produk yang kamu beli atau alat yang kamu install. Ini adalah praktik. Ini perbedaan antara mengoperasikan layananmu dan berharap layananmu beroperasi sendiri.

Stack yang saya jelaskan di sini — Pino untuk log, Prometheus untuk metrics, OpenTelemetry untuk traces, Sentry untuk error, Grafana untuk visualisasi, AlertManager untuk alert — bukan setup paling sederhana. Tapi setiap bagian membuktikan tempatnya dengan menjawab pertanyaan yang tidak bisa dijawab bagian lainnya.

Mulailah dengan structured log dan endpoint health. Tambahkan metrics saat kamu perlu tahu "seberapa parah." Tambahkan traces saat kamu perlu tahu "ke mana waktunya pergi." Setiap lapisan dibangun di atas yang terakhir, dan tidak ada yang mengharuskan kamu menulis ulang aplikasimu.

Waktu terbaik untuk menambahkan observability adalah sebelum insiden production terakhirmu. Waktu terbaik kedua adalah sekarang.

Artikel Terkait