0

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
JaegerPropagatordeprecated @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 b3traceparent, 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-idtraceparent; 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 ProcessCONSUMER. 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, baggagex-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:0 là 100% sampling. ot=th:cc0000000000000, 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 >= TRECORD_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, TraceIdRatioBasedSamplerchư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 attributestraceState. 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ônghttp.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

  • shouldSample chạy trên đường nóng, mỗi span một lần. Không I/O, không regex nặng, không JSON.parse, không đọc config từ disk. Dùng Set/Map dựng sẵn ở module scope như ví dụ trên.
  • attributes trả về trong SamplingResult đượ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 tag sampling.rule=critical-path trên Jaeger, hoặc thêm sampling.rule vào dimensions của spanmetrics, 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.route chính xác, cần status hay latency thì đừng cố làm ở head-based — đó là việc của tail sampling.
  • Với /healthz/metrics, chặn ở tầng instrumentation (ignoreIncomingRequestHook củ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 contribk8s — 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_wait và kích thước trace. Tăng decision_wait từ 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 đặt memory_limiter đầu pipeline, GOMEMLIMIT ≈ 80% hard limit; nghẽn ở khâu gom thì tăng num_shards (mặc định 1, tối đa 256).
  • Span đến sau decision_wait bị 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 loadbalancing exporter với routing_key: traceID phí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]

spanmetricsalpha (contrib), sinh traces.span.metrics.callstraces.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

  1. 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_spansotelcol_exporter_sent_spans, đừng đoán.
  2. 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?
  3. Route nào là đường tiền, bắt buộc giữ 100% dù có tốn kém?
  4. Đường nào sinh span rác (health check, scrape, polling) cần chặn ngay ở tầng instrumentation?
  5. Có dựng metrics hay alert từ span không? Nếu có, spanmetrics đã nằm trước tail_sampling chưa?
  6. Nếu dùng tail sampling: p99 tổng thời gian một trace là bao nhiêu, decision_wait có lớn hơn con số đó không, và đã có tầng loadbalancing với routing_key: traceID chưa?
  7. 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?
  8. Trước khi hạ tỉ lệ xuống dưới 100%: log đã mang trace_id kè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-web thà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
@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
@opentelemetry/semantic-conventions hằng số ATTR_* cho tên attribute
@opentelemetry/sdk-trace-base BatchSpanProcessor, ConsoleSpanExporter, ParentBasedSampler, TraceIdRatioBasedSampler
@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ệnh node thì không chắc, vì PM2 khởi chạy entry bằng launcher riêng và không truyền lại execArgv. An toàn nhất: export NODE_OPTIONS="--require ./dist/tracing.js", hoặc node_args: ['--require', './dist/tracing.js'] trong ecosystem.config.js, hoặc cluster.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.id phải phân biệt được từng worker. N worker cùng một service.instance.id là 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êm process.env.NODE_APP_INSTANCE (PM2 đặt) hoặc cluster.worker?.id, dự phòng bằng process.pid.
  • Tài nguyên nhân theo số worker. N hàng đợi × maxQueueSize span trong RAM và N kết nối tới Collector; với 8–16 worker nên hạ maxQueueSize còn 512–1024. PM2 gửi SIGINT khi stop/reload chứ không phải SIGTERM — lý do handler ở trên bắt cả hai — và kill_timeout mặc định chỉ 1600ms, để nguyên thì mọi worker đều bị SIGKILL giữa lúc flush.

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í