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