Distributed tracing z Jaeger

TL;DR: Distributed tracing pozwala śledzić żądania przez wiele mikroserwisów. Jaeger to open-source narzędzie które zbiera, przechowuje i wizualizuje trace’y. Integracja ze Spring Boot jest prosta dzięki OpenTracing API. Kluczowe: spans, traces, sampling, instrumentation.

Debugowanie problemów w architekturze mikroserwisowej to jak szukanie igły w stogu siana. Jedno żądanie użytkownika może przechodzić przez dziesiątki serwisów. Gdy coś idzie nie tak, tradycyjne logi często nie wystarczą. Distributed tracing daje nam pełny obraz przepływu żądania przez system.

Dlaczego distributed tracing jest kluczowy?

W monolicie śledzenie żądania jest proste – wszystko dzieje się w jednym procesie. W mikrousługach żądanie skacze między serwisami, każdy ze swoimi logami i metrykami. Bez distributed tracing nie wiemy gdzie jest bottleneck ani dlaczego żądanie trwa 5 sekund zamiast 500ms.

Co się nauczysz:

  • Czym jest distributed tracing i jak działa
  • Architektura i komponenty Jaeger
  • Implementacja tracing w Spring Boot z OpenTracing
  • Instrumentacja kodu – manualna i automatyczna
  • Analiza trace’ów i znajdowanie problemów wydajnościowych
  • Best practices i strategie sampling

Wymagania wstępne:

  • Doświadczenie z mikrousługami i Spring Boot
  • Podstawowa znajomość Docker i docker-compose
  • Zrozumienie komunikacji REST między serwisami
  • Znajomość podstawowych metryk wydajnościowych

Podstawy distributed tracing

Distributed tracing opiera się na kilku kluczowych konceptach:

Trace – kompletna ścieżka żądania przez system, od początku do końca
Span – pojedyncza operacja w ramach trace, np. wywołanie metody czy request HTTP
Context – metadane propagowane między serwisami (trace ID, span ID, baggage)

Jak to działa?

Każde żądanie dostaje unikalny trace ID. Gdy serwis A wywołuje serwis B, przekazuje ten ID w headerach HTTP. Każdy serwis tworzy własne span’y które są częścią większego trace.

// Przykład propagacji kontekstu
GET /api/orders/123 HTTP/1.1
X-B3-TraceId: 463ac35c9f6413ad48485a3953bb6124
X-B3-SpanId: a2fb4a1d1a96d312
X-B3-ParentSpanId: 0020000000000001
X-B3-Sampled: 1

Architektura Jaeger

Jaeger składa się z kilku komponentów:

1. Jaeger Client

Biblioteka w aplikacji która instrumentuje kod i wysyła trace’y do agenta.

2. Jaeger Agent

Lokalny daemon który zbiera trace’y z aplikacji i przekazuje do collectora. Redukuje network overhead.

3. Jaeger Collector

Przyjmuje trace’y od agentów, waliduje i zapisuje w storage.

4. Storage Backend

Cassandra lub Elasticsearch do przechowywania trace’ów.

5. Jaeger UI

Web interface do przeglądania i analizy trace’ów.

Instalacja Jaeger

Najprostszy sposób to all-in-one image dla developmentu:

# docker-compose.yml
version: '3'
services:
  jaeger:
    image: jaegertracing/all-in-one:1.8
    ports:
      - "5775:5775/udp"   # agent - accept zipkin.thrift
      - "6831:6831/udp"   # agent - accept jaeger.thrift
      - "6832:6832/udp"   # agent - accept jaeger.thrift binary
      - "5778:5778"       # agent - serve configs
      - "16686:16686"     # query - serve UI
      - "14268:14268"     # collector - accept jaeger.thrift
      - "9411:9411"       # collector - accept zipkin
    environment:
      - COLLECTOR_ZIPKIN_HTTP_PORT=9411
Pro tip: W produkcji używaj osobnych komponentów z Cassandra/Elasticsearch. All-in-one używa pamięci in-memory i trace’y znikają po restarcie!

Implementacja w Spring Boot

1. Dodanie zależności



    io.opentracing.contrib
    opentracing-spring-jaeger-web-starter
    1.0.1



    io.jaegertracing
    jaeger-client
    0.32.0

2. Konfiguracja

# application.yml
opentracing:
  jaeger:
    enabled: true
    service-name: ${spring.application.name}
    udp-sender:
      host: localhost
      port: 6831
    const-sampler:
      decision: true  # sample wszystkie requesty (dev)
    log-spans: true
    
# Produkcja - probabilistic sampler
# probabilistic-sampler:
#   sampling-rate: 0.1  # 10% requestów

3. Automatyczna instrumentacja

Spring Boot starter automatycznie instrumentuje:

  • Incoming HTTP requests (RestController)
  • Outgoing HTTP requests (RestTemplate)
  • JDBC queries
  • Scheduled tasks
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private RestTemplate restTemplate; // automatycznie instrumentowany
    
    @GetMapping("/{id}")
    public Order getOrder(@PathVariable String id) {
        // Ten endpoint automatycznie tworzy span
        return orderService.findOrder(id);
    }
}

@Service
public class OrderService {
    
    @Autowired
    private OrderRepository repository;
    
    public Order findOrder(String id) {
        // JDBC query też będzie traced
        Order order = repository.findById(id);
        
        // Wywołanie do innego serwisu - kontekst propagowany
        Customer customer = restTemplate.getForObject(
            "http://customer-service/api/customers/" + order.getCustomerId(),
            Customer.class
        );
        
        order.setCustomer(customer);
        return order;
    }
}

Manualna instrumentacja

Czasami potrzebujemy więcej kontroli nad span’ami:

Tworzenie custom spans

@Component
public class PaymentService {
    
    @Autowired
    private Tracer tracer;
    
    public PaymentResult processPayment(Order order) {
        // Tworzenie nowego span
        Span span = tracer.buildSpan("process-payment")
            .withTag("order.id", order.getId())
            .withTag("payment.amount", order.getTotalAmount())
            .start();
        
        try (Scope scope = tracer.activateSpan(span)) {
            // Symulacja processing
            validatePaymentDetails(order);
            
            // Child span dla external call
            Span paymentGatewaySpan = tracer.buildSpan("payment-gateway-call")
                .asChildOf(span)
                .withTag("gateway", "stripe")
                .start();
            
            try (Scope gwScope = tracer.activateSpan(paymentGatewaySpan)) {
                return callPaymentGateway(order);
            } finally {
                paymentGatewaySpan.finish();
            }
            
        } catch (Exception e) {
            span.setTag("error", true);
            span.log(ImmutableMap.of(
                "event", "error",
                "message", e.getMessage()
            ));
            throw e;
        } finally {
            span.finish();
        }
    }
}
Uwaga: Zawsze pamiętaj o finish() dla span! Używaj try-with-resources gdy to możliwe. Niezamknięte span’y to memory leak.

Dodawanie baggage

@Component
public class SecurityFilter implements Filter {
    
    @Autowired
    private Tracer tracer;
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                        FilterChain chain) throws IOException, ServletException {
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String userId = extractUserId(httpRequest);
        
        // Dodanie user ID do baggage - będzie propagowane do wszystkich serwisów
        Span activeSpan = tracer.activeSpan();
        if (activeSpan != null) {
            activeSpan.setBaggageItem("user.id", userId);
            activeSpan.setTag("user.id", userId);
        }
        
        chain.doFilter(request, response);
    }
}

Analiza trace’ów w Jaeger UI

