Satu request checkout bisa menyentuh layanan web PHP, database, gateway pembayaran, dan antrian email — empat tempat yang masing-masing punya log sendiri. Tanpa benang merah, menyusun ulang kronologi kejadian dari log yang tersebar adalah pekerjaan teka-teki. Distributed tracing memberi benang merah itu: satu identitas yang dibawa ke mana pun request berjalan, plus struktur data yang merekam setiap langkah beserta waktunya. Chapter ini membangun konsep tersebut dari nol — trace id, span, propagation header, sampling, dan korelasi log — sebelum menunjukkan ekosistem OpenTelemetry yang siap pakai.
Konsep Trace dan Span #
Sebuah trace merekam seluruh perjalanan satu request dari masuk hingga selesai. Di dalamnya, setiap unit kerja bernama menjadi span: satu pemanggilan handler, satu query database, satu panggilan HTTP ke layanan lain. Span berisi nama, waktu mulai, waktu selesai, dan referensi ke span induk, sehingga seluruhnya membentuk pohon kerja:
Diagram di atas memperlihatkan satu trace dengan span induk di layanan web dan tiga span anak: query database, panggilan gateway, serta publikasi pesan ke antrian. Panggilan ke layanan pembayaran memulai trace anak yang tetap terikat pada trace id yang sama, itulah makna “distributed” pada distributed tracing. Berbeda dari log yang merekam kejadian lepas, trace mempertahankan hubungan induk-anak inilah yang membuat kita bisa menjawab “request ini menghabiskan waktunya di langkah mana”.
Membuat dan Menyebarkan Trace ID #
Trace id berupa 32 karakter heksadesimal dan span id 16 karakter — cukup panjang agar nyaris mustahil bertabrakan antar request:
<?php
function buatTraceIdBaru(): string
{
return bin2hex(random_bytes(16));
}
function buatSpanIdBaru(): string
{
return bin2hex(random_bytes(8));
}Standar W3C Trace Context mendefinisikan cara memindahkan identitas ini antar layanan lewat satu header traceparent dengan format 00-{trace-id}-{span-id}-{flag}. Kode berikut merakit context trace, mencetaknya sebagai header, lalu membaca header tersebut di sisi penerima:
<?php
function buatTraceContext(string $traceId, string $spanId, array $atribut = []): array
{
return [
'trace_id' => $traceId,
'span_id' => $spanId,
'atribut' => $atribut,
];
}
function buatHeaderTraceparent(array $context): string
{
return sprintf(
'traceparent: 00-%s-%s-01',
$context['trace_id'],
$context['span_id'],
);
}
function bacaHeaderTraceparent(string $header): array
{
$nilai = trim(strstr($header, ' ') ?: $header);
[$versi, $traceId, $spanId, $flag] = explode('-', $nilai);
return ['trace_id' => $traceId, 'span_id' => $spanId, 'flag' => $flag];
}
$context = buatTraceContext(
'4bf92f3577b34da6a3ce929d0e0e4736',
'00f067aa0ba902b7',
['user_id' => 42, 'rute' => 'POST /api/checkout'],
);
echo buatHeaderTraceparent($context), "\n";
$diterima = bacaHeaderTraceparent('traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01');
echo json_encode($diterima, JSON_UNESCAPED_SLASHES), "\n";Output yang Dihasilkan:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","flag":"01"}Fungsi buatHeaderTraceparent() merangkai empat segmen dengan sprintf: versi format 00, trace id, span id, dan flag 01 yang menyatakan trace ini layak dicatat (sampled). Di sisi penerima, bacaHeaderTraceparent() membuang prefix nama header, memecah segmen dengan explode(), lalu mengembalikan identitas sebagai array — trace id yang sama, span id baru untuk pekerjaan di sisi sana. Context berupa array polos seperti ini mudah diturunkan ke job queue atau disimpan di atribut request; nilainya bisa diganti bebas karena struktur inilah kontraknya.
Merekam Span dalam Struktur Data #
Setiap span dicatat dengan lima informasi: nama, id, id induk, waktu mulai, dan waktu selesai. Waktu dinyatakan sebagai milidetik sejak awal request — bukan jam dinding — agar seluruh span bisa dibandingkan dan diurutkan dalam satu kerangka waktu yang sama:
<?php
class KolektorSpan
{
/** @var list<array{name: string, span_id: string, induk: ?string, mulai_ms: int, selesai_ms: int}> */
private array $span = [];
public function catat(string $nama, string $spanId, ?string $spanInduk, int $mulaiMs, int $selesaiMs): void
{
$this->span[] = [
'nama' => $nama,
'span_id' => $spanId,
'induk' => $spanInduk,
'mulai_ms' => $mulaiMs,
'selesai_ms' => $selesaiMs,
];
}
public function pohon(): string
{
$berdasarkanId = array_column($this->span, null, 'span_id');
$baris = [];
foreach ($this->span as $s) {
$kedalaman = 0;
$kursor = $s;
while ($kursor['induk'] !== null) {
$kedalaman++;
$kursor = $berdasarkanId[$kursor['induk']];
}
$baris[] = str_repeat(' ', $kedalaman) . sprintf(
'%s [%d ms]',
$s['nama'],
$s['selesai_ms'] - $s['mulai_ms'],
);
}
return implode("\n", $baris);
}
}
$trace = new KolektorSpan();
$trace->catat('GET /api/checkout', 'a1', null, 0, 220);
$trace->catat('handler checkout', 'b2', 'a1', 5, 215);
$trace->catat('db SELECT pesanan', 'c3', 'b2', 20, 60);
$trace->catat('http gateway bayar', 'd4', 'b2', 70, 190);
$trace->catat('db UPDATE stok', 'e5', 'b2', 200, 210);
echo $trace->pohon(), "\n";Output yang Dihasilkan:
GET /api/checkout [220 ms]
handler checkout [210 ms]
db SELECT pesanan [40 ms]
http gateway bayar [120 ms]
db UPDATE stok [10 ms]Baris array_column(..., null, 'span_id') membangun indeks span berdasarkan id-nya, sehingga perulangan while bisa menaiki rantai induk untuk menghitung kedalaman setiap span. Durasi setiap span adalah selisih selesai dikurangi mulai, dan indentasi dua spasi per kedalaman menghasilkan pohon yang langsung terbaca: handler menelan 210 ms dari total 220 ms, dan di dalamnya panggilan gateway penyumbang terbesar dengan 120 ms. Lima span pada contoh sengaja memakai waktu tetap agar outputnya dapat diverifikasi; pada aplikasi nyata angka-angka ini datang dari hrtime() di sekitar setiap operasi.
Membaca Waterfall Waktu #
Pohon teks bagus untuk melihat struktur, tetapi proporsi waktu lebih cepat ditangkap mata lewat waterfall — batang yang digambar proporsional terhadap posisi dan durasi setiap span:
<?php
function waterfall(array $span, int $totalMs, int $lebar = 40): string
{
$baris = [];
foreach ($span as $s) {
$posisiMulai = (int) round($s['mulai_ms'] / $totalMs * $lebar);
$panjang = max(1, (int) round(($s['selesai_ms'] - $s['mulai_ms']) / $totalMs * $lebar));
$baris[] = sprintf(
'%-24s|%s%s| %d ms',
$s['nama'],
str_repeat(' ', $posisiMulai),
str_repeat('=', $panjang),
$s['selesai_ms'] - $s['mulai_ms'],
);
}
return implode("\n", $baris);
}
$span = [
['nama' => 'GET /api/checkout', 'mulai_ms' => 0, 'selesai_ms' => 220],
['nama' => 'handler checkout', 'mulai_ms' => 5, 'selesai_ms' => 215],
['nama' => 'db SELECT pesanan', 'mulai_ms' => 20, 'selesai_ms' => 60],
['nama' => 'http gateway bayar', 'mulai_ms' => 70, 'selesai_ms' => 190],
['nama' => 'db UPDATE stok', 'mulai_ms' => 200, 'selesai_ms' => 210],
];
echo waterfall($span, 220), "\n";Output yang Dihasilkan:
GET /api/checkout |========================================| 220 ms
handler checkout | ======================================| 210 ms
db SELECT pesanan | =======| 40 ms
http gateway bayar | ======================| 120 ms
db UPDATE stok | ==| 10 msSetiap baris memetakan milidetik ke 40 kolom: posisi batang dihitung dari mulai_ms, panjangnya dari durasi, dan max(1, ...) menjaga span terpendek tetap terlihat minimal satu kolom. Batang gateway yang memanjang di tengah timeline langsung memperlihatkan bahwa lebih dari separuh request berjalan saat menunggu pembayaran — pola visual yang jauh lebih sulit ditangkap dari deretan angka. Antarmuka alat tracing seperti Jaeger, Zipkin, dan Grafana Tempo merender waterfall yang sama secara grafis dengan zoom dan filter; logika proporsinya identik dengan 40 kolom sederhana kita.
Memilih Trace yang Dicatat: Sampling #
Merekam semua trace dari semua request itu mahal: setiap span menulis data, dan 99% di antaranya tidak akan pernah dilihat siapa pun. Sampling memutuskan trace mana yang direkam penuh. Pendekatan head-based sampling memutus di awal request berdasarkan hash trace id, sehingga keputusannya deterministik — trace yang sama selalu mendapat keputusan yang sama di semua layanan:
<?php
function putuskanSampling(string $traceId, int $persenSample): bool
{
return crc32($traceId) % 100 < $persenSample;
}
$traceContoh = [
'4bf92f3577b34da6a3ce929d0e0e4736',
'b7ad6b71692033319071e4f0d1f6e52f',
'e2886c06b6a3f2e19a1e66f15c1a3a5f',
];
foreach ($traceContoh as $traceId) {
printf(
"%s 100%%: %s | 10%%: %s | 1%%: %s\n",
substr($traceId, 0, 12) . '...',
putuskanSampling($traceId, 100) ? 'sample' : 'skip',
putuskanSampling($traceId, 10) ? 'sample' : 'skip',
putuskanSampling($traceId, 1) ? 'sample' : 'skip',
);
}
$jumlahTersample = 0;
for ($i = 1; $i <= 100; $i++) {
$traceId = md5("permintaan-$i");
if (putuskanSampling($traceId, 10)) {
$jumlahTersample++;
}
}
echo "Dari 100 trace simulasi, tercatat pada rasio 10%: $jumlahTersample\n";Output yang Dihasilkan:
4bf92f3577b3... 100%: sample | 10%: skip | 1%: skip
b7ad6b716920... 100%: sample | 10%: skip | 1%: skip
e2886c06b6a3... 100%: sample | 10%: skip | 1%: skip
Dari 100 trace simulasi, tercatat pada rasio 10%: 8Fungsi putuskanSampling() memetakan trace id ke angka 0-99 lewat crc32(), lalu memilih semua trace yang jatuh di bawah persentase target. Tiga contoh pertama memperlihatkan bahwa rasio 100% merekam semuanya, sementara pada rasio 10% dan 1% ketiganya tidak terpilih; simulasi seratus trace id di bawahnya menunjukkan hasil agregat yang mendekati target — delapan trace tercatat dari seratus pada rasio 10%. Lawannya adalah tail-based sampling yang memutus di akhir request sehingga bisa berbakat “simpan hanya trace yang lambat atau error”, dengan harga harus menahan seluruh span di memori sampai keputusan diambil.
Mengorelasikan Log dengan Trace ID #
Trace dan log bekerja paling kuat ketika bertemu: log yang menyertakan trace_id bisa dikumpulkan kembali menjadi kronologi satu request dengan satu kueri sederhana. Polanya persis seperti context dinamis pada chapter Log Levels dan Context
— identitas disuntik sekali, lalu menempel di setiap catatan:
<?php
function tulisLogBerjejak(array $contextTrace, string $level, string $pesan, array $contextEkstra = []): string
{
$catatan = [
'waktu' => '2026-01-15T08:00:41+07:00',
'level' => $level,
'pesan' => $pesan,
'trace_id' => $contextTrace['trace_id'],
'span_id' => $contextTrace['span_id'],
] + $contextEkstra;
return json_encode($catatan, JSON_UNESCAPED_UNICODE) . "\n";
}
$context = ['trace_id' => '4bf92f3577b34da6a3ce929d0e0e4736', 'span_id' => 'b2'];
echo tulisLogBerjejak($context, 'info', 'Memproses checkout');
echo tulisLogBerjejak($context, 'error', 'Gateway timeout', ['gateway' => 'midtrans', 'percobaan' => 1]);Output yang Dihasilkan:
{"waktu":"2026-01-15T08:00:41+07:00","level":"info","pesan":"Memproses checkout","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"b2"}
{"waktu":"2026-01-15T08:00:41+07:00","level":"error","pesan":"Gateway timeout","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"b2","gateway":"midtrans","percobaan":1}Kedua baris membawa trace_id dan span_id yang sama sebagai kolom, sehingga kueri select(.trace_id == "4bf9...") di sistem log menampilkan kronologi lengkap request tersebut — meskipun barisnya tersebar di berkas log yang berbeda-beda. Ketika dashboard application metrics
menunjukkan lonjakan error, alur penyelidikannya menjadi pendek: ambil trace id dari log error, tarik seluruh log ber-trace id itu, lalu buka waterfall trace-nya untuk melihat langkah mana yang gagal.
Ekosistem OpenTelemetry #
Seluruh mekanisme di chapter ini — pembuatan id, propagation header, span, sampling — sudah dibakukan oleh OpenTelemetry, proyek standar terbuka yang kini menjadi bahasa bersama lintas vendor. SDK PHP resminya dipasang lewat Composer dan menawarkan semua komponen sebagai pustaka: API untuk membuat span, propagator W3C yang otomatis membaca dan menulis header traceparent, sampler berbasis rasio, serta exporter yang mengirim data ke backend pilihan (Jaeger, Grafana Tempo, Zipkin, atau vendor komersial) melalui protokol OTLP. Ekosistemnya juga menyediakan auto-instrumentation untuk framework populer, pembungkus siap pakai untuk PDO dan HTTP client yang otomatis mencatat span query maupun panggilan keluar.
Membuat semua komponen secara manual seperti contoh-contoh kita tetap bernilai: Anda memahami persis apa yang SDK lakukan di balik layar, dan bisa membaca datanya dengan kritis. Untuk aplikasi produksi, pilih SDK — kode yang ditulis terhadap API OpenTelemetry tidak terikat pada satu vendor backend, sehingga berganti alat visualisasi tidak berarti menulis ulang instrumentasi.
Ringkasan #
Trace mengikat seluruh perjalanan satu request lintas layanan lewat trace id yang dipropagasi header traceparent; span merekam setiap langkah beserta waktunya membentuk pohon yang bisa dibaca sebagai waterfall; sampling menjaga biaya perekaman tetap rasional; dan injeksi trace_id ke log menutup lingkaran antara angka, kronologi, dan penyebab. Semua perangkat observabilitas kini lengkap di tangan kita — tugas chapter penutup section ini adalah menyusunnya menjadi playbook diagnosis ketika produksi benar-benar bermasalah.