Tampilan
Kenapa penting
Di monolit, satu stack trace biasanya cukup untuk tahu di mana request macet. Di microservices, request checkout yang lambat 4 detik bisa melewati gateway, order-service, inventory-service, payment-service, dan sebuah topic Kafka — dan setiap service punya log sendiri yang tidak saling kenal. Tanpa tracing, investigasi berubah menjadi menebak dan mencocokkan timestamp. Distributed tracing memberi setiap request satu ID yang terbawa ke semua hop, sehingga kamu bisa melihat satu gambar utuh: hop mana yang lambat, mana yang gagal, dan berapa kali retry terjadi. Di tempat kerja, kemampuan membaca trace dan menyambungkannya ke log adalah pembeda antara insiden 10 menit dan insiden semalaman.
Konsep inti
Trace, span, dan traceparent
- Trace: seluruh perjalanan satu request, diidentifikasi trace id (16 byte, ditulis 32 hex).
- Span: satu unit kerja di dalam trace — misalnya "HTTP POST /orders" di
order-service, atau "SELECT stock" diinventory-service. Setiap span punya span id (8 byte), parent span id, waktu mulai, durasi, status, dan atribut (tag). - Propagasi konteks: agar span di service berikutnya tahu induknya, konteks dikirim lewat header standar W3C Trace Context:
text
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ └ flags (01 = sampled)
│ │ └ parent id: span id pengirim
│ └ trace id
└ versiLogika yang dilakukan library tracing di setiap hop kurang lebih seperti ini — trace id dipertahankan, span id diganti:
java
import java.util.HexFormat;
import java.util.random.RandomGenerator;
public class TraceParentDemo {
record TraceParent(String version, String traceId, String parentId, String flags) {
static TraceParent parse(String header) {
String[] p = header.split("-");
if (p.length != 4 || p[1].length() != 32 || p[2].length() != 16) {
throw new IllegalArgumentException("traceparent tidak valid: " + header);
}
return new TraceParent(p[0], p[1], p[2], p[3]);
}
boolean sampled() {
return (Integer.parseInt(flags, 16) & 0x01) == 1;
}
TraceParent childSpan(String newSpanId) {
return new TraceParent(version, traceId, newSpanId, flags);
}
String header() {
return version + "-" + traceId + "-" + parentId + "-" + flags;
}
}
public static void main(String[] args) {
var incoming = TraceParent.parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
System.out.println("trace id : " + incoming.traceId());
System.out.println("sampled : " + incoming.sampled());
// Span baru di service ini: trace id sama, span id baru (8 byte acak)
byte[] bytes = new byte[8];
RandomGenerator.of("L64X128MixRandom").nextBytes(bytes);
var outgoing = incoming.childSpan(HexFormat.of().formatHex(bytes));
System.out.println("sama trace? " + outgoing.traceId().equals(incoming.traceId()));
System.out.println("panjang header keluar: " + outgoing.header().length());
try {
TraceParent.parse("00-abc-123-01");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}text
trace id : 4bf92f3577b34da6a3ce929d0e0e4736
sampled : true
sama trace? true
panjang header keluar: 55
traceparent tidak valid: 00-abc-123-01Untuk Kafka, konteks yang sama ditaruh di record header traceparent, bukan di payload. Consumer membacanya dan membuat span anak, sehingga alur asinkron tetap tersambung dalam satu trace.
Micrometer Tracing + OpenTelemetry di setiap service
Spring Boot memakai Micrometer Observation sebagai API instrumentasi, Micrometer Tracing sebagai fasad, dan OpenTelemetry sebagai implementasi yang mengekspor span lewat protokol OTLP. Di Boot 4 cukup tambahkan starter OpenTelemetry (plus actuator) di setiap service:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>yaml
# application.yml — sama polanya di setiap service
spring:
application:
name: order-service # muncul sebagai service.name di trace
kafka:
template:
observation-enabled: true # producer menyuntik traceparent ke record header
listener:
observation-enabled: true # consumer melanjutkan trace dari record header
management:
tracing:
sampling:
probability: 1.0 # lokal: rekam semua; production: jauh lebih kecil
opentelemetry:
tracing:
export:
otlp:
endpoint: http://localhost:4318/v1/tracesInstrumentasi otomatis mencakup request HTTP masuk (Spring MVC), RestClient/WebClient yang dibangun dari builder hasil inject, KafkaTemplate dan @KafkaListener (bila observation diaktifkan), serta JDBC bila kamu menambah instrumentasinya. Untuk logika bisnis yang ingin terlihat sebagai span sendiri, pakai @Observed atau Observation secara manual.
OpenTelemetry Collector dan Jaeger lewat Docker
Service tidak mengirim span langsung ke backend tracing. Pola yang disarankan: service → OpenTelemetry Collector → backend (Jaeger, Grafana Tempo, atau vendor). Collector menangani batching, retry, sampling, penyaringan atribut sensitif, dan memudahkan ganti backend tanpa menyentuh aplikasi.
yaml
# otel-collector.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/jaeger]yaml
# compose.yaml — lingkungan tracing lokal
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.115.0
command: ["--config=/etc/otelcol/config.yaml"]
volumes:
- ./otel-collector.yaml:/etc/otelcol/config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
jaeger:
image: jaegertracing/jaeger:2.5.0
ports:
- "16686:16686" # UI Jaegerbash
docker compose up -d
# lalu buka http://localhost:16686, pilih service order-service, klik Find TracesIngin memakai Grafana Tempo? Ganti exporter di Collector ke tempo:4317 — aplikasi tidak berubah sama sekali.
Membaca waterfall trace
Jaeger menampilkan trace sebagai waterfall: setiap baris satu span, lebar bar = durasi, indentasi = hubungan parent–child.
text
POST /api/orders/checkout gateway ████████████████████████████ 4.12 s
POST /orders/checkout order-service ███████████████████████████ 4.08 s
POST /stock/reserve inventory-service ██ 0.09 s
POST /payments payment-service ████████████████████████ 3.95 s
POST /charge (client ke PSP) ███████ 1.00 s ERROR timeout
POST /charge (retry 1) ███████ 1.00 s ERROR timeout
POST /charge (retry 2) ██████ 0.92 s OK
send orders.placed order-service ▏0.01 s
receive orders.placed notification-service ▏(async)Cara membacanya: cari span terpanjang yang tidak "tertutup" oleh anak-anaknya (di sini panggilan ke payment service provider), cek span berstatus ERROR, dan perhatikan pola berulang — tiga span /charge berarti retry yang memakan hampir 3 detik. Ini menyambung langsung ke bab resilience: timeout dan retry yang terlalu longgar terlihat jelas di waterfall.
Sampling
Merekam setiap trace di trafik tinggi itu mahal. Ada dua jenis keputusan:
| Jenis | Di mana | Cara kerja | Catatan |
|---|---|---|---|
| Head sampling | Service pertama | Diputuskan di awal (mis. 10% request), lalu diteruskan lewat flag traceparent | Murah, tapi bisa membuang trace error yang langka |
| Tail sampling | Collector | Collector menahan span sampai trace lengkap, lalu memilih (error, latensi > 2 s, sisanya 5%) | Simpan trace yang penting; butuh memori di Collector |
Service hilir menghormati keputusan service hulu agar trace tidak bolong di tengah.
Korelasi log dan ProblemDetail
Saat tracing aktif, Spring Boot otomatis menambahkan trace id dan span id ke pola log (lewat MDC), sehingga satu baris log terlihat seperti:
text
2026-10-23T08:15:02.481Z WARN 1 --- [order-service] [nio-8080-exec-3] [4bf92f3577b34da6a3ce929d0e0e4736-a1b2c3d4e5f60718] o.j.order.CheckoutService : payment ditolak orderId=ORD-991Di log terpusat (Loki, Elasticsearch), cari trace id tersebut dan kamu mendapat log dari semua service untuk request itu. Langkah berikutnya: kembalikan trace id ke klien agar laporan pengguna bisa langsung dilacak.
java
// Potongan — menambahkan traceId ke setiap ProblemDetail
@RestControllerAdvice
class ApiErrorHandler {
private final Tracer tracer; // io.micrometer.tracing.Tracer
ApiErrorHandler(Tracer tracer) {
this.tracer = tracer;
}
@ExceptionHandler(PaymentDeclinedException.class)
ProblemDetail paymentDeclined(PaymentDeclinedException ex) {
var problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
problem.setTitle("Pembayaran ditolak");
Span span = tracer.currentSpan();
if (span != null) {
problem.setProperty("traceId", span.context().traceId());
}
return problem;
}
}Baggage sekilas
Baggage adalah pasangan kunci–nilai yang ikut berpropagasi bersama konteks trace (header baggage), misalnya tenant=toko-bandung. Spring Boot bisa meneruskannya dan memasukkannya ke MDC:
yaml
management:
tracing:
baggage:
remote-fields: tenant
correlation:
fields: tenantPakai seperlunya: baggage dikirim di setiap hop, jadi jangan isi data besar atau sensitif (token, email, nomor kartu) — dan jangan mempercayainya untuk otorisasi, karena header ini bisa diisi siapa saja.
Contoh di dunia backend
order-service menerima checkout, memanggil inventory-service lewat HTTP, lalu menerbitkan event orders.placed ke Kafka yang dikonsumsi notification-service. Semua tersambung dalam satu trace selama client dan template dibuat dari bean Boot:
java
// Potongan — order-service
@Service
class CheckoutService {
private static final Logger log = LoggerFactory.getLogger(CheckoutService.class);
private final RestClient inventory;
private final KafkaTemplate<String, OrderPlaced> kafka;
CheckoutService(RestClient.Builder builder, KafkaTemplate<String, OrderPlaced> kafka) {
this.inventory = builder.baseUrl("http://inventory-service").build(); // builder ter-instrumentasi
this.kafka = kafka;
}
@Observed(name = "checkout.process", contextualName = "checkout")
void checkout(Order order) {
log.info("checkout mulai orderId={}", order.id()); // log membawa traceId
inventory.post().uri("/stock/reservations").body(order.lines()).retrieve().toBodilessEntity();
kafka.send("orders.placed", order.id(), new OrderPlaced(order.id(), order.total()));
}
}
// Potongan — notification-service, trace berlanjut dari record header traceparent
@Component
class OrderPlacedListener {
private static final Logger log = LoggerFactory.getLogger(OrderPlacedListener.class);
@KafkaListener(topics = "orders.placed", groupId = "notification")
void on(OrderPlaced event) {
log.info("kirim email konfirmasi orderId={}", event.orderId()); // traceId sama dengan checkout
}
}Agar @Observed bekerja, aspek observation perlu aktif (dependency AOP dan bean ObservedAspect bila belum dikonfigurasi otomatis di versimu).
Pertanyaan interview
1. Bagaimana trace id bisa sampai dari service A ke service B, termasuk lewat Kafka?
Library tracing menyuntikkan konteks ke header standar W3C traceparent saat request keluar — header HTTP untuk panggilan sinkron, record header untuk Kafka. Service penerima mengekstrak header itu, membuat span anak dengan trace id yang sama dan parent id dari pengirim. Di Spring Boot ini otomatis selama client dibuat dari builder yang di-inject dan observation Kafka diaktifkan.
2. Kenapa memakai OpenTelemetry Collector, bukan langsung mengirim ke Jaeger?
Collector memisahkan aplikasi dari backend: batching dan retry dilakukan di luar proses aplikasi, tail sampling dan penyaringan atribut sensitif bisa diterapkan terpusat, dan backend bisa diganti (Jaeger ke Tempo atau vendor) tanpa redeploy semua service.
3. Head sampling atau tail sampling?
Head sampling murah dan sederhana — keputusan diambil di awal dan diteruskan — tetapi bisa membuang trace error yang jarang terjadi. Tail sampling memutuskan setelah trace lengkap sehingga bisa menyimpan semua trace error atau lambat, dengan biaya memori dan kompleksitas di Collector. Umumnya keduanya dikombinasikan.
Jebakan umum
- Membuat
RestClient.create()ataunew RestTemplate()manual, sehingga headertraceparenttidak terkirim dan trace terputus. - Lupa mengaktifkan
observation-enableduntuk Kafka, sehingga alur asinkron muncul sebagai trace terpisah. sampling.probability: 1.0dibawa ke production dengan trafik tinggi tanpa memikirkan biaya storage.- Menaruh data sensitif (email, nomor kartu, token) di atribut span atau baggage — trace sering bisa dibaca lebih banyak orang daripada database.
- Tidak menyetel
spring.application.name, sehingga semua service muncul dengan nama yang sama atauunknown_service. - Menganggap tracing pengganti log dan metrik; ketiganya saling melengkapi.
Ringkasan
- Trace = perjalanan satu request; span = satu unit kerja;
traceparentmembawa trace id, parent id, dan flag sampled lintas HTTP dan record header Kafka. - Di Boot 4,
spring-boot-starter-opentelemetry+ actuator memasang Micrometer Tracing dengan bridge OpenTelemetry dan ekspor OTLP. - Kirim span ke OpenTelemetry Collector, lalu ke Jaeger atau Tempo; backend bisa diganti tanpa mengubah aplikasi.
- Waterfall menunjukkan hop yang lambat, error, dan retry berulang dalam satu pandangan.
- Atur sampling sesuai trafik, korelasikan trace id di log dan
ProblemDetail, dan pakai baggage hanya untuk data kecil yang tidak sensitif.