0

OpenTelemetry Tracing — Toàn tập: Node.js/TypeScript, Collector và Jaeger phần 5

Muốn cắt cụt mọi attribute chuỗi thay vì từng khoá thì dùng truncate_all(span.attributes, 1024). Các condition trong filter được OR với nhau; khớp là dữ liệu bị drop. Cẩn thận: filter drop từng span riêng lẻ nên trace có thể mất span ở giữa và gãy trong Jaeger — với health check thì không sao vì span đó thường là root.

7.8 Exporters

Exporter Dùng cho Ghi chú
otlp OTLP/gRPC, cổng 4317 Lựa chọn mặc định giữa các Collector và tới Jaeger v2
otlphttp OTLP/HTTP, cổng 4318 Dùng khi đi qua proxy/ingress chỉ nói HTTP
debug In telemetry ra stdout Thay thế cho logging exporter cũ. verbosity: basic|normal|detailed, kèm sampling_initial/sampling_thereafter để không ngập log
file Ghi telemetry ra file JSON Hữu ích để bắt mẫu dữ liệu thật đem đi phân tích
prometheus / prometheusremotewrite Xuất metric, điển hình là metric do spanmetrics sinh prometheus mở endpoint cho Prometheus scrape; prometheusremotewrite đẩy chủ động
loadbalancing Phân phối tới nhiều Collector phía sau theo một khoá routing_key: traceID là điều kiện bắt buộc khi scale tail_samplingspanmetrics. Resolver: static, dns, k8s, aws_cloud_map. Traces/logs beta, metrics alpha

Không còn exporter jaeger. Nó đã bị gỡ khỏi contrib; Jaeger hỗ trợ OTLP native từ 2022 và OpenTelemetry bỏ Jaeger exporter từ tháng 7/2023. Cách đúng để gửi vào Jaeger là otlp exporter trỏ vào cổng 4317, hoặc otlphttp vào 4318. Chiều ngược lại, receiver jaeger vẫn còn (beta cho traces; core/contrib/k8s) để nhận traffic legacy còn nói Thrift/gRPC Jaeger — mặc định nó không mở protocol nào, phải khai báo dưới protocols; endpoint mặc định grpc localhost:14250, thrift_http localhost:14268, thrift_compact localhost:6831 (UDP), thrift_binary localhost:6832 (UDP). Remote sampling trong receiver này bị bỏ từ v0.61.0, thay bằng extension jaegerremotesampling.

sending_queue, retry_on_failure và backpressure

exporters:
  otlp/jaeger:
    endpoint: jaeger-collector.observability.svc.cluster.local:4317
    tls:
      insecure: true              # trong cluster; dùng TLS thật khi ra ngoài
    timeout: 10s                  # timeout cho MỘT request
    sending_queue:
      enabled: true
      num_consumers: 10           # số goroutine gửi song song
      queue_size: 10000           # số item chờ; vượt ngưỡng thì bị từ chối
      storage: file_storage/otlp  # có dòng này = persistent queue
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_interval: 30s           # trần backoff
      max_elapsed_time: 300s      # quá hạn này thì bỏ hẳn
      multiplier: 1.5             # hệ số nhân backoff

sending_queue còn nhận sizer (đơn vị đo hàng đợi: requests mặc định, items, bytes), block_on_overflow (mặc định false; đặt true thì chặn chờ thay vì trả lỗi khi đầy), wait_for_result, và nhóm batch.flush_timeout / batch.min_size / batch.max_size. Mặc định queue_size là 1000 và num_consumers là 10.

Mặc định queue nằm trong RAM: Collector restart là mất sạch những gì đang chờ. Trỏ storage vào extension file_storage (beta; contrib, k8s) thì queue ghi xuống đĩa và sống sót qua restart — phải mount một volume ghi được, và với DaemonSet thì volume phải là hostPath hoặc PVC chứ không phải emptyDir.

Backpressure: backend chậm → consumer không rút queue kịp → queue_size chạm trần → exporter từ chối lô mới → processor trả lỗi ngược lên receiver → receiver trả lỗi cho SDK (gRPC RESOURCE_EXHAUSTED, HTTP 429/503). SDK retry theo backoff và cuối cùng drop span nếu queue của chính nó cũng đầy — đúng thiết kế: thà mất trace còn hơn OOM cả Collector. Phát hiện bằng otelcol_exporter_queue_size tiệm cận otelcol_exporter_queue_capacity, kèm otelcol_exporter_send_failed_spans tăng và otelcol_receiver_refused_spans khác 0; phía app xem log của SDK (Phần 11).

7.9 Connectors

Connector vừa là exporter của pipeline này vừa là receiver của pipeline khác — cách duy nhất để nối hai pipeline khác signal, ví dụ traces → metrics.

Connector Sinh ra gì Trạng thái
spanmetrics RED metrics từ span: traces.span.metrics.calls, traces.span.metrics.duration, tuỳ chọn traces.span.metrics.events Alpha; contrib, k8s
servicegraph traces_service_graph_request_total, _request_failed_total, _request_server, _request_client, _unpaired_spans_total, _dropped_spans_total (label client/server/connection_type) Alpha; contrib, k8s
routing Định tuyến sang pipeline khác theo table gồm condition/statement/action (move mặc định hoặc copy)/pipelines, cộng default_pipelineserror_mode Alpha. match_once đã bị gỡ từ v0.120.0; context request deprecated, thay bằng otelcol.client.metadata / otelcol.grpc.metadata
count trace.span.count, trace.span.event.count, log.record.count Alpha
forward Gộp hoặc tách pipeline cùng loại; không có tham số Beta, nằm trong repo core
connectors:
  spanmetrics:
    # namespace mặc định traces.span.metrics; dimension mặc định gồm
    # service.name, span.name, span.kind, status.code, collector.instance.id
    histogram:
      explicit:
        buckets: [5ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s]
    dimensions:                      # thêm chiều — cẩn thận cardinality
      - name: http.request.method
      - name: http.route
    metrics_flush_interval: 60s      # mặc định 60s
    aggregation_cardinality_limit: 10000   # mặc định 0 = không giới hạn

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/jaeger, spanmetrics]   # spanmetrics ở vị trí exporter
    metrics/spanmetrics:
      receivers: [spanmetrics]                # và ở đây nó là receiver
      processors: [batch]
      exporters: [prometheusremotewrite]

Đừng đưa attribute cardinality cao (user id, order id, URL đầy đủ) vào dimensions — mỗi giá trị mới là một time series mới; dùng http.route đã chuẩn hoá chứ không dùng url.path. Temporality mặc định AGGREGATION_TEMPORALITY_CUMULATIVE. Lưu ý distro: spanmetrics có trong contrib và k8s, nhưng exporter prometheus/prometheusremotewrite chỉ có trong core và contrib — chạy ví dụ này trên otelcol-k8s thì phải đẩy metric bằng otlphttp.

7.10 Extensions

Extension Chức năng Mặc định
health_check Endpoint HTTP cho liveness/readiness probe localhost:13133, path /. Alpha. Tuỳ chọn check_collector_pipeline được cảnh báo chính thức là không hoạt động đúng — đừng dùng; contrib đã có healthcheckv2extension
pprof Profiling Go runtime localhost:1777, dùng go tool pprof với /debug/pprof/profile. Beta
zpages Trang chẩn đoán trong tiến trình localhost:55679. Beta
file_storage Lưu trữ cho persistent queue directory mặc định /var/lib/otelcol/file_storage (Windows: %ProgramData%\Otelcol\FileStorage) và phải tồn tại sẵn trừ khi bật create_directory; timeout 1s, fsync false, có nhóm compaction. Beta; contrib, k8s
basicauth / bearertokenauth / oidc / oauth2client Xác thực hai chiều: gắn credential khi Collector gọi backend, và kiểm tra client gọi vào qua auth.authenticator của receiver (ví dụ ở 7.5) Để credential chỉ nằm ở Collector. oidc chỉ dùng phía server, oauth2client chỉ phía client; basicauth/bearertokenauth dùng được cả hai
headers_setter Thêm header (ví dụ tenant ID lấy từ metadata client) vào request đi ra Cần cho multi-tenant

7.11 File cấu hình hoàn chỉnh

Hai file, đừng lẫn: một bản tối giản chạy được ngay trên docker-compose ở Phần 8, và một bản gateway đầy đủ chỉ chạy được bên trong Kubernetes. Người mới thường copy nhầm bản thứ hai vào compose rồi mất buổi tối vì Collector không khởi động nổi.

Bản local — collector-config.local.yaml

