0

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

Bẫy khi đọc trace

  • Clock skew: span con hiện ra như bắt đầu trước span cha, hoặc kết thúc sau span cha, vì đồng hồ hai máy lệch nhau. Jaeger tự hiệu chỉnh theo quan hệ nhân quả giữa các span và gắn nhãn cảnh báo lên span bị dịch; biên độ đặt bằng jaeger_query.max_clock_skew_adjust (0s để tắt hẳn, xem số liệu thô). Duration từng span vẫn đúng; chỉ vị trí tương đối giữa các process là không tin được. Bật NTP/chrony trên mọi node.
  • Span bị drop: trace mở ra chỉ có một span, hoặc thiếu hẳn nhánh service phía sau. Thường do queue của exporter hoặc Collector đầy (otelcol_exporter_send_failed_spans, otelcol_processor_refused_spans tăng), hoặc process thoát trước khi shutdown() kịp flush.
  • Sampling: request vừa gửi nhưng tìm không ra trace — bình thường nếu head-based sampling đang bỏ phần lớn request (Phần 4); với tail sampling còn có độ trễ decision_wait. Khi debug, tạm đặt sampler về always_on cho một service duy nhất.
  • Trace trông "thủng" nhưng không phải lỗi: một service trong đường đi chưa cài instrumentation, hoặc context propagation bị đứt (Phần 3) làm nhánh phía sau tách thành trace riêng.

Lựa chọn khác

Grafana Tempo hợp khi đã có Grafana + Loki + Prometheus và muốn một mặt phẳng quan sát duy nhất; lưu trace trên object storage (S3/GCS) nên rẻ ở quy mô lớn, đổi lại search theo tag yếu hơn nếu không bật index. Zipkin nhẹ hơn nhưng hệ sinh thái và UI kém xa. Các SaaS (Datadog, Honeycomb, New Relic, Grafana Cloud...) bỏ hẳn phần vận hành storage, chi phí tính theo lượng span nhập vào. Cả ba nhóm đều nhận OTLP, nên đổi backend chỉ là đổi endpoint ở exporter của Collector, không sửa dòng code ứng dụng nào — trong lúc chuyển đổi có thể fan-out song song sang hai backend rồi mới tắt cái cũ.

Tài liệu tra sau: jaegertracing.io/docs, Architecture & APIs, Storage, Sampling, SPM, Frontend/UI configuration, github.com/jaegertracing/jaeger.


Phần 9 — Semantic conventions và đặt tên

9.1 Tên attribute là hợp đồng máy đọc, không phải nhãn trang trí

Một span có đủ dữ liệu nhưng sai tên attribute thì với backend là dữ liệu vô danh. Mọi thứ hữu ích phía sau trace đều gắn cứng vào tên chuẩn:

Thành phần Phụ thuộc vào tên nào Hỏng ra sao khi đặt sai
Jaeger UI — filter, tab Monitor (SPM) service.name, tên span, SpanKind, span status Không lọc được theo endpoint; RED metrics rỗng hoặc nổ chiều
servicegraph connector (xem Phần 7) SpanKind CLIENT/SERVER + service.name trên cả hai đầu Service graph mất cạnh, đầy unpaired_spans
spanmetrics connector tên span, span.kind, status.code Metric calls/duration không nhóm được theo route
Alert / dashboard tên attribute trong query Panel trống im lặng, không báo lỗi
Sampling policy theo attribute (xem Phần 4) http.response.status_code, error.type... Policy không match, trace lỗi bị drop

Quy tắc rút ra: không tự nghĩ tên nếu semantic conventions đã có tên. Semconv là repo open-telemetry/semantic-conventions, phát hành độc lập với SDK; bản mới nhất khi biên soạn là v1.44.0 (2026-08-04). Package JS @opentelemetry/semantic-conventions bám theo số version của spec semconv (1.43.0 ứng với semconv v1.43.0).

9.2 Stable, Release Candidate, Development, Deprecated

Registry dùng bốn mức stability. Ý nghĩa thực tế:

Mức Cam kết Hệ quả vận hành
Stable Tên và ngữ nghĩa không đổi trong cùng major Dùng thẳng trong dashboard/alert dài hạn
Release Candidate (RC) Gần chốt, còn có thể chỉnh Dùng được, nhưng ghi vào tài liệu nội bộ để rà lại khi lên Stable
Development (tên cũ: Experimental) Có thể đổi tên hoặc bỏ ở bất kỳ minor release nào Dashboard/alert dựa vào nó sẽ vỡ im lặng khi nâng version instrumentation
Deprecated Còn phát nhưng đã có tên thay thế Phải lên kế hoạch migration, không xây thêm dashboard mới trên nó

Trong @opentelemetry/semantic-conventions, entry point chính chỉ chứa convention Stable; convention chưa ổn định nằm ở subpath @opentelemetry/semantic-conventions/incubating, và README ghi rõ subpath này "NOT subject to the restrictions of semantic versioning and MAY contain breaking changes in minor releases". Hằng theo quy ước ATTR_<attribute>, METRIC_<metric>, EVENT_<event>, <ATTR>_VALUE_<ENUM>; các dạng cũ SemanticAttributes/SemanticResourceAttributes deprecated từ v1.0.0 và SEMATTRS_*/SEMRESATTRS_*/DBSYSTEMVALUES_* deprecated từ v1.26.0.

Trạng thái từng nhóm:

Nhóm Trạng thái hiện hành
HTTP spans (http.*, url.*, server.*, client.address, network.*, user_agent.original, error.type) Stable — nhóm đầu tiên được tuyên bố stable, tại semconv v1.23.0 (2023-11-03)
Database span attribute (db.system.name, db.namespace, db.query.*, db.collection.name, db.operation.*, db.response.status_code) Stable
Exception (exception.type/message/stacktrace) Stable; exception.escaped Deprecated
Resource: service, deployment.environment.name, telemetry.sdk.* Stable
Kubernetes k8s.*, container.id, container.image.name, container.image.tags Stable; container.name còn Development
RPC, feature_flag.*, cicd.* Release Candidate
Messaging (toàn bộ messaging.*) Development
cloud.*, host.*, faas.*, user.*, enduser.* Development — riêng enduser.id chưa deprecated, chỉ enduser.roleuser.rolesenduser.scope bị bỏ
gen_ai.* Đã tách sang repo riêng, không có attribute nào Stable

9.3 HTTP — nhóm trung tâm

Đây là nhóm mọi service e-commerce đụng tới đầu tiên và cũng là nhóm bị đổi tên nhiều nhất.

Attribute (Stable) Ý nghĩa Mức yêu cầu
http.request.method Method đã chuẩn hoá; ngoài 9 method phổ biến đều thành _OTHER Required (client + server)
http.request.method_original Method thô khi giá trị trên là _OTHER Conditionally Required
http.response.status_code Mã HTTP trả về Conditionally Required
http.route Template route, ví dụ /api/products/:id Conditionally Required (server)
url.full URL đầy đủ, đã redact credential Required (client)
url.path Phần path Required (server)
url.scheme http / https Required (server), Opt-In (client)
url.query Query string Conditionally Required (server)
server.address Host phía server Required (client), Recommended (server)
server.port Port phía server Required (client), Conditionally Required (server)
client.address IP client, sau khi tính forwarding header Recommended (server)
network.protocol.name / network.protocol.version http + 1.1/2/3 Conditionally Required (khi protocol không phải HTTP) / Recommended
user_agent.original User-Agent thô Recommended (server), Opt-In (client)
error.type Phân loại lỗi low-cardinality, fallback _OTHER Conditionally Required

Span status HTTP: 1xx/2xx/3xx là Unset; 4xx là Error với SpanKind CLIENT nhưng MUST để Unset với SpanKind SERVER; 5xx là Error ở cả hai phía.

Mapping tên cũ sang tên mới

Tên cũ (Deprecated) Tên hiện hành
http.method http.request.method
http.status_code http.response.status_code
http.url url.full
http.target url.path + url.query (tách làm hai)
http.scheme url.scheme
http.host server.address / client.address
http.flavor network.protocol.name + network.protocol.version (giá trị đổi dạng: 2.02, 3.03)
http.user_agent user_agent.original
http.client_ip client.address
http.server_name server.address
http.request_content_length / http.response_content_length http.request.body.size / http.response.body.size (Development, Opt-In)
net.peer.name / net.peer.port server.address / server.port
net.host.name / net.host.port server.address / server.port — trên server span giá trị đến từ Host header nên phải chuẩn hoá trước khi dùng làm chiều nhóm
net.sock.peer.addr / net.sock.host.addr network.peer.address / network.local.address
net.transport network.transport
net.sock.family, net.sock.peer.name Bỏ hẳn, không có thay thế

Cảnh báo: đây không phải phép đổi tên 1-1. http.target tách thành hai attribute, còn http.host đổi đích tuỳ SpanKind. Mọi dashboard/alert dùng http.target phải viết lại query chứ không rename được bằng regex.

9.4 OTEL_SEMCONV_STABILITY_OPT_IN và lộ trình migration

Biến môi trường này nhận danh sách cách nhau bằng dấu phẩy, quyết định instrumentation phát bộ tên nào.

Giá trị Ý nghĩa
(không đặt) Tiếp tục phát bộ convention (experimental) như trước
http Chỉ phát bộ HTTP + networking Stable, ngừng phát bộ cũ
http/dup Phát cả hai bộ cùng lúc
database / database/dup Tương tự cho database
messaging / messaging/dup Tương tự cho messaging
rpc / rpc/dup Tương tự cho RPC

Giá trị trần (http) là chế độ cuối; giá trị /dup là chế độ chuyển tiếp và có độ ưu tiên cao hơn khi cả hai cùng xuất hiện trong danh sách.

flowchart LR
  A["Bước 1: bật /dup — span mang CẢ hai bộ tên"] --> B["Bước 2: sửa dashboard, alert, sampling policy sang tên mới; chạy song song 1-2 chu kỳ retention"]
  B --> C["Bước 3: bỏ hậu tố /dup — ngừng phát tên cũ, span nhỏ lại"]
# Bước 1 — bật dup cho toàn bộ service đang dùng tên cũ
export OTEL_SEMCONV_STABILITY_OPT_IN=http/dup,database/dup

# Bước 3 — sau khi mọi query đã đổi
export OTEL_SEMCONV_STABILITY_OPT_IN=http,database

Chi phí của bước 1 là span phình ra (mỗi attribute ghi hai lần) nên đừng để lâu quá thời gian retention. Bước 2 chỉ xong khi không còn panel hay alert nào tham chiếu tên cũ.

9.5 Database

Attribute Trạng thái Mức yêu cầu Ý nghĩa
db.system.name Stable Required DBMS; giá trị Stable gồm postgresql, mysql, mariadb, microsoft.sql_server (mẫu <vendor>.<product>), còn mongodb, redis, elasticsearch vẫn Development
db.namespace Stable Conditionally Required Tên database, định danh trong phạm vi server.address:server.port
db.collection.name Stable Conditionally Required Tên bảng / collection / container
db.operation.name Stable Conditionally Required SELECT, INSERT, findOne... giữ nguyên giá trị app cung cấp
db.response.status_code Stable Conditionally Required Mã trả về của DB, thường là mã lỗi
db.query.text Stable Recommended Câu query; query chưa tham số hoá phải sanitize trước khi ghi
db.query.summary Stable Recommended Tóm tắt low-cardinality của query, dùng làm tên span
db.stored_procedure.name, db.operation.batch.size Stable Recommended

Mapping cũ sang mới: db.systemdb.system.name; db.namedb.namespace; db.statementdb.query.text; db.operationdb.operation.name; db.sql.table / db.cassandra.table / db.mongodb.collection / db.cosmosdb.containerdb.collection.name. Riêng db.connection_stringdb.user bị bỏ hẳn — đừng tự phát lại chúng, đó là kênh rò credential.

Quy tắc sanitize db.query.text: spec yêu cầu thay mọi literal (chuỗi, số, ngày giờ, boolean, nhị phân, hex) bằng placeholder; query đã tham số hoá thì giữ nguyên vì giá trị đi đường khác. Nếu ORM sinh SQL đã inline literal thì thay trước khi set attribute, hoặc tắt hẳn query text cho bảng chứa PII — đây là nơi số điện thoại, email và mã đơn hàng rò ra ngoài nhiều nhất.

9.6 Messaging

Toàn bộ messaging.* còn ở mức Development — dùng được nhưng phải chấp nhận rủi ro đổi tên: messaging.system, messaging.destination.name, messaging.operation.type, messaging.operation.name, messaging.message.id, messaging.message.body.size, messaging.consumer.group.name, messaging.kafka.offset, messaging.kafka.message.key, messaging.kafka.cluster.id. Đã deprecated: messaging.operationmessaging.operation.type, messaging.kafka.message.offsetmessaging.kafka.offset, messaging.kafka.consumer.groupmessaging.consumer.group.name.

SpanKind theo spec:

Operation SpanKind Ghi chú
Create PRODUCER Chỉ cho single message
Send PRODUCER Khi Send span chính là context tạo message
Send CLIENT Khi đã có Create span riêng
Receive CLIENT Pull-based
Process CONSUMER Push-based
Settle CLIENT Ack / settlement

Nối producer với consumer: spec dùng span Link làm cơ chế mặc định, không phải parent. Một span chỉ có đúng một parent, nên khi consumer đọc batch 500 message thuộc 500 trace khác nhau thì không parent nào đúng. Ngoại lệ duy nhất: span Process được phép lấy message creation context làm parent khi xử lý đúng một message.

9.7 RPC / gRPC

Nhóm RPC đang ở Release Candidate và vừa đổi tên: rpc.system Deprecatedrpc.system.name; rpc.service Deprecated — giá trị được gộp vào rpc.method, vốn được kỳ vọng là tên fully-qualified; rpc.grpc.status_code Deprecated → dùng dạng chuỗi của mã gRPC trên rpc.response.status_code. RPC span yêu cầu rpc.system.name (Required), kèm rpc.method, rpc.response.status_code, error.type, server.address, server.port (Conditionally Required). Nếu instrumentation đang phát bộ cũ, chuyển đổi bằng OTEL_SEMCONV_STABILITY_OPT_IN=rpc/dup theo lộ trình ở mục 9.4.

9.8 Exception

exception.type, exception.message, exception.stacktrace đều Stable. exception.escaped đã Deprecated, lý do nêu trong spec: không còn khuyến nghị ghi nhận exception đã được xử lý và không thoát khỏi phạm vi span. Nhiều tài liệu cũ vẫn liệt kê nó như attribute hợp lệ — đừng chép theo.

9.9 Resource và service

Attribute Trạng thái Ghi chú
service.name Stable, bắt buộc Phải giống nhau cho mọi instance của cùng service scale ngang
service.version Stable Gắn version build/deploy
service.namespace Stable Nhóm logic, ví dụ shop-core
service.instance.id Stable Bộ ba service.namespace + service.name + service.instance.id phải duy nhất toàn cục
deployment.environment.name Stable Ví dụ development, staging, production. Tên cũ deployment.environment đã Deprecated
telemetry.sdk.name / .language / .version, telemetry.distro.name / .version Stable SDK tự set: telemetry.sdk.name = opentelemetry, telemetry.sdk.language = nodejs
k8s.*, container.id, container.image.name, container.image.tags Stable Do resource detector điền
container.name, host.*, cloud.* Development Dùng được nhưng đừng xây alert cứng lên chúng

Lưu ý: deployment.environment.name không tham gia ràng buộc duy nhất của service — hai resource cùng service.name khác environment vẫn là cùng một service theo spec. Muốn tách staging khỏi production thì phải tách backend hoặc tách service.namespace.

9.10 GenAI và các nhóm mới

gen_ai.* đã chuyển sang repo riêng open-telemetry/semantic-conventions-genaikhông có attribute nào Stable; nhiều tên đã đổi (gen_ai.systemgen_ai.provider.name, gen_ai.usage.prompt_tokensgen_ai.usage.input_tokens, gen_ai.prompt/gen_ai.completion deprecated). Coi cả nhóm này lẫn feature_flag.*, cicd.* (RC) là dữ liệu tạm: ghi được, nhưng dashboard dựng trên chúng phải có người chịu trách nhiệm rà lại mỗi lần nâng version instrumentation.

9.11 Quy tắc đặt tên span

Tên span là chiều aggregation chính của backend. Spec quy định rõ theo từng loại:

Loại Quy tắc Fallback
HTTP SERVER {method} {http.route}MUST NOT mặc định lấy URI path làm target {method} khi chưa resolve được route
HTTP CLIENT {method} {url.template} khi template có sẵn và được bật {method}; {method} = HTTP nếu method là _OTHER
Database 1) {db.query.summary} 2) {db.operation.name} {target} 3) {target} 4) {db.system.name} {target} = collection / stored procedure / namespace / server.address:server.port
Messaging {messaging.operation.name} {destination} destination ưu tiên messaging.destination.templatemessaging.destination.nameserver.address:server.port
RPC {rpc.method} nếu có và khác _OTHER {rpc.system.name}
INTERNAL Tên hàm hoặc nghiệp vụ ổn định, tập giá trị hữu hạn: checkout.validate_cart

Đúng và sai, kèm hậu quả:

Sai Đúng Hậu quả khi để sai
GET /api/products/98213 GET /api/products/:id Nổ cardinality: mỗi product một tên span, SPM vô dụng
SELECT * FROM orders WHERE id=771 SELECT orders Không nhóm được truy vấn chậm theo bảng
handleRequest POST /api/checkout Mọi endpoint gộp một tên, không tách được p99 theo route
publish publish shop.orders Không phân biệt được topic nào đang backlog
job-2026-09-08T10:00:00Z nightly.reindex_catalog Mỗi lần chạy là một operation mới, không vẽ được xu hướng
getUser(email=a@b.vn) user.get_by_email Rò PII thẳng vào chỉ mục tên span

9.12 Cardinality và chi phí

Backend index tên span như một chiều aggregation. Khi tên span chứa ID, mỗi request sinh một giá trị mới: chỉ mục phình tuyến tính theo traffic, mỗi tên sinh thêm một time series ở tầng metric, và query chậm dần tới lúc timeout. Mùa sale, một endpoint /products/{id} đặt tên sai có thể sinh hàng trăm nghìn series trong một giờ; chi phí đó không giảm khi traffic hạ vì index đã ghi.

Danh sách không bao giờ đưa vào tên span hoặc vào attribute có index:

Không dùng Thay bằng
ID bản ghi (order id, product id, user id) Attribute nghiệp vụ riêng, không đưa vào tên span
Email, số điện thoại, địa chỉ enduser.pseudo.id (Development) hoặc giá trị đã hash
Token, session id, API key, Authorization header Không ghi; nếu instrumentation lỡ ghi thì redact ở Collector
Timestamp, UUID ngẫu nhiên, request id Đưa vào attribute; trace id đã là định danh của request
Query string thô chứa tham số người dùng url.path + url.query đã sanitize

url.full phải redact credential dạng https://user:pass@host thành https://REDACTED:REDACTED@host; ngoài ra spec liệt kê sẵn các tham số nhạy cảm phải thay value bằng REDACTED: X-Amz-Signature, X-Amz-Credential, X-Amz-Security-Token (AWS), sig (Azure SAS) và X-Goog-Signature (Google signed URL). Key của query string vẫn được giữ lại kể cả khi value bị che.

9.13 Attribute nghiệp vụ do tổ chức tự đặt

Nghiệp vụ e-commerce luôn cần thứ semconv không có. Quy ước:

  • Chọn đúng một namespace của tổ chức rồi dùng thống nhất cho mọi service. Tài liệu này chốt shop.* làm tiền tố duy nhất trong mọi ví dụ.
  • Tuyệt đối không dùng tiền tố của OTel (http., db., messaging., service., k8s., url....) — tên trùng làm backend hiểu sai và sẽ va chạm khi OTel stabilize thêm attribute.
  • Đặt tên snake_case, phân cấp bằng dấu chấm theo phong cách semconv: <org>.<domain>.<thuộc tính>.
  • Giá trị phải low cardinality nếu dùng để nhóm; giá trị cardinality cao chỉ để tra một trace cụ thể.
  • Tài liệu hoá trong một file nội bộ duy nhất, review khi thêm mới như review schema DB.

shop. ở đây là placeholder cho tên tổ chức thật — khi triển khai, thay bằng một giá trị duy nhất (hasaki., acme....) và thay đồng loạt. Các phần khác của tài liệu đôi chỗ viết hasaki.* (tên tổ chức thật) hoặc app.* (placeholder trung tính); đó là cùng một namespace, chỉ khác chữ ở tiền tố, không phải ba họ attribute song song. Trộn lẫn chúng trong cùng một hệ thống là lỗi nặng và im lặng: shop.order.id, app.order.idhasaki.order.id là ba chiều aggregation khác nhau, nên một dashboard nhóm theo tiền tố này sẽ bỏ sót toàn bộ span mang tiền tố kia mà không hề báo lỗi. Chốt giá trị trong file semconv.ts dưới đây, và ép bằng CI (xem Weaver ở mục 9.14).

