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_spanstăng), hoặc process thoát trước khishutdown()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_oncho 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.role → user.roles và enduser.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.0 → 2, 3.0 → 3) |
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 cũ (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.system → db.system.name; db.name → db.namespace; db.statement → db.query.text; db.operation → db.operation.name; db.sql.table / db.cassandra.table / db.mongodb.collection / db.cosmosdb.container → db.collection.name. Riêng db.connection_string và db.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.operation → messaging.operation.type, messaging.kafka.message.offset → messaging.kafka.offset, messaging.kafka.consumer.group → messaging.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 Deprecated → rpc.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-genai và không có attribute nào Stable; nhiều tên đã đổi (gen_ai.system → gen_ai.provider.name, gen_ai.usage.prompt_tokens → gen_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.template → messaging.destination.name → server.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.id và hasaki.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 HTTP và database — 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áofile_format,schema_urlvà các khốichangesvớirename_attributescho từng version. SDK gắnschema_urlvà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:
- 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.
- Bắn tải giống nhau bằng
k6hoặcautocannon, tải cố định, đủ dài để qua warm-up của JIT (5–10 phút). - 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ỡ. - Đ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_INSTRUMENTATIONSthì 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. - 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_patterns và blocked_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.route và db.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ả:
- Chặn từ gốc: policy
span_counttrongtail_samplingđể trace khổng lồ không bao giờ vào storage. - 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. - Chặn ở UI và proxy:
search.maxLimit,search.maxLookbacktrong 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/tracesbỏ 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 APIopentelemetry.io/v1beta1): khai báo Collector như object Kubernetes, chọnmodelàdeployment,daemonset,statefulsethoặcsidecar; 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 containeropentelemetry-auto-instrumentation; init container copy SDK vào volume dùng chung rồi đặtNODE_OPTIONS,OTEL_EXPORTER_OTLP_ENDPOINT,OTEL_SERVICE_NAMEvà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 pods và namespaces; 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_adjustcủa extensionjaeger_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ốstartTimegiữ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) và ghi region kèmtrace_idvà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=gzipcho mọi đường liên vùng, và cân nhắc đặtprobabilistic_samplerngay 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ủaBatchSpanProcessorkhô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_total và traces_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