karawaci.kode

2026-07-25 · 12 min

Setup OpenTelemetry di Node.js untuk Monitoring 2026

Bulan lalu saya membantu sebuah tim yang layanan Node.js-nya sering “lambat tapi tidak tahu di mana”. Grafik CPU normal, memory normal, tapi ada request yang sesekali makan 3-4 detik dan tidak ada yang bisa menunjuk penyebabnya. Yang mereka punya cuma console.log di beberapa handler dan sebuah dashboard uptime. Ketika saya tanya “request lambat itu menghabiskan waktu di query database atau di panggilan ke API pihak ketiga?”, jawabannya: tidak tahu, harus nebak.

Ini masalah klasik yang diselesaikan observability, dan di 2026 standar de-facto untuk instrumentasi adalah OpenTelemetry (OTel). Menariknya, instrumentasi vendor-neutral: Anda menulis kode instrumentasi sekali, lalu bebas mengirim datanya ke backend mana pun yang bicara OTLP. Artikel ini membahas cara setup OpenTelemetry di aplikasi Node.js dari nol — auto-instrumentation, tiga sinyal (traces, metrics, logs), custom span, sampai export ke OTel Collector — dengan contoh kode yang bisa langsung Anda pakai.

Tiga sinyal: traces, metrics, logs

Sebelum ke kode, penting memahami apa yang sebenarnya kita kumpulkan. OpenTelemetry mendefinisikan tiga sinyal utama:

  • Traces: merekam perjalanan satu request melintasi kode Anda. Satu trace terdiri dari banyak span, tiap span mewakili satu unit kerja (misalnya “handle HTTP request”, “query ke Postgres”, “panggil API pembayaran”) lengkap dengan durasi dan atribut. Inilah yang menjawab “waktu request habis di mana”.
  • Metrics: angka agregat sepanjang waktu — jumlah request, latency p95, jumlah error, panjang antrian. Cocok untuk dashboard dan alert.
  • Logs: catatan peristiwa diskret. Nilainya berlipat ketika dikaitkan dengan trace, sehingga dari sebuah span lambat Anda bisa langsung melompat ke log yang relevan.

Kekuatan OTel adalah menyatukan ketiganya di bawah satu SDK dan satu konteks. Sebuah trace_id yang sama bisa mengalir dari trace ke metric exemplar hingga ke baris log, sehingga korelasi antar-sinyal jadi mungkin.

Instalasi paket

Kita mulai dari aplikasi Express sederhana. Paket inti yang dibutuhkan:

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

Ringkasnya:

  • @opentelemetry/api — permukaan API yang Anda pakai untuk membuat span/metric manual.
  • @opentelemetry/sdk-node — implementasi SDK yang menyalakan pipeline.
  • @opentelemetry/auto-instrumentations-node — bundle instrumentation untuk library populer.
  • exporter-*-otlp-http — exporter yang mengirim data via protokol OTLP di atas HTTP.
  • resources + semantic-conventions — untuk memberi identitas layanan (nama, versi, environment) sesuai konvensi standar.

File instrumentasi: fondasinya

Prinsip terpenting: instrumentasi harus di-load sebelum modul aplikasi apa pun. Auto-instrumentation mem-patch modul saat di-require, jadi kalau express atau pg sudah ter-require duluan, patch-nya terlewat dan span-nya tidak akan muncul.

Buat file instrumentation.js terpisah:

// instrumentation.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-http');
const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} = require('@opentelemetry/semantic-conventions');

const collectorUrl = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'checkout-api',
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION || '1.0.0',
    'deployment.environment': process.env.NODE_ENV || 'development',
  }),
  traceExporter: new OTLPTraceExporter({
    url: `${collectorUrl}/v1/traces`,
  }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: `${collectorUrl}/v1/metrics`,
    }),
    exportIntervalMillis: 15000, // export metric tiap 15 detik
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      // Matikan instrumentation fs — sering terlalu berisik
      '@opentelemetry/instrumentation-fs': { enabled: false },
    }),
  ],
});

sdk.start();

// Shutdown yang rapi agar sisa data ter-flush saat proses berhenti
process.on('SIGTERM', () => {
  sdk.shutdown()
    .then(() => console.log('OpenTelemetry SDK berhenti dengan bersih'))
    .catch((err) => console.error('Gagal shutdown OTel SDK', err))
    .finally(() => process.exit(0));
});

Beberapa hal yang perlu diperhatikan:

  • resource memberi identitas ke semua sinyal. service.name inilah yang muncul sebagai nama layanan di backend, jadi jangan biarkan default unknown_service.
  • Instrumentation fs dimatikan: ia menghasilkan span untuk setiap operasi file dan biasanya cuma menambah noise.
  • Graceful shutdown penting. Tanpa sdk.shutdown(), batch trace/metric terakhir bisa hilang saat container di-stop. Untuk deploy zero-downtime, sinyal SIGTERM yang ditangani dengan benar adalah bagian yang mudah terlupa.

Lalu jalankan aplikasi dengan file instrumentasi di-load lebih dulu:

# CommonJS
node --require ./instrumentation.js app.js

# ESM: pakai --import
node --import ./instrumentation.mjs app.js

Menaruh instrumentasi di file terpisah dan me-load-nya lewat --require/--import jauh lebih aman daripada import di baris pertama app.js, karena menjamin ia berjalan sebelum apa pun yang lain.

Apa yang langsung Anda dapat dari auto-instrumentation

Dengan hanya konfigurasi di atas, aplikasi Express Anda sudah menghasilkan trace tanpa satu baris pun kode instrumentasi tambahan. Ambil contoh handler biasa:

// app.js
const express = require('express');
const { Pool } = require('pg');

const app = express();
const pool = new Pool();

app.get('/orders/:id', async (req, res) => {
  const order = await pool.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);
  const items = await pool.query('SELECT * FROM order_items WHERE order_id = $1', [req.params.id]);
  res.json({ order: order.rows[0], items: items.rows });
});

app.listen(3000);

Satu request ke /orders/123 otomatis menghasilkan trace berisi span bertingkat:

  • Span HTTP server untuk GET /orders/:id (dari instrumentation express/http).
  • Dua span child untuk masing-masing pool.query (dari instrumentation pg), lengkap dengan statement SQL sebagai atribut.

Sekarang, “request lambat itu waktunya habis di mana” bisa dijawab dengan melihat span mana yang durasinya paling panjang. Untuk request contoh di atas, kalau ada N+1 query, Anda akan langsung melihat deretan span query yang menumpuk.

Custom span: menambah konteks bisnis

Auto-instrumentation bagus untuk infrastruktur, tapi ia tidak tahu logika bisnis Anda. Kalau ada blok kerja penting — validasi harga, panggilan ke payment gateway, kalkulasi ongkos kirim — buat span manual agar terlihat di trace.

const { trace, SpanStatusCode } = require('@opentelemetry/api');

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

async function processPayment(order) {
  return tracer.startActiveSpan('process-payment', async (span) => {
    try {
      span.setAttribute('order.id', order.id);
      span.setAttribute('payment.amount', order.total);
      span.setAttribute('payment.method', order.method);

      const result = await paymentGateway.charge(order);

      span.setAttribute('payment.transaction_id', result.transactionId);
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (err) {
      // Rekam exception dan tandai span sebagai error
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
      throw err;
    } finally {
      span.end(); // WAJIB — span yang tidak di-end tidak akan terkirim
    }
  });
}

Poin penting soal custom span:

  • startActiveSpan menjadikan span ini “aktif”, sehingga span apa pun yang dibuat di dalamnya (termasuk oleh auto-instrumentation) otomatis menjadi child-nya. Konteks trace mengalir mengikuti async context.
  • Atribut sebaiknya bermakna dan berdimensi rendah. Menaruh order.id boleh untuk trace, tapi hati-hati memakai nilai ber-kardinalitas sangat tinggi sebagai atribut metric.
  • Jangan menaruh data sensitif (nomor kartu, password, token) sebagai atribut span. Data ini ikut terkirim ke backend.
  • span.end() wajib. Span yang lupa di-end tidak pernah selesai dan tidak terkirim — ini bug instrumentasi yang paling sering saya temui.

Metrics: angka untuk dashboard dan alert

Trace menjelaskan satu request; metric menjelaskan tren. Pakai Meter API untuk menghitung hal-hal yang ingin Anda pantau agregatnya.

const { metrics } = require('@opentelemetry/api');

const meter = metrics.getMeter('checkout-service');

// Counter: jumlah kumulatif
const orderCounter = meter.createCounter('orders.created', {
  description: 'Jumlah order yang berhasil dibuat',
});

// Histogram: distribusi nilai (mis. durasi proses)
const paymentDuration = meter.createHistogram('payment.duration', {
  description: 'Durasi proses pembayaran',
  unit: 'ms',
});

async function createOrder(input) {
  const start = Date.now();
  const order = await saveOrder(input);

  // Tambahkan label berdimensi rendah, bukan order.id
  orderCounter.add(1, { channel: input.channel, status: 'success' });
  paymentDuration.record(Date.now() - start, { method: input.method });

  return order;
}

Ada tiga jenis instrumen yang paling sering dipakai:

  • Counter — nilai yang hanya naik (jumlah request, jumlah error).
  • Histogram — distribusi nilai, dari sini backend menghitung p50/p95/p99 (latency adalah contoh utama).
  • Observable Gauge — nilai sesaat yang dibaca saat export (panjang antrian, jumlah koneksi aktif).

Aturan emas metric: label (attribute) harus berdimensi rendah. Jangan pakai order.id atau user.id sebagai label metric — tiap nilai unik menciptakan time series baru, dan kardinalitas yang meledak adalah penyebab nomor satu tagihan observability membengkak dan backend melambat. Simpan ID unik itu untuk trace dan log, bukan metric.

Logs: mengaitkan log dengan trace

Log jadi jauh lebih berguna ketika membawa trace_id. Kalau Anda memakai Pino (logger yang cepat dan umum di ekosistem Node), tambahkan instrumentation-nya:

npm install @opentelemetry/instrumentation-pino

Aktifkan di file instrumentasi (auto-instrumentation bundle sudah menyertakannya, tapi bisa juga eksplisit). Setelah aktif, setiap log yang ditulis dalam konteks span aktif otomatis mendapat field trace_id dan span_id:

const pino = require('pino');
const logger = pino();

app.get('/orders/:id', async (req, res) => {
  // Log ini otomatis membawa trace_id & span_id dari request saat ini
  logger.info({ orderId: req.params.id }, 'Mengambil detail order');
  // ...
});

Hasil lognya kira-kira:

{
  "level": 30,
  "time": 1753400000000,
  "msg": "Mengambil detail order",
  "orderId": "123",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7"
}

Dengan trace_id di log, alur debugging berubah total: dari sebuah trace yang lambat di UI, Anda ambil trace_id-nya, tempel ke pencarian log, dan langsung dapat semua baris log yang terjadi di request itu. Pola menyisipkan trace context ke log terstruktur ini juga saya bahas lebih lanjut di Observability Stack Grafana + Loki + Tempo, di mana Loki menyimpan log dan Tempo menyimpan trace dengan korelasi lewat ID yang sama.

Export ke OpenTelemetry Collector

Sejauh ini exporter kita menembak http://localhost:4318. Di production, alamat itu sebaiknya menunjuk ke OTel Collector, bukan langsung ke backend. Collector adalah proses perantara yang menerima, memproses, dan meneruskan telemetri.

Kenapa repot-repot menambah satu komponen? Karena Collector memisahkan aplikasi dari backend:

  • Batching & retry ditangani Collector, bukan aplikasi Anda — mengurangi beban dan risiko kehilangan data saat backend sesaat tidak tersedia.
  • Sampling (misalnya tail-based) bisa diatur terpusat tanpa deploy ulang aplikasi.
  • Ganti atau tambah backend cukup di konfigurasi Collector; kode aplikasi tidak berubah.
  • Redaksi & enrichment — buang atribut sensitif atau tambahkan metadata resource di satu tempat.

Konfigurasi Collector minimal (otel-collector-config.yaml):

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

processors:
  batch:
    timeout: 5s
  # Batasi memori agar Collector tidak jadi sumber OOM
  memory_limiter:
    check_interval: 1s
    limit_mib: 512

exporters:
  # Contoh: kirim trace ke backend yang bicara OTLP (mis. Tempo)
  otlphttp/traces:
    endpoint: http://tempo:4318
  # Contoh: expose metric dalam format Prometheus untuk di-scrape
  prometheus:
    endpoint: 0.0.0.0:8889
  debug:
    verbosity: normal

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/traces]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]

Struktur Collector selalu sama: receivers (dari mana data masuk), processors (apa yang dilakukan ke data), exporters (ke mana data dikirim), lalu dirangkai jadi pipelines per sinyal. Perhatikan memory_limiter dan batch sebaiknya selalu ada di production — yang pertama mencegah Collector jadi biang OOM, yang kedua mengefisienkan pengiriman.

Menjalankan Collector paling praktis lewat container:

docker run -p 4317:4317 -p 4318:4318 -p 8889:8889 \
  -v $(pwd)/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml \
  otel/opentelemetry-collector-contrib:latest

Di aplikasi, arahkan endpoint ke Collector lewat environment variable standar OTel — tidak perlu ubah kode:

OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
OTEL_SERVICE_NAME=checkout-api \
node --require ./instrumentation.js app.js

Menyatukan semua di Docker

Untuk deployment, instrumentasi tetap di-load lewat --require. Contoh potongan Dockerfile:

FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV OTEL_SERVICE_NAME=checkout-api
ENV OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318

COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .

USER node
EXPOSE 3000

# Load instrumentasi sebelum app lewat --require
CMD ["node", "--require", "./instrumentation.js", "app.js"]

Pola base image slim dan npm ci --omit=dev di sini sengaja mengikuti prinsip yang saya bahas di Docker Multi-Stage Build untuk Optimasi Ukuran Image — instrumentasi OTel tidak menambah beban image yang berarti, dan file instrumentation.js cukup ikut ter-copy bersama source.

Sampling: jangan simpan semuanya

Di layanan dengan traffic tinggi, menyimpan 100% trace itu mahal dan sebagian besar tidak informatif. Sampling menjawab ini. Dua pendekatan utama:

  • Head-based sampling: keputusan diambil di awal request, di aplikasi. Sederhana dan murah, tapi Anda bisa kehilangan trace error yang penting kalau kebetulan tidak ter-sample. Contohnya TraceIdRatioBasedSampler yang menyimpan, katakanlah, 10% trace.
  • Tail-based sampling: keputusan diambil setelah seluruh trace selesai, di Collector. Ini memungkinkan aturan seperti “simpan 100% trace yang error atau lambat, dan hanya sampel kecil trace normal”. Jauh lebih hemat sekaligus tetap menangkap yang penting, dengan ongkos Collector perlu buffer trace sementara.

Untuk mulai, head-based ratio sampler sudah memadai; naik ke tail-based di Collector saat volume dan biaya mulai terasa. Angka rasio yang tepat bergantung pada volume traffic dan anggaran storage Anda, jadi anggap 5-10% untuk trace normal sebagai titik awal yang wajar, bukan patokan mutlak.

Kesalahan yang sering terjadi

Dari beberapa kali membantu tim mengadopsi OTel, ini pitfall yang paling sering muncul:

1. Instrumentasi di-load terlalu telat. import/require OTel setelah express membuat sebagian span hilang tanpa error yang jelas. Selalu pakai --require/--import.

2. Lupa span.end(). Custom span yang tidak di-end tidak terkirim. Bungkus dengan startActiveSpan dan finally seperti contoh di atas.

3. Kardinalitas metric meledak. Memakai ID unik sebagai label metric membuat time series tak terhingga. Simpan ID unik untuk trace/log.

4. Tidak ada graceful shutdown. Tanpa sdk.shutdown() pada SIGTERM, batch terakhir hilang saat pod di-restart.

5. service.name default. Semua layanan muncul sebagai unknown_service dan mustahil dibedakan. Set resource attribute sejak awal.

6. Data sensitif bocor ke span. SQL dengan parameter mentah, header Authorization, body request — semua bisa ikut terekam. Redaksi di Collector atau matikan atribut yang berisiko.

Penutup

OpenTelemetry di Node.js memberi lompatan besar dengan usaha yang relatif kecil: satu file instrumentasi dengan auto-instrumentation sudah membuat request lambat yang tadinya misterius menjadi trace yang bisa dibaca. Dari sana, tambahkan custom span di titik-titik bisnis, hitung metric yang relevan, kaitkan log dengan trace_id, dan arahkan semuanya ke Collector agar aplikasi tetap terpisah dari backend.

Kalau layanan Anda masih mengandalkan console.log dan tebak-tebakan seperti tim di awal cerita, mulailah dari yang paling murah: pasang instrumentation.js, jalankan Collector lokal, lihat trace pertama Anda. Setelah Anda sekali melihat waktu request terurai per span, sulit kembali ke cara lama. Dan karena OTel vendor-neutral, investasi instrumentasi ini tidak mengunci Anda ke satu backend — Anda bebas memilih dan berganti tujuan tanpa menyentuh ulang kode.

Ditulis oleh Reza Pradipta