OpenTelemetry Tracing — Toàn tập: Node.js/TypeScript, Collector và Jaeger phần 2
Rủi ro và best practice
| Rủi ro | Hệ quả cụ thể |
|---|---|
| Header phình ở mọi hop | Mỗi request nội bộ cõng thêm vài trăm byte; chain 8 service làm tăng cả latency lẫn bandwidth |
| Rò rỉ PII | user.email, phone trong baggage sẽ đi ra cả third-party API nếu outbound call cũng được instrument |
| Không có access control | Bất kỳ service nào trong chain cũng đọc/sửa/thêm được; không chữ ký, không ACL |
| Vector tấn công | Client ngoài Internet tự set header baggage khổng lồ hoặc chứa payload độc; dùng baggage để routing hay feature-flag tức là tin vào input không tin cậy |
Best practice: whitelist key ở cả nơi set lẫn nơi đọc; giới hạn số entry và độ dài value; strip baggage ở egress gateway trước khi gọi ra ngoài, và strip ở ingress nếu client không tin cậy. Use case đúng: tenant id, experiment/feature-flag id, region, request priority.
Bảng propagator trong OpenTelemetry JS
| Propagator | Package | Header sử dụng | Khi nào dùng |
|---|---|---|---|
W3CTraceContextPropagator |
@opentelemetry/core |
traceparent, tracestate |
Mặc định, luôn bật |
W3CBaggagePropagator |
@opentelemetry/core |
baggage |
Khi cần baggage (đã nằm trong bộ mặc định) |
CompositePropagator |
@opentelemetry/core |
gộp các propagator con | Nhận nhiều format cùng lúc |
B3Propagator (single) |
@opentelemetry/propagator-b3 |
b3: {TraceId}-{SpanId}-{SamplingState}-{ParentSpanId} |
Hệ thống cũ dùng Zipkin/Istio |
B3Propagator + B3InjectEncoding.MULTI_HEADER |
@opentelemetry/propagator-b3 |
X-B3-TraceId, X-B3-SpanId, X-B3-ParentSpanId, X-B3-Sampled |
Khi downstream chỉ hiểu multi-header |
JaegerPropagator — deprecated |
@opentelemetry/propagator-jaeger |
uber-trace-id: {trace-id}:{span-id}:{parent-span-id}:{flags} |
Chỉ để đọc traffic từ Jaeger client cũ |
AWSXRayPropagator |
@opentelemetry/propagator-aws-xray (contrib) |
X-Amzn-Trace-Id |
Chain đi qua API Gateway / ALB / Lambda của AWS |
B3Propagator luôn extract được cả single lẫn multi header, mặc định inject single header; riêng field {ParentSpanId} của b3 không được package JS inject. README của @opentelemetry/propagator-jaeger ghi rõ propagator này đã bị spec deprecate và sẽ bị gỡ ở một major release sau — thay bằng W3CTraceContextPropagator.
Giá trị OTEL_PROPAGATORS |
Ghi chú |
|---|---|
tracecontext,baggage |
Mặc định nếu không set |
b3 / b3multi |
B3 single header / B3 multi header |
jaeger |
Deprecated theo spec; SDK log cảnh báo khi dùng |
xray |
Third party (AWS X-Ray) |
ottrace |
Third party (OT Trace), deprecated theo spec |
none |
Tắt hoàn toàn propagation |
Danh sách cách nhau dấu phẩy, được dedupe và áp dụng theo thứ tự khai báo; giá trị lạ bị bỏ qua kèm cảnh báo trong log. Hai lưu ý riêng cho JS: bộ giải env dựng sẵn trong @opentelemetry/sdk-node chỉ biết tracecontext, baggage, b3, b3multi, jaeger — muốn xray hay ottrace chạy qua env thì cần package contrib @opentelemetry/auto-configuration-propagators; và từ SDK 2.x chỉ NodeSDK đọc OTEL_PROPAGATORS, BasicTracerProvider / NodeTracerProvider thì không.
Cấu hình CompositePropagator
// instrumentation.ts — chỉ phần propagator; bootstrap SDK đầy đủ xem Phần 5A
import {
CompositePropagator,
W3CTraceContextPropagator,
W3CBaggagePropagator,
} from '@opentelemetry/core';
import { B3Propagator } from '@opentelemetry/propagator-b3';
export const propagator = new CompositePropagator({
propagators: [
new B3Propagator(), // đọc traffic legacy còn dùng b3
new W3CBaggagePropagator(),
new W3CTraceContextPropagator(), // đặt cuối: format tin cậy nhất thắng khi trùng
],
});
// new NodeSDK({ textMapPropagator: propagator, ... })
CompositePropagator chạy các propagator con theo đúng thứ tự trong mảng cho cả inject lẫn extract, nên khi request mang đồng thời b3 và traceparent, propagator đứng sau ghi đè kết quả của propagator đứng trước.
Chiến lược migration khi đang dùng B3 hoặc Jaeger format
Nguyên tắc: nhận nhiều format, gửi một format.
| Giai đoạn | Cấu hình | Rủi ro cần theo dõi |
|---|---|---|
| 1. Chuẩn bị | Mọi service extract được cả b3/uber-trace-id và traceparent; vẫn inject format cũ |
Không có |
| 2. Chuyển inject | Bật inject tracecontext (tạm thời inject cả hai bằng composite) từ service ở rìa vào trong |
Header phình; downstream chưa update vẫn hiểu format cũ |
| 3. Gỡ format cũ | Bỏ B3Propagator / JaegerPropagator, giữ tracecontext,baggage |
Service nào chưa nâng cấp sẽ làm trace vỡ tại đúng hop đó |
Trong giai đoạn chuyển tiếp, trace vỡ thành hai trace-id ở bất kỳ hop nào chỉ hiểu format mà upstream không còn gửi. Dấu hiệu trên Jaeger: số span mỗi trace giảm đột ngột, xuất hiện nhiều trace có root là service tầng giữa. Đừng sang giai đoạn 3 trước khi rà hết service, kể cả job và worker.
Propagation qua từng transport
HTTP server và client
Tự động nhờ instrumentation HTTP/fetch/undici: server extract từ request header, client inject vào outgoing header, không cần viết code. Điều kiện: thư viện HTTP đang dùng phải nằm trong danh sách instrumentation được bật (xem Phần 5B).
gRPC
@opentelemetry/instrumentation-grpc inject/extract qua metadata của call chứ không phải HTTP header thô. Interceptor tự viết mà tạo object Metadata mới thay vì clone metadata gốc sẽ làm mất context.
Kafka
Inject vào message header ở producer, consumer extract từ đúng message đó. Theo semantic conventions messaging (toàn nhóm messaging vẫn ở mức Development), span gửi là PRODUCER, span Process là CONSUMER. Nối producer với consumer: cơ chế mặc định là Link; ngoại lệ duy nhất được phép lấy message creation context làm parent là khi chỉ có MỘT message. Consume theo batch (eachBatch) thì bắt buộc dùng Link vì một span chỉ có đúng một parent. Ví dụ TextMapGetter cho header dạng Buffer ở Phần 5B; quy tắc đặt tên span ở Phần 9.
BullMQ / Redis queue
Không có auto-instrumentation lo phần propagation — phải tự nhét traceparent vào job data.
// producer.ts
import { Queue } from 'bullmq';
import { context, propagation } from '@opentelemetry/api';
const queue = new Queue('order-email', { connection: { host: 'redis', port: 6379 } });
export async function enqueueOrderEmail(orderId: string) {
const otel: Record<string, string> = {};
propagation.inject(context.active(), otel); // traceparent + tracestate + baggage
await queue.add('send', { orderId, otel });
}
// worker.ts
import { Worker } from 'bullmq';
import { context, propagation, trace, SpanKind } from '@opentelemetry/api';
declare function sendEmail(orderId: string): Promise<void>;
const tracer = trace.getTracer('order-email-worker');
new Worker(
'order-email',
async job => {
const parentCtx = propagation.extract(context.active(), job.data.otel ?? {});
return tracer.startActiveSpan(
'process order-email',
{ kind: SpanKind.CONSUMER },
parentCtx,
async span => {
try {
await sendEmail(job.data.orderId);
} finally {
span.end();
}
}
);
},
{ connection: { host: 'redis', port: 6379 } }
);
Job nằm trong queue hàng giờ vẫn nối đúng trace, nhưng trace đó rất dài trên timeline — cân nhắc dùng Link thay parent khi độ trễ từ enqueue tới process lớn.
Cron / scheduled job
Không có caller, nên tạo root span mới, đừng cố tìm parent. Nếu job được kích hoạt bởi một request (admin bấm "chạy lại"), dùng Link trỏ về span trigger thay vì parent, để một trace hỏng không kéo theo cả job.
import { trace, SpanKind } from '@opentelemetry/api';
declare function reconcile(): Promise<void>;
const tracer = trace.getTracer('cron');
export async function runReconcile() {
// root: true => bỏ qua mọi context đang active, luôn bắt đầu trace mới
const opts = { root: true, kind: SpanKind.INTERNAL };
return tracer.startActiveSpan('reconcile inventory', opts, async span => {
try {
await reconcile();
} finally {
span.end();
}
});
}
Webhook / callback của đối tác
Callback từ cổng thanh toán đến từ hạ tầng bên ngoài nên không mang traceparent của bạn. Chiều đi ra: gửi traceparent sang đối tác thì vô hại, nhưng phải delete headers.baggage ở egress. Chiều đi vào: lưu traceparent của giao dịch vào DB, khi callback tới thì extract từ chuỗi đã lưu và gắn bằng Link, không phải parent — độ trễ có thể hàng phút, trace gốc đã export xong từ lâu. Đặt root: true cho span callback để không nhận traceparent do bên ngoài gửi làm parent; đó là input không tin cậy, một bên cố định traceparent rồi bắn hàng nghìn callback sẽ dồn hết span vào một trace-id.
Browser tới backend
Trình duyệt gửi thêm header lạ sẽ sinh preflight. Backend phải trả Access-Control-Allow-Headers: traceparent, tracestate, baggage. Phía web SDK, phải khai báo propagateTraceHeaderCorsUrls trong instrumentation fetch/XHR — nhận một chuỗi, một RegExp, hoặc mảng của chúng — nếu không SDK cố tình không inject header cho request cross-origin. Lưu ý các package instrumentation phía browser vẫn nằm trong dòng experimental.
Qua reverse proxy / CDN / load balancer / service mesh
Nginx với proxy_pass mặc định forward các header không phải hop-by-hop, kể cả traceparent; chỉ vỡ khi ai đó khai báo whitelist header thủ công. AWS ALB thêm X-Amzn-Trace-Id của riêng nó và không thay thế traceparent — muốn nối hai thế giới thì thêm propagator X-Ray. Cảnh báo chung: WAF, CDN và API gateway thường strip header lạ hoặc chuẩn hoá tên header; đây là nguyên nhân số một khiến trace vỡ tại biên public.
Service mesh (Istio/Envoy/Linkerd) có hai chế độ hoàn toàn khác nhau, và phải xác định cluster đang chạy chế độ nào trước khi chẩn đoán bất cứ triệu chứng nào bên dưới.
Chế độ A — mesh chỉ forward header (tracing ở mesh TẮT). Sidecar chỉ forward header chứ không tự nối trace: nó không biết request vào và request ra của cùng một app thuộc cùng một luồng. Service nào không instrument mà không copy tay traceparent, tracestate, x-request-id (và cả bộ x-b3-* nếu mesh đang chạy B3) sang mọi request ra thì egress proxy thấy một request không cha và mở trace mới. Provider tracing lịch sử của Istio là Zipkin, tức header B3 — lý do B3Propagator vẫn phải có mặt trong CompositePropagator ở nhiều cluster.
Chế độ B — mesh tự sinh span (tracing ở mesh BẬT). Envoy không còn chỉ chuyển tiếp header mà tạo span của riêng nó cho mỗi hop đi qua: ingress gateway một span, sidecar outbound của service gọi một span, sidecar inbound của service nhận một span.
| Hệ quả | Biểu hiện trên hệ thống |
|---|---|
| Số span mỗi request tăng 2–3 lần | Chi phí ingest và lưu trữ đội thẳng theo đúng tỉ lệ đó (Phần 10) |
Span SERVER lồng trong span SERVER cùng service.name |
Trên Jaeger, mỗi hop trông như bị gọi hai lần |
| Service graph có node và cạnh trùng tên | Node của proxy mang cùng tên service với node của app, đồ thị phụ thuộc đọc sai |
| Hai tầng sampling nhân với nhau | Tỉ lệ hiệu dụng thấp hơn hẳn con số dev cấu hình — xem đoạn dưới |
Sampling là cái bẫy nặng nhất và khó chẩn đoán nhất. Tỉ lệ của mesh do Telemetry API của Istio quyết định (bản cũ dùng meshConfig.defaultConfig.tracing.sampling), độc lập hoàn toàn với OTEL_TRACES_SAMPLER của SDK, và mesh ghi kết quả thẳng vào bit sampled của traceparent. Vì SDK mặc định dùng ParentBased, mesh mới là bên thực sự quyết định: mesh 1% nhân với SDK head-based 10% cho tỉ lệ hiệu dụng khoảng 0,1% — dev cấu hình 10% rồi ngồi hỏi vì sao Jaeger gần như trống (Phần 4).
Khuyến nghị dứt khoát cho hệ đã instrument bằng OTel: tắt tracing ở mesh, để mesh làm đúng một việc là forward traceparent, tracestate, baggage và x-b3-*. Nếu vì lý do tổ chức buộc phải giữ mesh tracing thì làm hai việc: (1) đồng bộ tỉ lệ sampling hai bên về cùng một con số thay vì để chúng nhân nhau, và (2) drop span của proxy ngay ở Collector bằng processor filter theo resource.attributes["service.name"] hoặc theo instrumentation scope của Envoy (Phần 7).
| Dấu hiệu mesh đang sinh span | Cách kiểm |
|---|---|
Tên service có hậu tố namespace của mesh (istio-ingressgateway, <app>.<namespace>) |
Danh sách service trong Jaeger UI |
Span mang attribute component=proxy, hoặc upstream_cluster / node_id |
Mở một span bất kỳ, xem tab Tags |
| Số span mỗi request gấp đôi khi bật sidecar | So một trace ở namespace có istio-injection=enabled với một trace ở namespace không inject |
Linkerd hành xử khác Istio — mặc định không sinh span, tracing là extension bật riêng và cấu hình không giống Istio; luôn đối chiếu docs của đúng version đang chạy thay vì suy từ Istio sang.
Các chỗ hay mất context trong Node
| Nguyên nhân | Dấu hiệu | Cách sửa |
|---|---|---|
| Instrumentation không bao phủ thư viện đang dùng | Có span HTTP vào, không có span DB / HTTP ra | Rà danh sách instrumentation; nếu không có, bọc thủ công bằng context.with |
EventEmitter, listener đăng ký trước khi có context |
Span trong handler thành root span mới | context.bind(context.active(), listener) lúc đăng ký |
worker_threads |
Worker thread không thấy trace nào của main thread | Tự inject/extract qua payload của postMessage |
Promise detached (không await) |
Span con kết thúc sau parent hoặc mồ côi | await đầy đủ, hoặc bind context rõ ràng trước khi thả |
| Callback do thư viện tự quản pool (connection pool, driver cũ) | Span DB gắn nhầm sang request khác | Nâng version thư viện có instrumentation, hoặc bind callback |
setImmediate / process.nextTick trong code chưa được patch |
Mất context ngay sau ranh giới async | Bind callback, hoặc đưa logic vào trong context.with |
| Serverless cold start | Vài request đầu không có span | Khởi tạo SDK trước handler; flush trước khi container bị freeze |
| Đi qua queue mà không inject header | Worker luôn tạo trace mới, producer không có span con | Inject vào job data / message header như mục BullMQ và Kafka |
Dùng context.with sai (không bọc toàn bộ phần async) |
Span đầu đúng, span sau mất parent | Đưa toàn bộ chuỗi await vào trong callback của context.with / startActiveSpan |
Thêm một nguyên nhân không thuộc async context: nhiều bản @opentelemetry/api cùng tồn tại trong node_modules gây Error: @opentelemetry/api: Attempted duplicate registration of API: trace hoặc mất span im lặng — kiểm tra bằng npm ls @opentelemetry/api (xem Phần 11).
Kiểm chứng propagation đang hoạt động
Tự đặt traceparent rồi tìm đúng trace-id đó trên Jaeger UI (port 16686):
TRACE_ID=$(openssl rand -hex 16)
SPAN_ID=$(openssl rand -hex 8)
curl -s -o /dev/null -D - http://localhost:3000/api/orders \
-H "traceparent: 00-${TRACE_ID}-${SPAN_ID}-01" \
-H "baggage: tenant.id=hasaki-vn"
echo "Tim tren Jaeger: http://localhost:16686/trace/${TRACE_ID}"
Trace hiện ra đúng TRACE_ID và span đầu tiên có parent là SPAN_ID nghĩa là extract chạy đúng. Nếu Jaeger tạo trace-id khác, header đã bị strip ở proxy hoặc traceparent bị coi là không hợp lệ (thường do trace-id toàn 0 hoặc sai độ dài). In SpanContext ở cả hai đầu để so trực tiếp:
import { trace, propagation, context } from '@opentelemetry/api';
export function debugContext(tag: string) {
const sc = trace.getActiveSpan()?.spanContext();
console.log(tag, {
traceId: sc?.traceId,
spanId: sc?.spanId,
traceFlags: sc?.traceFlags, // bit 0 bật = sampled
baggage: propagation.getBaggage(context.active())?.getAllEntries(),
});
}
traceId giống nhau ở hai service còn spanId bên nhận khác bên gửi là đúng. traceId khác nhau nghĩa là context đứt tại hop đó. Không in ra gì nghĩa là không có span active — mất context ngay trong process, tra bảng phía trên.
Tham khảo: W3C Trace Context, OpenTelemetry Propagators API, Baggage API spec, opentelemetry-js.
Phần 4 — Sampling: head-based, tail-based và chi phí
Vì sao phải sample: bài toán khối lượng
Lấy một hệ e-commerce ở giờ cao điểm: 5.000 request/s, mỗi request đi qua khoảng 20 span (HTTP server, vài lời gọi service nội bộ, vài query Postgres, Redis, một lần publish Kafka), mỗi span sau khi encode OTLP protobuf và nén gzip nặng khoảng 1 KB.
| Đại lượng | Công thức | Kết quả |
|---|---|---|
| span/s | 5.000 × 20 | 100.000 span/s |
| Băng thông telemetry | 100.000 × 1 KB | ~100 MB/s ≈ 800 Mbps liên tục |
| Dữ liệu/ngày | 100 MB/s × 86.400 | ~8,6 TB/ngày |
| Lưu 7 ngày, 1 replica | 8,6 × 7 × 2 | ~120 TB đĩa |
Ba ràng buộc phát sinh cùng lúc: băng thông egress app → Collector → backend (800 Mbps chỉ riêng telemetry là tiền thật trên cloud); chi phí lưu trữ và IOPS của storage backend; CPU/RAM của backend để index và truy vấn khối đó.
Sampling là đánh đổi có ý thức giữa chi phí và khả năng điều tra sự cố: mỗi trace bị drop là một trace không bao giờ lấy lại được. Vì vậy câu hỏi đúng không phải "sample bao nhiêu phần trăm" mà là "giữ lại đúng những trace nào".
Head-based và tail-based
Head-based quyết định ngay lúc tạo root span, trong process ứng dụng, trước khi request được xử lý. Tail-based gom toàn bộ span của một trace ở Collector, chờ trace kết thúc, rồi mới quyết định.
| Tiêu chí | Head-based | Tail-based |
|---|---|---|
| Quyết định ở đâu | Trong SDK của ứng dụng | Ở Collector (tail_sampling processor) |
| Quyết định lúc nào | Khi tạo root span | Sau decision_wait kể từ span đầu tiên |
| Biết gì khi quyết định | trace-id, span name, SpanKind, attribute có sẵn lúc start, parent, baggage | Toàn bộ trace: status, latency tổng, mọi attribute, số span, service tham gia |
| Chi phí | Gần như bằng 0, giảm tải ngay từ app | RAM ở Collector + phải gửi 100% span qua mạng tới Collector |
| Ưu | Rẻ, đơn giản, giảm băng thông từ app | Giữ đúng trace lỗi và trace chậm |
| Nhược | Mù với kết quả request | Stateful; mọi span của một trace phải về cùng một Collector instance |
| Mất gì | Trace lỗi nằm trong phần bị drop | Span về sau decision_wait bị bỏ → trace thiếu nhánh |
Hai cách không loại trừ nhau. Cấu hình phổ biến ở production tải cao là head-based 10-20% để cắt băng thông từ app, rồi tail sampling ở Collector để trong phần đã lọt qua giữ 100% error và slow.
Sampler có sẵn trong SDK
| Sampler | Giá trị OTEL_TRACES_SAMPLER |
Hành vi | Khi nào dùng |
|---|---|---|---|
| AlwaysOn | always_on, parentbased_always_on |
Sample mọi trace | dev, staging, service lưu lượng thấp |
| AlwaysOff | always_off, parentbased_always_off |
Trả về span không ghi (non-recording): không giữ attribute, không export, nhưng vẫn propagate context | tắt tracing một service mà không gỡ SDK |
| TraceIdRatioBased | traceidratio, parentbased_traceidratio |
Sample theo tỉ lệ tính tất định từ trace-id | production tải cao |
| ParentBased | tiền tố parentbased_* |
Sampler tổ hợp: theo quyết định của parent, ủy quyền root cho sampler khác | mặc định cho mọi service trong hệ phân tán |
| JaegerRemoteSampler | jaeger_remote, parentbased_jaeger_remote |
Poll strategy từ Jaeger, đổi tỉ lệ không cần deploy lại | khi muốn chỉnh tỉ lệ runtime tập trung |
Bốn sampler đầu nằm trong @opentelemetry/sdk-trace-base (và bản hợp nhất @opentelemetry/sdk-trace). JaegerRemoteSampler ở package riêng @opentelemetry/sampler-jaeger-remote, thuộc dòng experimental 0.x — kiểm tra CHANGELOG trước khi đưa vào production. Jaeger v2 phục vụ strategy qua port 5778 /sampling, cấu hình bằng extension remote_sampling với file hoặc adaptive. Về "mặc định" phải tách bạch hai thứ hay bị gộp làm một: hằng số mặc định trong mã Jaeger khi extension remote_sampling chạy mà không có strategy nào cả là probabilistic 0.001; nhưng image cr.jaegertracing.io/jaegertracing/jaeger chạy không kèm --config lại nạp sẵn /cmd/jaeger/sampling-strategies.json với default_strategy = probabilistic 1.0, tức 100%. Đừng đoán con số đang có hiệu lực — hỏi thẳng endpoint:
curl -s 'http://localhost:5778/sampling?service=shop-api'
# Image mặc định trả về:
# {"strategyType":"PROBABILISTIC","probabilisticSampling":{"samplingRate":1}}
Bật jaeger_remote rồi thấy trace biến mất gần hết thì đây là chỗ kiểm tra đầu tiên, nhưng phải đọc số thật trả về chứ không mặc định là 0.001.
OTEL_TRACES_SAMPLER và OTEL_TRACES_SAMPLER_ARG
# Giữ 10% trace gốc, tôn trọng quyết định của parent
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1
Mặc định của spec là OTEL_TRACES_SAMPLER=parentbased_always_on, OTEL_TRACES_SAMPLER_ARG rỗng. Với traceidratio / parentbased_traceidratio, OTEL_TRACES_SAMPLER_ARG là số trong [0..1] và mặc định 1.0 nếu không set. Với jaeger_remote, giá trị là chuỗi dạng endpoint=...,pollingIntervalMs=5000,initialSamplingRate=0.25. Giá trị không nhận diện được thì spec yêu cầu "MUST be logged and MUST be otherwise ignored" — ứng dụng vẫn chạy nhưng sampler rơi về mặc định, nên luôn đọc log khởi động để xác nhận sampler thực sự đang dùng. Danh sách enum chuẩn: always_on, always_off, traceidratio, parentbased_always_on, parentbased_always_off, parentbased_traceidratio, jaeger_remote, parentbased_jaeger_remote, xray.
Vì sao ParentBased là mặc định đúng
Nếu mỗi service tự quyết định độc lập, trace sẽ vỡ: service A giữ root span, service B drop span con → trên Jaeger còn lại một trace cụt, mất nguyên nhánh phía dưới, mà đúng nhánh đó thường là nơi có lỗi. ParentBased loại bỏ khả năng này: quyết định một khi đã ghi vào sampled flag của traceparent (định dạng header xem Phần 3) thì mọi service phía sau chỉ việc tuân theo.
| Trường hợp | parent.isRemote() |
parent.isSampled() |
Sampler được gọi | Mặc định |
|---|---|---|---|---|
| Không có parent (root) | — | — | root |
bắt buộc truyền |
| Có parent | true | true | remoteParentSampled |
AlwaysOn |
| Có parent | true | false | remoteParentNotSampled |
AlwaysOff |
| Có parent | false | true | localParentSampled |
AlwaysOn |
| Có parent | false | false | localParentNotSampled |
AlwaysOff |
"Remote parent" là parent đến từ header của request vào; "local parent" là span cha tạo trong cùng process. Chỉ nhánh root mới thực sự có quyền chọn — bốn nhánh còn lại tồn tại để đảm bảo tính nhất quán dọc theo trace. Hệ quả vận hành: service ở rìa (API gateway, BFF) là nơi duy nhất quyết định tỉ lệ; chỉnh ratio ở service nội bộ hầu như vô tác dụng vì span của nó đều có remote parent.
TraceIdRatioBased nhất quán như thế nào
TraceIdRatioBased không tung xúc xắc; nó tính quyết định bằng một hàm tất định trên chính trace-id. Cùng một trace-id, cùng một ratio, cùng một SDK → luôn ra cùng kết quả, và tính bao hàm được bảo đảm: trace nào được giữ ở 1% thì cũng được giữ ở 10%.
Hai cảnh báo:
- Spec ghi rõ thuật toán chính xác chưa từng được đặc tả, nên sampler này "is not defined so as to be compatible with any other SDK". Đừng trông cậy việc SDK Java và SDK JS chọn đúng cùng tập trace ở cùng ratio; thứ giữ trace liền mạch là
ParentBased, không phải sự trùng khớp của hàm băm. - Tính bao hàm chỉ đúng khi các service dùng cùng ratio. Nếu checkout-service để 0,5 còn payment-service để 0,1 và payment-service có lúc tự tạo root span (nhận job từ queue mà mất context), hai bên chọn tập trace khác nhau và bức tranh ghép lại bị lệch. Ratio phải là hằng số toàn hệ thống, đặt qua config chung.
TraceIdRatioBased vẫn Stable nhưng đã deprecated để nhường chỗ cho ProbabilitySampler composable; spec ghi implementer "SHALL NOT remove or modify the behavior" trước 1/1/2027, nên dùng hiện tại vẫn an toàn.
Consistent probability sampling và adjusted count
Sau khi sample, mọi phép đếm dựng từ span đều sai: giữ 10% thì "1.000 đơn hàng" thực ra là 10.000. Cách sửa là gắn cho mỗi span đã sample một adjusted count — số span thực tế mà nó đại diện — rồi backend cộng adjusted count thay vì đếm dòng.
OpenTelemetry mã hóa việc này trong tracestate, gói toàn bộ giá trị của mình vào một entry duy nhất key ot, các cặp key:value phân cách bằng ;, tổng chiều dài MUST NOT vượt 256 ký tự:
tracestate: ot=th:c
tracestate: ot=rv:6e6d1a75832a2f
th= rejection threshold, 1-14 chữ số hex chữ thường, bù 0 bên phải cho đủ 14 chữ số thành một số 56-bit.ot=th:0là 100% sampling.ot=th:c→c0000000000000, threshold / 2^56 = 75% → xác suất sampling 25%, adjusted count = 4.AdjustedCount = 2^56 / (2^56 - Threshold).rv= explicit randomness value, bắt buộc đúng 14 hex chữ thường, dùng thay cho 7 byte thấp của trace-id làm nguồn ngẫu nhiên.
Trạng thái spec — vùng đang phát triển, phải kiểm tra lại trước khi dùng. Tài liệu tracestate-probability-sampling.md ở mức Development. Sampler tương ứng tên đúng là ProbabilitySampler (Development, thuật toán R >= T → RECORD_AND_SAMPLE) và ComposableProbability trong CompositeSampler/ComposableSampler (đều Development) — trong spec không có sampler nào tên "ConsistentProbabilityBased", và OTEL_TRACES_SAMPLER chưa có giá trị chuẩn cho nhóm này.
Trạng thái JS. SDK tracing lõi (@opentelemetry/sdk-trace-base) chỉ có AlwaysOnSampler, AlwaysOffSampler, ParentBasedSampler, TraceIdRatioBasedSampler — chưa có ProbabilitySampler. Bản triển khai nằm ở package experimental @opentelemetry/sampler-composite (dòng 0.x) với createCompositeSampler / createComposableProbabilitySampler; README ghi rõ "This is an experimental package under active development". Con đường thực tế nhất để có adjusted count đúng hiện nay là probabilistic_sampler processor ở Collector, vì nó đã ghi threshold vào ot=th: khi chạy mode proportional hoặc equalizing.
Một thứ dễ bị bỏ sót là hàm factory createAlwaysRecordSampler() — lưu ý đây là một factory function, không phải class, và không có tên AlwaysRecordSampler nào tồn tại ở bất kỳ package nào. Nó bọc một sampler khác và nâng mọi quyết định NOT_RECORD thành RECORD — span vẫn được ghi đầy đủ cho processor đếm/đo, nhưng sampled flag không set nên không export. Hàm này chỉ được export từ package hợp nhất @opentelemetry/sdk-trace (bản thay thế cho sdk-trace-base / -node / -web), không có trong @opentelemetry/sdk-trace-base.
Viết custom sampler bằng TypeScript
Interface Sampler chỉ có một method quan trọng: shouldSample(...) trả về SamplingResult gồm decision (SamplingDecision.NOT_RECORD / RECORD / RECORD_AND_SAMPLED), tùy chọn attributes và traceState. Tên enum của OTel JS lệch tên trong spec: NOT_RECORD = DROP, RECORD = RECORD_ONLY, RECORD_AND_SAMPLED = RECORD_AND_SAMPLE.
Giới hạn tầm nhìn: shouldSample chạy trong lúc root span được tạo, nên chỉ thấy trace-id, span name, SpanKind, baggage/parent trong context và attribute mà instrumentation đặt được ngay lúc start. Với HTTP server: có url.path (path thô), http.request.method, server.address; không có http.route (route template chỉ có sau khi framework match router), không có status code, không có duration.
// src/telemetry/sampler.ts — ESM, Node.js 20+
import { propagation } from '@opentelemetry/api';
import type { Attributes, Context, Link, SpanKind } from '@opentelemetry/api';
import {
ParentBasedSampler,
SamplingDecision,
TraceIdRatioBasedSampler,
} from '@opentelemetry/sdk-trace-base';
import type { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-base';
import { ATTR_URL_PATH } from '@opentelemetry/semantic-conventions';
const DROP: SamplingResult = { decision: SamplingDecision.NOT_RECORD };
// Bỏ hẳn: health check và scrape metrics, không có giá trị điều tra
const IGNORED_PATHS = new Set(['/healthz', '/readyz', '/metrics']);
// Luôn giữ 100%: đường tiền. Phải là TIỀN TỐ của url.path thật ('/api/v1/checkout'),
// không phải route template ('/checkout') — lúc này chưa có http.route.
const CRITICAL_PATH_PREFIXES = ['/api/v1/checkout', '/api/v1/payment'];
export class EcommerceSampler implements Sampler {
// fallback nhận phần lưu lượng còn lại, giữ theo tỉ lệ
constructor(private readonly fallback: Sampler) {}
shouldSample(
ctx: Context,
traceId: string,
spanName: string,
spanKind: SpanKind,
attributes: Attributes,
links: Link[]
): SamplingResult {
const raw = attributes[ATTR_URL_PATH];
// Cắt query string cho chắc, nếu không '/api/v1/checkout?coupon=X' sẽ trượt
const path = typeof raw === 'string' ? raw.split('?')[0] : undefined;
if (path !== undefined && IGNORED_PATHS.has(path)) return DROP;
if (path !== undefined && CRITICAL_PATH_PREFIXES.some((p) => path.startsWith(p))) {
return {
decision: SamplingDecision.RECORD_AND_SAMPLED,
attributes: { 'sampling.rule': 'critical-path' },
};
}
// Rìa đọc header `x-debug-trace: 1` rồi đặt baggage `debug=1`;
// baggage được propagate nên mọi service phía sau cũng giữ 100%
if (propagation.getBaggage(ctx)?.getEntry('debug')?.value === '1') {
return {
decision: SamplingDecision.RECORD_AND_SAMPLED,
attributes: { 'sampling.rule': 'debug-baggage' },
};
}
return this.fallback.shouldSample(ctx, traceId, spanName, spanKind, attributes, links);
}
toString(): string {
return `EcommerceSampler{${this.fallback.toString()}}`;
}
}
// Bọc trong ParentBased để chỉ root span mới đi qua các luật trên
export const sampler = new ParentBasedSampler({
root: new EcommerceSampler(new TraceIdRatioBasedSampler(0.1)),
});
Truyền vào NodeSDK qua option sampler (khởi tạo SDK xem Phần 5A).
Những điều bắt buộc nhớ khi viết sampler
shouldSamplechạy trên đường nóng, mỗi span một lần. Không I/O, không regex nặng, khôngJSON.parse, không đọc config từ disk. DùngSet/Mapdựng sẵn ở module scope như ví dụ trên.attributestrả về trongSamplingResultđược gắn thẳng vào span. Đây là chỗ ghi lý do giữ trace (sampling.rule) — cũng là cách duy nhất kiểm chứng luật có khớp không, vì sampler viết sai không bao giờ báo lỗi, nó chỉ lặng lẽ không khớp. Tìm tagsampling.rule=critical-pathtrên Jaeger, hoặc thêmsampling.rulevàodimensionscủaspanmetrics, rồi đối chiếu tỉ lệ.- Không được dựa vào thứ chỉ biết sau khi request kết thúc: status code, exception, tổng latency. Luật cần
http.routechính xác, cần status hay latency thì đừng cố làm ở head-based — đó là việc của tail sampling. - Với
/healthzvà/metrics, chặn ở tầng instrumentation (ignoreIncomingRequestHookcủa@opentelemetry/instrumentation-http, xem Phần 5B) rẻ hơn chặn ở sampler, vì span không được tạo ra ngay từ đầu. - Baggage được propagate ra ngoài process; chỉ đặt cờ hoặc tier, không đặt tên, email hay số điện thoại (Phần 3, Phần 10).
Khi cần trace của một request cụ thể
Điều kiện tiên quyết trước khi hạ tỉ lệ xuống dưới 100%: ghi trace_id vào chỗ tra được — mỗi dòng log mang trace_id kèm khóa nghiệp vụ (mẫu pino/winston ở Phần 5B), và bản ghi đơn hàng lưu thẳng trace_id cùng một cờ lấy từ bit 0 của spanContext().traceFlags; trace_id vẫn tồn tại kể cả khi trace bị drop, nên có cờ đó thì tra Jaeger không thấy là biết ngay do sampling chứ không phải Collector chết. Ba đường force-trace còn lại đều phải bật trước khi sự cố xảy ra: gửi traceparent với sampled flag 01 (chỉ dùng được từ mạng nội bộ, vì rìa public nên bỏ header từ client không tin cậy — Phần 10); dịch header debug thành baggage ở proxy trước Node để sampler bắt được ở nhánh debug-baggage phía trên; hoặc tạm đặt OTEL_TRACES_SAMPLER_ARG=1. Nới ở Collector không tương đương: tail_sampling "100%" cũng chỉ là 100% của phần head-based đã cho qua.
Cấu hình ở biên: strip header không tin cậy và bật đường force-trace
Hai yêu cầu ở trên — "rìa public nên bỏ header từ client không tin cậy" và "dịch header debug thành baggage ở proxy trước Node" — đều là việc của tầng ingress, và chúng phải làm cùng một chỗ, theo đúng thứ tự đó: xoá sạch context do client gửi lên trước, rồi mới tự sinh ra baggage từ một header debug đã được kiểm soát. Bỏ vế đầu thì bất kỳ ai cũng gửi được traceparent với sampled flag 01 và tự cấp cho mình 100% sampling — đúng vector DoS mô tả ở Phần 10; bỏ vế sau thì nhánh debug-baggage trong sampler phía trên không bao giờ khớp.
nginx. map dịch header debug thành giá trị baggage, geo giới hạn quyền dùng nó theo dải IP nội bộ, và vì $baggage_out mặc định rỗng nên nginx không gửi header baggage cho client thường:
# http { ... }
geo $debug_allowed {
default 0;
10.0.0.0/8 1; # pod/VPN nội bộ
192.168.0.0/16 1;
}
# Chỉ dịch x-debug-trace: 1 thành baggage khi request đến từ dải được phép
map "$debug_allowed:$http_x_debug_trace" $baggage_out {
"1:1" "debug=1";
default "";
}
server {
listen 443 ssl;
server_name shop.example.com;
location / {
# 1. Xoá mọi context trace do client gửi lên — không tin header từ ngoài
proxy_set_header traceparent "";
proxy_set_header tracestate "";
# 2. Tự sinh baggage: rỗng => nginx bỏ hẳn header, không có đường tiêm giá trị lạ
proxy_set_header baggage $baggage_out;
proxy_pass http://shop-api;
}
}
ingress-nginx. Cùng logic, chia làm hai chỗ: geo/map vào http-snippet của ConfigMap, phần proxy_set_header vào annotation của Ingress. Từ ingress-nginx v1.9 allow-snippet-annotations mặc định false, phải bật thì configuration-snippet mới có hiệu lực:
apiVersion: v1
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
data:
allow-snippet-annotations: "true"
http-snippet: |
geo $debug_allowed {
default 0;
10.0.0.0/8 1;
}
map "$debug_allowed:$http_x_debug_trace" $baggage_out {
"1:1" "debug=1";
default "";
}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-api
annotations:
nginx.ingress.kubernetes.io/configuration-snippet: |
proxy_set_header traceparent "";
proxy_set_header tracestate "";
proxy_set_header baggage $baggage_out;
spec:
ingressClassName: nginx
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shop-api
port:
number: 8080
Envoy / Istio. Envoy gỡ header bằng request_headers_to_remove ở route config; gỡ luôn x-debug-trace ở biên public rồi chỉ cho phép nó đi vào từ gateway nội bộ:
route_config:
name: public_edge
request_headers_to_remove:
- traceparent
- tracestate
- baggage
- x-debug-trace
virtual_hosts:
- name: shop
domains: ["shop.example.com"]
routes:
- match: { prefix: "/" }
route: { cluster: shop_api }
Với Istio không cần EnvoyFilter: VirtualService biên dịch thẳng xuống cùng request_headers_to_remove đó và dễ review hơn nhiều:
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: shop-api-edge
namespace: shop
spec:
gateways: [istio-system/public-gateway]
hosts: ["shop.example.com"]
http:
- headers:
request:
remove: [traceparent, tracestate, baggage, x-debug-trace]
route:
- destination:
host: shop-api.shop.svc.cluster.local
port:
number: 8080
Bảo vệ đường force-trace. x-debug-trace là một công tắc bật 100% sampling, nên nó phải được đối xử như một quyền: chỉ chấp nhận khi request đến từ dải IP nội bộ (như geo ở trên) hoặc kèm một header nội bộ đã xác thực do gateway gắn sau khi kiểm token — không bao giờ nhận thẳng từ Internet. Ai bật được nó là tự cấp cho mình 100% sampling, và một script gửi header đó ở tốc độ cao sẽ đẩy khối lượng telemetry lên đúng mức mà Phần 10 mô tả. Cũng nên đặt một rate_limiting policy ở tail_sampling làm lưới chắn cuối, đề phòng đường nội bộ bị lạm dụng.
Kiểm chứng. Không có cách nào biết cấu hình đúng ngoài việc gửi thử một request và tìm lại nó:
# Từ trong mạng nội bộ (dải nằm trong geo ở trên)
curl -s -o /dev/null -H 'x-debug-trace: 1' https://shop.example.com/api/v1/orders/123
# Từ ngoài Internet: header bị bỏ, request này phải KHÔNG sinh trace 100%
curl -s -o /dev/null -H 'x-debug-trace: 1' \
-H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' \
https://shop.example.com/api/v1/orders/123
Trên Jaeger, tìm service shop-api với tag sampling.rule=debug-baggage: request đầu phải hiện ra, request thứ hai thì không — và nếu request thứ hai vẫn tạo trace mang đúng trace-id 0af765... mà client tự đặt thì nghĩa là proxy_set_header traceparent "" chưa có hiệu lực.
Tail sampling ở Collector
Cơ chế: tail_sampling giữ một map trong bộ nhớ khóa theo trace-id, dồn mọi span nhận được vào đúng nhóm. Khi span đầu tiên của một trace tới, đồng hồ decision_wait bắt đầu chạy; hết thời gian đó, processor chạy lần lượt các policy trên tập span đã gom được và quyết định giữ hay bỏ cả trace, rồi đẩy xuống exporter và giải phóng bộ nhớ. Kết quả ghi vào decision_cache để span đến muộn không tạo lại trace mới. Processor này là beta (traces), chỉ có trong distro contrib và k8s — không có trong otelcol core.
Bảng policy của tail_sampling
| Policy type | Tham số chính | Dùng để |
|---|---|---|
always_sample |
— | giữ tất cả (thường làm nhánh trong composite) |
latency |
threshold_ms, upper_threshold_ms |
giữ trace chậm |
numeric_attribute |
key, min_value, max_value |
lọc theo attribute số (vd order.total) |
probabilistic |
sampling_percentage, hash_salt |
giữ nền một tỉ lệ |
status_code |
status_codes: OK / ERROR / UNSET |
giữ trace lỗi |
string_attribute |
key, values, enabled_regex_matching, cache_max_size |
giữ theo route/tenant/service |
trace_state |
key, values |
quyết định theo tracestate |
trace_flags |
— | khớp khi sampled flag của trace được set |
rate_limiting |
spans_per_second, burst_capacity |
trần cứng chống bùng nổ |
bytes_limiting |
bytes_per_second, burst_capacity |
trần theo dung lượng |
span_count |
min_spans, max_spans |
bắt trace bất thường về số span |
boolean_attribute |
key, value |
lọc theo attribute boolean |
ottl_condition |
error_mode, span, spanevent |
điều kiện OTTL tự do |
and |
and_sub_policy |
AND nhiều điều kiện |
not |
not_sub_policy |
phủ định, thay cho invert_match |
drop |
drop_sub_policy |
bỏ hẳn trace khớp điều kiện |
composite |
max_total_spans_per_second, policy_order, composite_sub_policy, rate_allocation |
chia hạn ngạch giữa nhiều policy |
invert_match (trên string_attribute, numeric_attribute, boolean_attribute) đã deprecated; dùng drop hoặc not.
Config YAML đầy đủ
processors:
tail_sampling:
# Chờ đủ lâu để mọi span của một trace về tới Collector.
# Phải lớn hơn p99 tổng thời gian của một trace; càng lớn càng tốn RAM.
# Mặc định của processor là 30s.
decision_wait: 10s
# Số trace giữ đồng thời trong bộ nhớ (mặc định 50000).
# RAM ~ num_traces x kích thước trung bình một trace.
num_traces: 100000
# Gợi ý để cấp phát trước; đặt xấp xỉ số trace MỚI mỗi giây instance này nhận.
expected_new_traces_per_sec: 2000
# Giữ trace ngay khi khớp policy đầu tiên, không chạy nốt các policy sau
# (mặc định false). Bật lên thì thứ tự policy bên dưới thành thứ tự ưu tiên.
sample_on_first_match: true
policies:
# 1. Mọi trace có span ERROR — không bao giờ bỏ
- name: keep-errors
type: status_code
status_code:
status_codes: [ERROR]
# 2. Trace chậm hơn 1s
- name: keep-slow
type: latency
latency:
threshold_ms: 1000
# 3. Đường tiền: giữ 100% bất kể nhanh hay chậm
- name: keep-critical-routes
type: string_attribute
string_attribute:
key: http.route
values: ["/checkout", "/payment", "/orders/:id/confirm"]
# 4. Tổ hợp AND: checkout chậm hơn 500ms (ngưỡng chặt hơn ngưỡng chung)
- name: keep-slow-checkout
type: and
and:
and_sub_policy:
- name: is-checkout
type: string_attribute
string_attribute:
key: http.route
values: ["/checkout"]
- name: slower-than-500ms
type: latency
latency:
threshold_ms: 500
# 5. Nền: 5% lưu lượng bình thường, để còn thấy hệ thống lúc khỏe
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 5
# 6. Trần cứng: chặn kịch bản một incident làm 100% trace thành ERROR
# rồi giết backend.
- name: hard-cap
type: rate_limiting
rate_limiting:
spans_per_second: 4000
Ví dụ composite khi muốn chia hạn ngạch tường minh giữa các nhóm thay vì đặt một trần chung:
- name: quota-split
type: composite
composite:
max_total_spans_per_second: 5000
policy_order: [errors, slow, rest]
composite_sub_policy:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow
type: latency
latency: { threshold_ms: 1000 }
- name: rest
type: probabilistic
probabilistic: { sampling_percentage: 5 }
rate_allocation:
- policy: errors
percent: 50
- policy: slow
percent: 30
Cảnh báo vận hành: RAM, decision_wait và routing
- RAM tỉ lệ với
num_traces,decision_waitvà kích thước trace. Tăngdecision_waittừ 10s lên 30s ở mức 2.000 trace mới/s nghĩa là giữ thêm ~40.000 trace trong bộ nhớ. Luôn đặtmemory_limiterđầu pipeline,GOMEMLIMIT≈ 80% hard limit; nghẽn ở khâu gom thì tăngnum_shards(mặc định 1, tối đa 256). - Span đến sau
decision_waitbị drop → trace thiếu nhánh, hay gặp với background job và consumer Kafka chạy dài: nhánh async kéo dài 60s sẽ bị cắt cụt ở cấu hình mặc định. - Khi scale nhiều Collector instance, bắt buộc có một tầng
loadbalancingexporter vớirouting_key: traceIDphía trước. Không có tầng này, span của cùng một trace rơi vào các instance khác nhau, mỗi instance chỉ thấy một phần: instance thấy span ERROR thì giữ, instance khác thấy span "sạch" thì bỏ.
flowchart LR
A["App Node.js + OTel SDK<br/>head-based 10%"]
subgraph L1["Tầng 1 — loadbalancing gateway (stateless)"]
B["Collector<br/>exporter loadbalancing<br/>routing_key: traceID"]
end
subgraph L2["Tầng 2 — tail-sampling gateway (stateful theo trace-id)"]
C1["Collector A<br/>tail_sampling"]
C2["Collector B<br/>tail_sampling"]
end
D["Jaeger — OTLP 4317"]
A -->|"OTLP gRPC 4317"| B
B -->|"băm theo trace-id"| C1
B -->|"băm theo trace-id"| C2
C1 --> D
C2 --> D
# Cấu hình của TẦNG 1 — không có tail_sampling ở đây
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
exporters:
loadbalancing:
routing_key: traceID # bắt buộc: mọi span cùng trace về cùng backend
protocol:
otlp:
tls:
insecure: true
resolver:
dns:
hostname: otel-tail-gateway.observability.svc.cluster.local
# PHẢI là chuỗi. Viết `port: 4317` (số nguyên) thì Collector không khởi động được:
# 'resolver.dns' decoding failed: 'port' expected type 'string', got unconvertible type 'int'
port: "4317"
service:
pipelines:
traces:
receivers: [otlp]
exporters: [loadbalancing]
loadbalancing exporter là beta cho traces, có trong contrib và k8s; resolver hỗ trợ static, dns, k8s, aws_cloud_map. Với dns, khi số pod tầng 2 thay đổi, việc băm lại làm một số trace bị chia đôi trong thời gian ngắn — chấp nhận được, nhưng đừng autoscale tầng 2 quá thường xuyên.
probabilistic_sampler processor
probabilistic_sampler là sampler xác suất chạy ở Collector: sampling_percentage, mode (hash_seed mặc định, proportional, equalizing), hash_seed, fail_closed (mặc định true), sampling_precision (mặc định 4). Beta cho traces, có trong core, contrib và k8s. Ở mode proportional/equalizing — hai mode dùng thuật toán ngưỡng 56-bit — nó ghi threshold vào tracestate section ot (25% → ot=th:c), nhờ đó hạ nguồn tính được adjusted count. Mode mặc định hash_seed dùng hàm băm FNV và không ghi tracestate cho trace.
Dùng nó thay sampler ở SDK khi: không đổi được cấu hình ứng dụng (nhiều team, nhiều repo); có nguồn dữ liệu không phải OTel SDK (Zipkin, Jaeger receiver); muốn một điểm điều khiển tập trung; hoặc cần adjusted count mà SDK JS chưa hỗ trợ. Đổi lại, nó không tiết kiệm băng thông từ app tới Collector — 100% span vẫn đi qua mạng, mà ở tải rất cao đó mới là khoản lớn nhất, nên vẫn nên giữ head-based ở SDK.
Bẫy lớn: spanmetrics phải chạy trước tail_sampling
Nếu bạn dựng RED metrics (rate, errors, duration) từ span bằng spanmetrics connector, thứ tự trong pipeline quyết định tính đúng đắn của số liệu. tail_sampling bỏ đi phần lớn trace; nếu spanmetrics nhận dữ liệu sau nó, mọi counter bị nhân với tỉ lệ sampling — và tệ hơn, nhân với một tỉ lệ không đồng đều: trace lỗi giữ 100% còn trace thành công chỉ giữ 5%, nên error rate tính ra vọt lên hàng chục phần trăm trong khi thực tế là 0,3%.
Cách đặt: fan-out ngay sau receiver — một nhánh vào spanmetrics với 100% span, một nhánh vào pipeline có tail_sampling.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
memory_limiter:
check_interval: 1s
limit_mib: 4096
# rút gọn ở đây; danh sách policy đầy đủ xem khối YAML phía trên
tail_sampling:
decision_wait: 10s
policies:
- name: keep-errors
type: status_code
status_code:
status_codes: [ERROR]
batch: {}
connectors:
spanmetrics: {}
forward/tail: {}
exporters:
otlp/jaeger:
endpoint: jaeger-collector.observability.svc.cluster.local:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
service:
pipelines:
# Nhận 100% span rồi fan-out. KHÔNG có tail_sampling ở đây.
traces:
receivers: [otlp]
processors: [memory_limiter]
exporters: [spanmetrics, forward/tail]
# Nhánh trace: chỉ ở đây mới sample
traces/sampled:
receivers: [forward/tail]
processors: [tail_sampling, batch]
exporters: [otlp/jaeger]
# Nhánh metrics: dựng từ 100% span nên số liệu đúng
metrics/spanmetrics:
receivers: [spanmetrics]
exporters: [prometheus]
spanmetrics là alpha (contrib), sinh traces.span.metrics.calls và traces.span.metrics.duration với dimension mặc định service.name / span.name / span.kind / status.code / collector.instance.id; forward connector nằm trong repo core. Nó cũng stateful, nên khi scale nhiều instance vẫn cần routing_key: traceID ở tầng trước. Chi tiết pipeline xem Phần 7; đọc metric này trên tab Monitor của Jaeger xem Phần 8.
Chiến lược theo môi trường
| Môi trường / tải | Head-based (SDK) | Tail-based (Collector) | Ghi chú |
|---|---|---|---|
| Dev, local | parentbased_always_on (100%) |
không | cần thấy mọi trace ngay |
| Staging | parentbased_always_on (100%) |
không | lưu lượng nhỏ, giữ hết để test |
| Prod, < 100 request/s | parentbased_always_on (100%) |
không | ~170 GB/ngày ở 20 span/request, chấp nhận được |
| Prod, 100-1.000 request/s | parentbased_traceidratio 0.2-0.5 |
tùy chọn: giữ ERROR + slow | bắt đầu thấy áp lực storage |
| Prod, > 1.000 request/s | parentbased_traceidratio 0.1-0.2 |
bắt buộc: 100% ERROR, 100% slow, 5% nền, rate_limiting |
cần hai tầng Collector |
| Batch / consumer chạy dài | như dòng tương ứng ở trên | cẩn thận decision_wait |
nhánh async dài hơn decision_wait sẽ bị cắt |
Ước lượng chi phí trước và sau
span/ngày = RPS × span_mỗi_request × 86.400
bytes/ngày = span/ngày × bytes_mỗi_span
sau head = bytes/ngày × ratio_head
sau tail = sau_head × (tỉ_lệ_nền + tỉ_lệ_error + tỉ_lệ_slow)
đĩa cần = sau_tail × số_ngày_retention × (1 + số_replica)
Áp vào ví dụ đầu phần (5.000 RPS, 20 span/request, 1 KB/span, error 0,5%, slow 1%, nền 5%, retention 7 ngày, 1 replica):
| Bước | Dữ liệu/ngày | Đĩa 7 ngày × 2 |
|---|---|---|
| Không sample | 8.640 GB | ~120 TB |
| Head-based 10% | 864 GB | ~12 TB |
| + tail giữ 5% nền + 0,5% error + 1% slow | ~56 GB | ~0,8 TB |
Giảm hơn 150 lần dung lượng mà vẫn giữ 100% trace lỗi và trace chậm trong phần đã lọt qua head-based. Đánh đổi còn lại phải nói rõ với team: sự cố chỉ ảnh hưởng 0,05% request mà không sinh error và không chậm thì gần như chắc chắn không có trace để điều tra. Chi phí tổng thể xem Phần 10.
Checklist quyết định sampling
- Mỗi request sinh bao nhiêu span, mỗi span nặng bao nhiêu byte sau nén? Đo bằng
otelcol_receiver_accepted_spansvàotelcol_exporter_sent_spans, đừng đoán. - Ngân sách thực tế cho storage và băng thông telemetry là bao nhiêu mỗi tháng, và retention cần bao nhiêu ngày?
- Route nào là đường tiền, bắt buộc giữ 100% dù có tốn kém?
- Đường nào sinh span rác (health check, scrape, polling) cần chặn ngay ở tầng instrumentation?
- Có dựng metrics hay alert từ span không? Nếu có,
spanmetricsđã nằm trướctail_samplingchưa? - Nếu dùng tail sampling: p99 tổng thời gian một trace là bao nhiêu,
decision_waitcó lớn hơn con số đó không, và đã có tầngloadbalancingvớirouting_key: traceIDchưa? - Khi tỉ lệ cần đổi lúc 2 giờ sáng giữa sự cố, ai đổi được và mất bao lâu — phải deploy lại ứng dụng hay chỉ sửa config Collector / strategy file của Jaeger?
- Trước khi hạ tỉ lệ xuống dưới 100%: log đã mang
trace_idkèm khóa nghiệp vụ chưa, và đã có đường force-trace (baggage từ rìa) cho lúc cần điều tra một request cụ thể chưa?
Phần 5A — Node.js/TypeScript: cài đặt và khởi tạo SDK
Yêu cầu môi trường
| Hạng mục | Yêu cầu | Ghi chú |
|---|---|---|
| Node.js | engines.node của mọi package SDK dòng 2.x là ^18.19.0 \|\| >=20.6.0 |
README repo liệt kê v18, v20, v22, v24 và ghi rõ "Only Node.js Active or Maintenance LTS versions are supported" |
| Node.js cho ESM | tối thiểu 18.19.0 | dưới mức này bắt buộc dùng --require với file telemetry CommonJS |
| TypeScript | không có ràng buộc phiên bản riêng | quyết định là định dạng module sau khi biên dịch, không phải cú pháp trong file .ts |
| Trạng thái signal | Tracing: Stable (API + SDK). Metrics: Stable. Logs: Development | phần này chỉ nói về tracing |
OTel JS phát hành hai dòng version song song: stable 2.x (core, resources, sdk-trace-base, sdk-trace-node, context-async-hooks, propagator-b3) và experimental 0.x (sdk-node, instrumentation, exporter-trace-otlp-*). README của @opentelemetry/sdk-node ghi nguyên văn đây là "experimental package under active development" — minor release của nó được phép breaking. Bảng version và quy tắc pin xem Phần 10.
Trước khi pin version. Dòng 2.x đã feature-freeze; SDK 3.0 được thông báo sẽ yêu cầu Node.js v22 trở lên, gộp
sdk-trace-base/sdk-trace-node/sdk-trace-webthành một package@opentelemetry/sdk-trace, và bỏ các package không còn bảo trì (propagator-jaeger,exporter-jaeger,shim-opentracing…). 2.x còn nhận bugfix nghiêm trọng và bản vá bảo mật một năm sau ngày 3.0 ra. Code dưới đây viết theo API 2.x.
ESM hay CommonJS quyết định cách khởi động, không quyết định cách viết code: có "type": "module" trong package.json của bản đã build, hoặc file đuôi .mjs, thì ứng dụng là ESM.
Hai cách khởi tạo, chọn cách nào
| Zero-code (auto-instrumentation) | Manual bootstrap (tracing.ts) |
|
|---|---|---|
| Sửa code ứng dụng | không | không (vẫn nạp qua --require/--import) |
| Cấu hình | chỉ bằng biến môi trường | bằng code, đầy đủ mọi option |
| Sampler | chỉ OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG |
mọi sampler, kể cả sampler tự viết |
| Span processor | mặc định (BatchSpanProcessor) | nhiều processor song song, tuỳ biến |
| Lọc instrumentation | bật/tắt theo tên qua env | truyền config chi tiết cho từng instrumentation |
| Nên dùng khi | POC, dịch vụ phụ, muốn có trace trong 10 phút | dịch vụ chính, cần kiểm soát chi phí và nội dung span |
Đường đi thực tế: bắt đầu bằng zero-code để xác nhận đường tới Jaeger đã thông, rồi chuyển các service traffic lớn sang manual bootstrap.
Bảng package
| Package | Tác dụng | Bắt buộc |
|---|---|---|
@opentelemetry/api |
API tạo span, context, propagation. Là peer dependency của mọi package SDK | có |
@opentelemetry/sdk-node |
NodeSDK — lớp bọc gom trace/metrics/logs, đọc biến môi trường chuẩn |
có (nếu manual bootstrap) |
@opentelemetry/auto-instrumentations-node |
metapackage gom gần 40 instrumentation, kèm entry ./register cho zero-code |
có (thực tế) |
@opentelemetry/exporter-trace-otlp-proto |
exporter OTLP http/protobuf — mặc định của spec, payload nhỏ |
chọn 1 trong 3 |
@opentelemetry/exporter-trace-otlp-http |
exporter OTLP http/json, dễ soi bằng proxy nhưng payload lớn hơn |
chọn 1 trong 3 |
@opentelemetry/exporter-trace-otlp-grpc |
exporter OTLP/gRPC (port 4317), kéo thêm @grpc/grpc-js |
chọn 1 trong 3 |
@opentelemetry/resources |
resourceFromAttributes(), defaultResource() và các detector dựng sẵn |
có |
@opentelemetry/semantic-conventions |
hằng số ATTR_* cho tên attribute |
có |
@opentelemetry/sdk-trace-base |
BatchSpanProcessor, ConsoleSpanExporter, ParentBasedSampler, TraceIdRatioBasedSampler |
có |
@opentelemetry/core |
CompositePropagator, W3CTraceContextPropagator, W3CBaggagePropagator |
có (khi tự khai propagator) |
@opentelemetry/sdk-trace-node |
NodeTracerProvider — khi tự dựng provider thay vì dùng NodeSDK |
không |
@opentelemetry/instrumentation |
hạ tầng patch module và loader hook hook.mjs cho ESM |
gián tiếp (bắt buộc với ESM) |
@opentelemetry/propagator-b3 |
B3Propagator cho hệ thống cũ dùng header B3 (Zipkin) |
không |
@opentelemetry/context-async-hooks |
AsyncLocalStorageContextManager — giữ context qua ranh giới async |
gián tiếp (NodeSDK dùng mặc định) |
Ba exporter chỉ khác nhau ở encoding và transport, nội dung span y hệt; chi tiết giao thức xem Phần 6. @opentelemetry/sdk-trace là bản kế nhiệm của sdk-trace-base/sdk-trace-node và sẽ thay hẳn ở SDK 3.0; hai package cũ chưa bị đánh dấu deprecated trên npm nên ví dụ dưới đây vẫn dùng chúng.
@opentelemetry/api chỉ được có duy nhất một bản trong node_modules. API giữ một global registry singleton trên globalThis; hai bản khác nhau nghĩa là instrumentation ghi vào một registry còn SDK đọc registry khác. Hậu quả: lỗi Error: @opentelemetry/api: Attempted duplicate registration of API: trace, hoặc tệ hơn — span mất im lặng, không log gì. Kiểm tra rồi ép về một bản:
npm ls @opentelemetry/api # pnpm: pnpm why @opentelemetry/api — yarn: yarn why ...
{
"overrides": { "@opentelemetry/api": "1.9.1" }
}
npm dùng khoá overrides, pnpm dùng pnpm.overrides, Yarn dùng resolutions. pnpm không đọc khoá overrides ở cấp cao nhất — viết nhầm chỗ thì lệnh chạy trót lọt mà không ép được gì, đúng vào cái bẫy nhiều bản @opentelemetry/api ở trên:
{
"pnpm": { "overrides": { "@opentelemetry/api": "1.9.1" } }
}
pnpm từ v10 còn cho khai overrides trong pnpm-workspace.yaml. Thay 1.9.1 bằng bản bạn chốt. Chi tiết xem Phần 11 §11.10.
Cách 1 — zero-code
npm install @opentelemetry/api @opentelemetry/auto-instrumentations-node
# CommonJS
node --require @opentelemetry/auto-instrumentations-node/register app.js
# ESM (bắt buộc có loader hook)
node --experimental-loader=@opentelemetry/instrumentation/hook.mjs \
--import @opentelemetry/auto-instrumentations-node/register app.js
Bộ biến môi trường tối thiểu:
| Biến | Ví dụ | Ghi chú |
|---|---|---|
OTEL_SERVICE_NAME |
checkout-api |
đặt service.name; thiếu nó Jaeger gom hết vào unknown_service:node |
OTEL_RESOURCE_ATTRIBUTES |
deployment.environment.name=prod,service.version=2.4.1 |
danh sách key=value cách nhau dấu phẩy |
OTEL_TRACES_EXPORTER |
otlp |
mặc định là otlp; đặt none để không tự dựng exporter nào |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://otel-collector:4318 |
endpoint gốc, SDK tự nối /v1/traces |
OTEL_EXPORTER_OTLP_PROTOCOL |
http/protobuf |
hoặc grpc, http/json; mặc định là http/protobuf |
OTEL_PROPAGATORS |
tracecontext,baggage |
thêm b3multi khi phải nói chuyện với hệ thống cũ — xem Phần 3 |
OTEL_NODE_RESOURCE_DETECTORS |
env,host,os,process,container |
riêng của Node distro; thứ tự khai báo được tôn trọng |
OTEL_NODE_DISABLED_INSTRUMENTATIONS |
dns,net |
tên không có tiền tố @opentelemetry/instrumentation- |
Đặt cả OTEL_NODE_ENABLED_INSTRUMENTATIONS lẫn OTEL_NODE_DISABLED_INSTRUMENTATIONS thì ENABLED áp dụng trước rồi DISABLED áp lên kết quả — tên có ở cả hai sẽ bị tắt.
Ưu điểm: không chạm dòng code nào, rollback bằng cách bỏ flag. Giới hạn: không cấu hình được sampler phức tạp (chỉ những gì OTEL_TRACES_SAMPLER hỗ trợ), không thêm được span processor thứ hai, và — theo đúng chữ trong README của metapackage — cấu hình cho từng instrumentation chưa hỗ trợ qua biến môi trường, nên ai cần ignoreIncomingRequestHook hay hook tương tự bắt buộc phải khởi tạo bằng code. Tài liệu: https://opentelemetry.io/docs/zero-code/js/.
Cách 2 — manual bootstrap
src/tracing.ts — file này không được import từ code ứng dụng, nó được nạp trước bằng flag của Node.
// src/tracing.ts — nạp bằng: node --require ./dist/tracing.js dist/index.js
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
import {
BatchSpanProcessor,
ParentBasedSampler,
TraceIdRatioBasedSampler,
} from '@opentelemetry/sdk-trace-base';
import {
CompositePropagator,
W3CTraceContextPropagator,
W3CBaggagePropagator,
} from '@opentelemetry/core';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
// Hạ về ERROR cho log production gọn; mặc định của OTEL_LOG_LEVEL là `info` (Phần 6).
diag.setLogger(
new DiagConsoleLogger(),
process.env.OTEL_LOG_LEVEL === 'debug' ? DiagLogLevel.DEBUG : DiagLogLevel.ERROR,
);
// SDK 2.x: class Resource không còn được export, dùng resourceFromAttributes().
// PHẢI merge với defaultResource(): README của sdk-node cảnh báo rằng truyền resource
// "trần" làm mất chính các attribute service.name và telemetry.sdk.* mà Phần 9 coi là
// "SDK tự set". merge() cho bên phải thắng khi trùng key, nên giá trị dưới đây vẫn đè lên.
const resource = defaultResource().merge(
resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'checkout-api',
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION ?? '0.0.0',
// deployment.environment.name là tên Stable (deployment.environment đã deprecated).
// Hằng số tương ứng nằm ở subpath /incubating — subpath này được ghi rõ là có thể
// breaking ngay ở minor release, nên viết thẳng chuỗi.
'deployment.environment.name': process.env.APP_ENV ?? 'dev',
}),
);
// Bỏ option url thì exporter đọc OTEL_EXPORTER_OTLP_ENDPOINT (tự nối /v1/traces) hoặc
// OTEL_EXPORTER_OTLP_TRACES_ENDPOINT (dùng nguyên văn). Mặc định khi không khai gì:
// http://localhost:4318/v1/traces — Phần 6.
const exporter = new OTLPTraceExporter({ timeoutMillis: 10_000 });
// Ghi tham số tường minh: sdk-trace-base 2.x vẫn đọc OTEL_BSP_* làm giá trị fallback,
// nhưng package @opentelemetry/sdk-trace kế nhiệm thì bỏ hẳn việc đọc biến môi trường.
const batchProcessor = new BatchSpanProcessor(exporter, {
maxQueueSize: 2048, // hàng đợi tối đa, vượt là span bị drop
maxExportBatchSize: 512, // số span mỗi lần export
scheduledDelayMillis: 5_000, // chu kỳ flush
exportTimeoutMillis: 10_000, // phải nhỏ hơn hẳn grace period khi shutdown
});
// OTEL_TRACES_SAMPLER_ARG rất hay được khai báo nhưng để rỗng: `?? 0.1` không bắt chuỗi
// rỗng, mà Number('') === 0 nên ratio thành 0 và TOÀN BỘ trace bị drop im lặng.
const rawArg = process.env.OTEL_TRACES_SAMPLER_ARG?.trim();
const parsed = rawArg ? Number(rawArg) : NaN;
const ratio = Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0.1;
const sdk = new NodeSDK({
resource,
// Root span lấy mẫu theo tỉ lệ; span con theo đúng quyết định của parent (Phần 4).
sampler: new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(ratio) }),
// SDK 2.x: addSpanProcessor() đã bị xoá, và option spanProcessor (số ít) đã deprecated.
spanProcessors: [batchProcessor],
textMapPropagator: new CompositePropagator({
propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()],
}),
instrumentations: [
getNodeAutoInstrumentations({
// fs mặc định đã tắt trong metapackage; ghi lại cho tường minh.
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
'@opentelemetry/instrumentation-net': { enabled: false },
'@opentelemetry/instrumentation-http': {
// Bỏ probe của Kubernetes và scrape của Prometheus khỏi trace.
ignoreIncomingRequestHook: (req) =>
['/healthz', '/readyz', '/metrics'].includes((req.url ?? '').split('?')[0]),
},
}),
],
});
sdk.start();
// Flush hàng đợi trước khi process thoát, nếu không batch cuối sẽ mất.
const shutdown = (signal: string) => {
diag.info(`nhận ${signal}, đang shutdown OpenTelemetry SDK`);
sdk
.shutdown()
.then(() => diag.info('SDK đã shutdown'))
.catch((err) => diag.error('lỗi khi shutdown SDK', err))
.finally(() => process.exit(0));
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Ghi chú phiên bản: bài viết cũ dùng new Resource({...}), Resource.default() hay provider.addSpanProcessor(...) là API của SDK 1.x. Trong 2.x: resourceFromAttributes() / defaultResource() / emptyResource(), type IResource đổi thành interface Resource, span processor bắt buộc truyền qua option spanProcessors. Các hằng semconv SEMRESATTRS_*, SEMATTRS_*, SemanticResourceAttributes đã deprecated — dùng ATTR_*. Chi tiết di trú: https://github.com/open-telemetry/opentelemetry-js/blob/main/doc/upgrade-to-2.x.md.
Ghi chú về sampler: spec đã đánh dấu deprecated TraceIdRatioBased (vẫn Stable, SDK không được đổi hành vi trước 01/01/2027) để nhường chỗ cho nhóm sampler xác suất nhất quán; OTel JS mới có nhóm này ở package experimental @opentelemetry/sampler-composite, chưa vào SDK lõi. Với hầu hết hệ thống, ParentBasedSampler + TraceIdRatioBasedSampler vẫn là lựa chọn đúng lúc này — Phần 4.
package.json và tsconfig
{
"name": "checkout-api",
"type": "commonjs",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node --require ./dist/tracing.js dist/index.js",
"dev": "npx tsx --import ./src/tracing.ts src/index.ts"
}
}
| Mục tiêu build | "type" trong package.json |
compilerOptions |
Lệnh chạy |
|---|---|---|---|
| CommonJS (đường ít rủi ro nhất) | bỏ trống hoặc "commonjs" |
"module": "NodeNext", "moduleResolution": "NodeNext" |
--require ./dist/tracing.js |
| ESM | "type": "module" |
"module": "NodeNext", "moduleResolution": "NodeNext" |
--experimental-loader=@opentelemetry/instrumentation/hook.mjs --import ./dist/tracing.js |
Với module: NodeNext, định dạng output đi theo trường type của package.json — đó mới là chỗ quyết định thật sự. Thêm "target": "ES2022", "outDir": "dist", "sourceMap": true. Trong dev: ts-node chỉ chạy được khi module là CJS; tsx chạy TypeScript chưa biên dịch dưới dạng ESM với --import (ví dụ npx tsx --import ./instrumentation.ts app.ts cần Node.js v20 trở lên).
Thứ tự khởi tạo — chỗ hỏng nhiều nhất
Instrumentation hoạt động bằng cách monkey-patch module (http, express, pg, ioredis…), và chỉ có tác dụng nếu chạy trước khi ứng dụng require/import module đó. Vì vậy không được viết import './tracing' ở dòng đầu index.ts: với ESM, mọi câu lệnh import được hoisting và giải quyết xong trước khi bất kỳ dòng code nào chạy, nên express đã nạp xong trước khi tracing.ts kịp thực thi.
flowchart LR
A["node --require ./dist/tracing.js"] --> B["tracing.js chạy: sdk.start()"]
B --> C["instrumentation patch require cache / loader hook"]
C --> D["dist/index.js nạp express, pg"]
D --> E["module đã bị patch: có span HTTP/DB"]
F["index.ts viết import './tracing'"] --> G["ESM hoisting: express nạp trước"]
G --> H["tracing chạy sau: KHÔNG có span auto"]
| CommonJS | ESM | |
|---|---|---|
| Cơ chế nạp | require() đồng bộ, kết quả nằm trong require cache |
đồ thị module giải quyết trước khi chạy, binding là immutable |
| Cách patch | thay export trong require cache trước khi app gọi require |
phải dùng loader hook để viết lại module lúc nạp |
| Cách nạp telemetry | node --require ./dist/tracing.js |
node --experimental-loader=@opentelemetry/instrumentation/hook.mjs --import ./dist/tracing.js |
| Trạng thái | ổn định; là đường mà tài liệu OTel mặc định giả định | vẫn experimental |
Loader hook duy nhất được hỗ trợ là @opentelemetry/instrumentation/hook.mjs; không dùng trực tiếp import-in-the-middle/hook.mjs. Hướng tương lai là chuyển sang module.register(...) và bỏ cờ --experimental-loader (esm-support.md). Có thể đưa flag vào NODE_OPTIONS thay vì lệnh node, nhưng phải chắc không trùng --import/--require đã có sẵn trong biến đó.
| Triệu chứng | Nguyên nhân thường gặp | Xử lý |
|---|---|---|
| Chỉ thấy span thủ công, không có span HTTP/Express | tracing chạy sau khi framework đã được nạp | bỏ import './tracing', chuyển sang --require/--import |
| ESM: không có span auto nào, span thủ công vẫn có | thiếu --experimental-loader=@opentelemetry/instrumentation/hook.mjs |
thêm loader hook |
Có span HTTP nhưng không có span pg/mongodb |
instrumentation tương ứng bị tắt, hoặc driver được nạp trong file chạy trước hook | kiểm tra OTEL_NODE_DISABLED_INSTRUMENTATIONS, bật OTEL_LOG_LEVEL=debug xem danh sách module được patch |
| Không có span nào, cũng không có lỗi | nhiều bản @opentelemetry/api, sampler tắt, hoặc endpoint sai |
npm ls @opentelemetry/api; tạm đổi sang ConsoleSpanExporter |
Attempted duplicate registration of API: trace |
trùng bản @opentelemetry/api |
overrides / resolutions |
| Bundle bằng esbuild/webpack/ncc rồi mất hết span auto | bundler gộp module nên không còn điểm bám để patch | để package OTel và thư viện được instrument ở ngoài bundle (external), giữ node_modules |
Nhiều process: cluster, PM2
Mỗi worker là một process riêng nên có tracer provider và hàng đợi riêng — đúng như thiết kế. Nhưng ba giả định của các ví dụ trên bị phá:
- Flag nạp telemetry phải kế thừa được.
NODE_OPTIONSđi theo environment nên mọi process con đều nhận;--requiređặt trên dòng lệnhnodethì không chắc, vì PM2 khởi chạy entry bằng launcher riêng và không truyền lạiexecArgv. An toàn nhất:export NODE_OPTIONS="--require ./dist/tracing.js", hoặcnode_args: ['--require', './dist/tracing.js']trongecosystem.config.js, hoặccluster.setupPrimary({ execArgv: [...] })trước khi fork. Đây là nguyên nhân số một của "master có span, worker thì không". service.instance.idphải phân biệt được từng worker. N worker cùng mộtservice.instance.idlà vi phạm ràng buộc duy nhất toàn cục (Phần 9): backend gộp telemetry của N worker thành một instance. Ghép thêmprocess.env.NODE_APP_INSTANCE(PM2 đặt) hoặccluster.worker?.id, dự phòng bằngprocess.pid.- Tài nguyên nhân theo số worker. N hàng đợi ×
maxQueueSizespan trong RAM và N kết nối tới Collector; với 8–16 worker nên hạmaxQueueSizecòn 512–1024. PM2 gửiSIGINTkhistop/reloadchứ không phảiSIGTERM— lý do handler ở trên bắt cả hai — vàkill_timeoutmặc định chỉ 1600ms, để nguyên thì mọi worker đều bịSIGKILLgiữa lúc flush.
All rights reserved