Đây là file mà docker-compose ở Phần 8 mount vào. Nó chỉ dùng component có sẵn trong distro core, nên chạy được cả với image otel/opentelemetry-collector lẫn otel/opentelemetry-collector-contrib.

# collector-config.local.yaml — dev/docker-compose, KHÔNG dùng cho production
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317      # 0.0.0.0 vì phải nhận từ container khác
      http:
        endpoint: 0.0.0.0:4318

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 400                  # máy dev, không cần nhiều
    spike_limit_mib: 100
  batch:
    timeout: 200ms                  # flush nhanh để thấy trace ngay

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317           # tên service trong docker-compose
    tls: { insecure: true }
  debug:
    verbosity: normal               # detailed sẽ ngập log rất nhanh

extensions:
  health_check: { endpoint: 0.0.0.0:13133 }

service:
  extensions: [health_check]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/jaeger, debug]

Vì sao bản local phải cắt bớt — và giữ lại thì lỗi gì:

Component Có ở bản local? Vì sao
otlp receiver Cửa vào duy nhất; bind 0.0.0.0, nếu để mặc định localhost thì container app gọi sang sẽ connection refused
memory_limiter, batch Giữ đúng thói quen đặt memory_limiter đầu và batch cuối, để bản local và bản production cùng một hình dạng
otlp/jaeger, debug debug là công cụ chính để biết span có thật sự tới Collector hay không
health_check Compose đã publish cổng 13133; curl localhost:13133 là bước kiểm tra đầu tiên
k8sattributes Không auth_type: serviceAccount cần token ServiceAccount trong pod hoặc kubeconfig; ngoài Kubernetes nó không tạo được client và Collector chết ngay lúc khởi động. Ở dev cũng chẳng có k8s.pod.name nào để gắn
tail_sampling Không decision_wait: 10s nghĩa là mọi trace chỉ xuất hiện trong Jaeger sau 10 giây — đúng triệu chứng khiến người mới tưởng hệ thống hỏng. Ở dev thì muốn giữ 100% trace chứ không sample
file_storage + sending_queue.storage Không /var/lib/otelcol/queue không có volume nào mount vào, mà image chạy bằng user non-root nên thường không tạo/ghi được thư mục đó; exporter báo lỗi lúc khởi động. Queue trong RAM là quá đủ cho dev
redaction, transform/pii, filter/healthcheck Không Chạy được ngoài K8s, nhưng ở dev nên thấy nguyên vẹn attribute để gỡ lỗi. Nhớ bật lại từ staging trở đi
resourcedetection Không Detector env/system vẫn chạy bình thường ngoài K8s, chỉ là ở máy dev không có metadata nào đáng gắn
pprof, zpages Tuỳ Chúng bind localhost nên trong container phải docker exec vào mới curl được; muốn xem từ host thì đổi sang 0.0.0.0 và publish thêm cổng

Bản gateway Kubernetes — collector-config.yaml

Config dưới đây dành cho gateway chạy trong Kubernetes, KHÔNG dùng cho docker-compose ở Phần 8 — bảng trên liệt kê đúng những component khiến nó hoặc không khởi động nổi, hoặc chạy nhưng hành xử khó hiểu, khi đặt ngoài Kubernetes.

Kịch bản: gateway Collector trong Kubernetes, nhận OTLP từ các app Node.js, gắn metadata k8s, xoá PII, tail sampling giữ lỗi và chậm, xuất sang Jaeger v2 qua OTLP đồng thời in ra debug khi dev.

receivers:
  # Nhận OTLP từ app Node.js; 0.0.0.0 vì chạy trong container
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
        cors:
          allowed_origins: ["https://shop.example.vn"]
          allowed_headers: [traceparent, tracestate, baggage, content-type]

processors:
  # (1) LUÔN đứng đầu: chặn OOM và tạo backpressure ngược lên receiver.
  #     Đặt GOMEMLIMIT=1288MiB (~80% của limit_mib) trong env của container.
  memory_limiter:
    check_interval: 1s
    limit_mib: 1600
    spike_limit_mib: 400

  # (2) Gắn metadata Kubernetes. Phải đứng trước batch vì cần thông tin connection.
  k8sattributes:
    auth_type: serviceAccount
    passthrough: false
    extract:
      metadata:
        - k8s.namespace.name
        - k8s.pod.name
        - k8s.deployment.name
        - k8s.node.name
    pod_association:
      - sources: [{ from: resource_attribute, name: k8s.pod.ip }]
      - sources: [{ from: connection }]

  # (3) Bổ sung resource attribute từ môi trường; override:false để không đè giá trị app đặt
  resourcedetection:
    detectors: [env, system]
    timeout: 2s
    override: false

  # (4) Che dữ liệu nhạy cảm theo pattern tên khoá
  redaction:
    allow_all_keys: true
    blocked_key_patterns:
      - ".*token.*"
      - ".*password.*"
      - ".*authorization.*"
      - ".*secret.*"

  # (5) Xoá/băm PII và chuẩn hoá attribute (chi tiết ở mục 7.7)
  transform/pii:
    error_mode: ignore
    trace_statements:
      - delete_matching_keys(span.attributes, "^(app\\.(email|phone|id_card)|.*card_number)$")
      - set(span.attributes["db.query.text"], Substring(span.attributes["db.query.text"], 0, 500)) where Len(span.attributes["db.query.text"]) > 500

  # (6) Vứt span health check trước khi tốn RAM giữ trace
  filter/healthcheck:
    error_mode: ignore
    trace_conditions:
      - IsMatch(span.attributes["url.path"], "^/(healthz|readyz|livez|metrics)$")

  # (7) Tail sampling: giữ trace lỗi và trace chậm, còn lại lấy mẫu nhỏ.
  #     Chiến lược và đánh đổi: xem Phần 4. Chỉ có trong distro contrib/k8s.
  tail_sampling:
    decision_wait: 10s              # chờ span đến đủ; phải lớn hơn p99 độ dài trace
    num_traces: 100000              # số trace giữ trong RAM
    expected_new_traces_per_sec: 2000
    policies:
      - name: giu-trace-loi
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: giu-trace-cham
        type: latency
        latency: { threshold_ms: 1000 }
      - name: giu-luong-thanh-toan
        type: string_attribute
        string_attribute:
          key: http.route
          values: ["/checkout", "/payment/.*"]
          enabled_regex_matching: true
      - name: con-lai-lay-mau
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

  # (8) LUÔN đứng cuối
  batch:
    send_batch_size: 8192
    send_batch_max_size: 10000
    timeout: 5s

exporters:
  # Jaeger v2 nhận OTLP native — KHÔNG dùng exporter "jaeger" (đã bị gỡ khỏi contrib)
  otlp/jaeger:
    endpoint: jaeger-collector.observability.svc.cluster.local:4317
    tls: { insecure: true }
    timeout: 10s
    sending_queue:
      enabled: true
      num_consumers: 10
      queue_size: 10000
      storage: file_storage/otlp    # queue bền qua restart
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_interval: 30s
      max_elapsed_time: 300s

  # Chỉ bật ở môi trường dev; thay cho exporter "logging" cũ
  debug:
    verbosity: detailed
    sampling_initial: 5
    sampling_thereafter: 200

extensions:
  health_check: { endpoint: 0.0.0.0:13133 }
  pprof: { endpoint: localhost:1777 }
  zpages: { endpoint: localhost:55679 }
  file_storage/otlp:
    directory: /var/lib/otelcol/queue   # phải mount volume ghi được
    create_directory: true
    timeout: 1s

service:
  # Extension không nằm ở đây thì KHÔNG chạy
  extensions: [health_check, pprof, zpages, file_storage/otlp]
  pipelines:
    traces:
      receivers: [otlp]
      processors:
        - memory_limiter
        - k8sattributes
        - resourcedetection
        - redaction
        - transform/pii
        - filter/healthcheck
        - tail_sampling
        - batch
      exporters: [otlp/jaeger, debug]   # bỏ debug ở production
  telemetry:
    metrics:
      level: normal
      readers:
        - pull:
            exporter:
              prometheus:
                host: '0.0.0.0'
                port: 8888

Config này cần distro otelcol-contrib hoặc otelcol-k8s (core không có tail_sampling, k8sattributes, redaction, transform, file_storage) và một ServiceAccount có RBAC đọc pods + namespaces.