Attribute Kiểu Cardinality Dùng để
shop.order.id string cao Tra một đơn cụ thể, không dùng để nhóm
shop.order.status string thấp Nhóm theo trạng thái đơn
shop.payment.method string thấp So sánh latency theo cổng thanh toán
shop.cart.item_count int thấp Tương quan giỏ lớn với latency
shop.checkout.step string thấp Xác định bước rớt trong luồng checkout
shop.promotion.campaign_code string trung bình Đo tải theo campaign
shop.customer.tier string thấp Tách trải nghiệm khách VIP
// Hằng Stable: import thẳng từ entry point chính
import { ATTR_HTTP_ROUTE, ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';

// Hằng chưa Stable và attribute nghiệp vụ: khai báo tập trung trong một file semconv.ts
export const ATTR_ENDUSER_PSEUDO_ID = 'enduser.pseudo.id'; // Development, chép từ incubating

// Tiền tố nghiệp vụ khai báo đúng một lần; đổi sang tên tổ chức thật thì sửa duy nhất dòng này
const ORG = 'shop' as const;
export const ATTR_ORDER_STATUS = `${ORG}.order.status`;
export const ATTR_PAYMENT_METHOD = `${ORG}.payment.method`;

// Tập chiều được phép dùng để nhóm trong dashboard
export const GROUPING_KEYS = [ATTR_SERVICE_NAME, ATTR_HTTP_ROUTE, ATTR_ORDER_STATUS];

Với attribute chưa Stable, khuyến nghị chính thức của package JS là không import @opentelemetry/semantic-conventions/incubating trong runtime code mà chép hằng cần dùng vào file semconv.ts của chính mình, để breaking change ở minor release không kéo sập build.

9.14 Công cụ tra cứu

  • Semantic conventions registry — tra nhanh tên chính xác, kiểu, stability, enum hợp lệ. Đây là nguồn đúng duy nhất.
  • Trang migration HTTPdatabase — bảng mapping cũ sang mới đầy đủ.
  • Thư mục model/ trong repo semantic-conventions — nguồn YAML gốc sinh ra registry; diff giữa hai release cho biết chính xác attribute nào đổi tên.
  • Telemetry schema: mỗi bản semconv có một file schema tại https://opentelemetry.io/schemas/<version> (ví dụ .../1.44.0), khai báo file_format, schema_url và các khối changes với rename_attributes cho từng version. SDK gắn schema_url vào resource và instrumentation scope, nên backend hoặc công cụ có thể tự dịch tên cũ sang tên mới thay vì đoán.
  • OpenTelemetry Weaver — sinh code hằng attribute từ model YAML và kiểm tra telemetry thực tế theo một registry riêng của tổ chức; dùng khi muốn ép quy ước shop.* bằng CI.
  • Autocomplete ATTR_ trong IDE — cách tra nhanh nhất khi đang viết code.

Khi cần chuẩn hoá tên trên dữ liệu đã phát ra — service legacy, thư viện bên thứ ba không sửa được — việc rename thực hiện tập trung bằng OTTL trong Collector, xem Phần 7.


Phần 10 — Vận hành production: chi phí, bảo mật, Kubernetes

Ước lượng chi phí và dung lượng

Toàn bộ chi phí lưu trữ trace quy về một phép nhân. Đặt:

Ký hiệu Ý nghĩa Cách lấy số
RPS request/giây trung bình (không phải peak) metrics của ingress
S số span sinh ra trên mỗi request mở một trace thật trong Jaeger, đếm span
P tỷ lệ sampling hiệu dụng (0..1) head-based ratio nhân tỷ lệ giữ lại của tail-based (Phần 4)
B kích thước trung bình một span sau nén (byte) đo thực tế, xem bên dưới
D retention (ngày) policy của backend
F hệ số nhân của backend replication (1 replica = 2) nhân index overhead (Elasticsearch/OpenSearch thường 1,2–1,5)
Bytes/ngày  = RPS × S × P × B × 86400
GB/ngày     = Bytes/ngày / 1e9
GB lưu trữ  = GB/ngày × D × F

Đo B: chia lượng byte Collector xuất đi cho số span xuất đi trong cùng một khoảng (otelcol_exporter_sent_spans, telemetry nội bộ cổng 8888). Ước lượng thô: span OTLP/protobuf sau gzip thường 300–600 byte với 10–20 attribute; span mang db.query.text dài hoặc exception.stacktrace gấp nhiều lần.

Tham số Nhỏ Trung bình Lớn
RPS 20 200 2.000
S (span/request) 10 25 40
P (sampling) 1,0 0,2 0,05
B (byte/span sau nén) 400 450 500
Span/giây thực gửi 200 1.000 4.000
GB/ngày 6,9 38,9 172,8
D (retention) 7 ngày 14 ngày 30 ngày
F 1 (single node) 2 2
GB lưu trữ ≈ 48 ≈ 1.089 (1,1 TB) ≈ 10.368 (10,4 TB)

Cột "GB lưu trữ" chưa tính index overhead của Elasticsearch/OpenSearch; nhân thêm 1,2–1,5 nếu dùng backend đó. Retention do backend thực thi (Phần 8).

Biến Hành động Tác động Đánh đổi
P head-based ratio kết hợp tail-based giữ lỗi và request chậm (Phần 4) tuyến tính, đòn bẩy mạnh nhất mất trace của request bình thường
S giữ @opentelemetry/instrumentation-fs ở trạng thái tắt (mặc định đã tắt trong auto-instrumentations-node), drop span health check, /metrics, static asset 20–50% số span ở app Node.js điển hình mất chi tiết thao tác file
B bỏ attribute lớn: SQL kèm giá trị, request body, dump header; đặt OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT (mặc định theo spec là không giới hạn) tuyến tính debug query chậm khó hơn
B nén: OTLP/gRPC hoặc http/protobuf kèm gzip thay cho http/json nhiều lần so với JSON không nén thêm CPU ở exporter
D giảm retention, tách archive cho trace cần giữ lâu tuyến tính mất khả năng điều tra sự cố cũ
F hạ replica ở môi trường non-prod 2 lần mất dữ liệu khi node chết

Span health check và readiness probe là nguồn rác lớn nhất trong Kubernetes: mỗi pod bị probe vài lần mỗi giây. Chặn bằng ignoreIncomingRequestHook của @opentelemetry/instrumentation-http là rẻ nhất vì span không bao giờ được tạo (Phần 5B); filter processor ở Collector (alpha, cú pháp hiện hành trace_conditions, cú pháp cũ traces: span: đã deprecated) chỉ là lưới chắn cho service không sửa được — cấu hình ở Phần 7.

Overhead thực tế và cách tự đo

Thành phần Nơi tiêu tốn Ghi chú
Tạo span cấp phát object, sinh trace-id/span-id, ghi attribute rẻ, nhưng tuyến tính theo S
Context propagation AsyncLocalStorage qua @opentelemetry/context-async-hooks theo tần suất chuyển context, không theo số span
Serialize protobuf encode trong BatchSpanProcessor tăng theo tổng kích thước attribute
Export HTTP/gRPC cộng gzip chạy nền nhưng vẫn cạnh tranh CPU với event loop
Batch/flush tick định kỳ, gom mảng span gây gai p99 nếu batch quá lớn

Phạm vi hay được nhắc tới là vài phần trăm CPU với instrumentation HTTP/DB ở mức span vừa phải, lên tới hai chữ số phần trăm khi S lớn hoặc attribute dài. Đừng mang con số của người khác về dùng. Instrumentation HTTP và DB (pg, mysql2, ioredis) thường rẻ; đắt là @opentelemetry/instrumentation-fs — sinh span cho từng thao tác file, đúng lý do nó nằm trong hai instrumentation bị tắt mặc định — và cấu hình ghi query text đầy đủ vào db.query.text.

Cách tự đo thay vì tin con số trên mạng:

  1. Hai deployment giống hệt nhau, khác đúng một biến: có hoặc không có bước khởi tạo SDK.
  2. Bắn tải giống nhau bằng k6 hoặc autocannon, tải cố định, đủ dài để qua warm-up của JIT (5–10 phút).
  3. So p50/p95/p99 latency đo phía client, CPU (process.cpuUsage() hoặc cgroup), RSS (process.memoryUsage().rss) và throughput tối đa trước khi p99 vỡ.
  4. Đo từng instrumentation riêng bằng OTEL_NODE_ENABLED_INSTRUMENTATIONS, bật đúng một cái mỗi lần. Nếu đặt cả biến này và OTEL_NODE_DISABLED_INSTRUMENTATIONS thì ENABLED áp dụng trước rồi DISABLED áp lên kết quả, nên tên nằm ở cả hai sẽ bị tắt.
  5. Thêm biến thể "SDK bật nhưng sampler AlwaysOff" để tách chi phí tạo span khỏi chi phí export.
# cùng kịch bản, chỉ đổi biến môi trường giữa hai lần chạy
autocannon -c 50 -d 300 http://localhost:3000/api/checkout

Ghi kết quả vào repo kèm version package đã dùng: overhead đổi theo version instrumentation nên số đo cũ nhanh hết giá trị.

Bảo mật và PII

Loại dữ liệu Ví dụ Thay bằng
Thông tin xác thực mật khẩu, JWT, API key, refresh token không ghi gì
Header nhạy cảm Authorization, Cookie, Set-Cookie, X-Api-Key allowlist header, mặc định không ghi
Thanh toán số thẻ, CVV, tên chủ thẻ id giao dịch nội bộ
Định danh cá nhân CMND/CCCD, email, số điện thoại, địa chỉ, họ tên user id nội bộ đã pseudonym hoá
Nội dung body request/response, payload webhook, giỏ hàng đầy đủ số lượng item, tổng tiền theo khoảng
Query SQL kèm giá trị tham số db.query.summary, hoặc query đã tham số hoá

Attribute enduser.id trong semantic conventions vẫn ở mức Development và chưa deprecated (enduser.pseudo.id cũng Development). Nếu bắt buộc gắn danh tính vào trace, dùng giá trị đã băm, không dùng email làm giá trị attribute.

Tầng Nơi thực thi Công cụ Vai trò
1. Không sinh ra SDK trong app allowlist attribute trong code, tắt option ghi query đầy đủ, spanLimits rẻ nhất và đúng nhất
2. Xoá trên đường đi Collector redaction processor (beta cho traces, alpha cho logs/metrics), transform với OTTL, attributes với action delete/hash lưới an toàn khi lập trình viên lỡ tay
3. Kiểm soát truy cập Backend Jaeger không có authn/authz sẵn: phải bọc bằng reverse proxy có SSO, tách backend theo tenant, audit log ở proxy (mục "Bảo vệ Jaeger" bên dưới) giới hạn ai đọc được

Tầng 1 nên làm bằng một hàm dùng chung, không để mỗi người tự gọi setAttribute:

// telemetry/attrs.ts — chỉ cho phép attribute nghiệp vụ đã đăng ký
import type { Attributes } from '@opentelemetry/api';

const ALLOWED = new Set(['app.order.id', 'app.order.status', 'app.cart.item_count']);

export function safeAttrs(input: Attributes): Attributes {
  const out: Attributes = {};
  for (const [key, value] of Object.entries(input)) {
    if (ALLOWED.has(key)) out[key] = value;
  }
  return out;
}

Tầng 2 vẫn nên có kể cả khi tin tầng 1. redaction nhận allow_all_keys: false cộng allowed_keys (allowlist), blocked_key_patternsblocked_values (regex, che bằng mask hoặc hash_function); nhớ rằng khai báo processor mà không đưa vào service.pipelines thì nó không chạy. Cấu hình đầy đủ ở Phần 7.

Kiểm chứng lớp xoá PII

Ba tầng ở trên chỉ có giá trị nếu chứng minh được chúng đang chạy. Một regex viết sai trong transform/pii sẽ im lặng không khớp gì cảerror_mode mặc định là propagate, nhưng một điều kiện OTTL đúng cú pháp mà sai nội dung thì không sinh lỗi nào để mà propagate — và không ai biết cho tới khi có sự cố tuân thủ. Quy trình dưới đây chạy được bằng máy, lặp lại được, và đưa vào CI được.

Bước 1 — bắn một span mồi (canary). Một span chứa đủ mọi loại dữ liệu trong bảng PII ở trên, mỗi giá trị là một chuỗi dễ grep:

{
  "resourceSpans": [{
    "resource": { "attributes": [
      { "key": "service.name", "value": { "stringValue": "pii-canary" } }
    ]},
    "scopeSpans": [{
      "scope": { "name": "pii-canary" },
      "spans": [{
        "traceId": "__TRACE__", "spanId": "__SPAN__", "name": "canary",
        "kind": 2, "startTimeUnixNano": "__TS__", "endTimeUnixNano": "__TS__",
        "attributes": [
          { "key": "shop.email",       "value": { "stringValue": "canary@test.invalid" } },
          { "key": "shop.card_number", "value": { "stringValue": "4111111111111111" } },
          { "key": "authorization",    "value": { "stringValue": "Bearer CANARY-TOKEN" } },
          { "key": "shop.user_id",     "value": { "stringValue": "CANARY-USER-42" } },
          { "key": "db.query.text",    "value": { "stringValue": "SELECT id FROM users WHERE email = 'canary@test.invalid'" } }
        ]
      }]
    }]
  }]
}
# canary-span.json là file trên; gửi thẳng vào pipeline Collector đang chạy
TRACE_ID=$(openssl rand -hex 16)
sed -e "s/__TRACE__/${TRACE_ID}/" -e "s/__SPAN__/$(openssl rand -hex 8)/" \
    -e "s/__TS__/$(( $(date +%s) * 1000000000 ))/" canary-span.json \
  | curl -sS -X POST http://localhost:4318/v1/traces \
      -H 'Content-Type: application/json' --data-binary @-
echo "$TRACE_ID"

Bước 2 — đọc lại đầu ra của pipeline. Thêm tạm một file exporter song song với exporter thật (exporter chạy ở cuối pipeline nên thấy đúng dữ liệu sau mọi processor):

exporters:
  file/canary:
    path: /tmp/canary-out.json
service:
  pipelines:
    traces:
      exporters: [otlp/jaeger, file/canary]

Hoặc bỏ qua file exporter và query lại chính trace-id đó trên Jaeger sau khi hết decision_wait.

Bước 3 — assert. Không còn giá trị mồi nào, và giá trị thay thế đã xuất hiện:

if grep -i -E 'canary@test|4111111111111111|CANARY-TOKEN|CANARY-USER-42' /tmp/canary-out.json; then
  echo "PII RÒ RỈ: chuỗi mồi còn nguyên trong đầu ra Collector"; exit 1
fi
grep -q 'shop.user_hash' /tmp/canary-out.json || { echo "thiếu shop.user_hash: tầng 2 không chạy"; exit 1; }

Đóng gói ba bước thành một job CI của repo chứa config Collector: khởi Collector trong container với đúng config production, bắn payload mồi, grep, fail build nếu khớp. Đây là thứ chặn được commit làm hỏng regex — thứ mà rà soát bằng mắt không bao giờ chặn được.

Tầng 1 kiểm chứng ở SDK bằng một test Vitest (nối về mẫu test ở Phần 5B):

// telemetry/attrs.test.ts
import { describe, expect, it } from 'vitest';
import {
  BasicTracerProvider,
  InMemorySpanExporter,
  SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { safeAttrs } from './attrs.js';

describe('safeAttrs', () => {
  it('chỉ giữ attribute nằm trong allowlist', async () => {
    const exporter = new InMemorySpanExporter();
    const provider = new BasicTracerProvider({
      spanProcessors: [new SimpleSpanProcessor(exporter)],
    });

    const span = provider.getTracer('test').startSpan('checkout');
    span.setAttributes(safeAttrs({
      'app.order.id': 'o-1',
      'shop.email': 'canary@test.invalid',
    }));
    span.end();
    await provider.forceFlush();

    const attrs = exporter.getFinishedSpans()[0].attributes;
    expect(attrs['app.order.id']).toBe('o-1');
    expect(attrs['shop.email']).toBeUndefined();
  });
});

GDPR. Trace gần như không thể xoá theo cá nhân: dữ liệu rải trong hàng triệu span, không có index theo chủ thể dữ liệu, và Jaeger không có API xoá theo điều kiện. "Quyền được xoá" phải giải quyết bằng cách không ghi ngay từ đầu — trace chỉ chứa id nội bộ, việc ánh xạ id sang con người nằm ở hệ thống nghiệp vụ vốn xoá được. Retention ngắn chỉ là biện pháp bổ trợ.

Baggage đi ra ngoài phạm vi tổ chức: giá trị baggage được inject vào header của mọi outbound request mà instrumentation quản lý, kể cả khi gọi sang cổng thanh toán hay đối tác vận chuyển. Không đặt dữ liệu nhạy cảm vào baggage, và chỉ propagate tracecontext khi gọi ra ngoài (Phần 3).

traceparent đến từ Internet là input không tin cậy. Sampler parentbased_* mặc định tôn trọng sampled=1 của remote parent — nhánh remoteParentSampled mặc định là AlwaysOn — nên client tự đặt header ép được P lên 1,0 và biến hoá đơn telemetry thành đích tấn công, dạng DoS mà rate limit ở tầng ứng dụng không nhìn thấy. Ở biên public, xoá traceparent/tracestate/baggage tại ingress rồi tạo root span mới; ở biên đối tác, thay remoteParentSampled bằng một sampler có tỷ lệ. Chi tiết cấu hình sampler ở Phần 4.

Hướng multi-tenant Cách làm Phù hợp khi
Tách logic gắn tenant.id vào resource attribute, phân quyền đọc ở backend tenant nội bộ, tin tưởng backend
Tách vật lý routing connector tách theo otelcol.client.metadata sang pipeline và exporter riêng, mỗi tenant một index/cluster có ràng buộc hợp đồng hoặc pháp lý về cô lập dữ liệu

Bảo vệ Jaeger

Jaeger không có authentication hay authorization cho UI và query API. Không có user, không có role, không có tenant filter, không có audit log. Mọi phương án bảo vệ đều là lớp bọc bên ngoài. Điều này quan trọng hơn vẻ ngoài của nó: tài liệu này vừa hướng dẫn nhét order id, tenant id, tier khách hàng, http.routedb.query.summary vào span — nghĩa là ai mở được cổng 16686 là đọc được toàn bộ luồng nghiệp vụ, kể cả khi ba tầng xoá PII ở trên đã làm đúng việc của chúng.

Bề mặt Cổng Nguyên tắc
Jaeger UI + query HTTP (gồm /api/v3/*) 16686 không expose ra ngoài cluster; đặt sau reverse proxy có SSO
Query gRPC 16685 chỉ mở cho Grafana/công cụ nội bộ, qua mTLS hoặc network policy
OTLP receiver của Jaeger 4317 / 4318 đóng hoàn toàn nếu đã có Collector đứng trước — đó mới là điểm nhận duy nhất
Sampling config 5778 chỉ mở trong cluster
pprof 1777 không bao giờ expose: dump được heap chứa nội dung span
zpages 55679 không bao giờ expose: xem được span đang đi qua

Mô hình khuyến nghị: đặt Jaeger sau oauth2-proxy hoặc Ingress kèm OIDC, chỉ truy cập được qua VPN hoặc mạng nội bộ, và ghi access log ở reverse proxy — vì Jaeger không tự ghi ai đã xem trace nào.

Phân quyền theo tenant phải làm ở tầng dữ liệu. Jaeger không lọc kết quả theo người dùng, nên tenant.id trong resource attribute chỉ là nhãn, không phải ranh giới bảo mật: bất kỳ ai vào được UI đều search được mọi tenant. Cô lập thật sự nghĩa là dùng routing connector tách tenant sang backend/index riêng (bảng multi-tenant ở trên) rồi dựng mỗi tenant một instance query đứng sau một entry SSO riêng.

Chặn query nặng làm sập storage. UI và query API không có rate limit; một truy vấn lookback 30 ngày với limit lớn, hoặc mở một trace hàng chục nghìn span (Phần 8), treo được cả UI lẫn Elasticsearch. Ba lớp chặn, theo thứ tự hiệu quả:

  1. Chặn từ gốc: policy span_count trong tail_sampling để trace khổng lồ không bao giờ vào storage.
  2. Chặn ở storage: giới hạn số document mỗi truy vấn của backend (Elasticsearch: es.max-doc-count) và đặt trần retention.
  3. Chặn ở UI và proxy: search.maxLimit, search.maxLookback trong ui-config, cộng rate limit ở reverse proxy. Lưu ý ui-config chỉ ràng buộc giao diện — gọi thẳng /api/v3/traces bỏ qua được nó, nên đây là lớp mỏng nhất, không thay được hai lớp trên.

Kubernetes

OpenTelemetry Operator cung cấp hai CRD chính:

  • OpenTelemetryCollector (nhóm API opentelemetry.io/v1beta1): khai báo Collector như object Kubernetes, chọn modedeployment, daemonset, statefulset hoặc sidecar; operator tự sinh workload, ConfigMap và Service.
  • Instrumentation (vẫn ở opentelemetry.io/v1alpha1): cấu hình auto-instrumentation. Pod có annotation tương ứng được operator gắn init container opentelemetry-auto-instrumentation; init container copy SDK vào volume dùng chung rồi đặt NODE_OPTIONS, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME vào container ứng dụng.
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: otel-nodejs
  namespace: shop
spec:
  exporter:
    endpoint: http://otel-collector.observability.svc.cluster.local:4318
  propagators: [tracecontext, baggage]
  sampler:
    type: parentbased_traceidratio
    argument: "0.1"
  nodejs:
    env:
      - name: OTEL_NODE_DISABLED_INSTRUMENTATIONS
        value: fs
# trong podTemplate của Deployment
metadata:
  annotations:
    instrumentation.opentelemetry.io/inject-nodejs: "shop/otel-nodejs"
    instrumentation.opentelemetry.io/container-names: "api"

Giá trị annotation là "true" khi Instrumentation nằm cùng namespace với pod, "<namespace>/<tên>" khi ở namespace khác, "false" để tắt.

Tiêu chí Operator auto-instrumentation Tự thêm SDK vào image
Sửa code hoặc Dockerfile không cần cần
Kiểm soát version SDK tập trung ở Instrumentation CRD theo package.json từng service
Span nghiệp vụ, instrumentation tuỳ biến không làm được, vẫn phải bổ sung code làm được
App ESM rủi ro: injection dựa trên --require (CJS), còn ESM cần --import kèm @opentelemetry/instrumentation/hook.mjs kiểm soát được (Phần 5A)
Debug khi span không xuất hiện khó, phải đọc log init container dễ, log ngay trong app

Cách dùng thực dụng: Operator để phủ nhanh diện rộng ở giai đoạn đầu, rồi chuyển dần các service lõi sang SDK trong image khi cần span nghiệp vụ.

Mô hình Ưu Nhược Dùng khi
Sidecar cô lập theo pod, không mất telemetry khi node quá tải nhân tài nguyên theo số pod pod ít, yêu cầu cô lập cao
DaemonSet (node agent) một instance mỗi node, gắn được metadata node, đường mạng ngắn node nhiều pod thì thành điểm nghẽn phổ biến nhất cho tầng thu gom
Deployment gateway scale độc lập, là nơi đặt tail sampling và spanmetrics thêm một chặng mạng, cần loadbalancing bắt buộc khi có tail-based sampling

Kiến trúc hai tầng (agent DaemonSet đẩy sang gateway Deployment) là mặc định hợp lý. Nếu gateway chạy nhiều replica và có tail_sampling hoặc spanmetrics, tầng trước bắt buộc dùng loadbalancing exporter với routing_key: traceID, trỏ vào một Service headless — Service ClusterIP thường sẽ phá vỡ routing. Pin version image Collector, không dùng :latest. Đặt terminationGracePeriodSeconds của gateway lớn hơn decision_wait, nếu không mỗi lần rolling update sẽ mất trace đang chờ quyết định. Cấu hình chi tiết ở Phần 7.

k8sattributes processor đã stable cho traces/metrics/logs (distro contrib và k8s). Mặc định nó gắn k8s.namespace.name, k8s.pod.name, k8s.pod.uid, k8s.pod.start_time, k8s.deployment.name, k8s.node.name vào resource dựa trên IP nguồn của telemetry. RBAC cần get/watch/list trên podsnamespaces; k8s.deployment.name suy ra qua replicasets nên phải cấp thêm quyền ở nhóm apps, và thêm nodes nếu lấy metadata node. Đặt nó ở tầng agent, nơi còn thấy IP pod thật.

Triển khai ngoài Kubernetes

Mọi thứ ở trên giả định Kubernetes. Chạy Collector trên VM hoặc Docker Compose ở production là chuyện bình thường, và những gì thay đổi thì cụ thể chứ không mơ hồ.

Cài bằng package. Repo open-telemetry/opentelemetry-collector-releases phát hành .deb/.rpm cho otelcol-contrib. Sau khi cài:

Thứ Đường dẫn / lệnh
Config /etc/otelcol-contrib/config.yaml
Biến môi trường /etc/otelcol-contrib/otelcol-contrib.conf (nạp qua EnvironmentFile của unit systemd)
Restart systemctl restart otelcol-contrib
Log journalctl -u otelcol-contrib -f
User chạy service otelcol-contrib (do package tạo)

Sửa config xong phải restart — Collector không tự reload file. Đặt OTEL_RESOURCE_ATTRIBUTES và các secret của exporter vào file .conf, không nhúng thẳng vào config.yaml (dùng cú pháp ${env:TÊN_BIẾN}).

Thay k8sattributes bằng resourcedetection. Ngoài K8s không có pod IP để tra ngược, nên metadata phải lấy từ chính máy chủ:

processors:
  resourcedetection:
    detectors: [env, system, docker, ec2]   # đổi ec2 sang gcp / azure tuỳ hạ tầng
    timeout: 5s
    override: false
    system:
      hostname_sources: [os]

Đánh đổi: mất k8s.namespace.name, k8s.pod.name, k8s.deployment.name, k8s.node.name — tức mất luôn khả năng lọc trace theo namespace hay deployment trong Jaeger. Đổi lại được host.name, host.id, os.type, cloud.provider, cloud.region, cloud.availability_zone. Vì không còn label của pod để suy ra danh tính service, service.name bắt buộc phải do app tự đặt qua OTEL_SERVICE_NAME; detector env chỉ đọc lại OTEL_RESOURCE_ATTRIBUTES chứ không đoán hộ. Đặt override: false để không ghi đè giá trị app đã khai.

file_storage cần thư mục thật trên đĩa, không phải emptyDir:

extensions:
  file_storage/queue:
    directory: /var/lib/otelcol-contrib/file_storage
    timeout: 1s

Thư mục phải tồn tại và thuộc user chạy service (chown otelcol-contrib:otelcol-contrib), nằm trên volume sống sót qua restart — nếu không thì sending_queue bền vững chỉ là bền vững trên giấy.

Giám sát. Scrape cổng 8888 bằng Prometheus. Bẫy phiên bản mới: mặc định telemetry nội bộ chỉ bind localhost:8888, nên Prometheus ở máy khác scrape không ra gì. Mở tường minh:

service:
  telemetry:
    metrics:
      readers:
        - pull:
            exporter:
              prometheus:
                host: 0.0.0.0
                port: 8888

Mở ra 0.0.0.0 thì phải chặn cổng 8888 bằng firewall hoặc security group. Log của Collector đi vào journald nên xoay vòng đã có sẵn, nhưng journald rate limit mặc định sẽ nuốt bớt log lúc sự cố — nâng RateLimitBurst cho unit này nếu đang debug. Với Docker, đặt log-opts max-size/max-file, nếu không log Collector sẽ ăn hết đĩa trước khi trace kịp ăn.

Docker Compose ở production:

services:
  otelcol:
    image: otel/opentelemetry-collector-contrib:<version cụ thể>   # pin, không dùng latest
    restart: unless-stopped
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./config.yaml:/etc/otelcol/config.yaml:ro
      - otelcol-storage:/var/lib/otelcol/file_storage
    mem_limit: 1g            # đặt cùng lúc với memory_limiter.limit_mib = 700–800
    ports:
      - "127.0.0.1:4318:4318"
    logging:
      options: { max-size: "50m", max-file: "3" }
volumes:
  otelcol-storage:

Quy tắc bất di bất dịch: mem_limit của container và limit_mib của memory_limiter phải đặt cùng lúc, với limit_mib bằng 70–80% mem_limit. Chỉ đặt một trong hai thì hoặc container bị OOM-kill trước khi processor kịp phản ứng, hoặc processor chặn dữ liệu trong khi còn thừa RAM. Về healthcheck: image contrib là distroless, không có shell lẫn curl, nên healthcheck kiểu CMD-SHELL curl ... luôn fail — bật extension health_check (mặc định localhost:13133, đổi sang 0.0.0.0:13133) rồi để một prober bên ngoài hoặc container khác gọi vào.

Windows Server: Collector chạy như một Windows service; đặt file_storage.directory vào %ProgramData%\Otelcol\FileStorage và nhớ ràng buộc đường dẫn Windows đã nêu ở Phần 8, xem log ở Event Viewer thay cho journalctl.

Nhiều region / nhiều datacenter

Một cluster là trường hợp dễ. Khi có hai region trở lên, loadbalancing exporter với routing_key: traceID không còn đủ cho tail sampling: một trace đi qua hai region sẽ có span về hai gateway khác nhau, mỗi gateway chỉ thấy một nửa và mọi quyết định tail-based đều sai — đúng cái bẫy đã cảnh báo ở phần Kubernetes, nhưng ở quy mô mà giải pháp ở đó không với tới.

Nguyên tắc nền: app không gửi span xuyên region. Mỗi region có tầng agent và gateway riêng, app chỉ nói chuyện với endpoint trong region của nó. Gửi xuyên vùng từ app nghĩa là trả tiền egress cho từng span, cộng latency liên vùng vào đường export, và tạo phụ thuộc chéo region cho một hệ thống vốn chỉ để quan sát.

Còn lại là bài toán tail sampling xuyên region, ba lựa chọn:

Cách Cấu hình Được Mất
(a) Quyết định độc lập theo region mỗi region một cụm gateway tail_sampling riêng rẻ nhất, không có traffic liên vùng trace xuyên vùng bị cắt: nửa được giữ, nửa bị bỏ, và điều đó không hiện ra như lỗi
(b) Gom về một gateway trung tâm loadbalancing với resolver static hoặc dns trỏ sang gateway liên vùng quyết định đúng trên trace hoàn chỉnh trả tiền egress cho 100% span, cộng latency và một điểm phụ thuộc liên vùng
(c) Bỏ tail sampling cho luồng xuyên vùng chỉ head-based ParentBased với một ratio thống nhất toàn cầu trace xuyên vùng luôn nguyên vẹn (mọi service cùng theo quyết định của root) mất khả năng "giữ mọi trace lỗi" ở luồng đó

Khuyến nghị mặc định là (c): nhất quán quan trọng hơn thông minh. Trace xuyên vùng còn nguyên vẹn ở ratio 5% có giá trị điều tra cao hơn hẳn một đống trace bị cắt nửa mà không ai biết. Nếu vẫn cần tail sampling, dùng (a) cho luồng nội vùng và (c) cho luồng xuyên vùng, đừng trộn.

Số region Tỉ lệ traffic xuyên vùng Nên chọn
2–3 dưới 5% (a) cho nội vùng, chấp nhận trace xuyên vùng bị cắt hoặc phủ bằng (c)
2–3 trên 20% (c) toàn cục, hoặc (b) nếu ngân sách egress cho phép
trên 3 bất kỳ (c) — (b) không scale, mỗi region thêm vào là một luồng egress nữa

Ba việc phải làm kèm theo:

  • Clock skew giữa region lớn hơn hẳn trong cùng DC. Nâng max_clock_skew_adjust của extension jaeger_query (v2 đặt mặc định rất thấp, kiểm tra giá trị hiện tại trước khi sửa) để Jaeger không vẽ span con nằm ngoài span cha. Nhắc lại quy tắc ở §11.4: không suy latency mạng từ hiệu số startTime giữa span client và span server — xuyên vùng thì con số đó gần như chắc chắn sai.
  • Storage. Một Jaeger trung tâm cho toàn bộ region là dễ tra cứu nhất nhưng đòi Elasticsearch cross-region — tốn kém và mong manh. Mỗi region một Jaeger thì rẻ và bền, đổi lại tra một trace phải biết nó ở region nào: bù bằng cách nhúng mã region vào resource attribute (cloud.region) ghi region kèm trace_id vào log, để từ log nhảy thẳng sang đúng UI.
  • Nén và chặn từ gốc. Bắt buộc OTEL_EXPORTER_OTLP_COMPRESSION=gzip cho mọi đường liên vùng, và cân nhắc đặt probabilistic_sampler ngay tại gateway region trước khi gửi đi để không trả tiền egress cho span sẽ bị bỏ ở đầu kia.

Serverless / FaaS

Mô hình xuất span mặc định của SDK hỏng trên FaaS, vì hai lý do độc lập:

  • Nền tảng đóng băng container ngay khi handler return. Tick nền của BatchSpanProcessor không chạy nữa, span nằm lại trong buffer: hoặc rơi sang invocation sau (gắn nhầm ngữ cảnh), hoặc mất hẳn khi container bị thu hồi.
  • Cold start đặt toàn bộ chi phí khởi tạo SDK vào đúng đường request đầu tiên. Auto-instrumentation càng nhiều thì cold start càng dài.
Cách Đổi lấy
Layer/extension chính thức của nhà cung cấp (AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-handler) ít code nhất, nhưng ràng vào nhà cung cấp và khó debug khi span không ra
Tự bootstrap SDK + await forceFlush() trong finally kiểm soát hoàn toàn, nhưng flush là round-trip đồng bộ cộng thẳng vào latency và thời gian tính tiền
SimpleSpanProcessor đơn giản nhất, đúng đắn theo định nghĩa, nhưng mỗi span một request mạng — chỉ chấp nhận được khi số span mỗi invocation rất nhỏ

Ba cách không mâu thuẫn nhau, chúng là ba điểm trên cùng một trục "ai chịu trách nhiệm flush". Dưới đây là cách 2, đầy đủ:

// telemetry.ts — chạy ở module scope, ngoài handler, để cold start trả tiền một lần
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';

export const processor = new BatchSpanProcessor(
  new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }),
);

new NodeTracerProvider({
  resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'checkout-fn' }),
  spanProcessors: [processor],
}).register();
// handler.ts
import { SpanStatusCode, trace } from '@opentelemetry/api';
import { processor } from './telemetry.js';

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

export const handler = async (event: unknown) =>
  tracer.startActiveSpan('handler', async (span) => {
    try {
      return await doWork(event);
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw err;
    } finally {
      span.end();
      await processor.forceFlush(); // phải await: return xong là container đóng băng
    }
  });

span.end() đứng trước forceFlush() trong cùng khối finally, nếu không span cuối cùng không nằm trong lô được flush.

Giảm chi phí flush: trỏ OTLP vào một Collector chạy dạng Lambda extension hoặc sidecar cục bộ thay vì ra Internet — round-trip từ vài trăm ms xuống dưới một ms, và phần chậm chuyển sang chạy nền ngoài thời gian tính tiền. Đây cũng là một trong số ít tình huống nên bỏ hẳn Collector và cho SDK bắn thẳng vào backend: hàm ít, traffic thấp, không cần tail sampling hay xử lý tập trung (điều kiện đầy đủ ở Phần 7 §7.1). Với FaaS, sampling luôn là head-based — không có tầng gateway nào ổn định để đặt tail sampling.

Sinh metrics từ trace

spanmetrics (alpha, contrib) đọc span và sinh RED metrics: traces.span.metrics.calls (rate và errors) cùng traces.span.metrics.duration (histogram), namespace mặc định traces.span.metrics, dimension mặc định service.name, span.name, span.kind, status.code, collector.instance.id, metrics_flush_interval mặc định 60s, temporality mặc định cumulative.

Giá trị thực dụng: dashboard và alert ổn định bất kể sampling trace bao nhiêu phần trăm, vì metric tính trên toàn bộ span đi qua connector. Đây cũng là nguồn cho tab Monitor (SPM) của Jaeger khi ghép Prometheus (Phần 8). Bẫy: connector phải nhận span trước tail_sampling, nếu không error rate và p99 sai có hệ thống — pipeline fan-out mẫu ở Phần 4. Kiểm soát cardinality: mỗi dimension nhân số time series, nên không đưa giá trị tự do (order id, user id, url.path) vào dimensions; dùng http.route.

servicegraph (alpha; contrib, k8s) ghép span client với span server để dựng sơ đồ phụ thuộc, sinh traces_service_graph_request_total, traces_service_graph_request_failed_total, traces_service_graph_request_server, traces_service_graph_request_client, traces_service_graph_unpaired_spans_total, traces_service_graph_dropped_spans_total, tất cả mang label client, server, connection_type. store.ttl mặc định 2s: cặp span đến cách nhau xa hơn ngưỡng đó rơi vào unpaired_spans_total — theo dõi chính metric này để biết cấu hình đúng chưa.

Trace không thay thế metrics. Trace trả lời "request này chậm ở đâu", metrics trả lời "hệ thống đang thế nào"; metric sinh từ trace lại phụ thuộc vào pipeline trace còn sống, nên vẫn cần metric hạ tầng và metric ứng dụng độc lập với đường trace.

Giám sát chất lượng trace

Kiểm chứng propagation bằng curl một lần (Phần 3) chỉ chứng minh được rằng nó đã hoạt động, trên một luồng, tại một thời điểm. Ở quy mô hàng chục service, câu hỏi thật là: trace đang vỡ ở đâu, vỡ bao nhiêu phần trăm, và deploy vừa rồi có làm đứt propagation không. Bốn chỉ số dưới đây trả lời được, và tất cả đều dựng từ thứ đã có sẵn trong pipeline.

1. Tỉ lệ root span theo service — tín hiệu sớm nhất của propagation đứt. Một service nội bộ đáng lẽ gần như không bao giờ tạo root span: mọi request tới nó đều phải mang traceparent. Root span xuất hiện ở service không nằm ở rìa nghĩa là header bị strip (ingress, service mesh, một proxy mới), hoặc context bị mất trong app. Dựng bằng connector count:

connectors:
  count:
    spans:
      trace_spans:
        description: Tổng số span đi qua connector
      trace_root_spans:
        description: Span không có parent
        conditions:
          - IsRootSpan()

service:
  pipelines:
    traces/quality:                 # đặt TRƯỚC tail_sampling, cùng lý do với spanmetrics
      receivers: [otlp]
      processors: [memory_limiter, k8sattributes]
      exporters: [count]
    metrics/quality:
      receivers: [count]
      exporters: [prometheus]

IsRootSpan() là converter của OTTL, đúng hơn so sánh span.parent_span_id với chuỗi rỗng vì parent_span_id là mảng byte. Ai không muốn thêm pipeline có thể thêm một dimension tự tính vào spanmetrics thay vì dùng count, đánh đổi là cardinality của toàn bộ RED metrics tăng gấp đôi.

service.name đi vào metric dưới dạng resource attribute, nên qua exporter prometheus nó thành label job chứ không phải service_name — trừ khi bật resource_to_telemetry_conversion.enabled: true. Alert:

sum by (job) (rate(trace_root_spans_total[10m]))
  / clamp_min(sum by (job) (rate(trace_spans_total[10m])), 1) > 0.01

Áp cho mọi service không nằm ở rìa (loại trừ ingress, BFF, cron, consumer của queue — những chỗ tạo root span là đúng). Ngưỡng 1% cố tình thấp: propagation hoặc chạy hoặc không, con số này bình thường phải sát 0.

2. Span không ghép được cặp. traces_service_graph_unpaired_spans_totaltraces_service_graph_dropped_spans_total của connector servicegraph đo trực tiếp số span client không tìm thấy span server tương ứng trong store.ttl (mặc định 2s):

sum(rate(traces_service_graph_unpaired_spans_total[10m]))
  / clamp_min(sum(rate(traces_service_graph_request_total[10m])), 1) > 0.05

Đọc kết quả cẩn thận: tỉ lệ này tăng có thể là propagation vỡ, mà cũng có thể chỉ là request thật đang chậm hơn store.ttl — nâng store.ttl lên 10s rồi đo lại trước khi kết luận.

3. Số cạnh service graph so với sơ đồ kiến trúc kỳ vọng. count(count by (client, server) (traces_service_graph_request_total)) cho số cạnh đang thấy. Ghi số kỳ vọng vào một recording rule và alert khi lệch. Một cạnh biến mất sau deploy hầu như luôn là SpanKind bị đặt sai (CLIENT/SERVER thành INTERNAL nên không ghép được cặp) hoặc header bị strip trên đúng chặng đó — thứ mà error rate và latency không hề phản ánh.

4. Synthetic check định kỳ. Ba chỉ số trên đo hệ thống ở trạng thái tổng hợp; synthetic check trả lời câu hỏi nhị phân "một request đi hết luồng chính bây giờ có sinh ra một trace đầy đủ không". Chạy bằng cron mỗi 5–15 phút:

#!/usr/bin/env bash
set -euo pipefail
TRACE_ID=$(openssl rand -hex 16)
curl -sS -o /dev/null -X POST https://shop.example.com/api/checkout \
  -H "traceparent: 00-${TRACE_ID}-$(openssl rand -hex 8)-01" \
  -H "baggage: debug=1" \
  -H 'Content-Type: application/json' -d '{"sku":"SYNTH-1","qty":1}'
sleep 45   # > ngân sách độ trễ của luồng + decision_wait + batch timeout + refresh của storage
BODY=$(curl -sS "http://jaeger-query:16686/api/v3/traces/${TRACE_ID}")
SERVICES=$(jq '[.result.resourceSpans[].resource.attributes[]
                | select(.key == "service.name") | .value.stringValue] | unique | length' <<<"$BODY")
SPANS=$(jq '[.result.resourceSpans[].scopeSpans[].spans[]] | length' <<<"$BODY")
if [ "${SERVICES:-0}" -lt 4 ] || [ "${SPANS:-0}" -lt 12 ]; then
  echo "TRACE VỠ ${TRACE_ID}: ${SERVICES} service / ${SPANS} span (kỳ vọng >=4 / >=12)"; exit 1
fi
echo "ok ${TRACE_ID}: ${SERVICES} service, ${SPANS} span"

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.