0

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

Hạng mục Nội dung
Phạm vi Chỉ tracing. Metrics và Logs chỉ xuất hiện ở đúng chỗ chúng chạm vào trace (RED metrics sinh từ span, log correlation qua trace_id) chứ không được trình bày như signal riêng. Profiling nằm ngoài phạm vi.
Stack trong ví dụ Node.js 20+ / TypeScript với @opentelemetry/api + @opentelemetry/sdk-node; OpenTelemetry Collector (otelcol-contrib / otelcol-k8s); Jaeger v2 làm backend. Triển khai bằng Docker Compose ở dev và Kubernetes ở production.
Ví dụ xuyên suốt Một hệ e-commerce: api-gateway → order-service → inventory-service → payment-service → Kafka → notification-worker. Mọi phần dùng lại đúng luồng này (Phần 1).
Đối tượng đọc Backend developer Node.js/TypeScript, SRE và platform engineer đang dựng hoặc đang chữa một hệ tracing tự host. Yêu cầu: biết HTTP, Docker, và đọc được TypeScript. Không yêu cầu kinh nghiệm OpenTelemetry trước đó.
Không bao gồm Ngôn ngữ khác Node.js (nguyên lý áp dụng được, API thì không), backend thương mại (Datadog, Honeycomb…) ngoài phần so sánh ngắn ở Phần 8, và eBPF/continuous profiling.

Cảnh báo bắt buộc đọc trước: OpenTelemetry thay đổi nhanh.

Mọi con số version, tên khoá cấu hình và nhãn trạng thái (Stable / Release Candidate / Development / Deprecated, hay alpha / beta / stable của component Collector) trong tài liệu này là ảnh chụp tại thời điểm biên soạn, không phải hằng số. Dòng experimental 0.x của OpenTelemetry JS được phép breaking ở minor release; component Collector từ mức Alpha trở lên có thể đổi tên khoá cấu hình ở bất kỳ release nào; semantic conventions ở mức Development có thể đổi tên attribute và làm dashboard vỡ im lặng.

Trước mỗi quyết định kiến trúc hoặc mỗi lần nâng version, đối chiếu lại với tài liệu chính thức thay vì tin bảng trong tài liệu này:

Quy tắc thực dụng: pin version chính xác trong package.json và trong tag image, rồi đọc CHANGELOG trước khi nâng. Ba thang stability trong tài liệu này độc lập với nhau — đừng suy từ cái này ra cái kia.

Đã kiểm chứng bằng cách chạy thật (2026-09-09). Code và config dưới đây không chỉ được đối chiếu với tài liệu upstream mà còn được biên dịch và chạy một lượt:

Hạng mục Cách kiểm Kết quả
62 khối TypeScript tsc --noEmit trên toàn bộ, với package thật đã cài Sạch — mọi import phân giải được, mọi tên export tồn tại
tracing.ts + index.ts + tsconfig.json (Phần 5A) npm run build đúng lệnh tài liệu ghi Biên dịch thành công dưới strict: true
23 config Collector và Jaeger otelcol validatejaeger validate Hợp lệ; các lỗi còn lại chỉ do snippet tham chiếu component khai ở block khác
Stack Compose Phần 8 + overlay Phần 5A docker compose up --build, bắn request thật 5 container chạy; trace tới Jaeger; trace phân tán qua 2 process bằng traceparent
Attribute HTTP sinh ra thực tế Đọc lại span từ API của Jaeger Đúng bộ stable mới: http.request.method, http.response.status_code, http.route, url.path, url.scheme, server.address
Bảng OTEL_EXPORTER_OTLP_ENDPOINT vs ..._TRACES_ENDPOINT (Phần 6) Trỏ exporter vào HTTP server ghi log path Cả 4 dòng đúng, kể cả ca hỏng /v1/traces/v1/traces
Khối curl OTLP (Phần 6) Gửi vào Collector đang chạy 200 {"partialSuccess":{}}; sai path → 404; thiếu Content-Type415
ignoreIncomingRequestHook (Phần 5A) Gọi /healthz rồi tìm span Không có span /healthz — hook hoạt động
Cảnh báo cổng 8888 chỉ bind localhost (Phần 7) Scrape từ container khác cùng network Connection refused — đúng như cảnh báo
/sampling của Jaeger cổng 5778 (Phần 8) curl Trả về strategy JSON

Phiên bản dùng khi kiểm: Node.js 24 · TypeScript 7.0.2 · @opentelemetry/api 1.9.1 · sdk-node 0.222.0 · sdk-trace-base / resources / core 2.11.0 · semantic-conventions 1.43.0 · auto-instrumentations-node 0.80.0 · otelcol-contrib 0.160.0 · jaeger 2.20.0 · postgres 17 · redis 7. Ba lỗi được phát hiện và sửa nhờ lần chạy này: import Redis from 'ioredis' không còn dùng được ở ioredis v6 (đã bỏ default export), hai import tương đối thiếu đuôi .js dưới moduleResolution: NodeNext, và một snippet resourceDetectors thiếu object bao ngoài.

Tài liệu này dành cho ai và đọc thế nào

Tài liệu dài; không ai nên đọc tuần tự từ đầu đến cuối trong lần đầu. Chọn một lộ trình rồi bám theo.

Lộ trình Bạn đang ở đâu Thứ tự đọc Đọc xong thì làm được gì
A — Người mới hoàn toàn Chưa từng dùng distributed tracing, chỉ có log rời rạc 1235A8412 Hiểu span/trace/context, dựng được một stack dev bằng Docker Compose và đọc được một trace trên Jaeger
B — Dev cần instrument một service Node ngay Collector và Jaeger đã có sẵn, chỉ cần service của mình lên trace 5A5B91312, quay lại 13 khi trace bị vỡ Khởi tạo SDK đúng thứ tự, đặt tên span và attribute đúng chuẩn, nối được log với trace
C — Vận hành Collector và Jaeger SRE / platform, chịu trách nhiệm pipeline và chi phí 267841013 Dựng pipeline hai tầng, đặt tail sampling và PII redaction đúng chỗ, ước lượng được dung lượng và alert cho chính pipeline
D — Đang đi chữa sự cố Trace trắng, trace vỡ, mất span, hoặc CSKH cần trace của một đơn cụ thể 11 trước tiên — bắt đầu ở ngân sách độ trễ end-to-end rồi mới chạy cây chẩn đoán → 3 nếu trace vỡ → 6 nếu sai endpoint → 7 nếu mất ở Collector → 4 nếu nghi sampling Khoanh vùng được lỗi nằm ở app, ở đường truyền, ở Collector hay ở backend, và có runbook tìm trace theo mã đơn

Hai mục có thể đọc độc lập bất cứ lúc nào: Phần 12 (bảng anti-pattern, dùng làm checklist review code) và Phần 13 (biến môi trường, API, port, glossary Việt–Anh).

Quy ước trong tài liệu

  • Thuật ngữ tiếng Anh giữ nguyên. span, trace, context propagation, sampling, exporter, receiver, processor, baggage, cardinality… không được dịch, vì bản dịch tiếng Việt sẽ không khớp với tên trong code, trong config và trong tài liệu upstream mà bạn phải tra. Bảng đối chiếu Việt–Anh kèm nghĩa ngắn nằm ở Phần 13.
  • Code là TypeScript, chạy trên Node.js 20+ và bản build ESM, trừ khi ghi khác. Chỗ nào cách chạy khác nhau giữa ESM và CommonJS thì cả hai lệnh đều được ghi ra (Phần 5A). Cấu hình Collector và Jaeger là YAML; lệnh shell viết cho Linux/macOS, khác biệt trên Windows ghi riêng ở Phần 8.
  • Nhãn trạng thái được ghi tại chỗ. Mỗi khi một API, một attribute hay một component chưa ổn định, nhãn đi kèm ngay trong câu hoặc trong ô bảng: Stable, Release Candidate, Development, Deprecated cho semantic conventions và spec; alpha / beta / stable cho component Collector; dòng 2.x (stable) so với dòng 0.x (experimental) cho package OpenTelemetry JS. Ba thang này độc lập với nhau — một component Collector Stable vẫn có thể phát ra attribute còn ở mức Development.
  • Các phần trỏ qua nhau bằng số phần, ví dụ "xem Phần 7 §7.11" hoặc "chi tiết ở Phần 4". Không có số trang; dùng Mục lục bên dưới để nhảy.
  • Ví dụ nghiệp vụ dùng chung một hệ e-commerce, đặt tên service theo luồng chuẩn ở Phần 1. Namespace cho attribute nghiệp vụ tự đặt được chốt là shop.* (Phần 9 §9.13); một vài đoạn còn viết hasaki.* hoặc app.* — đó là cùng một namespace, chỉ khác chữ ở tiền tố, khi triển khai phải chọn đúng một giá trị và dùng thống nhất.
  • Giá trị mặc định chỉ được ghi khi spec chốt. Chỗ nào con số phụ thuộc cài đặt cụ thể thì tài liệu nói rõ phải tự kiểm tra bằng lệnh nào.

Mục lục


Phần 1 — Nền tảng: trace, span và data model

Bài toán: log rời rạc không trả lời được "chậm ở đâu"

Ví dụ nền của tài liệu này là một hệ e-commerce với luồng đặt hàng:

api-gateway → order-service → inventory-service → payment-service → Kafka → notification-worker

Đây là bộ tên chuẩn để đối chiếu. Nếu một phần sau đặt tên ngắn hơn cho cùng vai trò (checkout-api, checkout-service, shop-api là service nhận đơn; payment-api là service thanh toán; order-worker, order-email-worker là worker tiêu thụ message), hãy đọc theo vai trò tương ứng trong luồng trên — cấu trúc luồng không đổi.

22:15, cảnh báo nổ: p99 của POST /api/v1/orders tăng từ 400ms lên 2.1s. Bạn có log của cả sáu service, nhưng không gì nối dòng POST /api/v1/orders 200 2148ms ở gateway với dòng tương ứng ở payment-service: mỗi service sinh requestId riêng. Còn lại là grep theo timestamp, đoán, thêm log, deploy lại, chờ sự cố lặp lại.

Metrics và trace trả lời hai câu hỏi khác nhau và không thay thế được nhau:

Câu hỏi Tín hiệu trả lời Vì sao
Có vấn đề không? Từ lúc nào? Ảnh hưởng bao nhiêu phần trăm request? Metrics Số liệu đã aggregate theo thời gian: rẻ, giữ được lâu, alert được
Vấn đề nằm ở đâu trong chuỗi xử lý? Request cụ thể này đã đi qua những gì? Trace Giữ nguyên quan hệ nhân quả giữa các đơn vị công việc của một request
Vì sao đoạn đó lỗi? Payload nào? Logs Chi tiết dạng văn bản, gắn được vào trace qua TraceId

Metrics nói "p99 checkout đang 2.1s". Trace nói "trong 2.1s đó, 1.8s nằm ở span UPDATE hasaki_inventory.stock của inventory-service vì chờ row lock". Metrics đã cộng gộp nên vứt bỏ thứ tự và quan hệ cha-con ngay khi ghi nhận; trace giữ lại đúng cái đó.

Trace và span

Span là một đơn vị công việc có tên, có mốc bắt đầu và mốc kết thúc, và có một parent (trừ span gốc). "Đơn vị công việc" là bất cứ gì đáng đo: xử lý một HTTP request, một câu SQL, một lần gọi Redis, một lần publish message.

Trace là tập hợp tất cả span chia sẻ cùng một TraceId. Không có object "Trace" nào trong process của bạn — trace chỉ hình thành ở backend khi span từ nhiều process được ghép lại theo TraceId, nên thiếu một span thì phần còn lại vẫn xem được.

Trace thường được vẽ thành cây vì mỗi span có đúng một parent. Nhưng về bản chất nó là một DAG: span còn có thể mang Link trỏ tới span thuộc trace khác, tạo ra cạnh nằm ngoài quan hệ cha-con.

flowchart LR
  A["api-gateway<br/>SERVER"] --> B["order-service<br/>SERVER"]
  B --> C["inventory-service<br/>SERVER"]
  B --> D["payment-service<br/>SERVER"]
  B --> P["order-service PRODUCER<br/>send order.created"]
  P -.->|Link| W["notification-worker CONSUMER<br/>process order.created"]

Vòng đời của một span

Bốn giai đoạn, đúng thứ tự này:

  1. start — SDK sinh SpanId, lấy parent từ context hiện hành, chốt StartTimeUnixNano, hỏi sampler (Phần 4). Links nên truyền ngay tại đây.
  2. set attributes / add events — bổ sung dữ liệu trong lúc công việc chạy; span đang mutable.
  3. set status — đánh dấu kết quả.
  4. end — chốt EndTimeUnixNano. Sau đó span bất biến; mọi lời gọi setAttribute tiếp theo bị bỏ qua im lặng.

Ba hệ quả cần khắc cốt:

  • Span chỉ tới exporter sau khi end(). SpanProcessor có hai hook trên vòng đời span: onStart gọi ngay lúc span bắt đầu, nhưng chỉ onEnd mới đẩy span sang exporter. Với Collector và Jaeger, span đang chạy không tồn tại — không có "live span".
  • duration = EndTimeUnixNano − StartTimeUnixNano. Data model không có trường duration riêng; backend tự tính.
  • Span không bao giờ end() là leak: nó giữ tham chiếu trong bộ nhớ và không bao giờ xuất hiện trong backend. Triệu chứng là trace bị cụt — thấy span cha, mất hẳn nhánh con — kèm RSS tăng dần. Nguyên nhân phổ biến nhất: nhánh throw không đi qua finally.

Data model đầy đủ của span

Field Kiểu Ý nghĩa Ai set
TraceId 16 byte / 32 ký tự hex thường Định danh trace. Không được toàn số 0 (giá trị invalid) SDK sinh ở root span; span con kế thừa từ parent
SpanId 8 byte / 16 ký tự hex thường Định danh span, duy nhất trong trace. Không được toàn 0 SDK, mỗi span một lần
TraceFlags 8 bit Bit thấp nhất 0x01 = sampled. Phải kiểm tra từng bit, không so sánh cả số Sampler + SDK (Phần 4)
TraceState Danh sách key-value của vendor, tối đa 32 list-member Dữ liệu vendor đi kèm trace, ví dụ ngưỡng sampling Vendor/SDK ghi; mọi hop khác propagate nguyên vẹn (Phần 3)
SpanContext Bộ 4: TraceId + SpanId + TraceFlags + TraceState Immutable. Đây chính là thứ được propagate qua mạng SDK tạo lúc start
ParentSpanId 8 byte, rỗng nếu là root Span cha trực tiếp SDK lấy từ context hiện hành
Name string Mô tả loại công việc. Phải low cardinality Instrumentation library, hoặc application code
Kind enum 5 giá trị Vai trò của span trong quan hệ gọi (bảng dưới) Instrumentation; mặc định INTERNAL
StartTimeUnixNano uint64 Nanosecond kể từ Unix epoch SDK lúc start (cho phép override)
EndTimeUnixNano uint64 Nanosecond kể từ Unix epoch SDK lúc end
Attributes map string → giá trị nguyên thuỷ Thuộc tính mô tả span Instrumentation + application code
Events list {name, timeUnixNano, attributes} Các mốc thời điểm bên trong span Instrumentation + application code
Links list {SpanContext, attributes} Liên kết sang span/trace khác Nên truyền lúc start; spec cho phép thêm sau bằng AddLink (API tuỳ chọn)
Status {code, message} UNSET / OK / ERROR Application code là chính (quy tắc bên dưới)
Resource tập attribute Entity phát telemetry (process/pod/host) SDK, gắn một lần cho cả process, nằm ngoài span
InstrumentationScope {name, version, schemaUrl, attributes} Thư viện nào tạo ra span này SDK, lấy từ tham số khi xin tracer
DroppedAttributesCount uint32 Số attribute bị bỏ do vượt limit SDK
DroppedEventsCount uint32 Số event bị bỏ do vượt limit SDK
DroppedLinksCount uint32 Số link bị bỏ do vượt limit SDK

SpanContext khác Span thế nào

Đây là chỗ người mới lẫn nhiều nhất.

SpanContext Span
Bản chất Struct dữ liệu thuần, 4 trường Object sống trong process
Tính biến đổi Immutable kể từ khi tạo Mutable cho tới khi end()
Phạm vi Serialize được, đi qua mạng và qua Kafka header Chỉ tồn tại trong process tạo ra nó
Vòng đời Còn giá trị kể cả khi span đã end hoặc process kia đã chết Kết thúc tại end()
Được propagate? — đây là thứ duy nhất được truyền đi Không bao giờ

Khi order-service gọi inventory-service, không có gì của object span được gửi đi — chỉ SpanContext được mã hoá vào header. Cơ chế mã hoá và các header cụ thể thuộc Phần 3.

// @opentelemetry/api: SpanContext chỉ là dữ liệu, không có method nghiệp vụ
import { type SpanContext, TraceFlags } from '@opentelemetry/api';

const ctx: SpanContext = {
  traceId: '4bf92f3577b34da6a3ce929d0e0e4736', // 32 hex
  spanId: '00f067aa0ba902b7',                  // 16 hex
  traceFlags: TraceFlags.SAMPLED,              // = 0x01
  // traceState là tuỳ chọn
};

SpanKind

Kind Khi nào dùng Ai set Trong ví dụ e-commerce
SERVER Xử lý một request đồng bộ đến từ xa HTTP/gRPC server instrumentation order-service nhận POST /orders
CLIENT Gọi ra ngoài đồng bộ và chờ kết quả HTTP client, driver DB, Redis client order-service gọi Postgres, gọi payment-service
PRODUCER Tạo/gửi message bất đồng bộ, không chờ xử lý Kafka producer instrumentation order-service publish order.created
CONSUMER Xử lý message nhận được (push-based) Kafka consumer instrumentation notification-worker xử lý order.created
INTERNAL Công việc trong cùng process, không qua ranh giới nào Application code Hàm tính phí ship, vòng lặp validate giỏ hàng

Mặc định của mọi span là INTERNAL. Kind chỉ đặt được lúc start span và không đổi được sau đó.

Cặp remote parent và service dependency graph

CLIENT ở phía gọi và SERVER ở phía nhận là cùng một lần gọi mạng nhìn từ hai đầu: span SERVERParentSpanId trỏ tới span CLIENT ở process khác — đây là remote parent, và hiệu số duration giữa hai span chính là chi phí mạng cộng thời gian chờ hàng đợi. Cặp PRODUCER/CONSUMER cũng vậy nhưng bất đồng bộ, thường nối bằng Link thay vì parent.

Jaeger, Tempo và connector servicegraph (alpha) của Collector dựng đồ thị phụ thuộc bằng đúng hai thứ: ghép span CLIENT với span SERVER con của nó (hoặc PRODUCER với CONSUMER), rồi lấy service.name ở Resource mỗi bên làm hai đầu cạnh. Không có SpanKind đúng thì không có cạnh.

Hậu quả khi set sai kind:

  • Gọi HTTP ra ngoài mà để INTERNAL → cạnh order-service → payment-service biến mất khỏi service graph, dù trace vẫn hiển thị bình thường.
  • Để SERVER cho hàm nội bộ → xuất hiện service ảo; SPM (tab Monitor của Jaeger, thường dựa trên connector spanmetrics — alpha — với dimension mặc định gồm span.kind) chia sai nhóm, RED metrics (rate, errors, duration — bộ ba dựng từ span, xem Phần 4) của endpoint thật bị pha loãng.
  • Consumer để SERVERtraces_service_graph_unpaired_spans_total tăng, cạnh Kafka không dựng được.

Attributes

Kiểu được phép: string, boolean, int, double, và array đồng nhất của bốn kiểu đó. Không có object lồng nhau, không có map lồng map. Muốn ghi cấu trúc phức tạp thì làm phẳng key (http.request.header.x_request_id), JSON.stringify (chịu chi phí lưu trữ), hoặc chuyển sang log.

Giới hạn mặc định theo spec, cấu hình bằng biến môi trường, áp dụng ở SDK:

Biến môi trường Mặc định Áp dụng cho
OTEL_ATTRIBUTE_COUNT_LIMIT 128 Số attribute, mức chung cho mọi signal
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT 128 Số attribute trên một span
OTEL_SPAN_EVENT_COUNT_LIMIT 128 Số event trên một span
OTEL_SPAN_LINK_COUNT_LIMIT 128 Số link trên một span
OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT 128 Số attribute trên một event
OTEL_LINK_ATTRIBUTE_COUNT_LIMIT 128 Số attribute trên một link
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT không giới hạn Độ dài giá trị chuỗi, mức chung
OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT không giới hạn Độ dài giá trị chuỗi của span attribute

Biến dành riêng cho span thắng biến chung khi cả hai cùng được đặt. Chú ý hai biến độ dài mặc định là không giới hạn, không phải 128 như nhiều người tưởng.

Vượt giới hạn số lượng: phần thừa bị drop và counter tương ứng tăng lên. Vượt giới hạn độ dài: giá trị bị cắt chứ không drop. Ba counter đi kèm span trong OTLP; khi map sang định dạng không phải OTLP, spec quy định ghi chúng thành tag otel.dropped_attributes_count, otel.dropped_events_count, otel.dropped_links_count và chỉ ghi khi giá trị khác 0. Khác 0 nghĩa là instrumentation đang ghi thừa, không phải backend lỗi.

Cảnh báo cardinality: đưa order_id, user_id, session_id vào attribute là bình thường và hữu ích để tìm trace; đưa vào span name là sai vì name phải low cardinality. Với connector spanmetrics, mỗi attribute khai báo làm dimension nhân số time series lên — một attribute high-cardinality đủ làm nổ Prometheus (Phần 7, Phần 10).

Span Event, child span hay log?

Tình huống Dùng gì Lý do
Một mốc thời điểm trong span, không có thời lượng riêng Span Event Event có timestamp, không có duration
Một đoạn công việc đo được start/end và bạn muốn biết nó tốn bao lâu Child span Chỉ span mới có duration
Đoạn công việc vượt ranh giới process/network Child span (CLIENT/SERVER) Cần cho service dependency graph
Exception xảy ra Span Event tên exception Có convention chuẩn, backend hiển thị riêng
Cache hit/miss, retry lần thứ N, đã lấy được lock Span Event Rẻ hơn span, không làm rối cây
Nội dung chi tiết: payload, response body, dump Log (đính TraceId) Không nên nhét khối dữ liệu lớn vào span
Vòng lặp chạy hàng nghìn lần Không ghi gì, hoặc metric Sẽ đụng ngay limit 128 event

Exception là một event chuẩn hoá: tên event đúng là exception, với các attribute exception.type, exception.message, exception.stacktrace (cả ba đều Stable). Attribute exception.escaped đã deprecated — không dùng nữa.

Span Links

Link là tham chiếu từ span hiện tại tới một SpanContext khác, kèm attribute tuỳ chọn. Khác parent ở bốn điểm:

Parent Link
Số lượng Đúng một Nhiều (đến limit 128)
Ảnh hưởng TraceId Span con kế thừa TraceId của parent Không đổi TraceId của span hiện tại; link thường trỏ sang trace khác
Ngữ nghĩa "Được gây ra trực tiếp bởi" "Có liên quan tới", "follows from"
Thời điểm gán Lúc start Nên gán lúc start; link thêm sau bằng AddLink có thể không được sampler xét tới

Use case thực tế:

  • Batch worker: notification-worker poll 100 message Kafka và xử lý chung một lô. Span chỉ có một parent nên không chọn được message nào làm cha — tạo một span xử lý với N link trỏ về trace của từng producer. Vì vậy semconv (semantic conventions — quy ước đặt tên chuẩn của OpenTelemetry, Phần 9) nhóm messaging lấy Link làm cơ chế mặc định nối producer với consumer — quy tắc đầy đủ và ngoại lệ single-message nằm ở Phần 9 §9.6. Lưu ý nhóm semconv messaging hiện vẫn ở mức Development, quy ước còn có thể đổi; code producer/consumer ở Phần 5B.
  • Fan-in: một job tổng hợp kết quả của nhiều request độc lập.
  • Retry chain: lần thử thứ hai link về lần thử thứ nhất để giữ dấu vết nguyên nhân.
  • Quan hệ "follows from": công việc được kích hoạt bởi một trace trước đó nhưng không nằm trong đường phản hồi của nó — ví dụ tính điểm loyalty sau khi đơn hàng đã trả về client.

Hạn chế phải biết trước: nhiều UI hiển thị Link kém hơn parent-child. Jaeger cho link dưới dạng tham chiếu bấm được trong chi tiết span, không nối hai trace thành một biểu đồ liền mạch. Muốn nhìn cả đơn hàng như một hình duy nhất thì phải chấp nhận điều đó, hoặc dùng ngoại lệ single-message của semconv messaging (Phần 9 §9.6).

Status

Code Ý nghĩa Ai được set
UNSET Mặc định: không ai tuyên bố gì. Backend coi là "không lỗi", nhưng đây không phải khẳng định thành công Không ai; đây là giá trị khởi tạo
OK Nhà phát triển khẳng định thao tác thành công Chỉ application code
ERROR Thao tác thất bại Instrumentation library theo semconv, hoặc application code

Thứ tự ưu tiên khi ghi đè là OK > ERROR > UNSET: đã set OK thì mọi thay đổi sau bị bỏ qua, và mọi ý định set ngược về UNSET cũng bị bỏ qua.

Ba quy tắc hay bị vi phạm:

  1. Instrumentation library chỉ set ERROR khi chắc chắn đó là lỗi. Thư viện không biết ngữ cảnh nghiệp vụ, nên khi mơ hồ phải để UNSET.
  2. OK là đặc quyền của application code. Đừng viết wrapper tự động set OK cho mọi span — làm vậy là mất khả năng phân biệt "thành công có kiểm chứng" với "không rõ".
  3. recordException() KHÔNG tự động set status ERROR. Bẫy phổ biến nhất: stacktrace hiện đủ trong Jaeger nhưng span vẫn xanh, mọi alert dựa trên status_code = STATUS_CODE_ERROR (kể cả SPM và traces_service_graph_request_failed_total) đều im lặng. Ghi exception và set status là hai thao tác riêng biệt.

HTTP 404 có phải lỗi không? Tuỳ SpanKind, và semconv HTTP nói rõ: với span SERVER, mã 4xx phải để UNSET — đó là lỗi phía gọi, không phải của server. Với span CLIENT, 4xx nên đặt ERROR, trừ khi ứng dụng coi 404 là một nhánh hợp lệ (kiểm tra tồn tại) và chủ động ghi đè. Mã 5xx thì nên đặt ERROR ở cả hai kind.

Resource

Resource mô tả entity phát ra telemetry — process Node.js nào, chạy trên pod nào, thuộc môi trường nào. Nó được gắn một lần cho cả process lúc khởi tạo SDK và nằm ngoài span: trong OTLP, resource xuất hiện ở cấp resourceSpans, mỗi span không mang bản sao nào của nó.

Attribute Trạng thái Ghi chú
service.name Stable Bắt buộc trên thực tế. Thiếu thì SDK tự điền unknown_service:<tên tiến trình> (với Node là unknown_service:node)
service.version Stable Gắn version build để đối chiếu khi có regression
service.instance.id Stable Bộ ba service.namespace + service.name + service.instance.id phải duy nhất toàn cục
service.namespace Stable Nhóm logic; ví dụ ecom cho hệ e-commerce mẫu. Chọn đúng một giá trị rồi dùng thống nhất cho cả tổ chức
deployment.environment.name Stable Tên cũ deployment.environment đã deprecated
k8s.*, container.id, container.image.name, container.image.tags Stable Do resource detector tự phát hiện
host.*, cloud.*, container.name Development (tên cũ: Experimental) Có thể thay đổi giữa các bản semconv

Nguyên tắc: đừng nhét thông tin cấp process vào span attribute. Ghi service.version hay k8s.pod.name vào từng span là lặp một chuỗi trên hàng triệu span mỗi giờ — tốn băng thông, tốn storage, và mất khả năng routing theo resource ở Collector. Danh sách đầy đủ ở Phần 9; cách khai báo ở Phần 5A.

InstrumentationScope

Mỗi span mang thông tin về thư viện đã tạo ra nó: bộ name (thường là tên package, ví dụ @opentelemetry/instrumentation-http), version, schemaUrl và attribute, trong đó chỉ name là bắt buộc. Giá trị lấy từ tham số bạn truyền khi xin tracer, nên code nghiệp vụ nên khai báo scope riêng (ví dụ hasaki.orders, hasaki.checkout.pricing — đặt theo module/package sinh span, không đặt theo tên service) thay vì dùng chung tên với thư viện. Quy tắc đặt tên và bảng đánh giá các cách đặt nằm ở Phần 5B.

Công dụng: lọc trong UI, và tắt hoặc drop span theo scope ở Collector khi một thư viện quá ồn. Trong OTLP, scope là cấp trung gian giữa resource và span — resourceSpans[].scopeSpans[].spans[], với schemaUrl nằm ở cấp scopeSpans (Phần 6).

Hình dạng thực tế của một trace

Một lần đặt hàng bị chậm, in ở dạng cây text kèm duration:

Trace 4bf92f3577b34da6a3ce929d0e0e4736                                    842ms
└─ api-gateway        SERVER    POST /api/v1/orders                       842ms
   └─ order-service   SERVER    POST /orders                              835ms
      ├─ order-service    CLIENT    SELECT hasaki_order.customers          12ms
      ├─ order-service    INTERNAL  validate-cart                           4ms
      ├─ order-service    CLIENT    POST /inventory/reserve                318ms
      │  └─ inventory-service SERVER  POST /inventory/reserve              311ms
      │     ├─ inventory-service CLIENT  HGET sku:stock                      3ms
      │     └─ inventory-service CLIENT  UPDATE hasaki_inventory.stock     288ms  <-- nút thắt
      ├─ order-service    CLIENT    POST /payments/charge                  472ms
      │  └─ payment-service  SERVER   POST /payments/charge                468ms
      │     └─ payment-service CLIENT  POST /v2/transactions               455ms
      │        └─ event: exception (exception.type=TimeoutError) @ t+420ms
      └─ order-service    PRODUCER  order.created send                       6ms

Trace b7c2e1f4a9d3c0b85e6f2a1d7c4b3e90                                    134ms
└─ notification-worker CONSUMER  order.created process                    134ms
   └─ link → 4bf92f3577b34da6a3ce929d0e0e4736 / 00f067aa0ba902b7 (PRODUCER span)

Đọc được ngay ba điều: inventorypayment chạy tuần tự chứ không song song (318ms + 472ms cộng lại đã xấp xỉ 835ms của span cha); 288ms nằm gọn trong một câu UPDATE; và TimeoutError được ghi nhận ở lần gọi PSP nhưng span cha vẫn trả 200 — code đã retry thành công hoặc đã nuốt lỗi. Notification nằm ở trace riêng, nối bằng Link vì đi qua Kafka.

Tra cứu sâu hơn: TracesTrace semantic conventions trên opentelemetry.io, opentelemetry-specification trên GitHub.


Phần 2 — Kiến trúc OpenTelemetry: API, SDK, Instrumentation

2.1 OpenTracing + OpenCensus hợp nhất thành OpenTelemetry

Trước 2019 có hai dự án cạnh tranh. OpenTracing (CNCF) chỉ định nghĩa API — thu thập và gửi đi do vendor tự cài đặt. OpenCensus (Google) đi hướng ngược lại: có cả SDK lẫn agent, nhưng API gắn chặt với cài đặt. Tháng 5/2019 hai dự án hợp nhất thành OpenTelemetry dưới CNCF; OpenTracing sau đó được archive (CNCF thông báo 31/01/2022), OpenCensus archive kho mã tháng 7/2023. OpenTelemetry lên mức graduated của CNCF ngày 11/05/2026.

Kiến trúc hôm nay là bài học của cuộc hợp nhất: giữ API tách rời SDK (từ OpenTracing), chuẩn hoá wire protocol (OTLP — Phần 6) và semantic conventions (Phần 9) để dữ liệu từ thư viện bất kỳ đọc được ở backend bất kỳ (từ OpenCensus).

Nói rõ ngay: OpenTelemetry không phải backend — không lưu trữ, không UI, không ngôn ngữ truy vấn, không cảnh báo. Nó sinh dữ liệu, gắn ngữ cảnh và đẩy ra khỏi process theo định dạng chuẩn; lưu và hiển thị là việc của Jaeger (Phần 8) hoặc backend thương mại. Tham chiếu: what-is-opentelemetry.

2.2 Bốn lớp của một hệ OpenTelemetry

Lớp Package/binary tiêu biểu (Node.js) Ai cài Vai trò
API @opentelemetry/api Tác giả thư viện ứng dụng cuối Interface + cài đặt no-op: Tracer, Span, Context, propagation. Không export, không I/O, không đọc env var.
SDK @opentelemetry/sdk-trace-base, /sdk-trace-node, /sdk-node Chỉ ứng dụng cuối (process chạy thật) Cài đặt thật của API: TracerProvider, Resource, Sampler, SpanLimits, SpanProcessor.
Instrumentation libraries @opentelemetry/instrumentation-http, -express, -pg, -ioredis…; gói meta @opentelemetry/auto-instrumentations-node Ứng dụng cuối Patch thư viện bên thứ ba để tự sinh span. Chỉ phụ thuộc @opentelemetry/api + /instrumentation, không phụ thuộc SDK.
Exporter + Collector @opentelemetry/exporter-trace-otlp-http / -proto / -grpc; binary otelcol-contrib; Jaeger v2 Ứng dụng (exporter) + đội hạ tầng (Collector) Serialize span sang OTLP, đẩy khỏi process; Collector nhận, xử lý, định tuyến (Phần 7).

Một chi tiết dễ vấp khi đọc package.json: OTel JS phát hành hai dòng version song song. Dòng stable 2.x gồm @opentelemetry/core, /resources, /sdk-trace-base, /sdk-trace-node, /sdk-metrics, /context-async-hooks, /propagator-b3, /propagator-jaeger. Dòng experimental 0.x gồm @opentelemetry/sdk-node, /instrumentation, các exporter OTLP, /api-logs, /sdk-logs — dùng được trong production nhưng API có thể đổi ở minor release. @opentelemetry/api@opentelemetry/auto-instrumentations-node đánh số độc lập với cả hai dòng, nên đừng cố khớp version giữa các package.

Quy tắc phụ thuộc một chiều: thư viện → API; ứng dụng → API + SDK + instrumentation + exporter. Không để shared lib nội bộ khai báo dependencies vào SDK.

2.3 Tách API và SDK: quyết định thiết kế then chốt

Thư viện dùng chung muốn sinh span thì chỉ thêm @opentelemetry/api vào dependencies. Ứng dụng có cài SDK thì span xuất hiện; không cài thì mọi lời gọi tracer.startSpan() rơi vào no-op — gần như miễn phí và không ném lỗi. README của @opentelemetry/api: "The methods in this package perform no operations by default".

Cũng vì thế các package SDK khai báo @opentelemetry/apipeerDependency (range >=1.3.0 <1.10.0 hoặc ^1.3.0) chứ không phải dependencies: ép cả cây phụ thuộc dùng chung đúng một bản API. Chặn trên là cố ý — SDK chỉ bảo đảm hành vi với API version nó biết cách proxy. Bản latest của API tại thời điểm viết là 1.9.1; kiểm tra lại bằng npm view @opentelemetry/api version.

Global registration hoạt động thế nào

@opentelemetry/api giữ một registry singleton trên globalThis. Khi SDK khởi động, nó gọi trace.setGlobalTracerProvider(...), context.setGlobalContextManager(...), propagation.setGlobalPropagator(...) — hoặc provider.register() / sdk.start() gọi hộ. Mỗi lời gọi API sau đó tra registry này và, theo đúng chữ trong README, chỉ proxy sang "if and only if it is a compatible API version"; chưa đăng ký hoặc không tương thích thì trả về no-op.

Bẫy nhiều bản @opentelemetry/api trong node_modules

Đây là lỗi số một khiến "cài xong mà không thấy span nào". Nếu node_modules chứa nhiều bản @opentelemetry/api khác nhau, instrumentation dùng bản A còn SDK đăng ký vào bản B → tracer mà instrumentation lấy được là no-op, span biến mất im lặng, không exception, không log. Trường hợp ồn ào hơn:

Error: @opentelemetry/api: Attempted duplicate registration of API: trace

Kiểm tra và xử lý:

# Phải chỉ ra ĐÚNG MỘT version. Nhiều hơn là dính bẫy.
npm ls @opentelemetry/api
pnpm why @opentelemetry/api
yarn why @opentelemetry/api
{
  "dependencies": { "@opentelemetry/api": "1.9.1" },
  "overrides": { "@opentelemetry/api": "1.9.1" }
}

overrides là của npm; yarn dùng resolutions, pnpm dùng pnpm.overrides. Luôn pin @opentelemetry/apidependencies cấp cao nhất của ứng dụng.

2.4 Pipeline bên trong SDK

TracerProvider là object trung tâm: giữ Resource (mô tả process này là ai), Sampler, SpanLimits và danh sách SpanProcessor. Từ provider lấy ra Tracer; tracer tạo Span; span khi kết thúc chảy qua chuỗi processor rồi tới exporter.

flowchart LR
  A["Code ứng dụng + instrumentation library"] -->|"@opentelemetry/api"| T["Tracer từ TracerProvider"]
  T -->|"startSpan()"| S["Sampler: DROP / RECORD_ONLY / RECORD_AND_SAMPLE"]
  S --> SP["Span: gắn Resource, SpanLimits, SpanContext"]
  SP -->|"onStart và onEnd"| P["Chuỗi SpanProcessor theo thứ tự mảng spanProcessors"]
  P --> BSP["BatchSpanProcessor: queue và gom lô"]
  BSP --> E["SpanExporter: OTLP http/protobuf hoặc gRPC"]
  E -->|"OTLP cổng 4318 hoặc 4317"| C["OpenTelemetry Collector"]
  C -->|"OTLP cổng 4317"| J["Jaeger v2, UI cổng 16686"]

Sampler được gọi ngay trong startSpan, trước khi span thành hình — tức là trước cả onStart. Quyết định DROP nghĩa là span không bao giờ tới processor, nên đây là cần gạt rẻ nhất để giảm chi phí. Chi tiết thuật toán, head-based vs tail-based: xem Phần 4.

Resource gắn ở mức provider, không phải mức span — mọi span từ provider đó dùng chung một Resource. SpanLimits cắt bớt attribute/event/link vượt ngưỡng; mặc định theo spec là 128 cho mỗi loại đếm, còn độ dài giá trị attribute mặc định là không giới hạn.

2.5 SpanProcessor: nơi span đi qua trước khi rời process

SimpleSpanProcessor BatchSpanProcessor
Thời điểm export Ngay tại onEnd, từng span một Đẩy vào queue, export theo lô
Số request mạng 1 / span 1 / tối đa maxExportBatchSize span
Ảnh hưởng tới app Đồng bộ, dồn tải thẳng lên đường request Chạy nền theo timer, không chặn request
Khi backend chậm/chết Ứ đọng, kéo theo latency ứng dụng Queue đầy thì DROP span mới, app vẫn chạy
Dùng khi Debug/test cục bộ, kèm ConsoleSpanExporter Mọi thứ chạy production

DROP khi queue đầy là im lặng theo thiết kế: không throw, không làm hỏng request. Nghi ngờ mất span thì xem Phần 11.

Tham số của BatchSpanProcessor

Tham số trong code Env var Mặc định theo spec Ý nghĩa
maxQueueSize OTEL_BSP_MAX_QUEUE_SIZE 2048 Span chờ tối đa trong bộ nhớ; vượt là DROP span mới
maxExportBatchSize OTEL_BSP_MAX_EXPORT_BATCH_SIZE 512 Kích thước một lô; MUST ≤ maxQueueSize
scheduledDelayMillis OTEL_BSP_SCHEDULE_DELAY 5000 ms Chu kỳ giữa hai lần export
exportTimeoutMillis OTEL_BSP_EXPORT_TIMEOUT 30000 ms Huỷ lượt export quá hạn

Cảnh báo về package: bên cạnh sdk-trace-base/sdk-trace-node/sdk-trace-web đã có package hợp nhất @opentelemetry/sdk-trace, upstream tuyên bố là bản thay thế cho cả ba ở dòng SDK 3.0; hiện cả bốn vẫn phát hành song song trên 2.x và chưa package nào bị đánh dấu deprecated trên npm. README của sdk-trace nói rõ nó là sdk-trace-base với toàn bộ phần đọc env var bị gỡ bỏ — cả bốn biến OTEL_BSP_* ở trên lẫn nhóm OTEL_SPAN_* limit, OTEL_TRACES_SAMPLEROTEL_TRACES_SAMPLER_ARG. Muốn cấu hình bằng env var thì dùng @opentelemetry/sdk-node, hoặc truyền tham số thẳng vào constructor.

Gắn nhiều SpanProcessor cùng lúc

Được, và rất hay dùng: một processor tuỳ biến để bổ sung attribute, một BatchSpanProcessor để export. Chúng được gọi lần lượt theo thứ tự trong mảng spanProcessors, cho cả onStart lẫn onEnd, và cùng nhận một object span — nên processor đứng trước sửa được span cho processor đứng sau và cho exporter.

Trong SDK 2.x, BasicTracerProvider#addSpanProcessor(...) đã bị xoá; bắt buộc truyền qua option spanProcessors (số nhiều) của constructor NodeTracerProvider / BasicTracerProvider / WebTracerProvider. Trên NodeSDK cũng vậy: option spanProcessor số ít đã deprecated, dùng spanProcessors: [...].

Viết SpanProcessor riêng

Use case thực tế: gắn attribute chung cho mọi span (deploy.env, mã build, tên cụm); copy vài key từ baggage sang attribute (baggage không tự động thành attribute — Phần 3); lọc span rác như health-check; đẩy trace_id/span_id vào context của logger ngay tại onStart (pino/winston đã có sẵn @opentelemetry/instrumentation-pino-winston).

Interface SpanProcessor có bốn method bắt buộc — onStart, onEnd, forceFlush, shutdown — cộng một onEnding? tuỳ chọn còn được đánh dấu experimental:

// src/telemetry/hasaki-span-processor.ts
import { propagation, type Context } from '@opentelemetry/api';
import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';

export class HasakiSpanProcessor implements SpanProcessor {
  constructor(
    private readonly next: SpanProcessor, // thường là BatchSpanProcessor
    private readonly deployEnv: string,
  ) {}

  onStart(span: Span, parentContext: Context): void {
    // Attribute chung cho mọi span sinh ra trong process này
    span.setAttribute('hasaki.deploy.env', this.deployEnv);

    // Copy đúng key baggage cần dùng; copy tất cả sẽ đội cardinality
    const tenant = propagation.getBaggage(parentContext)?.getEntry('tenant.id')?.value;
    if (tenant) span.setAttribute('hasaki.tenant.id', tenant);

    this.next.onStart(span, parentContext);
  }

  onEnd(span: ReadableSpan): void {
    // Không forward xuống processor sau => span này không bao giờ được export
    if (span.attributes['url.path'] === '/healthz') return;
    this.next.onEnd(span);
  }

  forceFlush(): Promise<void> {
    return this.next.forceFlush();
  }

  shutdown(): Promise<void> {
    return this.next.shutdown();
  }
}

Ba lưu ý:

  • Đây là processor bọc ngoài: chỉ đăng ký nó vào spanProcessors, đừng đăng ký kèm BatchSpanProcessor bên trong — đăng ký cả hai thì provider gọi thẳng vào batch processor và span bị lọc ở onEnd vẫn được export.
  • Lọc ở onEnd chỉ tiết kiệm băng thông vì span đã được tạo và đo rồi; muốn tiết kiệm CPU thì chặn ở sampler (Phần 4), muốn lọc tập trung nhiều service thì dùng processor filter của Collector (Phần 7).
  • forceFlushshutdown phải thực sự uỷ quyền xuống processor kế tiếp, không thì lúc tắt process sẽ mất lô span cuối. Trước khi tự viết, xem opentelemetry-js-contrib đã có sẵn chưa.

2.6 Context và Context Manager

Context là một map key-value immutable: mọi thao tác "thêm" đều trả về Context mới, bản cũ không đổi. Span hiện hành, baggage và các giá trị propagation đều nằm trong đó.

ContextManager trả lời câu hỏi "context đang active là cái nào". Trong Node, cài đặt mặc định là AsyncLocalStorageContextManager từ @opentelemetry/context-async-hooks. Package này còn export AsyncHooksContextManager (bản cũ dựa trên async_hooks), nhưng nó đã deprecated và sẽ bị gỡ ở SDK 3.0 — README khuyến nghị AsyncLocalStorage vì đơn giản hơn, nhanh hơn và tránh một lỗ hổng DoS đã biết. NodeTracerProvider.register()NodeSDK.start() tự đăng ký context manager; dùng BasicTracerProvider trần thì phải tự gọi context.setGlobalContextManager(...), không thì mọi span đều thành root.

Context là cơ chế duy nhất để SDK biết span cha là ai trong cùng một process. Không có tham số ẩn nào khác: tracer.startSpan() đọc context.active(), thấy span thì lấy làm parent, không thấy thì mở một trace mới.

import { context, trace } from '@opentelemetry/api';

const tracer = trace.getTracer('checkout');

// Hai hàm nghiệp vụ, để rỗng cho gọn
async function authorize(): Promise<void> { /* gọi cổng thanh toán */ }
async function reserve(): Promise<void> { /* trừ tồn kho */ }

export async function submitCheckout(): Promise<void> {
  const parent = tracer.startSpan('checkout.submit');
  // Gắn span vào một Context MỚI; Context cũ không hề thay đổi
  const ctx = trace.setSpan(context.active(), parent);

  try {
    await context.with(ctx, async () => {
      // Trong phạm vi callback này, context.active() chính là ctx
      const child = tracer.startSpan('payment.authorize'); // parent lấy từ context.active()
      try {
        await authorize();
      } finally {
        child.end();
      }

      // Bản rút gọn tương đương: tự tạo span, tự set active, tự khôi phục context cũ
      await tracer.startActiveSpan('inventory.reserve', async (span) => {
        try {
          await reserve();
        } finally {
          span.end();
        }
      });
    });
  } finally {
    // end() PHẢI nằm trong finally, không thì span rò rỉ khi có exception
    parent.end();
  }
}

Mất context = trace vỡ

Nếu context active bị mất giữa chừng, span tiếp theo không tìm thấy parent và trở thành root của một trace mới. Hậu quả không phải "thiếu một span" mà là trace bị cắt thành nhiều mảnh rời rạc, mỗi mảnh một trace_id, và trên Jaeger không bao giờ ghép lại được.

Nguyên nhân hay gặp trong Node: callback đưa vào hàng đợi thủ công, EventEmitter đăng ký ngoài context, connection pool giữ callback từ trước, setInterval tạo trước khi có context, thư viện promise tự chế không đi qua async hooks, và worker_threads (mỗi worker có AsyncLocalStorage riêng — phải propagate qua message như giữa hai process, xem Phần 3). Cách vá tại chỗ:

import { context, trace } from '@opentelemetry/api';
import type { EventEmitter } from 'node:events';

const tracer = trace.getTracer('checkout');

export function subscribe(emitter: EventEmitter): void {
  // Đóng băng context hiện tại vào callback TRƯỚC khi đưa nó ra ngoài phạm vi
  emitter.on('order.paid', context.bind(context.active(), () => {
    tracer.startSpan('order.fulfill').end();
  }));
}

2.7 Trạng thái stability của từng signal

Signal Specification OpenTelemetry JS
Tracing API Stable, SDK Stable API Stable, SDK Stable
Metrics API Stable, SDK Mixed API Stable, SDK Stable
Logs API Stable, SDK Stable Development
Profiling Alpha Chưa có trong SDK lõi — tra trang status
Baggage API Stable

Hai cột lệch nhau là bình thường: spec ổn định trước, từng ngôn ngữ bắt kịp sau. Với hệ Node.js mới bắt đầu, chỉ nên dựa vào Tracing (và Metrics nếu cần); Logs qua OTel trong JS còn ở mức Development nên chưa đặt SLA lên nó.

Bảng trên chụp tại thời điểm viết, phải kiểm tra lại trước khi ra quyết định kiến trúc: opentelemetry.io/status, trang OpenTelemetry JavaScript, README của repo opentelemetry-js.

2.8 Distribution (distro)

Distro là bản đóng gói lại của upstream OpenTelemetry, thêm cấu hình mặc định, resource detector, exporter hoặc component sẵn có cho một nền tảng cụ thể. Không phải fork: API vẫn là API OTel, dữ liệu vẫn là OTLP.

Ví dụ có thật:

  • Collector có 5 distribution chính thức kèm binary: otelcol (core), otelcol-contrib, otelcol-k8s, otelcol-otlp, otelcol-ebpf-profiler. Chọn sai distro là nguyên nhân phổ biến của lỗi "unknown type" lúc khởi động — ví dụ processor tail_sampling chỉ có trong contrib và k8s, không có trong core (Phần 7).
  • Jaeger v2 chính là một distro của OpenTelemetry Collector — docs Jaeger ghi nguyên văn: "Jaeger is a customized distribution of the OpenTelemetry Collector with different roles" (Phần 8).
  • Phía Node, @opentelemetry/auto-instrumentations-node cộng entry point /register là distro zero-code: cài một package là có hàng chục instrumentation và bộ resource detector.
  • Các nhà cung cấp APM đều có distro riêng, cài sẵn endpoint, header xác thực và resource detector của họ.

Đánh đổi:

Ưu Nhược
Setup nhanh, ít code bootstrap Khoá vào vendor: đổi backend phải làm lại lớp khởi tạo
Đã kiểm thử sẵn với backend của vendor Bắt kịp upstream chậm hơn, vá bug phụ thuộc lịch phát hành của vendor
Có component hoặc tính năng không có ở upstream Khó debug: hành vi lệch upstream, tài liệu OTel không áp dụng nguyên vẹn

Docs OpenTelemetry ghi rõ dự án không validate và không endorse các distro bên thứ ba. Semantic conventions có sẵn hai resource attribute Stable là telemetry.distro.nametelemetry.distro.version để đánh dấu process đang chạy distro nào — điền chúng thì lúc điều tra sự cố mới phân biệt được (Phần 9).

Với bối cảnh "Node.js/TypeScript + Jaeger", khuyến nghị mặc định là upstream thuần: @opentelemetry/sdk-node hoặc NodeTracerProvider cộng exporter OTLP, đẩy sang Collector rồi sang Jaeger — giữ toàn quyền đổi backend mà không phải viết lại lớp khởi tạo.


Phần 3 — Context propagation: traceparent, tracestate, baggage

Vấn đề: SpanContext không tự đi qua biên process

Trong một process, active context được giữ bằng AsyncLocalStorage (@opentelemetry/context-async-hooks). Khi request rời process, vùng nhớ đó không đi theo — thứ duy nhất đi được là bytes trên dây. Vì vậy SpanContext (trace-id, span-id, trace-flags, tracestate) phải được serialize vào carrier ở bên gọi (inject) và deserialize ở bên nhận (extract). Carrier là bất kỳ object key-value nào: HTTP header, gRPC metadata, Kafka message header, hay một field trong job payload.

sequenceDiagram
    participant A as checkout-api
    participant H as carrier HTTP headers
    participant B as payment-api
    A->>A: startActiveSpan POST /orders
    A->>H: propagation.inject(context.active(), carrier)
    H->>B: traceparent / tracestate / baggage
    B->>B: propagation.extract(ctx, headers)
    B->>B: startActiveSpan trong parentCtx, cùng trace-id
import { context, propagation, trace, SpanKind } from '@opentelemetry/api';

declare function sendToWorker(carrier: Record<string, string>, payload: unknown): Promise<void>;
declare function doWork(payload: unknown): Promise<void>;

// --- Bên gọi: inject context hiện hành vào carrier tự tạo ---
const callerTracer = trace.getTracer('checkout-api');

export async function createOrder(payload: unknown) {
  // inject CHỈ ghi header khi context đang có span hợp lệ. Gọi ở top-level module
  // (không span nào active) thì carrier rỗng — lý do quen thuộc khiến người mới
  // tưởng propagation hỏng.
  return callerTracer.startActiveSpan('POST /orders', async span => {
    try {
      const carrier: Record<string, string> = {};
      propagation.inject(context.active(), carrier);
      // carrier => { traceparent: '00-<32 hex>-<16 hex>-01', baggage: 'tenant.id=hasaki-vn' }
      await sendToWorker(carrier, payload);
    } finally {
      span.end();
    }
  });
}

// --- Bên nhận: extract rồi mở span con trong context đó ---
const tracer = trace.getTracer('order-worker');

export async function handle(incoming: Record<string, string>, payload: unknown) {
  const parentCtx = propagation.extract(context.active(), incoming);
  return tracer.startActiveSpan(
    'process order',
    { kind: SpanKind.CONSUMER },
    parentCtx, // parent là remote span context vừa extract
    async span => {
      try {
        await doWork(payload);
      } finally {
        span.end();
      }
    }
  );
}

Khi carrier không phải object string phẳng (header Kafka là Buffer), truyền thêm TextMapGetter / TextMapSetter cho extract / inject (ví dụ đầy đủ ở Phần 5B). Nếu extract không thấy header hợp lệ, nó trả về context không có remote span context — span tạo ra thành root span của một trace mới, đúng biểu hiện của "trace bị vỡ".

Header traceparent (W3C Trace Context)

Format: version "-" trace-id "-" parent-id "-" trace-flags, toàn bộ hex chữ thường, tổng 55 ký tự ở version 00.

Field Độ dài Ý nghĩa Giá trị không hợp lệ
version 2 hex Phiên bản format; spec định nghĩa version 00 ff là invalid; prefix không phải 2 hex + dấu - thì restart trace
trace-id 32 hex (16 byte) ID của cả trace, SHOULD unique toàn cục Toàn số 0 hoặc ký tự không hợp lệ: MUST ignore cả header
parent-id 16 hex (8 byte) Span-id của span đang active ở bên gọi Toàn số 0 hoặc hex không phải chữ thường: MUST ignore cả header
trace-flags 2 hex (bit-field 8 bit) Cờ; bit 0 (0x01) = sampled, bit 1 (0x02) = random-trace-id Không có giá trị cấm; bit chưa định nghĩa phải bỏ qua
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ^^ ^------------------------------- ^--------------- ^^
             |  trace-id                         parent-id        trace-flags
             version=00                          (span cha)       01 = sampled

4bf92f3577b34da6a3ce929d0e0e4736 chính là chuỗi dán vào ô search của Jaeger UI; 00f067aa0ba902b7 là span đang active ở service gọi, span mới ở service nhận lấy nó làm parent; 01 nghĩa là bit sampled bật — quyết định sampling là nội dung Phần 4, ở đây chỉ cần biết bit này tồn tại và được truyền đi.

Tình huống version Hành vi bắt buộc
00 Parse đúng theo format 55 ký tự
Cao hơn (01, cd…) SHOULD cố parse: header ngắn hơn 55 ký tự thì bỏ và restart trace; ngược lại đọc 32 hex trace-id, 16 hex parent-id, 2 hex flags; field lạ phía sau MUST NOT được suy diễn
Không parse được Restart trace (sinh trace-id mới)
Pass-through service (proxy) Không phân tích version, forward nguyên vẹn

Khi tự viết code đọc trace-flags, không so sánh cả số (flags === '01') mà phải test từng bit: cả 0103 đều là sampled, cả 0203 đều có random bit.

Header tracestate

tracestate là danh sách key=value cách nhau dấu phẩy, mang thông tin vendor-specific đi kèm traceparent.

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate: ot=th:c,congo=t61rcWkgMzE,rojo=00f067aa0ba902b7
Ràng buộc Giá trị
Số list-member tối đa 32; nếu thêm entry làm vượt 32 thì bỏ entry ngoài cùng bên phải
Độ dài key tối đa 256 ký tự (bắt đầu bằng chữ thường hoặc số; cho phép a-z, 0-9, _ - * /@ cho dạng multi-tenant)
Độ dài value tối đa 256 ký tự ASCII in được (0x200x7E), trừ ,=, không kết thúc bằng khoảng trắng
Tổng độ dài vendor SHOULD propagate ít nhất 512 ký tự của header hợp nhất (mức tối thiểu, không phải hard limit)
Khi phải cắt MUST cắt nguyên entry; ưu tiên bỏ entry dài hơn 128 ký tự trước, rồi bỏ dần từ cuối danh sách

Vendor nào ghi lại traceparent thì thêm/cập nhật entry của mình vào đầu danh sách (trái nhất), giữ nguyên thứ tự các entry còn lại — vị trí trái nhất cho hop sau biết hệ thống nào vừa ghi traceparent.

Điểm phải nhớ: service trung gian không sửa traceparent thì MUST forward tracestate nguyên vẹn. Đây là nơi OpenTelemetry mang thông tin sampling nhất quán: toàn bộ giá trị của OTel gom vào một entry duy nhất key ot, các cặp key:value cách nhau bằng ;, cả chuỗi ot MUST NOT vượt 256 ký tự — ví dụ ot=th:c (rejection threshold, tương ứng 25%) hay ot=rv:6e6d1a75832a2f. Một gateway strip tracestate sẽ phá cơ chế này ở toàn bộ downstream (Phần 4). tracestate chỉ dành cho thuộc tính do tracing system định nghĩa; dữ liệu tầng ứng dụng phải đi qua baggage.

Trace Context Level 1 và Level 2

Bản Trạng thái Nội dung liên quan
Level 1 W3C Recommendation traceparent version 00, tracestate, sampled flag
Level 2 W3C Candidate Recommendation Thêm random-trace-id flag (bit 0x02); vẫn dùng version 00, không đổi format

Khi random-trace-id flag được set: ít nhất 7 byte phải nhất của trace-id MUST được sinh ngẫu nhiên, phân bố đều trên [0..2^56-1]. Khi tiếp tục cùng một trace-id, giá trị flag ở header vào MUST được giữ nguyên ở header ra. Vì Level 2 chưa lên Recommendation, mức độ hỗ trợ khác nhau giữa các SDK và backend — kiểm tra release notes trước khi dựa vào bit này, và tuyệt đối không so sánh nguyên byte trace-flags.

Baggage

baggage là header riêng, chứa các cặp key-value do ứng dụng đặt, đi theo request qua mọi hop. Format: danh sách key=value cách nhau dấu phẩy, mỗi member có thể mang metadata sau dấu chấm phẩy.

baggage: tenant.id=hasaki-vn,checkout.experiment=b2;ttl=300,user.region=hcm

Value nằm ngoài tập baggage-octet phải percent-encode (serverNode=DF%2028). Giới hạn bắt buộc: platform MUST propagate hết khi baggage-string có ≤ 64 list-member VÀ ≤ 8192 bytes (ABNF cho phép tới 180 member). Đừng nhầm với con số 32 entry / 512 ký tự của tracestate. Baggage API trong spec OpenTelemetry ở trạng thái Stable; bản W3C Baggage vẫn là Candidate Recommendation.

import { context, propagation } from '@opentelemetry/api';

declare function callInventoryService(): Promise<void>;

// Đặt baggage ở edge service; mọi outbound call sau đó mang theo
const ctxWithBaggage = propagation.setBaggage(
  context.active(),
  propagation.createBaggage({ 'tenant.id': { value: 'hasaki-vn' } })
);

await context.with(ctxWithBaggage, async () => {
  await callInventoryService(); // instrumentation HTTP tự inject header baggage
});

Baggage không tự trở thành span attribute

Đây là hiểu nhầm phổ biến nhất. Baggage chỉ nằm trong context và trên header; nó không xuất hiện trong span trên Jaeger trừ khi bạn tự copy sang attribute. Hai cách, cả hai đều ở phía SDK:

  1. Dùng package contrib @opentelemetry/baggage-span-processor (class BaggageSpanProcessor), nhận một key predicate — hằng ALLOW_ALL_BAGGAGE_KEYS hoặc hàm (key: string) => boolean. Package thuộc repo opentelemetry-js-contrib nên đánh số version độc lập với core.
  2. Tự viết SpanProcessor đọc baggage ở onStart, khi cần whitelist, đổi tên key hoặc chuẩn hoá giá trị.

Collector không thay thế được hai cách trên: nó chỉ nhận span đã hoàn thành qua OTLP, không hề thấy header baggage của request gốc. Nó chỉ xử lý tiếp attribute mà SDK đã copy — ví dụ đổi tên key bằng OTTL (Phần 7).

import { propagation, type Context } from '@opentelemetry/api';
import type { Span, ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base';

const ALLOWED_BAGGAGE_KEYS = new Set(['tenant.id', 'checkout.experiment', 'request.priority']);

export class BaggageAttributeSpanProcessor implements SpanProcessor {
  onStart(span: Span, parentContext: Context): void {
    const bag = propagation.getBaggage(parentContext);
    if (!bag) return;
    for (const [key, entry] of bag.getAllEntries()) {
      // whitelist: không đổ nguyên baggage vào span (cardinality + PII)
      if (ALLOWED_BAGGAGE_KEYS.has(key)) span.setAttribute(key, entry.value);
    }
  }
  onEnd(_span: ReadableSpan): void {}
  async forceFlush(): Promise<void> {}
  async shutdown(): Promise<void> {}
}

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í