Config này cũng cố tình không có spanmetrics: connector đó phải nhận 100% span, tức phải fan-out ngay sau receiver bằng forward connector, trước tail_sampling. Đặt spanmetrics vào cuối pipeline trên — chỗ trực giác nhất, ngay cạnh otlp/jaeger trong danh sách exporters — sẽ làm error rate và p99 sai có hệ thống, vì tail_sampling giữ 100% trace lỗi nhưng chỉ giữ 5% trace thành công. Pipeline fan-out đúng nằm ở Phần 4, mục "Bẫy lớn: spanmetrics phải chạy trước tail_sampling"; muốn gateway làm cả hai việc thì lấy bộ pipeline ở đó làm khung rồi ghép danh sách processor phía trên vào nhánh traces/sampled, chứ đừng thêm một dòng spanmetrics vào config này.

Về cấu hình tail_sampling (chiến lược và đánh đổi thuộc Phần 4): processor hỗ trợ 17 loại policy — always_sample, latency, numeric_attribute, probabilistic, status_code, string_attribute, trace_state, trace_flags, rate_limiting, bytes_limiting, span_count, boolean_attribute, ottl_condition, and, not, drop, composite. decision_wait mặc định 30s, num_traces mặc định 50000. Khoá invert_match trên string_attribute/numeric_attribute/boolean_attribute đã deprecated — dùng policy drop hoặc not thay thế.

7.12 Tự giám sát Collector

Collector không tự báo là nó đang mất dữ liệu; phải đọc internal telemetry. Metric nội bộ expose qua interface Prometheus, mặc định cổng 8888; service::telemetry::metrics::level mặc định normal (none | basic | normal | detailed).

Metric Ý nghĩa
otelcol_receiver_accepted_spans Span nhận và chấp nhận thành công
otelcol_receiver_refused_spans Span bị từ chối ở receiver, thường do memory_limiter
otelcol_processor_incoming_items / otelcol_processor_outgoing_items Số item vào và ra khỏi mỗi processor; chênh lệch cho biết processor nào đang loại dữ liệu. Đây là cặp chỉ số processor mà tài liệu internal telemetry hiện hành mô tả
otelcol_processor_refused_spans / otelcol_processor_dropped_spans Span bị processor từ chối hoặc loại bỏ. Chỉ một số processor phát các metric này, nên đừng cảnh báo chỉ dựa vào dropped_spans — đối chiếu thêm incoming/outgoing_items
otelcol_exporter_sent_spans Span gửi thành công tới backend
otelcol_exporter_send_failed_spans Span gửi thất bại
otelcol_exporter_queue_size / otelcol_exporter_queue_capacity Số item đang chờ trong sending_queue và sức chứa của nó
Biểu hiện Nguyên nhân thường gặp Hành động
receiver_refused_spans > 0 kéo dài memory_limiter đang chặn vì RAM chạm ngưỡng Tăng limit_mib memory limit của container cùng lúc; đặt GOMEMLIMIT = 80% hard limit; scale thêm replica
queue_size bám sát queue_capacity Backend chậm hơn tốc độ nhận Tăng num_consumers; kiểm tra Jaeger và storage phía sau; chỉ tăng queue_size khi đã có file_storage và còn RAM
exporter_send_failed_spans tăng đều Sai endpoint/TLS/credential, hoặc backend từ chối Xem log Collector; bật debug exporter để xác nhận dữ liệu vào tới nơi; kiểm tra tls.insecure và DNS của Service
accepted_spans cao, sent_spans thấp, không có lỗi Đang bị filter hoặc tail_sampling loại — có thể đúng ý, có thể do điều kiện viết sai So sánh processor_incoming_items với processor_outgoing_items của từng processor; tạm bỏ filter khỏi pipeline để đối chiếu
accepted_spans = 0 App không gửi tới đúng endpoint, hoặc receiver bind localhost Kiểm tra endpoint: 0.0.0.0:...; kiểm tra OTEL_EXPORTER_OTLP_ENDPOINT phía app (Phần 6)
sent_spans bình thường nhưng Jaeger không thấy trace Sai cổng (4317 gRPC so với 4318 HTTP), hoặc trace gãy do sampling Xem Phần 8 và Phần 11

Hai công cụ chẩn đoán tại chỗ:

# zPages: kiểm tra pipeline có đúng như mình nghĩ không
curl -s localhost:55679/debug/pipelinez   # component nào thực sự nằm trong pipeline nào
curl -s localhost:55679/debug/servicez    # trạng thái tổng thể
curl -s localhost:55679/debug/tracez      # mẫu span gần đây theo latency/error

# pprof: khi Collector ăn CPU hoặc RAM bất thường
go tool pprof http://localhost:1777/debug/pprof/profile   # CPU 30 giây
go tool pprof http://localhost:1777/debug/pprof/heap

zpages phải đăng ký SpanProcessor nội bộ nên không dùng được khi service::telemetry::traces::level: none. Cả pprofzpages nên bind localhost và truy cập qua kubectl port-forward, không expose ra Service.

7.13 Scaling và kiến trúc hai tầng trong Kubernetes

flowchart LR
  subgraph nodeA["Node A"]
    P1["Pod: order-service (OTel JS SDK)"]
    P2["Pod: cart-service (OTel JS SDK)"]
    AG1["Agent Collector (DaemonSet)"]
    P1 -->|"OTLP gRPC 4317"| AG1
    P2 -->|"OTLP gRPC 4317"| AG1
  end
  subgraph nodeB["Node B"]
    P3["Pod: payment-service"]
    AG2["Agent Collector (DaemonSet)"]
    P3 -->|"OTLP gRPC 4317"| AG2
  end
  AG1 -->|"loadbalancing routing_key=traceID"| GW["Gateway Collector: tail_sampling + spanmetrics"]
  AG2 -->|"loadbalancing routing_key=traceID"| GW
  GW -->|"OTLP 4317"| J["Jaeger v2"]
  GW -->|"remote_write"| PR["Prometheus"]

Agent chỉ chạy memory_limiter + k8sattributes + batch; gateway lo tail_sampling, spanmetrics và fan-out ra backend. Ô GWmột Collector nhưng bên trong là hai pipeline tách nhau, không phải một chuỗi: pipeline traces nhận 100% span rồi fan-out sang spanmetrics (đường đi Prometheus) và sang traces/sampled — chỉ nhánh sau mới có tail_sampling rồi đẩy vào Jaeger. Khối YAML của kiểu ghép này ở Phần 4; config §7.11 chỉ dựng nhánh trace nên không có spanmetrics.

Stateless hay stateful. memory_limiter, attributes, transform, batch là stateless: scale ngang thoải mái sau một Service bình thường. tail_sampling, groupbytrace, spanmetrics, servicegraphstateful — chúng giữ dữ liệu trong RAM theo trace ID hoặc theo cặp service. Span của cùng một trace rơi vào hai replica khác nhau thì mỗi replica chỉ thấy một phần trace và quyết định sampling sẽ sai: trace bị cắt xén, hoặc lỗi không được giữ vì replica đó không thấy span lỗi. Vì vậy tầng trước gateway phải dùng loadbalancing exporter với routing_key: traceID:

exporters:
  loadbalancing:
    routing_key: traceID
    protocol:
      otlp:
        tls: { insecure: true }
    resolver:
      k8s:
        service: otel-gateway.observability   # headless Service của gateway
        ports: [4317]

Quy tắc vận hành:

  • Gateway có tail_sampling nên chạy với số replica ổn định: mỗi lần thay đổi số replica, loadbalancing rehash và một phần trace đang chờ quyết định sẽ mất. Đừng bật autoscaling quá nhạy ở tầng này.
  • HPA theo CPU là chỉ số dễ dùng nhất; bổ sung custom metric otelcol_exporter_queue_size (qua Prometheus Adapter) để scale trước khi queue tràn chứ không phải sau.
  • Luôn đặt resources.limits.memory cùng với memory_limiter: limit_mib phải thấp hơn memory limit của container (thường 70–80%), và GOMEMLIMIT bằng ~80% hard limit. Chỉ có limit mà không có memory_limiter thì kernel OOM-kill; chỉ có memory_limiter mà không có limit thì Collector ăn hết RAM node.
  • Giới hạn ở receiver để một client hỏng không kéo sập gateway: max_recv_msg_size_mibmax_concurrent_streams.
  • k8s_cluster receiver và các component thu dữ liệu cấp cluster chỉ được chạy đúng một instance — dùng Deployment 1 replica hoặc k8sleaderelector, không dùng DaemonSet.
  • Agent DaemonSet: dùng hostPath hoặc PVC cho file_storage nếu bật persistent queue; emptyDir sẽ mất dữ liệu khi pod bị tạo lại.

Tài liệu tra sâu: opentelemetry.io/docs/collector, component trong contrib, OTTL functions, internal telemetry.


Phần 8 — Jaeger: triển khai và đọc trace

Jaeger trong bức tranh

Jaeger là backend của trace: nhận span qua mạng, lưu vào storage, phục vụ truy vấn và hiển thị UI. Nó không sinh trace (việc của SDK — Phần 2) và không phải nơi biến đổi dữ liệu quy mô lớn (việc của Collector — Phần 7). Dự án khởi nguồn tại Uber, được hiến cho CNCF và đã graduated, nên đây là lựa chọn mặc định khi muốn một backend trace tự host, miễn phí, không ràng buộc vendor.

Kiến trúc: Jaeger v1 và Jaeger v2

Jaeger v1 gồm nhiều binary riêng biệt: client library → jaeger-agent (Thrift qua UDP, chạy sidecar/DaemonSet) → jaeger-collector → storage → jaeger-query + UI. Ở quy mô lớn có thể chèn Kafka giữa collector và storage, khi đó jaeger-ingester đọc từ Kafka rồi ghi xuống storage để hấp thụ burst.

Jaeger v2 là thứ khác hẳn. Docs ghi nguyên văn: "Jaeger is a customized distribution of the OpenTelemetry Collector with different roles". Chỉ còn một binary/image duy nhất tên jaeger; các vai trò collector, query, ingester, all-in-one, agent chọn bằng file config YAML theo đúng schema OTel Collector (receivers / processors / connectors / exporters / extensions / service). Phần riêng của Jaeger là nhóm component cắm thêm: extension jaeger_storage (khai báo backendsmetric_backends), jaeger_query, remote_sampling, exporter jaeger_storage_exporter, processor adaptive_sampling.

flowchart LR
  subgraph V1["Jaeger v1 (EOL)"]
    A1["client library"] --> A2["jaeger-agent (UDP Thrift)"]
    A2 --> A3["jaeger-collector"]
    A3 -.->|"tuỳ chọn"| A4["Kafka"]
    A4 --> A5["jaeger-ingester"]
    A3 --> A6["storage"]
    A5 --> A6
    A6 --> A7["jaeger-query + UI"]
  end
  subgraph V2["Jaeger v2"]
    B1["OTel SDK"] -->|"OTLP"| B2["OTel Collector"]
    B2 -->|"OTLP 4317"| B3["jaeger (role collector)"]
    B3 --> B4["storage"]
    B4 --> B5["jaeger (role query) + UI"]
  end
Tiêu chí Jaeger v1 Jaeger v2
Trạng thái EOL 31/12/2025, bản cuối v1.76.0 (03/12/2025), không còn cập nhật GA từ v2.0.0 (10/11/2024); bản hiện hành v2.20.0
Binary Nhiều: collector, query, ingester, agent, all-in-one Một binary jaeger, role chọn bằng config
Nền tảng Codebase riêng của Jaeger Bản phân phối tùy biến của OpenTelemetry Collector
Cấu hình CLI flag + biến môi trường (COLLECTOR_OTLP_ENABLED...) File YAML qua --config, schema OTel Collector
Image jaegertracing/all-in-one:1.76.0, jaeger-collector, jaeger-query... (đều deprecated) cr.jaegertracing.io/jaegertracing/jaeger:2.20.0không có image all-in-one cho v2
Admin port 14269 (health + metrics) 13133 /status, 8888 /metrics, 8889 (ingester), 27777 expvar, 1777 pprof
Agent jaeger-agent (image dừng ở 1.62) Deprecated; docs khuyên chạy OpenTelemetry Collector làm sidecar/host agent

Dự án mới: chọn v2, không có lựa chọn nào khác. v1 đã hết vòng đời, và từ bản v2.14.0 (02/01/2026) các component v1 collector, query, ingester không còn được publish. Gặp hướng dẫn nào dùng jaegertracing/all-in-one hay COLLECTOR_OTLP_ENABLED, đó là tài liệu v1.

Nhận OTLP trực tiếp

Jaeger v2 có sẵn receiver otlp, nên SDK bắn thẳng vào Jaeger được mà không cần bất kỳ exporter đặc thù nào.

Port Giao thức Endpoint Trạng thái
4317 gRPC ExportTraceServiceRequest (OTLP Protobuf) Stable
4318 HTTP /v1/traces (OTLP Protobuf/JSON) Stable
16686 HTTP UI và /api/v3/* (JSON) Stable — riêng /api/* là API nội bộ, đừng xây tích hợp lên nó
16685 gRPC jaeger.api_v3.QueryService Stable
5778 HTTP /sampling Stable
5779 gRPC jaeger.api_v2.SamplingManager Stable
9411 HTTP /api/v1/spans, /api/v2/spans (Zipkin) Stable
14250 / 14268 / 6831 / 6832 gRPC / HTTP / UDP Jaeger proto và Thrift Deprecated

COLLECTOR_OTLP_ENABLED chỉ tồn tại ở v1: OTLP thêm vào Jaeger v1 từ v1.35 và bật mặc định từ v1.46.0 (05/06/2023) — trước đó phải set true, sau đó chỉ dùng để set false. Jaeger v2 không có biến này; OTLP là receiver otlp khai báo trong config.yaml như mọi Collector khác. Config mặc định của v2 vẫn bật thêm receiver jaeger (Thrift/gRPC) và zipkin để tương thích ngược, dù các protocol đó đã deprecated — đừng xây mới lên chúng.

Gửi thẳng SDK → Jaeger là cách nhanh nhất để thử nghiệm trên máy dev. Production thì đặt một OpenTelemetry Collector ở giữa: đó là nơi batching, retry, redaction, tail sampling và fan-out sang nhiều backend (Phần 7). Chi phí chuyển đổi chỉ là một biến môi trường endpoint.

Storage backend

Backend Quy mô phù hợp Ưu Nhược Retention / TTL Lưu ý vận hành
memory Chỉ để thử, demo, CI Chạy tức thì, không cài gì Mất sạch khi restart; ăn RAM tuyến tính Không có TTL, chỉ có trần max_traces Không dùng production, kể cả staging dài ngày
badger Single node, dev/staging, POC Embedded, persistent, không cần service ngoài Chỉ single-node, không scale ngang ttl.spans, mặc định 72h trong code Dùng kèm role all-in-one; cần volume bền
cassandra Production, throughput ghi cao Ghi nhanh, scale ngang tốt, TTL ở tầng storage Vận hành tốn công; search theo tag yếu hơn ES schema.trace_ttl (48h), schema.dependencies_ttl (48d) Chạy image jaeger-cassandra-schema (MODE=prod, DATACENTER)
elasticsearch Production, cần search theo attribute mạnh Truy vấn tag/full-text tốt; nhiều team đã có sẵn ES Tốn RAM/disk, cần tuning shard Không có TTL — rollover + jaeger-es-index-cleaner, hoặc ILM jaeger-ilm-policy indices.<type>.rotation + create_mappings: false; xem mục mapping/sizing bên dưới
opensearch Như ES; docs khuyên dùng hơn Cassandra ở quy mô lớn Chung implementation với ES Như ES Như ES, dùng ISM policy Đối chiếu ma trận version hỗ trợ trong README repo
clickhouse Chưa nên đặt cược production Nén tốt, truy vấn phân tích nhanh EXPERIMENTAL — cần feature gate storage.clickhouse Docs chưa ghi TTL; đọc config.go Theo dõi release notes trước khi dùng thật
kafka Buffer, không phải storage Hấp thụ burst, tách collector khỏi storage Không đọc trace trực tiếp được n/a Cần role ingester để ghi xuống storage thật
Remote Storage (gRPC) Cắm backend riêng của tổ chức Không phải fork Jaeger Tự lo hiệu năng và schema Do backend tự quyết Chỉ chọn khi không backend sẵn nào hợp

Chọn nhanh: dev/CI dùng memory; POC một node dùng badger; production mà vận hành dựa vào tìm trace theo attribute nghiệp vụ thì Elasticsearch/OpenSearch, ưu tiên throughput ghi thì Cassandra (ước lượng dung lượng ở Phần 10). Archive storage (jaeger_query.storage.traces_archive) là backend riêng với TTL dài hơn; trace trong archive chỉ mở được bằng trace ID, không search được, và UI cần archiveEnabled: true.

config.yaml: Elasticsearch và retention

Bộ khung của mọi config Jaeger v2 giống nhau: một jaeger_storage khai báo backend, một jaeger_query trỏ vào backend đó, một pipeline traces kết thúc ở jaeger_storage_exporter. File dưới đây chạy ở role all-in-one; role collector bỏ khối jaeger_query, role query giữ hai extension và dùng pipeline nop (vẫn bắt buộc khai báo một pipeline).

# config.yaml — role all-in-one, Elasticsearch, retention giao cho ILM
service:
  # Extension khai báo mà không liệt kê ở đây thì KHÔNG được nạp
  extensions: [jaeger_storage, jaeger_query, healthcheckv2]
  pipelines:
    traces:
      receivers: [otlp]          # bỏ jaeger/zipkin nếu không cần tương thích ngược
      processors: [batch]
      exporters: [jaeger_storage_exporter]

extensions:
  healthcheckv2:
    use_v2: true
    http:                        # /status trên 13133

  jaeger_storage:
    backends:
      es_main: &es_main
        elasticsearch:
          server_urls:
            - https://es-0.internal:9200
          indices:
            index_prefix: jaeger-main
            spans:
              shards: 5           # xem mục sizing bên dưới, 5 không phải con số vạn năng
              replicas: 1
              rotation:
                auto_rollover: {} # ILM/ISM tự rollover và tự xoá; PHẢI là map, không phải chuỗi
          auth:
            basic:
              username: jaeger
              password: ${env:ES_PASSWORD}
          tls:
            ca_file: /etc/ssl/certs/internal-ca.pem
          create_mappings: false
    metric_backends:
      es_main: *es_main          # tab Monitor tính RED ngay trên chính cụm ES

  jaeger_query:
    storage:
      traces: es_main            # tên phải khớp đúng khoá trong backends
      metrics: es_main           # thiếu dòng này thì tab Monitor trống
    ui:
      config_file: /jaeger/ui-config.json   # nơi bật monitor.menuEnabled, archiveEnabled;
                                            # nội dung file ở mục "ui-config.json" phía dưới
    max_clock_skew_adjust: 30s   # 0s để tắt hiệu chỉnh, xem mục Bẫy khi đọc trace
    http:
      endpoint: 0.0.0.0:16686
    grpc:
      endpoint: 0.0.0.0:16685

receivers:
  otlp:
    protocols:
      grpc:
        # Bind 0.0.0.0, không phải localhost — nếu không, chỉ process trong
        # cùng container gọi được và Collector bên ngoài sẽ connection refused.
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch: {}

exporters:
  jaeger_storage_exporter:
    trace_storage: es_main

Mount và chạy: -v ./config.yaml:/jaeger/config.yaml kèm --config /jaeger/config.yaml; ${env:...} là cú pháp giãn biến môi trường của OTel Collector, dùng được vì Jaeger v2 chính là một distro của Collector.

Elasticsearch không có TTL ở tầng storage như Cassandra: không xoá thì index nằm đó mãi. Chiến lược xoay index khai báo ở indices.<type>.rotation với <type>spans, services, dependencies hoặc sampling; bốn biến thể, mỗi index type chọn đúng một:

Chiến lược Cấu hình Ai xoá dữ liệu
Index theo ngày (mặc định khi không khai báo rotation) rotation: { periodic: {} } — Jaeger tạo jaeger-span-2026-09-08…; nhận date_layout, rollover_frequency Cron job jaeger-es-index-cleaner
Rollover thủ công rotation: { manual_rollover: {} } (nhận read_alias, write_alias), chạy jaeger-es-rollover init một lần Cron rollover + lookback + es-index-cleaner
ILM/ISM (khuyến dùng) rotation: { auto_rollover: {} } + create_mappings: false Chính Elasticsearch, không cần cron nào
Data stream rotation: { data_stream: {} } Chưa dùng được: 2.20.0 từ chối khởi động với data_stream is not yet implemented

Bộ khoá cũ đã bị gỡ, không phải chỉ deprecated. use_aliases, use_ilm, span_read_alias, span_write_alias, service_read_alias, service_write_alias khiến jaeger:2.20.0 từ chối khởi động: deprecated ES rotation flags ... are no longer supported; migrate to 'indices.<type>.rotation' config, báo riêng cho từng backend (kể cả metric_backends). Cách chữa tạm là --feature-gates=-es.config.rejectLegacyRotationFlags, nhưng đó chỉ để hoãn migration một nhịp. Hai chi tiết cú pháp hay vấp: rotation phải là map (rotation: auto_rollover dạng chuỗi báo expected a map or struct, got "string"), và không được vừa đặt rotation vừa giữ date_layout/rollover_frequency ở cấp indices.<type> — chuyển chúng vào trong periodic. Riêng create_mappings không bị gỡ, giữ nguyên ý nghĩa cũ. Kiểm tra trước khi deploy bằng chính image sẽ chạy — subcommand validate của Collector đọc config rồi thoát, không cần ES thật:

docker run --rm -v "$PWD/config.yaml:/cfg.yaml:ro" \
  cr.jaegertracing.io/jaegertracing/jaeger:2.20.0 validate --config /cfg.yaml

In ra đúng một dòng version rồi thoát 0 là đạt. Không cần ES thật và không cần set biến môi trường: ${env:...} chưa set thì giãn thành chuỗi rỗng, validate chỉ nạp và kiểm tra config chứ không kết nối backend.

Đường ILM cần hai bước làm trước khi Jaeger khởi động, vì Jaeger đọc policy chứ không tạo policy: (1) tạo trong Elasticsearch policy tên jaeger-ilm-policy với phase hot có action rollover và phase deletemin_age; (2) chạy image jaeger-es-rollover với ES_USE_ILM=true và lệnh init <URL> để sinh index template, index đầu tiên (jaeger-span-000001) và cặp alias jaeger-span-read / jaeger-span-write — thiếu policy thì init dừng và báo ILM policy jaeger-ilm-policy doesn't exist in Elasticsearch.

Hai khoá đi kèm nhau: rotation: auto_rollover để Jaeger đọc/ghi qua alias và để Elasticsearch tự rollover rồi tự xoá, create_mappings: false vì template đã do bước init tạo kèm thiết lập ILM — để Jaeger tự tạo mapping thì template bị ghi đè, index mới rơi ra ngoài policy và dữ liệu không bao giờ bị xoá cho tới lúc đầy đĩa. min_age của phase delete tính từ lúc index được rollover, không phải từ lúc span được ghi, nên retention thực tế bằng max_age cộng min_age. Cụm không bật ILM thì phải chạy cron jaeger-es-index-cleaner <số ngày giữ lại> <URL> (thêm biến ROLLOVER=true nếu đang dùng alias) và giám sát chính cron đó — job hỏng lặng lẽ là nguyên nhân kinh điển khiến ES đầy đĩa. Cách đóng gói job trên Kubernetes ở Phần 10.

Elasticsearch: mapping, sizing và các cách sập

Elasticsearch là backend được chọn nhiều nhất cho production và cũng là chỗ đội tự host hay sập nhất. Ba nhóm rủi ro: mapping nổ số field, sizing sai, và job dọn dẹp chết lặng.

1. Mapping: vấn đề nằm ở cardinality của tên attribute, không phải của giá trị.

Mặc định Jaeger lưu tag dạng nested key/value (tags: [{key, value}]): số field cố định, đổi lại phải dùng nested query và attribute không hiện thành cột trong Kibana. Bật tags_as_fields.all: true (cờ v1 tương ứng --es.tags-as-fields.all) thì mỗi tên attribute trở thành một field trong mapping — lọc nhanh hơn, nhưng mapping từ đó lớn dần theo số tên khoá khác nhau; dot_replacement (mặc định @) đổi dấu chấm trong tên khoá vì ES hiểu dấu chấm là phân cấp object.

Trần là index.mapping.total_fields.limit, mặc định 1000. Thứ làm nổ nó là tên khoá sinh động: http.request.header.x-request-id, db.param.0, cache.<tenant>.hit — bất cứ chỗ nào nhét id vào tên khoá. Giá trị cardinality cao (shop.order.id) không làm tăng số field; nó ăn heap qua global ordinals và làm chậm aggregation — một vấn đề khác, nhẹ hơn.

Triệu chứng khi chạm trần Nhìn ở đâu
Limit of total fields [1000] has been exceeded log Elasticsearch, và log của Jaeger role collector
otelcol_exporter_send_failed_spans tăng đều trong khi sent_spans tụt telemetry nội bộ Collector, cổng 8888 (Phần 7)
Trace thủng từng mảng: còn span cha, mất span con của đúng một service UI Jaeger — rất dễ chẩn đoán nhầm thành lỗi instrumentation

Phòng bằng hai lớp, làm cả hai. Lớp một, chặn ở Collector trước khi ghi xuống ES: whitelist attribute bằng OTTL (Phần 7).

processors:
  transform/whitelist:
    error_mode: ignore
    trace_statements:
      # Chỉ giữ những khoá đã đăng ký; mọi khoá lạ bị bỏ trước khi tới Elasticsearch
      - keep_keys(span.attributes, ["http.request.method", "http.route", "http.response.status_code", "url.path", "db.system.name", "db.query.summary", "server.address", "shop.order.id", "shop.order.status", "shop.payment.method"])

keep_keys xoá mọi khoá không có trong danh sách, nên danh sách phải được review cùng lúc với việc thêm attribute mới — nếu không, attribute vừa thêm biến mất mà không ai báo. Lớp hai, chặn ở nguồn: quy ước đặt tên ở Phần 9 §9.13 — namespace riêng shop.*, tên khoá tĩnh, id đi vào giá trị chứ không vào tên. Nâng index.mapping.total_fields.limit lên 2000 chỉ là cấp cứu để cụm nhận lại span, không phải cách chữa: mapping lớn ăn heap trên mọi node và làm chậm mọi truy vấn.

2. Sizing: từ GB/ngày sang số shard và số node.

Lấy GB/ngày từ công thức Phần 10 rồi đi bốn bước (ví dụ dưới đây theo cột "trung bình" của Phần 10: 38,9 GB/ngày, retention 14 ngày):

Bước Cách tính Ví dụ
Dung lượng primary mỗi ngày GB/ngày × index overhead 1,2–1,5 (con số đã nêu ở Phần 10) 38,9 × 1,3 ≈ 50 GB/ngày
Số primary shard mỗi index ngày primary/ngày ÷ 20–50 GB/shard 50 ÷ 25 ≈ 2 shardshards: 5 trong config mẫu là dư ở quy mô này
Đĩa thật phải mua primary/ngày × (1 + replicas) × retention 50 × 2 × 14 ≈ 1,4 TB
Số data node tổng đĩa ÷ (đĩa mỗi node × 0,7) 1,4 TB ÷ (700 GB × 0,7) ≈ 3 node

Chia nhỏ quá cũng tốn: mỗi shard là một Lucene index, ăn heap và làm phình cluster state — giữ tổng số shard dưới khoảng 20 shard cho mỗi GB heap của node. Ngược lại, shard vượt 50 GB thì recovery và rebalance chậm tới mức một node chết là mất hàng giờ. replicas: 1tối thiểu cho production: với replicas: 0, một node chết là index chuyển red, Jaeger query trả lỗi và phần trace nằm trên node đó mất hẳn; replica cũng chia tải đọc. Chỉ hạ về 0 ở non-prod — đúng ý nghĩa hệ số F trong bảng chi phí Phần 10.

3. Checklist vận hành hay bị bỏ.

Việc Giám sát bằng gì Vì sao bắt buộc
Cron jaeger-es-index-cleaner Alert khi index cũ nhất già hơn retention + 1 ngày, hoặc khi CronJob quá X giờ chưa có lần chạy thành công (đóng gói ở Phần 10) Job chết không kêu; phát hiện lúc đĩa đầy là đã muộn
Rollover (cron hoặc ILM) Alert khi index gắn alias jaeger-span-write không đổi quá X giờ, hoặc khi tổng số index vượt ngưỡng đã tính Rollover hỏng thì một index phình vô hạn, cleaner cũng không xoá được gì
Disk watermark của ES Alert từ 75%; ES chặn cấp shard mới ở 85% (low), dời shard đi ở 90% (high), chuyển index sang read_only_allow_delete ở 95% (flood stage) Chạm flood stage là mọi lệnh ghi span fail cho tới khi gỡ tay
index.refresh_interval Mặc định 1s; nâng lên 30s giảm tải ghi nhưng span mới hiện chậm thêm đúng 30s Cộng thẳng vào ngân sách độ trễ end-to-end ở Phần 11 — trừ khoản này ra trước khi kết luận "mất trace"
ILM policy có tồn tại GET _ilm/policy/jaeger-ilm-policy như một bước trong pipeline deploy Thiếu policy thì init dừng giữa chừng và index mới rơi ra ngoài vòng đời

docker-compose.yml chạy được

Ba service trên một network chung: app Node build từ Dockerfile ở Phần 5A, Collector mount bản local collector-config.local.yaml ở Phần 7 §7.11, và Jaeger. Đừng mount nhầm bản gateway Kubernetes cũng nằm trong §7.11 — k8sattributes với auth_type: serviceAccount không tạo được client ngoài cluster nên Collector chết ngay lúc khởi động, và endpoint jaeger-collector.observability.svc.cluster.local không phân giải được trong compose.

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile          # Dockerfile ở Phần 5A
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      OTEL_SERVICE_NAME: shop-api
      OTEL_RESOURCE_ATTRIBUTES: "service.namespace=shop,service.version=1.4.2,deployment.environment.name=dev"
      # Gửi vào Collector, không gửi thẳng Jaeger (xem Phần 7)
      OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318"
      OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
      OTEL_TRACES_EXPORTER: "otlp"
      OTEL_METRICS_EXPORTER: "none"
      OTEL_LOGS_EXPORTER: "none"
      OTEL_TRACES_SAMPLER_ARG: "1.0"  # dev lấy hết, xem Phần 4
    depends_on: [otel-collector]
    networks: [otel]

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.160.0
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      # BẢN LOCAL ở Phần 7 (mục 7.11), không phải config gateway Kubernetes:
      # bản gateway có k8sattributes/file_storage/tail_sampling sẽ không khởi động nổi ở đây.
      - ./collector-config.local.yaml:/etc/otelcol/config.yaml:ro
    ports:
      - "4317:4317"     # OTLP gRPC
      - "4318:4318"     # OTLP HTTP
      - "13133:13133"   # health_check
    depends_on: [jaeger]
    networks: [otel]

  jaeger:
    image: cr.jaegertracing.io/jaegertracing/jaeger:2.20.0
    ports:
      - "16686:16686"   # UI
      - "5778:5778"     # /sampling
      # OTLP của Jaeger đẩy sang cổng host khác để không đụng Collector.
      # Chỉ dùng khi muốn bắn thẳng SDK vào Jaeger để thử nghiệm.
      - "14317:4317"    # OTLP gRPC
      - "14318:4318"    # OTLP HTTP
    networks: [otel]

networks:
  otel:
    driver: bridge

Chạy image jaeger không kèm --config sẽ dùng cấu hình mặc định built-in, storage memory — đủ cho dev, mất sạch khi restart. Muốn đổi storage thì mount file config rồi thêm command: ["--config", "/jaeger/config.yaml"].

Trong Collector config (Phần 7), exporter trỏ sang Jaeger là otlp chuẩn — exporter jaeger đã bị gỡ khỏi opentelemetry-collector-contrib:

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true

Ba lưu ý. OTEL_EXPORTER_OTLP_ENDPOINT được nối thêm /v1/traces, còn OTEL_EXPORTER_OTLP_TRACES_ENDPOINT dùng nguyên văn nên phải ghi đủ path (Phần 6). OTEL_METRICS_EXPORTEROTEL_LOGS_EXPORTER là biến chuẩn của spec, đặt none vì ví dụ này chỉ dựng tracing. Và ở đây không có OTEL_TRACES_SAMPLER: tracing.ts của Phần 5A truyền tường minh option sampler vào NodeSDK, mà option trong code thắng biến môi trường nên đặt biến đó vô tác dụng; chỉ OTEL_TRACES_SAMPLER_ARG còn tác dụng vì chính đoạn code ở 5A đọc nó.

docker compose up -d --build
docker compose logs -f otel-collector       # xem span có tới không
curl -s http://localhost:3000/api/products  # sinh vài request
docker compose down -v

Mở UI: http://localhost:16686. Kiểm tra Collector sống: http://localhost:13133. Endpoint sampling của Jaeger: http://localhost:5778/sampling?service=shop-api.

Ghi chú khi chạy trên Windows

  • Dùng Docker Desktop với backend WSL2; đặt source code trong filesystem của WSL thay vì C:\Users\... để bind mount nhanh hơn nhiều lần.
  • Đường dẫn volume viết kiểu POSIX (./collector-config.local.yaml:/etc/otelcol/config.yaml:ro), Compose tự chuyển đổi; tránh viết C:\... trực tiếp.
  • File config và script đưa vào container phải xuống dòng LF. Git trên Windows với core.autocrlf=true là nguyên nhân phổ biến khiến Collector báo lỗi parse YAML hoặc entrypoint không chạy.
  • App chạy ngoài container (npm run dev trên host) còn Collector trong Docker thì endpoint là http://localhost:4318; chiều ngược lại dùng host.docker.internal.

Remote sampling từ Jaeger

Jaeger v2 phục vụ sampling strategy qua extension remote_sampling, hai chế độ loại trừ nhau: file (tĩnh, tự reload khi file đổi) hoặc adaptive (cần thêm processor adaptive_sampling trong pipeline).

extensions:
  remote_sampling:
    file:
      path: /jaeger/sampling-strategies.json
    http:      # mở endpoint HTTP /sampling (5778)
    grpc:      # mở SamplingManager gRPC (5779)

service:
  # config đầy đủ liệt kê thêm jaeger_storage, jaeger_query...
  extensions: [remote_sampling]

Đưa file vào container bằng volume: -v ./sampling-strategies.json:/jaeger/sampling-strategies.json.

{
  "default_strategy": { "type": "probabilistic", "param": 0.1 },
  "service_strategies": [
    {
      "service": "shop-api",
      "type": "probabilistic",
      "param": 0.2,
      "operation_strategies": [
        { "operation": "POST /api/checkout", "type": "probabilistic", "param": 1.0 }
      ]
    },
    { "service": "image-resizer", "type": "ratelimiting", "param": 2 }
  ]
}

type chỉ nhận probabilistic hoặc ratelimiting; operation_strategies chỉ dùng được với probabilistic. Khi không cấu hình gì, mặc định là probabilistic 0.001. Client lấy strategy tại GET /sampling?service=<name> trên port 5778, hoặc qua gRPC jaeger.api_v2.SamplingManager trên 5779.

Trạng thái ở JS: SDK tracing lõi chỉ có AlwaysOnSampler, AlwaysOffSampler, ParentBasedSampler, TraceIdRatioBasedSampler — không có sampler remote trong nhóm đó. Bản triển khai nằm ở package experimental @opentelemetry/sampler-jaeger-remote (đánh số theo dòng 0.x của OTel JS); README ghi rõ "This is an experimental package under active development. Minor releases may include breaking changes".

// instrumentation.ts — phần khởi tạo còn lại xem Phần 5A
import { NodeSDK } from '@opentelemetry/sdk-node';
import { AlwaysOnSampler } from '@opentelemetry/sdk-trace-base';
import { JaegerRemoteSampler } from '@opentelemetry/sampler-jaeger-remote';

const sdk = new NodeSDK({
  sampler: new JaegerRemoteSampler({
    // sampler tự nối `/sampling?service=<serviceName>` vào endpoint,
    // nên chỉ ghi phần gốc, không ghi kèm đường dẫn
    endpoint: 'http://jaeger:5778',
    serviceName: 'shop-api',
    initialSampler: new AlwaysOnSampler(), // dùng khi chưa kéo được strategy
    // ms — upstream viết "pooling", không phải "polling"; đây là tên option của
    // package JS, khác với khoá `pollingIntervalMs` trong chuỗi
    // OTEL_TRACES_SAMPLER_ARG ở Phần 4. Hai bề mặt cấu hình khác nhau, không
    // phải lỗi chính tả của tài liệu.
    poolingInterval: 60000,
  }),
});

sdk.start();

Package còn experimental nên phải đọc CHANGELOG trước mỗi lần nâng version. Với Node.js chạy production, đường chắc chắn hơn là cấu hình sampler bằng biến môi trường (Phần 4) và đẩy quyết định phức tạp xuống tail sampling ở Collector (Phần 7).

Sử dụng UI

Tìm trace

Màn hình Search có các trường: Service, Operation (tên span gốc), Tags, Lookback, Min/Max Duration, Limit Results.

  • Tags nhận cú pháp logfmt: từng cặp key=value phân cách bằng khoảng trắng, ví dụ http.response.status_code=500 user.tier=vip; giá trị có khoảng trắng thì bọc nháy kép: db.query.summary="SELECT products". Chỉ attribute đã thực sự lên span mới tìm được — đây là lý do đặt tên theo semantic conventions rất quan trọng (Phần 9).
  • Min Duration là bộ lọc hiệu quả nhất: gõ 1s hoặc 500ms để loại toàn bộ request nhanh.
  • Kết quả hiển thị dạng scatter plot theo trục thời gian × duration, sắp xếp được theo Most Recent / Longest First / Most Spans. Chấm nằm cao là trace chậm, chấm đỏ là trace có span lỗi; click vào chấm lệch ra ngoài đám đông để mở trace bất thường.

Tìm theo tag hoạt động thế nào — tuỳ storage, không tuỳ UI. Ô Tags giống nhau ở mọi cài đặt, nhưng thứ chạy phía sau là câu truy vấn của backend đang dùng:

Storage Cơ chế tìm theo tag Hệ quả cho người dùng UI
memory / badger Quét dữ liệu trong RAM hoặc index cục bộ của đúng một node Chính xác nhưng chỉ hợp dev/POC; chậm dần theo dung lượng
cassandra Index tag hạn chế; lọc nhiều tag cùng lúc chậm hoặc không được hỗ trợ Tìm shop.order.id=ORD-... rất dễ ra rỗng dù trace vẫn nằm trong storage — không phải lỗi instrumentation
elasticsearch / opensearch Tag được index như field, tổ hợp nhiều điều kiện tốt nhất Mạnh nhất, nhưng phụ thuộc mapping: nhiều tên attribute khác nhau làm nổ số field khi bật tags_as_fields — xem mục mapping/sizing ở trên

Ba cảnh báo còn lại khi dùng màn hình Search:

  • Limit Results không phải "top N". Jaeger trả về các trace mới nhất khớp điều kiện rồi cắt theo limit, không phải trace chậm nhất — trace bạn cần có thể bị cắt mất mà UI không báo gì. Cách đúng là thu hẹp Lookback và nâng Min Duration; trần của hai ô này đặt bằng search.maxLimitsearch.maxLookback trong UI config (mẫu file đầy đủ ở mục ui-config.json).
  • Trace quá lớn làm treo UI. Vài nghìn span đã đủ làm timeline giật; hàng chục nghìn span có thể treo tab hoặc vượt giới hạn kích thước document/response của storage. Chặn từ gốc: policy span_count (min_spans / max_spans) trong tail_sampling ở Collector loại thẳng "trace bom" do retry loop hoặc vòng lặp sinh span (Phần 4 và Phần 7).
  • Tra theo trace ID luôn hoạt động. Dán ID vào http://localhost:16686/trace/<trace-id> mở được trace kể cả khi Search không tìm ra, vì không cần tới index tag. Đây cũng là cách duy nhất mở trace trong archive storage.

Đọc trang chi tiết trace

Timeline xếp span theo thời điểm bắt đầu, thụt lề theo quan hệ cha–con.

  • Total time là toàn bộ độ dài thanh; self time là total time trừ đi phần các child span chiếm. Bottleneck luôn nằm ở span có self time lớn, không phải span có total time lớn nhất — root luôn có total time lớn nhất.
  • Span dài nhưng không có child = chờ một thứ chưa được instrument: gọi HTTP ra ngoài, chờ lock, chờ connection pool, hoặc CPU-bound thật.
  • Khoảng trống giữa các span (không thanh nào phủ) = code không được instrument. Đây là chỗ hay giấu bottleneck nhất: serialize JSON lớn, vòng lặp tính toán, chờ event loop.
  • Click vào span để xem Tags, Process (resource attribute) và Logs (span event, gồm cả exception).
  • Chế độ critical path làm nổi chuỗi span thực sự quyết định tổng thời gian (bật bằng criticalPathEnabled trong UI config) — nhanh hơn tự dò từng thanh.

Compare, Trace Graph, System Architecture, Monitor

  • Compare traces (tính năng trong UI, không có trang docs riêng): dán hai trace ID để xem cây hợp nhất, node tô màu theo chênh lệch — thấy ngay span nào phình ra hoặc xuất hiện thêm.
  • Trace Graph: gộp các span cùng tên thành node đồ thị kèm số lần gọi — dùng để đếm nhanh N+1.
  • System Architecture: đồ thị phụ thuộc một hop giữa các service. Với storage memory thì tính trực tiếp, còn Cassandra/Elasticsearch cần job ngoài tổng hợp dữ liệu (spark-dependencies) — đây là lý do trang này hay trống ở production. Deep Dependency Graph cho phép nhìn đường đi xuyên qua một service tâm điểm, đổi được độ chi tiết giữa mức service và mức endpoint.
  • Monitor (Service Performance Monitoring): RED metrics theo service và operation, latency P50/P75/P95 kèm chỉ số Impact = latency × request rate. Tab này mặc định tắt, phải đặt monitor.menuEnabled: true trong UI config (jaeger_query.ui.config_file).

Hai cách cấp dữ liệu cho tab Monitor. Cách một: connector spanmetrics (alpha, contrib; kiểu component đang được đổi tên thành span_metrics, tên cũ còn chạy nhưng đã deprecated) sinh traces.span.metrics.callstraces.span.metrics.duration, đẩy qua exporter prometheus cho Prometheus scrape; phía Jaeger khai báo backend prometheus với normalize_calls: true + normalize_duration: true để đọc đúng tên calls_totalduration_milliseconds_*. Hai khoá metric_namespace (mặc định traces_span_metrics) và latency_unit (mặc định ms) của backend phải khớp namespacehistogram.unit của connector — đổi một bên mà quên bên kia là kiểu hỏng phổ biến nhất: tab Monitor trống trong khi Prometheus vẫn đầy metric. Span lỗi nhận biết bằng label status_code="STATUS_CODE_ERROR". Cách hai: tính RED metrics ngay tại query-time trên Elasticsearch/OpenSearch, không cần Prometheus và không cần connector. Cả hai đều khai báo nguồn metric ở jaeger_storage.metric_backends.<tên> rồi trỏ jaeger_query.storage.metrics vào đó — chú ý là metric_backends, không phải metrics_storage.

ui-config.json: bật Monitor, archive và link sang log

Mọi khoá UI nhắc rải rác ở trên — archiveEnabled, criticalPathEnabled, search.maxLimit / search.maxLookback, monitor.menuEnabled — nằm trong một file JSON duy nhất. File này được trỏ tới bằng jaeger_query.ui.config_file (xem config.yaml ở trên) và phải mount vào container. Không mount thì Jaeger dùng cấu hình UI mặc định: không có tab Monitor, không có nút archive, không có link nào từ span sang log.

{
  "monitor": { "menuEnabled": true },
  "archiveEnabled": true,
  "criticalPathEnabled": true,
  "search": {
    "maxLimit": 1500,
    "maxLookback": { "label": "2 Days", "value": "2d" }
  },
  "dependencies": { "menuEnabled": true, "dagMaxNumServices": 200 },
  "menu": [
    { "label": "Runbook tracing", "url": "https://wiki.internal/runbook/tracing", "anchorTarget": "_blank" },
    { "label": "Grafana", "url": "https://grafana.internal/d/otel-overview", "anchorTarget": "_blank" }
  ],
  "linkPatterns": [
    {
      "type": "traces",
      "key": "traceID",
      "url": "https://grafana.internal/explore?orgId=1&left=%7B%22datasource%22%3A%22loki%22%2C%22queries%22%3A%5B%7B%22expr%22%3A%22%7Bservice_name%3D%5C%22shop-api%5C%22%7D%20%7C%3D%20%5C%22#{trace.traceID}%5C%22%22%7D%5D%7D",
      "text": "Xem log của trace này"
    },
    {
      "type": "tags",
      "key": "shop.order.id",
      "url": "https://admin.internal/orders/#{shop.order.id}",
      "text": "Mở đơn #{shop.order.id} trong admin"
    }
  ]
}

Đọc từng khối:

  • monitor.menuEnabled bật tab Monitor — vẫn cần jaeger_query.storage.metrics trỏ đúng một metric_backends như mục trên, bật menu mà thiếu nguồn metric thì tab hiện ra nhưng trống.
  • archiveEnabled bật nút Archive this trace và cho phép mở trace trong archive storage; criticalPathEnabled bật chế độ critical path trong timeline.
  • search.maxLimitsearch.maxLookbacktrần của hai ô tương ứng trên màn hình Search; maxLookback nhận cặp label (hiện trong dropdown) và value (dạng 2d, 1w).
  • dependencies.dagMaxNumServices chặn trang System Architecture vẽ đồ thị quá lớn làm treo trình duyệt.
  • menu thay thế hoàn toàn menu mặc định chứ không nối thêm — khai báo rồi thì mục About Jaeger biến mất, muốn giữ phải tự liệt kê lại.
  • linkPatterns là chỗ đóng vòng "nhảy hai chiều log ↔ trace" còn bỏ dở ở cuối Phần 5B. Chiều log → trace đã có sẵn (derived field của Loki, hoặc dán trace ID vào UI); chiều trace → log chính là entry type: "traces", biến #{trace.traceID} được thay bằng trace ID thật. Entry type: "tags" chạy trên từng tag của span: khớp key rồi thay #{shop.order.id} bằng giá trị tag. Phần query của URL phải tự URL-encode; trace ID và mã đơn chỉ gồm ký tự an toàn nên chèn thẳng vào chuỗi đã encode vẫn đúng.

Mount vào docker-compose — lưu ý service jaeger ở trên chạy không kèm --config nên dùng cấu hình built-in, mà ui.config_file chỉ tồn tại bên trong jaeger_query; muốn có UI config thì phải mount cả hai file:

  jaeger:
    image: cr.jaegertracing.io/jaegertracing/jaeger:2.20.0
    command: ["--config", "/jaeger/config.yaml"]
    volumes:
      - ./config.yaml:/jaeger/config.yaml:ro
      - ./ui-config.json:/jaeger/ui-config.json:ro

Trên Kubernetes gom cả hai vào một ConfigMap rồi mount vào /jaeger: kubectl create configmap jaeger-config --from-file=config.yaml --from-file=ui-config.json. Đổi nội dung ConfigMap thì phải restart pod — jaeger_query đọc file một lần lúc khởi động.

Kiểm chứng theo thứ tự, đừng nhìn UI trước:

docker compose restart jaeger
docker compose logs jaeger | grep "Using UI configuration"        # phải in đúng path đã mount
curl -s http://localhost:16686/ | grep -o '"monitor":{"menuEnabled":true}'

Jaeger nhúng thẳng cấu hình UI vào HTML mà nó phục vụ, nên lệnh curl phải in ra đúng chuỗi đó. Không có dòng log Using UI configuration, hoặc curl không khớp gì, nghĩa là file chưa vào được container (sai đường dẫn mount) hoặc JSON sai cú pháp — UI vẫn chạy bình thường và không báo lỗi gì, đây là kiểu hỏng im lặng hay mất thời gian nhất. Khi đã khớp: thanh menu trên cùng có thêm mục Monitor; mở một trace có tag shop.order.id rồi click span, phần Tags xuất hiện link Mở đơn ...; đầu trang trace có link Xem log của trace này.

Quy trình đọc một trace để tìm bottleneck

  1. Khoanh vùng: Search đúng service, đặt Min Duration gần ngưỡng SLO, sắp xếp Longest First. Lấy trace chậm nhất mà vẫn thành công — trace lỗi thường chậm vì lý do khác.
  2. Tìm self time lớn nhất: quét từ trên xuống, bỏ qua span có child phủ gần hết độ dài. Span còn lại với thanh dài và ít child chính là ứng viên.
  3. Soi khoảng trống: đo các đoạn timeline không span nào phủ. Trống hơn 10% tổng duration nghĩa là thiếu instrumentation — thêm span thủ công ở đó (Phần 5B) rồi đo lại.
  4. Đếm span trùng tên: mở Trace Graph hoặc đếm trên timeline. Vài chục span SELECT products liên tiếp là N+1 query kinh điển.
  5. Compare: lấy một trace nhanh của cùng operation, đưa cả hai vào Compare traces, xem span nào phình.
  6. Đọc attribute: mở span thủ phạm, tìm tham số gây chậm — db.query.summary, kích thước batch, http.route, id khách hàng, cờ cache miss.

Ví dụ một trace checkout 1842 ms:

POST /api/checkout                          1842 ms  ← root, total time
├─ auth.verify                                12 ms
├─ SELECT carts                               18 ms
├─ POST /inventory/reserve                   226 ms   (inventory-svc)
│  └─ SELECT stock                            31 ms
├─ (khoảng trống 340 ms — không span nào phủ)         ← bước 3
├─ SELECT products  × 47 span liên tiếp      612 ms   ← bước 4: N+1
├─ POST /payment/charge                      498 ms   (payment-svc)
│  └─ (không có child span)                            ← bước 2: chờ bên ngoài
└─ INSERT orders                              44 ms

Self time của root ≈ 1842 − (12 + 18 + 226 + 612 + 498 + 44) = 432 ms, phần lớn nằm trong khoảng trống 340 ms. Ba việc phải làm theo đúng thứ tự giá trị: gộp 47 query thành một truy vấn WHERE id IN (...), instrument đoạn trống 340 ms, rồi mới đàm phán timeout với payment provider.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí