karawaci.kode

2026-08-15 · 10 min

OpenTelemetry + Grafana Tempo: Distributed Tracing untuk Node.js Monolith dari Nol

Klien saya di Jakarta datang dengan keluhan klasik: latency endpoint checkout naik dari 200ms ke 800ms setelah deploy fitur baru, tapi tidak ada yang tahu di mana waktu itu habis. Log ada, metrics ada, tapi keduanya tidak bisa menjawab pertanyaan “span mana yang lambat di request ini.” Satu sore setup OpenTelemetry dengan Grafana Tempo, dan dalam 15 menit masalahnya langsung kelihatan.

Kenapa Tracing, dan Kenapa Sekarang

Jika Anda menjalankan monolith Node.js yang sudah punya logging dan Prometheus metrics, tracing adalah lapisan ketiga observability yang melengkapi keduanya. Metrics memberi tahu berapa banyak (throughput, error rate, P95 latency). Log memberi tahu apa yang terjadi di satu titik. Trace memberi tahu bagaimana satu request mengalir — dari masuk ke handler, ke database, ke external API, sampai balik ke client.

Di monolith, ini berguna khususnya untuk:

  • Menemukan query N+1 yang tidak terlihat dari metrics aggregate
  • Melihat apakah slow request disebabkan database atau eksternal API
  • Correlate error di log dengan konteks request yang utuh
  • Baseline latency per-endpoint sebelum refactoring

Alternatif yang sering dipertimbangkan: Jaeger (battle-tested tapi perlu Elasticsearch/Cassandra), Zipkin (lebih sederhana tapi ekosistemnya tidak semaju), Datadog APM (paling mudah setup tapi harganya bisa mengejutkan saat traffic naik). Saya pilih Grafana Tempo karena klien ini sudah pakai Grafana + Loki — correlation antara trace dan log gratis tanpa vendor lock-in.

Arsitektur Setup

Untuk monolith, alur datanya sederhana:

Node.js App
  └── OTLP exporter (HTTP/gRPC)
        └── OpenTelemetry Collector
              └── Grafana Tempo
                    └── Grafana (visualisasi)

Kita pakai OpenTelemetry Collector sebagai buffer — jangan kirim langsung dari app ke Tempo. Collector memberi kita kemampuan batch, retry, dan filtering tanpa mengubah kode app.

Langkah 1: Jalankan Grafana Tempo dengan Docker Compose

Buat file docker-compose.yml untuk infrastruktur observability:

# docker-compose.yml
version: '3.8'
services:
  tempo:
    image: grafana/tempo:2.4.0
    command: ["-config.file=/etc/tempo.yaml"]
    volumes:
      - ./tempo.yaml:/etc/tempo.yaml
      - tempo-data:/var/tempo
    ports:
      - "3200:3200"   # HTTP API Tempo
      - "4317:4317"   # OTLP gRPC receiver
      - "4318:4318"   # OTLP HTTP receiver

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.98.0
    command: ["--config=/etc/otel-collector.yaml"]
    volumes:
      - ./otel-collector.yaml:/etc/otel-collector.yaml
    ports:
      - "4319:4318"   # OTLP HTTP dari app ke collector
    depends_on:
      - tempo

  grafana:
    image: grafana/grafana:10.4.0
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - grafana-data:/var/lib/grafana
      - ./grafana-provisioning:/etc/grafana/provisioning

volumes:
  tempo-data:
  grafana-data:

Konfigurasi Tempo minimal (tempo.yaml):

# tempo.yaml
server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

storage:
  trace:
    backend: local
    local:
      path: /var/tempo/blocks
    wal:
      path: /var/tempo/wal

compactor:
  compaction:
    block_retention: 48h   # simpan trace 48 jam, sesuaikan dengan kebutuhan

Konfigurasi Collector (otel-collector.yaml):

# otel-collector.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 1s
    send_batch_size: 1024
  memory_limiter:
    check_interval: 1s
    limit_mib: 256

exporters:
  otlp:
    endpoint: tempo:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp]

Jalankan:

docker compose up -d

Langkah 2: Instrumentasi Node.js App

Install package yang diperlukan:

npm install \
  @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

Buat file src/instrumentation.ts — ini harus di-load sebelum kode app lain:

// src/instrumentation.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { Resource } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';

const exporter = new OTLPTraceExporter({
  url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4319/v1/traces',
});

const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: process.env.SERVICE_NAME ?? 'checkout-service',
    [ATTR_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0',
    'deployment.environment': process.env.NODE_ENV ?? 'development',
  }),
  spanProcessor: new BatchSpanProcessor(exporter, {
    maxQueueSize: 2048,
    scheduledDelayMillis: 1000,
  }),
  // Sample 20% traffic normal, 100% untuk dev
  sampler: process.env.NODE_ENV === 'production'
    ? new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(0.2) })
    : new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(1.0) }),
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-fs': { enabled: false }, // terlalu noisy
      '@opentelemetry/instrumentation-http': { enabled: true },
      '@opentelemetry/instrumentation-express': { enabled: true },
      '@opentelemetry/instrumentation-pg': { enabled: true },
      '@opentelemetry/instrumentation-ioredis': { enabled: true },
    }),
  ],
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown().finally(() => process.exit(0));
});