Jaeger UI (http://localhost:16686) oferuje kilka widoków:

1. Search traces

Wyszukiwanie po serwisie, operacji, tagach, czasie trwania.

2. Trace Timeline

Wizualizacja wszystkich span’ów w trace – widać dokładnie gdzie jest bottleneck.

3. Service Dependencies

Graf zależności między serwisami na podstawie trace’ów.

4. Trace Comparison

Porównanie dwóch trace’ów – świetne do debugowania regression.

Strategie sampling

W produkcji nie możemy trace’ować wszystkiego – to za dużo danych:

1. Const Sampler

// Wszystko (dev) lub nic (prod)
Configuration.SamplerConfiguration samplerConfig = 
    Configuration.SamplerConfiguration.fromEnv()
        .withType(ConstSampler.TYPE)
        .withParam(1); // 1 = wszystko, 0 = nic

2. Probabilistic Sampler

// Losowy % requestów
Configuration.SamplerConfiguration samplerConfig = 
    Configuration.SamplerConfiguration.fromEnv()
        .withType(ProbabilisticSampler.TYPE)
        .withParam(0.1); // 10% requestów

3. Rate Limiting Sampler

// Max N trace'ów na sekundę
Configuration.SamplerConfiguration samplerConfig = 
    Configuration.SamplerConfiguration.fromEnv()
        .withType(RateLimitingSampler.TYPE)
        .withParam(2.0); // max 2 trace/sec

4. Adaptive Sampler

// Dynamiczny sampling based on traffic
Configuration.SamplerConfiguration samplerConfig = 
    Configuration.SamplerConfiguration.fromEnv()
        .withType(AdaptiveSampler.TYPE)
        .withParam(1.0); // target 1 trace/sec per endpoint
Pro tip: Zawsze trace’uj błędy! Używaj custom sampler który zawsze sampluje requesty z error=true.

Best practices

1. Sensowne nazwy operacji

Typowy błąd: Używanie pełnego URL jako nazwy operacji: GET /api/orders/12345. To tworzy tysiące różnych operacji!
// ŹLE
span = tracer.buildSpan(request.getRequestURI()).start();

// DOBRZE
span = tracer.buildSpan("GET /api/orders/{id}").start();

2. Użyteczne tagi

span.setTag("http.method", "GET");
span.setTag("http.status_code", 200);
span.setTag("user.id", userId);
span.setTag("order.total", orderTotal);
span.setTag("cache.hit", cacheHit);
span.setTag("db.statement", "SELECT * FROM orders WHERE id = ?");

3. Correlation z logami

// Dodaj trace ID do MDC
@Component
public class TracingMDCFilter implements Filter {
    
    @Autowired
    private Tracer tracer;
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                        FilterChain chain) throws IOException, ServletException {
        Span span = tracer.activeSpan();
        if (span != null) {
            MDC.put("traceId", span.context().toTraceId());
            MDC.put("spanId", span.context().toSpanId());
        }
        
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

// Logback pattern
%d{ISO8601} [%thread] [%X{traceId}/%X{spanId}] %-5level %logger{36} - %msg%n

Troubleshooting

Brakujące trace’y

Pułapka: Trace’y nie pojawiają się w Jaeger UI? Sprawdź sampling rate! W dev często zapominamy że mamy sampling 0.01 (1%).

Broken traces

Gdy trace jest „połamany” (brak połączenia między span’ami):

  • Sprawdź propagację kontekstu w HTTP headers
  • Upewnij się że wszystkie serwisy używają tego samego formatu (B3, Jaeger, W3C)
  • Async operations mogą gubić kontekst – użyj tracer.activeSpan()

Performance overhead

# Optymalizacje dla produkcji
opentracing:
  jaeger:
    udp-sender:
      max-packet-size: 65000  # większe batche
    reporter:
      max-queue-size: 10000   # większy buffer
      flush-interval: 1000    # rzadsze wysyłanie

FAQ – Często zadawane pytania

Jaka jest różnica między OpenTracing a OpenTelemetry?

OpenTracing to starszy standard (używany tutaj z Jaeger). OpenTelemetry to nowy, zunifikowany standard który łączy OpenTracing i OpenCensus. Jaeger wspiera oba, ale OpenTelemetry jest przyszłością – migruj gdy będziesz gotowy.

Czy distributed tracing zastępuje logi i metryki?

Nie! To uzupełnienie. Logi pokazują co się stało, metryki pokazują trendy, a trace’y pokazują jak żądanie przepływa przez system. Potrzebujesz wszystkich trzech dla pełnej observability.

Jak długo przechowywać trace’y?

Zależy od ruchu i miejsca. Typowo: 2-7 dni dla wszystkich trace’ów, 30 dni dla trace’ów z błędami, sampling historycznych trace’ów na dłużej. W Cassandra łatwo o retention policy.

Czy mogę użyć Zipkin zamiast Jaeger?

Tak! Zipkin to popularna alternatywa. Jaeger ma lepsze wsparcie dla dużej skali, więcej features (adaptive sampling) i aktywniejszy development. Ale Zipkin jest prostszy w setup.

Jak trace’ować async operations?

Musisz ręcznie propagować kontekst. Przed async operation pobierz span: Span span = tracer.activeSpan(), potem w async code aktywuj go: try (Scope scope = tracer.activateSpan(span)) {…}

Jaki jest performance overhead?

Z sensownym sampling (0.1%) overhead jest minimalny – <1% CPU. Główny koszt to network (wysyłanie do Jaeger). Używaj local agent i batching. Unikaj za dużo tagów i logów w spans.

Jak debugować problemy z Jaeger?

Włącz log-spans: true w konfiguracji – zobaczysz span’y w logach. Sprawdź metrics Jaeger agent (port 5778). Użyj tcpdump do sprawdzenia czy pakiety UDP docierają do agenta.

Przydatne zasoby:

🚀 Zadanie dla Ciebie

Zaimplementuj distributed tracing w przykładowej aplikacji e-commerce:

  1. Stwórz 3 mikroserwisy: order-service, inventory-service, payment-service
  2. Dodaj Jaeger i OpenTracing do każdego serwisu
  3. Zaimplementuj flow: create order → check inventory → process payment
  4. Dodaj custom span dla każdej business operation
  5. Symuluj błąd w payment-service i znajdź go w Jaeger UI
  6. Bonus: Dodaj correlation z logami przez MDC

Cel: Zobacz jak trace pomaga znaleźć bottleneck gdy payment-service odpowiada wolno!

Distributed tracing z Jaeger to must-have w każdej poważnej architekturze mikroserwisowej. Jak radzisz sobie z debugowaniem w swoich systemach rozproszonych? Jakie inne narzędzia do observability polecasz? Podziel się doświadczeniami!

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry