Distributed tracing menyatukan semua operasi dari satu request, walaupun operasinya tersebar di banyak service, menjadi satu trace yang bisa dibaca dari awal sampai akhir. Bayangin melacak paket kiriman. Paketmu lewat loket pengirim, gudang sortir, truk antarkota, lalu kurir. Tiap tempat memindai nomor resi yang sama dan mencatat jam masuk dan keluarnya. Begitu paket telat, kamu langsung tahu: tiga hari tertahan di gudang sortir. Nomor resi itu trace ID, tiap pindaian di satu tempat adalah span, dan "diterima dari mana" adalah parent span ID.
Di pelajaran OpenTelemetry Fundamentals kamu sudah kenal span, trace ID, dan header traceparent. Sekarang kita pakai semuanya untuk mencari kenapa pencarian catatan di catatan-api lambat.
🧪 Tebakan cepat: Sari mencari catatan berisi "resep", dan request-nya butuh 1.221 ms. Di trace-nya, span catatan-api
GET /catatanberdurasi 1.215 ms. Jadi kode catatan-api yang lambat? Jawabannya ada di bagian "Mencari span yang lambat: self time".
Satu request, banyak service, satu trace
Di arsitektur microservices, satu request user bisa melintasi 5 sampai 10 service. Masing-masing punya log dan metric sendiri, dan gak ada satu pun yang melihat gambar utuhnya. Distributed tracing menyambungkan semua operasi itu lewat trace ID yang sama, jadi kamu bisa melihat urutan, durasi, dan letak error-nya dalam satu tampilan.
catatan-api masih sederhana: nginx, catatan-api, PostgreSQL. Tapi polanya sama persis, dan tiga lapis sudah cukup untuk tersesat tanpa trace.
Propagasi: header yang bikin span tersambung
Span cuma tersambung kalau konteks trace ikut terkirim ke tiap service. nginx bisa membuat span sendiri lewat modul resminya, ngx_otel_module:
load_module modules/ngx_otel_module.so; # paling atas nginx.conf
http {
otel_exporter {
endpoint localhost:4317; # OTLP gRPC ke collector
}
otel_service_name nginx;
otel_trace on;
server {
server_name catatan.example.com;
location / {
otel_trace_context propagate; # baca traceparent yang masuk, kirim yang baru
otel_span_name "$request_method $uri";
proxy_pass http://127.0.0.1:3000;
}
}
}
otel_trace onmembuat span untuk tiap request.otel_trace_context propagatemembacatraceparentdari browser (kalau ada) dan meneruskantraceparentbaru ke catatan-api, berisi trace ID dan span ID nginx. Nilai bawaannyaignore: gak membaca, gak meneruskan.- Di catatan-api, SDK OpenTelemetry membaca header itu dan membuat span anak, persis seperti di pelajaran sebelumnya.
Kalau header-nya gak sampai, misalnya propagate lupa ditulis, catatan-api gak tahu induknya dan memulai trace baru. Di Jaeger, span catatan-api muncul sebagai trace terpisah yang gak tersambung ke nginx. Coba matikan propagasi di Lab: satu trace pecah jadi dua.
Span PostgreSQL dibuat oleh catatan-api, bukan oleh PostgreSQL. Instrumentasi driver pg mencatat kapan query dikirim dan kapan hasilnya kembali. PostgreSQL sendiri gak menerima traceparent.
Waterfall dari parent ID
Backend tracing menerima span satu per satu, tanpa urutan, dari service yang berbeda. Ini 7 span trace a3c9e1f07b2d4e58961c0d7f3b5a8e24 di Lab:
| span ID | parent | service | nama | mulai | durasi |
|---|---|---|---|---|---|
411040a7e0c772b3 |
(root) | nginx | GET /catatan |
0 ms | 1.221 ms |
338bcacd9627d923 |
411040a7... |
catatan-api | GET /catatan |
3 ms | 1.215 ms |
e7120c544d183da4 |
338bcacd... |
catatan-api | middleware - auth |
4 ms | 6 ms |
aee6a787c3a60c4d |
338bcacd... |
catatan-api | pg-pool.connect |
11 ms | 1 ms |
eb1ae73b369e8c7a |
338bcacd... |
catatan-api | SELECT catatan |
13 ms | 1.178 ms |
6c59a15d0fce260e |
338bcacd... |
catatan-api | SELECT tag |
1.192 ms | 7 ms |
0008c964f55bab20 |
338bcacd... |
catatan-api | render-json |
1.200 ms | 12 ms |
Dari parent ID-nya, backend menyusun pohon, lalu menggambarnya sebagai waterfall: satu baris per span, batang mulai di waktu mulai dan panjangnya sebanding dengan durasi. Ini kode yang sama dengan yang dijalankan Lab:
function cetakWaterfall(spans) {
const anakDari = {};
for (const span of spans) {
const key = span.parent || "root";
if (!anakDari[key]) anakDari[key] = [];
anakDari[key].push(span);
}
function cetak(span, kedalaman, root) {
const anak = (anakDari[span.id] || []).sort((a, b) => a.start - b.start);
const msAnak = anak.reduce((jumlah, a) => jumlah + a.ms, 0);
const lebar = Math.max(1, Math.round((span.ms / root.ms) * 30));
const kiri = Math.min(30 - lebar, Math.round(((span.start - root.start) / root.ms) * 30));
const batang = " ".repeat(kiri) + "#".repeat(lebar) + " ".repeat(Math.max(0, 30 - kiri - lebar));
console.log("|" + batang + "| " + " ".repeat(kedalaman) + span.name + " " + span.ms + " ms, self " + (span.ms - msAnak) + " ms");
anak.forEach((a) => cetak(a, kedalaman + 1, root));
}
for (const root of anakDari.root || []) {
console.log("trace " + root.trace);
cetak(root, 0, root);
}
}
trace a3c9e1f07b2d4e58961c0d7f3b5a8e24
|##############################| GET /catatan 1221 ms, self 6 ms
|##############################| GET /catatan 1215 ms, self 11 ms
|# | middleware - auth 6 ms, self 6 ms
|# | pg-pool.connect 1 ms, self 1 ms
|############################# | SELECT catatan 1178 ms, self 1178 ms
| #| SELECT tag 7 ms, self 7 ms
| #| render-json 12 ms, self 12 ms
anakDarimengelompokkan span menurut parent-nya. Span tanpa parent masuk"root".cetakmemanggil dirinya sendiri untuk tiap anak (rekursi), jadi kedalaman pohon jadi indentasi.kiridanlebarmengubah milidetik jadi posisi batang dari 30 kolom, dihitung dari awal dan durasi root.
Mencari span yang lambat: self time
Durasi span induk termasuk durasi anak-anaknya. Jadi yang dicari bukan span terpanjang, tapi span dengan self time terbesar: durasinya dikurangi durasi anak-anaknya (selama anaknya gak jalan bersamaan). GET /catatan di catatan-api memang 1.215 ms, tapi self time-nya cuma 11 ms. Yang makan waktu adalah anaknya, SELECT catatan: 1.178 ms, 96% dari seluruh request. Jadi bukan kode catatan-api yang lambat, tapi query-nya. Itu jawaban tebakannya.
Atribut span itu menunjukkan query-nya: WHERE user_id = $1 AND isi ILIKE $2, dengan $2 berisi '%resep%'. Pola yang diawali % gak bisa memakai index B-tree biasa, jadi PostgreSQL membaca semua baris catatan. Perbaikannya index trigram:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY catatan_isi_trgm ON catatan USING gin (isi gin_trgm_ops);
Setelah index-nya ada, SELECT catatan turun ke 21 ms, dan seluruh request dari 1.221 ms jadi 64 ms. CONCURRENTLY membuat index tanpa mengunci tabel dari INSERT dan UPDATE, penting di tabel yang sedang dipakai.
Sampling: head atau tail
Merekam trace dari setiap request di production berarti data yang sangat banyak. Sampling memilih trace mana yang disimpan, dan ada dua waktu untuk memutuskannya:
- Head-based: diputuskan di awal, oleh service pertama, sebelum request jalan. nginx memakai
split_clientsatas$otel_trace_id(10%on) laluotel_trace $ratio_sampler. Keputusannya ikut di flagtraceparent(01atau00), dan catatan-api denganOTEL_TRACES_SAMPLER=parentbased_traceidratiomengikutinya. Murah, tapi buta: saat memutuskan, belum ada yang tahu request ini bakal error atau lambat. - Tail-based: diputuskan setelah request selesai, di collector, saat semua span-nya sudah terkumpul. Semua service mengirim 100%, collector menahan tiap trace sebentar (
decision_wait), lalu memilih:
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: semua-error
type: status_code
status_code:
status_codes: [ERROR]
- name: lambat
type: latency
latency:
threshold_ms: 1000
- name: sebagian-sisanya
type: probabilistic
probabilistic:
sampling_percentage: 10
Lab memuat 20 trace dari satu menit: 16 normal, 2 lambat (1.221 ms dan 1.480 ms), 2 error. Head 10% menyimpan 2 trace, keduanya normal: trace pencarian Sari yang lambat ikut terbuang, begitu juga kedua error. Tail menyimpan 6: kedua error, kedua yang lambat, plus 2 trace normal. Kalau kamu ingin semua trace error tersimpan dan yang sukses cukup sebagian, pakai tail-based. Harganya: collector butuh memori untuk menahan trace, dan semua span satu trace harus sampai di collector yang sama.
💡 Pilihan backend tracing
- Jaeger: open-source, self-hosted, UI waterfall yang enak.
- Grafana Tempo: menyatu dengan Grafana, Loki, dan Prometheus. Dari satu baris log bisa lompat ke trace-nya.
- Datadog APM: layanan berbayar yang lengkap, tapi mahal di volume besar.
- AWS X-Ray: tracing bawaan AWS, cocok kalau semua service sudah di AWS.
Semuanya bisa menerima data OpenTelemetry, jadi instrumentasimu gak terikat ke salah satunya.
⚠️ Jebakan yang sering ketemu
- Propagasi putus di satu lapis. Satu service yang gak meneruskan
traceparent(nginx tanpapropagate, HTTP client yang dibungkus sendiri, antrean job) memotong trace jadi dua. Cek tiap lapis. - Membaca span terpanjang, bukan self time. Root selalu terpanjang. Cari span yang durasinya hampir habis untuk dirinya sendiri.
- Head sampling untuk mencari error langka. Error 0,1% dengan sampling 10% berarti 9 dari 10 error gak punya trace. Pakai tail sampling, atau naikkan rasionya.
- Tail sampling dengan beberapa collector tanpa pengaturan. Span satu trace tersebar ke collector berbeda, jadi gak ada yang melihat trace utuhnya. Arahkan per trace ID ke collector yang sama.
TL;DR: Distributed tracing menyatukan operasi dari banyak service menjadi satu trace, disambung trace ID yang sama. Span tersambung hanya kalau traceparent diteruskan di tiap lapis (nginx: otel_trace_context propagate), kalau putus, service berikutnya memulai trace baru yang terpisah. Backend menyusun waterfall dari parent ID, dan span yang lambat dicari lewat self time (durasi dikurangi anak-anaknya): di Lab, SELECT catatan 1.178 ms dari 1.221 ms, turun ke 21 ms dengan index trigram. Head sampling memutuskan di awal (murah, tapi buta terhadap error), tail sampling memutuskan setelah request selesai di collector, jadi semua error dan request lambat bisa disimpan sementara yang sukses cukup sebagian. Backend-nya bisa Jaeger, Tempo, Datadog APM, atau AWS X-Ray.