Di package.json, pastikan instrumentation di-load pertama:

{
  "scripts": {
    "start": "node --require ./dist/instrumentation.js dist/server.js",
    "dev": "ts-node --require ./src/instrumentation.ts src/server.ts"
  }
}

Langkah 3: Manual Span untuk Konteks Bisnis

Auto-instrumentation menangkap Express route, query pg, call Redis secara otomatis. Tapi untuk logika bisnis yang penting — misalnya proses kalkulasi harga, validasi stok — Anda perlu manual span:

import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('checkout-service', '1.0.0');

async function calculateOrderTotal(items: CartItem[]): Promise<number> {
  return tracer.startActiveSpan('checkout.calculateTotal', async (span) => {
    span.setAttributes({
      'checkout.item_count': items.length,
      'checkout.has_discount': items.some(i => i.discountCode),
    });

    try {
      const total = await doCalculation(items);
      span.setAttributes({ 'checkout.total_idr': total });
      span.setStatus({ code: SpanStatusCode.OK });
      return total;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
      throw err;
    } finally {
      span.end();
    }
  });
}

Langkah 4: Inject Trace ID ke Log

Supaya bisa jump dari log ke trace di Grafana, inject trace ID ke setiap log entry:

import { trace, context } from '@opentelemetry/api';
import pino from 'pino';

const baseLogger = pino({ level: 'info' });

export function getLogger() {
  const activeSpan = trace.getActiveSpan();
  const spanContext = activeSpan?.spanContext();

  if (spanContext?.isValid) {
    return baseLogger.child({
      traceId: spanContext.traceId,
      spanId: spanContext.spanId,
    });
  }

  return baseLogger;
}

Penggunaan di handler:

app.post('/checkout', async (req, res) => {
  const log = getLogger(); // otomatis bawa traceId dari active span
  log.info({ userId: req.user.id }, 'Checkout dimulai');
  // ...
});

Langkah 5: Konfigurasi Grafana

Tambah Tempo sebagai datasource di Grafana melalui grafana-provisioning/datasources/tempo.yaml:

apiVersion: 1
datasources:
  - name: Tempo
    type: tempo
    url: http://tempo:3200
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki  # jika sudah pakai Loki
        tags:
          - key: service.name
            value: service
      serviceMap:
        datasourceUid: prometheus

Buka Grafana di http://localhost:3000, navigasi ke Explore → Tempo, dan cari trace berdasarkan service name atau trace ID.

Trade-off yang Perlu Diketahui

Sampling rate adalah keputusan paling penting. Menyimpan 100% trace di production dengan throughput tinggi bisa cepat menghabiskan storage dan CPU. Mulai dengan 10-20% untuk traffic normal, tapi pastikan semua request yang error atau lambat (>1 detik) selalu di-sample — ini disebut tail-based sampling dan butuh setup lebih kompleks di Collector.

Auto-instrumentation pg mengekspos query SQL di span attributes. Ini sangat berguna untuk debugging, tapi hati-hati jika query mengandung data sensitif. Atur dbStatementSerializer untuk meredaksi nilai parameter.

Collector adalah single point of failure. Jika Collector mati, app tidak perlu ikut mati — eksporter OTLP punya buffer internal. Tapi trace yang belum terkirim akan hilang. Untuk production serius, deploy Collector dengan lebih dari satu instance atau pertimbangkan otlp-http langsung ke Tempo dengan retry logic.

Cold start saat init SDK. NodeSDK.start() bersifat synchronous dan membutuhkan beberapa puluh milidetik. Untuk serverless atau cold start yang kritis, ini perlu dipertimbangkan.

Verdict

Setup ini layak dijalankan di production Node.js monolith mulai hari ini. Investasi waktunya sekitar 2-3 jam untuk setup infrastruktur dan instrumentasi dasar — setelah itu, debugging latency yang sebelumnya butuh berjam-jam menerka menjadi analisis 5 menit di Grafana.

Saya rekomendasikan pendekatan ini untuk tim yang sudah pakai Grafana stack (Loki + Prometheus). Jika belum, dan Anda tidak keberatan dengan biaya SaaS, Grafana Cloud tier gratis sudah cukup untuk menyimpan trace dengan retensi 14 hari — deploy Collector di server Anda, eksport ke Grafana Cloud, selesai tanpa mengelola Tempo sendiri.

Yang paling penting: pasang tracing sebelum ada masalah latency, bukan sesudah. Baseline yang terdokumentasi jauh lebih berguna daripada mencoba memahami anomali tanpa historis.

Ditulis oleh Reza Pradipta