cat blog/.md
OpenTelemetry distributed tracing: tìm bottleneck trong microservices không cần đoán mò
Có một sự cố tôi mất gần một tuần để tìm ra. Endpoint POST /orders đôi khi mất 4 giây, đôi khi 800ms, không có pattern rõ ràng. Service Order chỉ ghi nhận “tổng thời gian xử lý 4s”, còn bên trong nó gọi sang Inventory, Pricing, Notification và nhận phản hồi — nhưng log chỉ ghi mỗi service “OK” với timestamp riêng. Để biết bottleneck nằm ở đâu, tôi phải grep log theo request_id ở năm chỗ khác nhau, ráp lại bằng tay, đối chiếu giờ. Hai đêm sau tôi quyết định dừng cách làm thủ công đó và setup OpenTelemetry. Trong vòng một ngày, dashboard chỉ thẳng ra: 70% thời gian nằm trong một query Postgres ở Pricing service mà không ai biết là chậm.
Bài này tổng hợp lại những gì tôi rút ra: cách distributed tracing hoạt động, setup OpenTelemetry (OTel) cho FastAPI và Node, propagate trace context qua HTTP và message queue, và những cái bẫy thực tế.
Trace, span và context propagation
Trước khi cài, cần hiểu ba khái niệm cốt lõi.
Trace là toàn bộ hành trình của một request từ đầu đến cuối, xuyên qua nhiều service. Mỗi trace có một trace_id duy nhất.
Span là một đơn vị công việc trong trace — ví dụ “HTTP call đến Inventory”, “query Postgres”, “gọi Redis SET”. Span có span_id, thời điểm bắt đầu/kết thúc, và quan trọng nhất là parent_span_id để dựng cây phụ thuộc. Một trace là một cây các span.
Context propagation là cách để trace_id và parent_span_id “đi cùng” request khi nó nhảy từ service này sang service khác. Mặc định OTel dùng W3C Trace Context — hai header HTTP: traceparent và tracestate. Nếu không propagate, mỗi service sẽ tạo trace riêng và bạn không bao giờ thấy bức tranh tổng thể.
Đây là bài học đầu tiên: cài OTel mà quên propagation thì giống như mỗi service ghi nhật ký riêng nhưng không có cách gắn lại. Trace bị “đứt” thành mảnh, dashboard không hiện cây.
Setup cho FastAPI
Stack tôi dùng: FastAPI + SQLAlchemy + httpx + Redis. Cách đơn giản nhất là dùng auto-instrumentation — OTel cài hook vào các thư viện phổ biến để tự tạo span.
# requirements.txt
opentelemetry-distro
opentelemetry-exporter-otlp
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-sqlalchemy
opentelemetry-instrumentation-httpx
opentelemetry-instrumentation-redis
# tracing.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
def setup_tracing(app, engine):
resource = Resource.create({SERVICE_NAME: "order-service"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317"))
)
trace.set_tracer_provider(provider)
FastAPIInstrumentor.instrument_app(app)
SQLAlchemyInstrumentor().instrument(engine=engine)
HTTPXClientInstrumentor().instrument()
Gọi setup_tracing(app, engine) lúc startup là xong. Mỗi HTTP request vào FastAPI tự thành span; mỗi query SQLAlchemy là child span; mỗi httpx call là child span và còn tự inject traceparent header sang service kế tiếp. Không phải sửa một dòng business code nào.
Đây là điểm cộng lớn của OTel so với các solution cũ: bạn không phải rải tracer.start_span(...) khắp codebase. Nhưng đôi khi cần span thủ công cho block logic riêng — ví dụ một thuật toán tính giá phức tạp:
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
def calculate_price(items):
with tracer.start_as_current_span("calculate_price") as span:
span.set_attribute("items.count", len(items))
result = run_pricing_engine(items)
span.set_attribute("price.total", result.total)
return result
Span attribute là gold — đặt user_id, order_id, items.count để khi dashboard lọc theo trace, bạn biết ngay request đó là của ai và quy mô thế nào.
Propagate qua message queue
HTTP thì OTel xử lý sẵn header. Nhưng khi service A publish message lên RabbitMQ/Kafka/SNS và service B consume, trace_id phải đi kèm trong message — nếu không, B sẽ tạo trace mới và bạn mất kết nối.
Cách làm: inject context vào message header lúc publish, extract lúc consume.
from opentelemetry import propagate, trace
# Publisher
def publish_event(channel, body):
headers = {}
propagate.inject(headers) # ghi traceparent vào dict
channel.basic_publish(
exchange="orders",
routing_key="order.created",
body=json.dumps(body),
properties=pika.BasicProperties(headers=headers),
)
# Consumer
def on_message(ch, method, properties, body):
ctx = propagate.extract(properties.headers or {})
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("consume order.created", context=ctx):
process(json.loads(body))
Đây là chỗ tôi mất một buổi chiều mới sửa được. Lúc đầu tôi nghĩ auto-instrumentation cho pika sẽ lo, hóa ra thư viện tôi dùng (custom wrapper) không nằm trong danh sách hỗ trợ. Sau khi thêm 6 dòng inject/extract như trên, dashboard mới hiện đầy đủ chuỗi Order → SNS → Notification → Email API.
Setup cho Node
Bên frontend BFF tôi dùng Node + Fastify, đoạn này tương tự nhưng thư viện tên khác:
// tracing.mjs
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'bff-buyer',
}),
traceExporter: new OTLPTraceExporter({ url: 'http://otel-collector:4317' }),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
Quan trọng: file này phải được import trước mọi thứ khác (kể cả Fastify), nếu không các thư viện đã load sẽ không bị instrument. Cách an toàn là chạy với node --import ./tracing.mjs server.mjs.
Backend lưu trace ở đâu
OTel chỉ là spec + SDK xuất span ra một collector. Bạn cần một backend hiển thị. Tùy ngân sách:
- Jaeger — open source, deploy qua Docker, đủ cho team nhỏ. UI gọn, search theo
servicevàoperation, hiện cây span rõ ràng. - Grafana Tempo — tích hợp tốt với Loki/Prometheus nếu bạn đã có Grafana stack. Lưu trace trên S3 nên rẻ.
- Honeycomb / Datadog / New Relic — SaaS, đắt nhưng query mạnh, có “BubbleUp” tự gợi ý nguyên nhân outlier.
Setup tôi dùng cho dev là OTel Collector → Jaeger, free và đủ mạnh:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls: { insecure: true }
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlp/jaeger]
Sampling — đừng trace 100%
Trace có chi phí: mỗi span là một bản ghi, đẩy sang collector, lưu vào storage. Trên một service 10K req/s, trace tất cả sẽ làm collector ngợp và hóa đơn cloud lên trời.
Có hai chiến lược chính:
Head-based sampling — quyết định trace hay không ngay từ service đầu tiên, dựa trên xác suất (vd 10%). Đơn giản nhưng có thể bỏ sót request lỗi nếu nó không lọt vào 10%.
Tail-based sampling — buffer toàn bộ trace ở collector, sau khi trace kết thúc mới quyết định: nếu có lỗi hoặc latency > 1s thì giữ, còn lại drop. Bắt được mọi sự cố nhưng tốn RAM ở collector.
Tôi mặc định dùng head-based 5% cho traffic bình thường, kết hợp tail-based ở collector để giữ 100% trace có error hoặc chậm bất thường. Thực dụng và đủ để debug.
Bài học rút ra
Sau vài tháng dùng OTel ở production, đây là những điều tôi muốn ai đó nói với mình từ đầu:
- Cài instrumentation HTTP client trước, vì 80% giá trị đến từ việc thấy được chuỗi gọi giữa các service. Database span thêm sau cũng được.
- Đừng trace mọi internal function — span quá chi tiết làm rối UI, chi phí lưu trữ tăng, mà bạn cũng không nhìn nổi 200 span trong một trace.
- Span attribute đáng giá hơn span mới — thêm
user_id,tenant_id,order_idvào span hiện có giúp lọc nhanh trên dashboard, không cần thêm span con. - Trace context phải đi qua mọi ranh giới async — message queue, background job, retry. Quên một chỗ là dashboard mất nửa cây.
- Liên kết trace với log bằng cách đưa
trace_idvào structured log. Khi thấy lỗi trong log, click thẳng sang Jaeger để xem hoàn cảnh request lúc đó.
Distributed tracing không phải viên đạn bạc — nó không thay thế metric (Prometheus) hay log (Loki/CloudWatch). Nhưng khi câu hỏi là “request này chậm vì sao”, trace là công cụ duy nhất trả lời được trong vài giây thay vì vài giờ. Hai vụ N+1 ẩn ở Pricing service mà tôi nhắc đầu bài đều được tìm ra trong dưới 10 phút sau khi dashboard lên — và một trong số đó đã sống yên lành ba tháng trước đó dưới mọi log analytics.
cat comments.log