0

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

Resource detectors

Resource là tập attribute mô tả tiến trình sinh telemetry; Jaeger dựa vào service.name để dựng danh sách service.

Detector Giá trị trong OTEL_NODE_RESOURCE_DETECTORS Attribute tiêu biểu
env env mọi thứ khai trong OTEL_RESOURCE_ATTRIBUTES
host host host.name, host.arch
os os os.type, os.version
process process process.pid, process.runtime.name, process.command_line
service instance serviceinstance (README đánh dấu experimental) service.instance.id
container container container.id (đọc cgroup)
cloud aws, azure, gcp, alibaba attribute cloud.* tương ứng
gộp / tắt all, none thứ tự khai báo được tôn trọng

Hai phạm vi rất hay nhầm: danh sách đầy đủ ở trên (kể cả container và nhóm cloud, mặc định bật tất cả) là của distro @opentelemetry/auto-instrumentations-node. Riêng @opentelemetry/sdk-node chỉ hiểu env, host, os, process, serviceinstance, all, none; theo README của nó bộ mặc định chỉ gồm envDetector, processDetector, hostDetector, nên muốn có container.id khi tự bootstrap phải cài package detector riêng.

Với manual bootstrap, truyền tường minh qua option resourceDetectors — đã tự khai thì phải liệt kê đủ, kể cả envDetectorprocessDetector, nếu không chúng bị loại (autoDetectResources phải để true, vốn là mặc định):

import { NodeSDK } from '@opentelemetry/sdk-node';
import { envDetector, hostDetector, osDetector, processDetector } from '@opentelemetry/resources';
import { containerDetector } from '@opentelemetry/resource-detector-container';

const sdk = new NodeSDK({
  // Đã tự khai resourceDetectors thì phải liệt kê ĐỦ: envDetector và processDetector
  // không còn được thêm ngầm nữa.
  resourceDetectors: [envDetector, hostDetector, osDetector, processDetector, containerDetector],
  // ...các option khác: spanProcessors, sampler, textMapPropagator
});

Attribute Kubernetes (k8s.pod.name, k8s.namespace.name…) nên để Collector gắn bằng processor k8sattributes thay vì detect trong app — Phần 7 và Phần 10.

export OTEL_SERVICE_NAME="checkout-api"
export OTEL_RESOURCE_ATTRIBUTES="service.namespace=ecom,service.version=2.4.1,deployment.environment.name=prod,service.instance.id=${HOSTNAME}"

Thứ tự ưu tiên khi trùng key: OTEL_SERVICE_NAME thắng service.name khai trong OTEL_RESOURCE_ATTRIBUTES; biến môi trường thắng giá trị đặt trong code; giữa các detector thì detector chạy sau ghi đè detector chạy trước, theo đúng thứ tự khai trong OTEL_NODE_RESOURCE_DETECTORS. Bộ ba service.namespace + service.name + service.instance.id phải duy nhất toàn cục — nên service.instance.id=${HOSTNAME} chỉ đúng khi mỗi container chạy đúng một process Node.

Docker

# ---- build ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- runtime ----
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
# Nạp telemetry trước code ứng dụng (bản build là CommonJS).
ENV NODE_OPTIONS="--require ./dist/tracing.js"
EXPOSE 3000
# Chạy node trực tiếp (không qua npm/sh) để process nhận đúng SIGTERM.
CMD ["node", "dist/index.js"]

Nếu bản build là ESM: ENV NODE_OPTIONS="--experimental-loader=@opentelemetry/instrumentation/hook.mjs --import ./dist/tracing.js".

services:
  checkout-api:
    build: .
    environment:
      OTEL_SERVICE_NAME: checkout-api
      OTEL_RESOURCE_ATTRIBUTES: "service.namespace=ecom,deployment.environment.name=dev"
      OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318"
      OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
      OTEL_TRACES_SAMPLER_ARG: "0.1"
      OTEL_NODE_DISABLED_INSTRUMENTATIONS: "dns,net"

Không đặt OTEL_TRACES_SAMPLER ở đây: tracing.ts truyền tường minh option sampler vào NodeSDK nên biến này vô tác dụng; chỉ OTEL_TRACES_SAMPLER_ARG còn tác dụng vì chính code ở trên đọc nó (Phần 8). Ngược lại, ở đường zero-code thì OTEL_TRACES_SAMPLER mới là chỗ duy nhất chọn được sampler.

service.name phải đặt theo vai trò logic của dịch vụ, không theo tên container hay pod: mọi replica của checkout-api đều mang service.name=checkout-api và phân biệt nhau bằng service.instance.id. Đặt theo tên container sẽ làm danh sách service trong Jaeger nở ra theo số replica và phá service graph.

Graceful shutdown và flush

BatchSpanProcessor giữ span trong hàng đợi và chỉ export theo chu kỳ. Process kết thúc trước chu kỳ đó thì span trong hàng đợi mất sạch — lý do CLI, cron job và job ngắn "chạy xong mà Jaeger không có gì".

  • Luôn gọi await sdk.shutdown() (nó flush rồi mới đóng exporter) trong handler SIGTERM/SIGINT, và ở cuối hàm main() của job ngắn.
  • Cần flush giữa chừng mà không tắt SDK: NodeSDK khôngforceFlush() — lớp này chỉ có start()shutdown(). Giữ tham chiếu tới span processor và gọi await batchProcessor.forceFlush().
  • Đặt timeout cho shutdown và vẫn thoát khi hết hạn, tránh treo pod: bọc sdk.shutdown() trong Promise.race với một timer.
  • Kubernetes: sau SIGTERM, kubelet chờ terminationGracePeriodSeconds (mặc định 30s) rồi mới SIGKILL. Ngân sách đó phải chứa cả thời gian drain HTTP lẫn thời gian flush, nên giữ exportTimeoutMillis và timeout shutdown nhỏ hơn hẳn grace period; đặt exportTimeoutMillis bằng 30s là chắc chắn không kịp.
  • Dùng CMD ["node", ...] chứ không CMD npm start: qua shell hoặc npm, SIGTERM tới PID 1 mà không tới process Node.
  • Serverless: trong AWS Lambda, container bị đóng băng ngay khi handler trả về nên chu kỳ nền của BatchSpanProcessor không bao giờ chạy — span rơi sang invocation sau hoặc mất hẳn. Dùng layer/wrapper chính thức qua AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-handler: wrapper đổi entry point và khởi tạo SDK trước runtime. Nếu tự bootstrap thì await một forceFlush() trước khi handler resolve, hoặc dùng SimpleSpanProcessorngoại lệ production duy nhất cho quy tắc "Simple chỉ để debug local" ở Phần 2 §2.5 và Phần 12. Xem https://opentelemetry.io/docs/faas/lambda-auto-instrument/.

Bật debug khi không thấy dữ liệu

OTEL_LOG_LEVEL=debug node --require ./dist/tracing.js dist/index.js

diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG) in ra danh sách instrumentation được đăng ký, kết quả từng resource detector, và mỗi lần export kèm mã lỗi. Để tách bạch "app không sinh span" với "không gửi được tới Collector", chạy song song hai processor trong môi trường dev:

import type { SpanProcessor } from '@opentelemetry/sdk-trace-base';
import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';

const spanProcessors: SpanProcessor[] = [batchProcessor];
if (process.env.OTEL_DEBUG_CONSOLE === '1') {
  // In span ra stdout ngay khi span kết thúc, không chờ batch.
  spanProcessors.push(new SimpleSpanProcessor(new ConsoleSpanExporter()));
}
// rồi truyền cả mảng vào SDK: new NodeSDK({ resource, sampler, spanProcessors, ... })

Thấy span ở stdout mà Jaeger trống thì vấn đề nằm ở đường mạng, endpoint hoặc Collector (Phần 6, Phần 7, Phần 11). Không thấy gì ở stdout thì vấn đề nằm ở thứ tự khởi tạo hoặc sampler.

Tuning overhead

Việc Cách làm Tác dụng
Chỉ bật instrumentation cần thiết OTEL_NODE_ENABLED_INSTRUMENTATIONS="http,express,pg,ioredis", hoặc truyền config trong code giảm thời gian khởi động và số span rác
Giữ fs ở trạng thái tắt mặc định đã tắt trong metapackage — đừng bật lại instrumentation-fs sinh span cho từng thao tác file, làm nổ khối lượng span
Bỏ probe khỏi trace ignoreIncomingRequestHook cho /healthz, /readyz, /metrics các endpoint này chiếm phần lớn request nhưng không có giá trị chẩn đoán
Giới hạn attribute option spanLimits của NodeSDK, hoặc OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT / OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT chặn payload phình; riêng giới hạn độ dài giá trị mặc định là "không giới hạn"
Thận trọng với enhancedDatabaseReporting chỉ bật tạm khi debug, không bật ở production bật lên sẽ ghi cả tham số truy vấn vào span: rủi ro lộ dữ liệu khách hàng và tăng mạnh kích thước span
Giảm khối lượng ngay từ gốc hạ tỉ lệ TraceIdRatioBasedSampler, hoặc đẩy tail sampling xuống Collector xem Phần 4

Quy tắc đặt tên span và attribute chuẩn xem Phần 9; cấu hình đường truyền, bảo mật và Kubernetes ở quy mô production xem Phần 10.

Dự án mẫu chạy được từ đầu

Các khối ở trên là từng mảnh rời. Mục này ghép chúng thành một repo chạy được, để không phải tự đoán chỗ nào đặt file gì. Ba file trong cây dưới đây được dùng lại nguyên văn ở phần khác: collector-config.yaml lấy từ Phần 7 §7.11, docker-compose.yml lấy từ Phần 8 (chính compose đó build Dockerfile ở mục "Docker" trên đây và curl /api/products của src/index.ts dưới đây), còn src/observability/* cùng test/ là code của Phần 5B.

checkout-api/
├─ src/
│  ├─ index.ts                # ứng dụng Express — file ở dưới
│  ├─ tracing.ts              # bootstrap SDK — khối ở mục "Cách 2 — manual bootstrap"
│  └─ observability/
│     ├─ tracer.ts            # Phần 5B — tracer theo instrumentation scope
│     ├─ with-span.ts         # Phần 5B — helper bọc span thủ công
│     └─ traced.decorator.ts  # Phần 5B — decorator @Traced
├─ test/
│  └─ with-span.test.ts       # Phần 5B — assert span bằng InMemorySpanExporter
├─ db/
│  └─ init.sql                # seed bảng products cho ví dụ
├─ collector-config.yaml      # Phần 7 §7.11 — mount vào Collector
├─ docker-compose.yml         # Phần 8 — app + otel-collector + jaeger
├─ Dockerfile                 # mục "Docker" ở trên
├─ tsconfig.json
└─ package.json               # mục "package.json và tsconfig" ở trên

Cài đặt — version chốt đúng theo bảng ở Phần 10 §Versioning, --save-exact vì dòng experimental 0.x được phép breaking ở minor:

npm install --save-exact \
  @opentelemetry/api@1.9.1 \
  @opentelemetry/sdk-node@0.222.0 \
  @opentelemetry/auto-instrumentations-node@0.80.0 \
  @opentelemetry/exporter-trace-otlp-proto@0.222.0 \
  @opentelemetry/sdk-trace-base@2.11.0 \
  @opentelemetry/resources@2.11.0 \
  @opentelemetry/core@2.11.0 \
  @opentelemetry/semantic-conventions@1.43.0

npm install express pg ioredis
npm install -D typescript @types/node @types/express tsx vitest

Rồi thêm khối overrides (hoặc pnpm.overrides / resolutions) ở mục đầu phần này vào package.json và chạy npm ls @opentelemetry/api để chắc chỉ có một bản.

tsconfig.json đầy đủ — bản viết ra của mấy dòng mô tả ở mục "package.json và tsconfig":

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

lib dừng ở ES2022 chứ không thêm DOM: type của fetch global đến từ @types/node (v20 trở lên), thêm DOM chỉ tạo ra hai khai báo chồng nhau.

// src/index.ts — KHÔNG có `import './tracing'` ở đây: telemetry được nạp bằng
// --require/--import trước khi file này chạy (xem "Thứ tự khởi tạo").
import express, { type Request, type Response } from 'express';
import { Pool } from 'pg';
import { Redis } from 'ioredis'; // ioredis v6 bỏ default export; dạng named hoạt động cả v5 lẫn v6

const port = Number(process.env.PORT ?? 3000);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');

const app = express();
app.use(express.json());

// Cố ý bị ignoreIncomingRequestHook trong tracing.ts loại khỏi trace.
app.get('/healthz', (_req: Request, res: Response) => {
  res.json({ status: 'ok' });
});

app.get('/api/products', async (_req: Request, res: Response) => {
  const cached = await redis.get('products:top');          // -> span ioredis
  if (cached) {
    res.json({ source: 'cache', items: JSON.parse(cached) });
    return;
  }
  const { rows } = await pool.query(                       // -> span pg
    'SELECT id, name, price FROM products ORDER BY id LIMIT 20',
  );
  await redis.set('products:top', JSON.stringify(rows), 'EX', 30);
  res.json({ source: 'db', items: rows });
});

// Đóng vai downstream khi chưa có service thanh toán thật.
app.post('/internal/payment-stub', (_req: Request, res: Response) => {
  res.json({ ok: true });
});

app.post('/api/orders', async (req: Request, res: Response) => {
  // fetch global chạy trên undici, KHÔNG đi qua module http: thiếu
  // instrumentation-undici là mất hẳn span CLIENT lẫn header traceparent (Phần 5B).
  const target = process.env.PAYMENT_URL ?? `http://127.0.0.1:${port}/internal/payment-stub`;
  const r = await fetch(target, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(req.body ?? {}),
  });
  res.status(r.ok ? 201 : 502).json({ paid: r.ok });
});

const server = app.listen(port, () => console.log(`listening on ${port}`));
// Đóng HTTP khi nhận SIGTERM; việc flush span do handler trong tracing.ts lo.
process.on('SIGTERM', () => server.close());

Code trên giả định Express 5, bản tự chuyển promise bị reject sang error handler; với Express 4 phải bọc try/catch trong từng handler async, nếu không request treo và span SERVER không bao giờ end().

-- db/init.sql
CREATE TABLE IF NOT EXISTS products (
  id    serial PRIMARY KEY,
  name  text NOT NULL,
  price numeric(12, 2) NOT NULL
);

INSERT INTO products (name, price)
SELECT * FROM (VALUES ('Sữa rửa mặt', 129000), ('Kem chống nắng', 349000), ('Serum B5', 590000))
  AS seed(name, price)
WHERE NOT EXISTS (SELECT 1 FROM products);

Compose ở Phần 8 mới có app, otel-collector, jaeger. Thêm hai backing service và ba biến môi trường sau vào chính file đó:

services:
  app:
    environment:
      DATABASE_URL: "postgres://app:app@postgres:5432/shop"
      REDIS_URL: "redis://redis:6379"
      PAYMENT_URL: "http://app:3000/internal/payment-stub"
    depends_on: [otel-collector, postgres, redis]

  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: shop
    volumes:
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    networks: [otel]

  redis:
    image: redis:7-alpine
    networks: [otel]

Chuỗi kiểm chứng end-to-end. Compose ở Phần 8 đặt OTEL_TRACES_SAMPLER_ARG: "1.0" nên mọi request đều lên Jaeger; giữ 0.1 như ví dụ Docker ở trên thì phải bắn vài chục request mới thấy một trace:

npm ci
npm run build                                  # sinh dist/tracing.js và dist/index.js
docker compose up -d --build
docker compose logs otel-collector | tail -5   # chờ dòng "Everything is ready"

curl -s http://localhost:3000/api/products     # lần 1: cache miss
curl -s http://localhost:3000/api/products     # lần 2: cache hit
curl -s -X POST http://localhost:3000/api/orders -H 'content-type: application/json' -d '{"sku":"A1"}'
curl -s http://localhost:3000/healthz          # cố ý không sinh trace

Mở http://localhost:16686, chọn service shop-api (tên do OTEL_SERVICE_NAME trong compose Phần 8 đặt, thắng giá trị checkout-api viết trong code). Trace mong đợi:

Request Span mong đợi
GET /api/products lần 1 1 span SERVER GET /api/products (tên có route là nhờ instrumentation-express đặt http.route), 1 span pg.query…, 2 span ioredis (get rồi set), cộng span layer Express nếu chưa tắt ExpressLayerType.MIDDLEWARE
GET /api/products lần 2 1 span SERVER + 1 span ioredis get — không có span pg, đúng như đường cache hit
POST /api/orders 1 span SERVER, 1 span CLIENT của instrumentation-undici, và 1 span SERVER thứ hai cho /internal/payment-stub nằm cùng một trace — bằng chứng traceparent đã được inject
GET /healthz không có trace nào

Không thấy gì thì chạy lại theo bảng triệu chứng ở mục "Thứ tự khởi tạo" và bật OTEL_LOG_LEVEL=debug. Thấy span ở app nhưng Jaeger trống là chuyện của Collector — Phần 7 và Phần 11.


Phần 5B — Node.js/TypeScript: instrument thực chiến

Lấy tracer

trace.getTracer(name, version) trả về một tracer gắn với một instrumentation scope. Tên scope đi theo mọi span sinh ra từ tracer đó và hiển thị trong Jaeger ở otel.scope.name, nên nó phải trả lời câu hỏi "đoạn code nào tạo span này", không phải "service nào".

// src/observability/tracer.ts
import { trace } from '@opentelemetry/api';

// name = module/thư viện sinh span, KHÔNG phải service.name
export const orderTracer = trace.getTracer('hasaki.orders', '1.4.0');
export const paymentTracer = trace.getTracer('hasaki.payments', '1.4.0');
Cách đặt Ví dụ Đánh giá
Theo module/package hasaki.orders, hasaki.checkout.pricing Đúng — lọc được span theo scope, biết version code sinh span
Theo service order-service Sai — trùng nghĩa với service.name trên resource, scope thành vô nghĩa
Một tracer global duy nhất default Sai — mất khả năng phân biệt nguồn
Tạo tracer mới trong mỗi request gọi getTracer(...) trong handler Lãng phí — tracer là singleton mức module

service.name là attribute của resource, đặt một lần lúc khởi tạo SDK (xem Phần 5A), độc lập hoàn toàn với tên tracer.

startActiveSpan vs startSpan + context.with

Tiêu chí tracer.startActiveSpan(name, opts, fn) tracer.startSpan(name, opts, ctx) + context.with(...)
Đưa span vào active context Tự động, trong phạm vi callback Phải tự làm bằng trace.setSpan(context.active(), span)
Tự gọi end() KHÔNG — phải tự span.end() Không
Code bên trong tự thành span con Chỉ khi bọc trong context.with
Chỉ định parent tùy ý (remote parent) Được, qua overload 4 tham số Tự nhiên hơn — truyền ctx trực tiếp
Span không cần active (đo song song, kết thúc ở callback khác) Cồng kềnh Phù hợp
Trường hợp dùng chính ~90% code nghiệp vụ Consumer message, span bắc cầu, span sống qua nhiều callback
// Cách 1 — startActiveSpan: gọn nhất cho code nghiệp vụ
import { SpanStatusCode } from '@opentelemetry/api';
import { orderTracer } from './tracer.js';

declare const stockRepo: { reserve(orderId: string): Promise<void> };

async function reserveStock(orderId: string) {
  return orderTracer.startActiveSpan('reserve stock', async (span) => {
    try {
      span.setAttribute('hasaki.order.id', orderId);
      return await stockRepo.reserve(orderId); // span con tự bám vào span này
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
      throw err;
    } finally {
      span.end(); // BẮT BUỘC — startActiveSpan không tự end
    }
  });
}
// Cách 2 — startSpan + context.with: khi cần kiểm soát context cha
import { type Context, context, trace } from '@opentelemetry/api';
import { orderTracer } from './tracer.js';

declare const stockRepo: { reserve(orderId: string): Promise<void> };

export async function reserveStockUnder(orderId: string, parentCtx: Context) {
  const span = orderTracer.startSpan('reserve stock', undefined, parentCtx);
  const ctx = trace.setSpan(parentCtx, span);   // KHÔNG phải context.active()
  try {
    await context.with(ctx, () => stockRepo.reserve(orderId));
  } finally {
    span.end();
  }
}

Điểm dễ sai ở Cách 2: viết trace.setSpan(context.active(), span) thì quan hệ cha-con vẫn đúng (vì startSpan đã nhận parentCtx), nhưng scope chạy stockRepo.reserve() mất mọi thứ khác nằm trong parentCtx — trước hết là baggage vừa propagation.extract từ header hay từ message. Hệ quả: mọi outbound call bên trong không inject được header baggage (xem Phần 3). Luôn dựng context mới từ parentCtx.

Quên span.end() là lỗi phổ biến nhất: span không bao giờ đi qua onEnd của span processor nên không bao giờ được export — trace thiếu hẳn một nhánh mà không có lỗi nào báo ra. Nếu span còn bị giữ trong một closure hay biến sống lâu, mọi attribute và event của nó cũng nằm lại trong bộ nhớ.

async/await: ví dụ đúng và ví dụ sai

// SAI 1 — end span trước khi await xong
import { paymentTracer } from './tracer.js';

declare const paymentGateway: { charge(orderId: string): Promise<{ id: string }> };

async function chargeWrong(orderId: string) {
  const span = paymentTracer.startSpan('charge');
  const p = paymentGateway.charge(orderId); // chưa await
  span.end();                               // đóng ngay lập tức
  return p;
}

Triệu chứng: span charge có duration gần 0 (thường dưới 1ms) trong khi request thật mất 800ms; span HTTP client gọi gateway không nằm trong charge mà bị đẩy lên span cha xa hơn hoặc mất hẳn.

// SAI 2 — callback đồng bộ, trả promise mà không await bên trong
import { paymentTracer } from './tracer.js';

function chargeWrong2(orderId: string) {
  return paymentTracer.startActiveSpan('charge', (span) => {
    const p = paymentGateway.charge(orderId); // promise chưa settle
    span.end();                               // end chạy trước khi charge xong
    return p;
  });
}

Triệu chứng: charge có duration gần 0 vì end() chạy trước khi promise settle. Quan hệ cha-con vẫn đúng: startActiveSpan bọc callback trong context.with(...), tức AsyncLocalStorage.run(...), và mọi continuation async sinh ra từ bên trong scope đó vẫn thấy charge là span active — span HTTP client gọi gateway vẫn nhận charge làm cha. Cái hỏng là thời gian: span con kết thúc sau span cha, timeline vẽ thanh con tràn ra ngoài thanh cha, và duration của charge vô nghĩa (xem Phần 11).

// ĐÚNG — callback async, await mọi thứ, end trong finally
import { paymentTracer } from './tracer.js';

function charge(orderId: string) {
  return paymentTracer.startActiveSpan('charge', async (span) => {
    try {
      return await paymentGateway.charge(orderId);
    } finally {
      span.end();
    }
  });
}

Quy tắc kiểm tra nhanh: nếu trong thân callback có return somePromise mà không await, và span.end() nằm ngoài promise đó, span sai. Với Promise.all, phải await cả mảng bên trong callback.

Xử lý lỗi chuẩn

recordException chỉ thêm span event exception (exception.type, exception.message, exception.stacktrace — cả ba Stable), không đổi status — hai thao tác tách rời (xem Phần 1, mục Status).

import { SpanStatusCode } from '@opentelemetry/api';
import { orderTracer } from './tracer.js';

declare const couponService: { apply(code: string): Promise<{ discount: number }> };

async function applyCoupon(code: string) {
  return orderTracer.startActiveSpan('apply coupon', async (span) => {
    try {
      return await couponService.apply(code);
    } catch (err) {
      const e = err as Error;
      span.recordException(e);                                    // event, KHÔNG set status
      span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
      span.setAttribute('error.type', e.name);                    // error.type là Stable
      throw err;                                                  // rethrow: không nuốt lỗi
    } finally {
      span.end();                                                 // luôn chạy
    }
  });
}

exception.escaped đã deprecated — không ghi nữa. Không set SpanStatusCode.OK cho đường thành công; UNSET là mặc định đúng, OK dành cho trường hợp code chủ động khẳng định thành công. Lỗi nghiệp vụ đã xử lý (coupon hết hạn, trả 400 hợp lệ) thì cân nhắc không set ERROR mà chỉ ghi attribute — nếu không, tỷ lệ lỗi trên service graph sẽ nhiễu.

Helper withSpan và decorator @Traced

withSpan<T>()

// src/observability/with-span.ts
import { Attributes, Span, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api';

const tracer = trace.getTracer('hasaki.core');

export interface WithSpanOptions {
  attributes?: Attributes;
  kind?: SpanKind;
}

export function withSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T>;
export function withSpan<T>(
  name: string,
  opts: WithSpanOptions,
  fn: (span: Span) => Promise<T>,
): Promise<T>;
export function withSpan<T>(
  name: string,
  a: WithSpanOptions | ((span: Span) => Promise<T>),
  b?: (span: Span) => Promise<T>,
): Promise<T> {
  const opts: WithSpanOptions = typeof a === 'function' ? {} : a;
  const fn = (typeof a === 'function' ? a : b)!;

  return tracer.startActiveSpan(
    name,
    { kind: opts.kind ?? SpanKind.INTERNAL, attributes: opts.attributes },
    async (span): Promise<T> => {
      try {
        return await fn(span);
      } catch (err) {
        const e = err as Error;
        span.recordException(e);
        span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
        throw err;
      } finally {
        span.end();
      }
    },
  );
}

// Dùng: kiểu trả về suy ra là Promise<Order>, không mất type
interface Order { id: string; total: number }
declare function buildOrder(): Promise<Order>;

export function checkout(): Promise<Order> {
  return withSpan(
    'build order',
    { attributes: { 'hasaki.cart.item_count': 3 } },
    async (span) => {
      const o = await buildOrder();
      span.setAttribute('hasaki.order.id', o.id);
      return o;
    },
  );
}

Decorator @Traced() cho NestJS provider

// src/observability/traced.decorator.ts
import { SpanKind, SpanStatusCode, trace } from '@opentelemetry/api';

// Tracer là singleton mức module — KHÔNG gọi getTracer() bên trong method
const nestTracer = trace.getTracer('hasaki.nest');

export function Traced(spanName?: string, kind: SpanKind = SpanKind.INTERNAL): MethodDecorator {
  return (target, propertyKey, descriptor: PropertyDescriptor) => {
    const original = descriptor.value;
    const name = spanName ?? `${target.constructor.name}.${String(propertyKey)}`;

    descriptor.value = function (this: unknown, ...args: unknown[]) {
      return nestTracer.startActiveSpan(name, { kind }, async (span) => {
        try {
          return await original.apply(this, args);
        } catch (err) {
          const e = err as Error;
          span.recordException(e);
          span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
          throw err;
        } finally {
          span.end();
        }
      });
    };
    return descriptor;
  };
}

// @Injectable()
// export class OrderService {
//   @Traced('order.checkout')
//   async checkout(dto: CheckoutDto) { /* ... */ }
// }

Decorator luôn await kết quả nên method đồng bộ sẽ bị biến thành Promise — chỉ dùng cho method async. Cần "experimentalDecorators": true trong tsconfig.json (mặc định của NestJS).

Đặt tên span: quy tắc low cardinality

Tên span là chiều gom nhóm chính của mọi backend. Nhét ID vào tên làm nổ cardinality: mỗi order thành một "operation" mới, RED metrics vô dụng, index storage phình.

Sai (high cardinality) Đúng (low cardinality) ID ghi ở đâu
GET /orders/12345 GET /orders/:id hasaki.order.id
getUser(0912345678) getUser hasaki.customer.id
SELECT * FROM orders WHERE id=99 SELECT hasaki.orders db.query.text (đã sanitize)
publish order.created.12345 publish order.created messaging.message.id
charge card 4111... charge payment hasaki.payment.method

Với HTTP server, semconv quy định tên span là {method} {http.route}MUST NOT dùng URI path làm target (chi tiết ở Phần 9). http.route là template do framework cung cấp (/orders/:id), không phải path thật.

Framework

Express

@opentelemetry/instrumentation-http tạo span SERVER cho mỗi request nhưng không biết route template — chỉ thấy path. @opentelemetry/instrumentation-express bổ sung hai thứ:

  1. Ghi route template vào RPC metadata của context; instrumentation-http đọc lại, đặt http.route và đổi tên span SERVER thành GET /orders/:id. Không có nó thì span chỉ tên GET.
  2. Sinh span cho từng layer Express (middleware, router, request handler) — hữu ích để tìm middleware chậm, nhưng rất nhiều span.
// tracing.ts — giảm nhiễu: bỏ span middleware, giữ request handler.
// PHẢI chạy trước khi express được nạp (xem Phần 5A, mục thứ tự khởi tạo).
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { ExpressInstrumentation, ExpressLayerType } from '@opentelemetry/instrumentation-express';

registerInstrumentations({
  instrumentations: [
    new ExpressInstrumentation({ ignoreLayersType: [ExpressLayerType.MIDDLEWARE] }),
  ],
});

Mọi khối cấu hình instrumentation trong phần này (Express, undici, ioredis) đều phải được đăng ký: qua registerInstrumentations() hoặc mảng instrumentations của NodeSDK. new XInstrumentation({...}) đứng một mình chỉ là một object rồi bị bỏ đi — không patch gì cả; đăng ký muộn hơn lúc module đích được require/import cũng vô tác dụng.

Thêm attribute nghiệp vụ vào span đang active ngay trong route handler:

import { trace } from '@opentelemetry/api';
import type { Express } from 'express';

declare const app: Express;
declare const orderService: { get(id: string): Promise<unknown> };

app.get('/orders/:id', async (req, res) => {
  trace.getActiveSpan()?.setAttributes({
    'hasaki.order.id': req.params.id,
    'hasaki.tenant.id': req.header('x-tenant') ?? 'default',
  });
  res.json(await orderService.get(req.params.id));
});

trace.getActiveSpan() ở đây trả về span của layer hiện tại (span handler nếu express instrumentation bật, span SERVER nếu không) — nói cách khác, nó không bảo đảm là span SERVER. Cách gắn cho chắc nằm ngay ở mục dưới.

Gắn attribute nghiệp vụ lên đúng span SERVER

Nhu cầu phổ biến nhất khi instrument code nghiệp vụ: CSKH có ORD-2026-887431, muốn gõ vào Jaeger ra đúng trace. Muốn vậy hasaki.order.id phải nằm trên span SERVER — span đầu trace, span mà tail sampling và spanmetrics đọc.

Chỗ mắc kẹt: OpenTelemetry không có API kiểu getRootSpan() hay "lấy span SERVER của request này". trace.getActiveSpan() chỉ trả về span gần nhất trong context hiện tại, mà ở tầng service sâu bên trong thì đó là span của chính tầng đó. Ba cách xử lý, từ đơn giản đến linh hoạt.

Cách 1 — hook applyCustomAttributesOnSpan của instrumentation-http. Hook chạy trên chính span SERVER, tại thời điểm response kết thúc.

// tracing.ts
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import type { ServerResponse } from 'node:http';

registerInstrumentations({
  instrumentations: [
    new HttpInstrumentation({
      // (span, request, response) => void
      applyCustomAttributesOnSpan: (span, _req, res) => {
        // Hook dùng chung cho cả request VÀO và request RA -> chỉ chiều vào mới có res.locals
        const biz = (res as ServerResponse & { locals?: Record<string, string> }).locals;
        if (!biz) return;
        if (biz.orderId) span.setAttribute('hasaki.order.id', biz.orderId);
        if (biz.tenantId) span.setAttribute('hasaki.tenant.id', biz.tenantId);
      },
    }),
  ],
});
// route handler: đẩy giá trị nghiệp vụ ra chỗ hook đọc được
app.get('/orders/:id', async (req, res) => {
  res.locals.orderId = req.params.id;
  res.json(await orderService.get(req.params.id));
});

Hạn chế đúng như code cho thấy: hook chỉ nhìn được thứ nằm trên req/res, nên tầng nghiệp vụ phải chủ động gửi giá trị ra ngoài qua res.locals (Express) hoặc một property tự đặt. Tên và chữ ký hook có đổi giữa các version (applyCustomAttributesOnSpan, requestHook, responseHook, startIncomingSpanHook) — đối chiếu README của bản @opentelemetry/instrumentation-http đang cài.

Cách 2 — ghim span SERVER vào context. Linh hoạt nhất: mọi tầng bên trong gọi được, không phải chuyền req xuống.

// src/observability/server-span.ts
import { type Attributes, type Span, context, createContextKey, trace } from '@opentelemetry/api';

const SERVER_SPAN_KEY = createContextKey('hasaki.server-span');

/** Middleware đặt SỚM NHẤT trong chuỗi: ghim span SERVER vào context của cả request. */
export function pinServerSpan(_req: unknown, _res: unknown, next: () => void) {
  const span = trace.getActiveSpan();
  if (!span) return next();
  context.with(context.active().setValue(SERVER_SPAN_KEY, span), next);
}

export function getServerSpan(): Span | undefined {
  return context.active().getValue(SERVER_SPAN_KEY) as Span | undefined;
}

/** Gọi được từ bất kỳ tầng nào — service, repository, plugin GraphQL. */
export function setServerAttributes(attrs: Attributes): void {
  getServerSpan()?.setAttributes(attrs);
}
// src/services/order.service.ts — không biết gì về req/res
import { setServerAttributes } from '../observability/server-span.js';

export async function getOrder(id: string) {
  setServerAttributes({ 'hasaki.order.id': id });
  // ...
}

Một cái bẫy trong chính cách này: nếu express instrumentation đang sinh span MIDDLEWARE thì trace.getActiveSpan() bên trong middleware trả về span layer, không phải span SERVER — và bạn ghim nhầm. Hoặc tắt span middleware bằng ignoreLayersType: [ExpressLayerType.MIDDLEWARE] như ở mục Express phía trên, hoặc kiểm tra span.spanContext() khớp với span SERVER trước khi ghim.

Cách 3 — SpanProcessor. onStart(span, parentContext) chạy lúc span vừa được tạo và nhìn thấy context cha, nên đây là chỗ đúng để copy những giá trị đã có sẵn trong context (baggage) lên span:

// src/observability/baggage-processor.ts
import { type Context, propagation } from '@opentelemetry/api';
import type { Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';

const COPIED = ['tenant.id', 'channel'];

export class BaggageAttributeProcessor implements SpanProcessor {
  onStart(span: Span, parentCtx: Context): void {
    const bag = propagation.getBaggage(parentCtx);
    for (const key of COPIED) {
      const entry = bag?.getEntry(key);
      if (entry) span.setAttribute(`hasaki.${key}`, entry.value);
    }
  }
  onEnd(): void {}
  forceFlush(): Promise<void> { return Promise.resolve(); }
  shutdown(): Promise<void> { return Promise.resolve(); }
}

Đừng trông chờ vào onEnd để nhét thêm attribute: ở OTel JS, setAttribute trên span đã end là no-op và SDK ghi cảnh báo Can not execute the operation on ended Span. Enrichment muộn phải làm ở Collector bằng processor transform/attributes (Phần 7).

Vị trí attribute có quan trọng không? Tuỳ mục đích dùng:

Mục đích Đặt ở span nào cũng được? Vì sao
Tra cứu thủ công trên Jaeger ("mở trace của đơn ORD-…") Được Tìm theo tag khớp cả trace nếu bất kỳ span nào trong trace mang tag đó
tail_sampling policy string_attribute (Phần 4) Gần như được Policy quét mọi span của trace, nhưng span mang attribute phải tới trước decision_wait
dimensions của connector spanmetrics (Phần 8) Không Metric sinh theo từng span; chỉ span mang attribute mới có dimension đó
SLO/dashboard theo route hoặc theo tenant Không Query gom trên span SERVER; attribute nằm ở span con thì không gom được

Quy tắc rút gọn: attribute chỉ để tra cứu thì đặt ở đâu cũng được; attribute dùng để lọc, nhóm, tính số liệu thì phải nằm trên span SERVER.

NestJS

Tracing phải khởi tạo trước khi bất kỳ module nào được import, vì instrumentation vá module tại thời điểm require/import.

// main.ts — KHÔNG import tracing ở đây
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
void bootstrap();

Tracing nạp bằng flag của node, không bằng import: CJS node --require ./dist/tracing.js dist/main.js; ESM node --experimental-loader=@opentelemetry/instrumentation/hook.mjs --import ./dist/tracing.js dist/main.js (xem Phần 5A).

Viết import './tracing.js'; ở dòng đầu main.tsanti-pattern (Phần 12): với ESM, mọi câu import được hoisting và giải quyết xong trước khi dòng code đầu tiên chạy, nên @nestjs/core, express, pg đã nạp xong trước khi tracing.ts kịp thực thi — không sinh span auto nào, và không có lỗi nào báo ra. Ở build CommonJS thì thứ tự require đúng bằng thứ tự viết nên cách đó tình cờ chạy được, nhưng vẫn kém an toàn hơn --require: chỉ cần một import khác vô tình chen lên trên là hỏng, và bundler có quyền sắp xếp lại.

@opentelemetry/instrumentation-nestjs-core sinh span Create Nest App lúc khởi động, một span <Controller>.<method> cho request context (kèm http.route, http.request.method) và một span cho chính handler — không sinh span cho provider/service. Cho tầng service, dùng @Traced() ở trên hoặc một interceptor:

// tracing.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { context, SpanStatusCode, trace } from '@opentelemetry/api';
import { Observable } from 'rxjs';

@Injectable()
export class TracingInterceptor implements NestInterceptor {
  private readonly tracer = trace.getTracer('hasaki.nest');

  intercept(execCtx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const name = `${execCtx.getClass().name}.${execCtx.getHandler().name}`;
    const span = this.tracer.startSpan(name);
    const spanCtx = trace.setSpan(context.active(), span);

    // Bọc cả lúc subscribe: handler chạy trong context của span -> span con bám đúng cha
    return new Observable((subscriber) =>
      context.with(spanCtx, () =>
        next.handle().subscribe({
          next: (value) => subscriber.next(value),
          error: (err: Error) => {
            span.recordException(err);
            span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
            span.end();
            subscriber.error(err);
          },
          complete: () => {
            span.end();
            subscriber.complete();
          },
        }),
      ),
    );
  }
}

Bẫy hay gặp khi tự viết interceptor: chỉ startSpan rồi pipe(finalize(() => span.end())) mà quên context.with. Span vẫn được export với duration đúng, nhưng mọi span tạo bên trong service không nhận nó làm cha — trace bị phẳng đi một tầng.

Với NestJS microservice transport, span SERVER không đến từ instrumentation-http. Kafka và RabbitMQ có instrumentation cho client nhưng không tự nối context vào handler @MessagePattern — phải tự extract như phần message queue bên dưới. Transport TCP mặc định của Nest không có instrumentation nào, propagate hoàn toàn thủ công.

Fastify

@opentelemetry/instrumentation-fastify đã deprecated từ đầu năm 2025 và sau đó bị gỡ khỏi cả opentelemetry-js-contrib lẫn auto-instrumentations-node (mã cũ chỉ còn nằm trong thư mục archive/ của repo). Thay thế chính thức là plugin @fastify/otel do chính nhóm Fastify duy trì: đăng ký như một plugin Fastify bình thường, nó hook vào vòng đời request (onRequest, onResponse, onError) để đặt http.route và sinh span cho từng hook/handler. API đăng ký còn thay đổi giữa các bản phát hành — đối chiếu README của @fastify/otel. Vẫn cần @opentelemetry/instrumentation-http cho span SERVER và HTTP client.

Next.js và các meta-framework

Next.js không nạp telemetry bằng --require/--import mà bằng quy ước riêng: một file instrumentation.ts ở gốc project (cạnh app/, hoặc trong src/ nếu dùng thư mục đó) export hàm register(), framework tự gọi đúng một lần trước khi server bắt đầu nhận request. Cơ chế bên dưới vẫn y hệt Phần 5A — chỉ khác là Next đứng ra gọi hộ, nên đừng thêm --require nữa. Hook này là experimental từ 13.4 và đã ổn định ở các bản gần đây; đối chiếu docs của đúng version đang dùng.

// instrumentation.ts — ở gốc project, KHÔNG nằm trong app/
export async function register() {
  // BẮT BUỘC: Edge runtime không chạy được Node SDK.
  // Thiếu guard này là lỗi build hoặc lỗi runtime rất khó đọc.
  if (process.env.NEXT_RUNTIME !== 'nodejs') return;

  // Đường 1 — nhanh: @vercel/otel tự dựng provider, exporter, propagator
  // const { registerOTel } = await import('@vercel/otel');
  // registerOTel({ serviceName: 'hasaki-web' });

  // Đường 2 — tự dựng NodeSDK y như Phần 5A: kiểm soát sampler, exporter, resource
  await import('./tracing.node.js');
}

await import(...) chứ không phải import tĩnh ở đầu file: module Node SDK không được phép lọt vào bundle Edge.

Next còn tự sinh span của riêng nó dưới instrumentation scope next.js: BaseServer.handleRequest, render route, resolve page components, và fetch <url> cho mỗi fetch phía server. Biến môi trường NEXT_OTEL_VERBOSE=1 bật thêm nhóm span chi tiết (mặc định Next chỉ phát nhóm rút gọn).

Ba bẫy:

  • Trùng span fetch: App Router đã patch fetch global để phục vụ cache của nó, nên bật thêm @opentelemetry/instrumentation-undici có thể cho hai span cho cùng một lời gọi. Chọn một nguồn, tắt nguồn kia.
  • RSC streaming: span SERVER chỉ end khi response stream đóng, nên duration của nó là "thời gian tới byte cuối" chứ không phải thời gian xử lý — xem mục kết nối sống lâu bên dưới.
  • Build và ISR cũng sinh span: next build và các lần revalidate nền phát span vào cùng service. Tách bằng deployment.environment.name hoặc lọc ở Collector, nếu không latency của trang bị pha loãng bởi span của build.

Các framework còn lại

Framework Instrumentation Ghi chú
Express @opentelemetry/instrumentation-express Cần kèm -http; ignoreLayersType để bớt span middleware
NestJS @opentelemetry/instrumentation-nestjs-core Không sinh span cho provider/service — tự thêm bằng @Traced() hoặc interceptor
Fastify @fastify/otel (plugin) instrumentation-fastify đã bị gỡ khỏi contrib
Koa @opentelemetry/instrumentation-koa Đặt http.route, sinh span cho từng middleware; có ignoreLayersType như Express
Hapi @opentelemetry/instrumentation-hapi Span cho route handler và extension
Hono Không có trong contrib Chạy trên nhiều runtime; trên Node vẫn có span SERVER từ -http nhưng không có http.route — tự đặt bằng middleware
Next.js Hook register() + @vercel/otel hoặc NodeSDK Xem mục trên

Với framework không có instrumentation: span SERVER vẫn có (từ -http) nhưng tên chỉ là GET và thiếu http.route — mọi thứ dựa vào route (SLO theo endpoint, dimensions của spanmetrics, tail sampling theo route) mất hiệu lực. Bù bằng một middleware đặt http.route lên span active rồi đổi tên span thành {method} {route}, đúng dạng semconv quy định.

GraphQL

GraphQL phá gần hết giả định của phần này: mọi request đều là POST /graphql, nên http.route chỉ có đúng một giá trị; tên span low-cardinality không còn phân biệt được gì; head-based sampler không thấy operation nào đang chạy nên không lọc theo nó được; và span resolver có thể nổ hàng nghìn cái trong một request. Semconv cho GraphQL đang ở mức Development (graphql.operation.name, graphql.operation.type, graphql.document), tên còn có thể đổi — Phần 9.

npm i @opentelemetry/instrumentation-graphql

Package này vá chính thư viện graphql, nên chạy được với Apollo Server, GraphQL Yoga, Mercurius… miễn là chúng dùng graphql-js bên dưới. Span nó sinh ra: graphql.parse, graphql.validate, graphql.execute, cộng một span cho mỗi resolver, tên dạng <Type>.<field> (Query.orders, Order.items).

// tracing.ts — PHẢI đăng ký trước khi module graphql được nạp
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql';

registerInstrumentations({
  instrumentations: [
    new GraphQLInstrumentation({
      // Mặc định -1 = sinh span cho MỌI tầng resolver. Cắt lại ngay từ đầu.
      depth: 2,
      // Bỏ resolver mặc định (chỉ đọc field có sẵn trên object) — phần lớn span rác nằm ở đây
      ignoreTrivialResolveSpans: true,
      // Mảng N phần tử -> một span cho cả mảng thay vì N span
      mergeItems: true,
      // MẶC ĐỊNH false. Bật lên là ghi giá trị biến GraphQL vào span.
      allowValues: false,
    }),
  ],
});

Nổ span. Một query trả 500 item, mỗi item 5 field, là 2.500 span resolver cho một request — so với ngưỡng thực dụng ~20 span/request ở cuối phần này thì lệch hai bậc độ lớn. mergeItems, ignoreTrivialResolveSpansdepth phải bật ngay từ ngày đầu ở production, không phải đợi đến lúc nhận hoá đơn.

PII. allowValues: true ghi giá trị biến của operation vào span — email, số điện thoại, địa chỉ giao hàng nằm thẳng trong Jaeger. Giữ mặc định false ở production, bật ở staging khi cần debug; cùng nguyên tắc với enhancedDatabaseReporting ở mục Database.

Đặt tên theo operation. Đưa operationName lên span SERVER rồi đổi tên span, để mỗi operation thành một "endpoint" riêng:

// apollo-otel.plugin.ts
import type { ApolloServerPlugin } from '@apollo/server';
import { getServerSpan, setServerAttributes } from './observability/server-span.js';

// operationName do CLIENT gửi -> BẮT BUỘC allowlist, nếu không cardinality do người lạ quyết định
const KNOWN_OPS = new Set(['GetCart', 'Checkout', 'SearchProducts', 'OrderDetail']);

export const otelOperationPlugin: ApolloServerPlugin = {
  async requestDidStart() {
    return {
      async didResolveOperation(ctx) {
        const raw = ctx.operationName ?? 'anonymous';
        const op = KNOWN_OPS.has(raw) ? raw : 'other';
        const type = ctx.operation.operation; // 'query' | 'mutation' | 'subscription'

        setServerAttributes({ 'graphql.operation.name': op, 'graphql.operation.type': type });
        getServerSpan()?.updateName('POST /graphql ' + op);
      },
    };
  },
};

Không có allowlist thì chỉ cần một client gửi operationName sinh ngẫu nhiên là span name thành high cardinality — đúng thứ mục "Đặt tên span" ở trên cấm, và lần này người quyết định cardinality lại là người ngoài.

N+1 và DataLoader. Span resolver không cho thấy batching: 500 span Order.customer vẫn hiện ra dù DataLoader đã gộp chúng thành một query duy nhất — nhìn trace tưởng N+1 trong khi không phải. Contrib có @opentelemetry/instrumentation-dataloader; nếu không dùng, tự bọc batch function để thấy đúng số lần chạm DB:

import { SpanKind } from '@opentelemetry/api';
import DataLoader from 'dataloader';
import { withSpan } from './observability/with-span.js';

declare function loadCustomers(ids: readonly string[]): Promise<unknown[]>;

export const customerLoader = new DataLoader<string, unknown>((ids) =>
  withSpan(
    'dataloader customer',
    { kind: SpanKind.INTERNAL, attributes: { 'hasaki.batch.size': ids.length } },
    () => loadCustomers(ids),
  ),
);

Batched query. Client được phép gửi một mảng nhiều operation trong cùng một POST. Khi đó một trace chứa nhiều operation không liên quan gì nhau, duration của span SERVER là tổng của tất cả, và cách đặt tên ở trên chỉ chọn được một cái. Hoặc tắt batching ở server, hoặc chấp nhận và đặt graphql.operation.name = 'batch' kèm attribute đếm số operation.

Sampling. Head-based sampler chỉ nhìn thấy url.path=/graphql, nên không thể giữ 100% mutation Checkout mà bỏ bớt SearchProducts. Quyết định phải đẩy về Collector bằng tail_sampling với policy string_attribute trên graphql.operation.name (Phần 4) — và đây chính là lý do attribute đó phải nằm trên span SERVER chứ không phải trên span resolver.

# Trích policy; pipeline tail_sampling đầy đủ ở Phần 4
- name: graphql-checkout
  type: string_attribute
  string_attribute:
    key: graphql.operation.name
    values: [Checkout, PlaceOrder]

HTTP client

Client Instrumentation cần Ghi chú
fetch global (Node 18+) @opentelemetry/instrumentation-undici fetch của Node dựa trên undici, không đi qua module http/https
undici.request @opentelemetry/instrumentation-undici Cùng lý do
axios @opentelemetry/instrumentation-http axios dùng module http/https — đã được bọc sẵn, không cần package riêng
got, node-fetch @opentelemetry/instrumentation-http Cùng cơ chế như axios

Nếu app dùng fetch mà chỉ cài instrumentation-http thì span CLIENT biến mất hoàn toàn và header traceparent không được inject — trace đứt tại biên service. Đây là nguyên nhân số một của "trace bị cụt" trên Node 18+.

// tracing.ts — đăng ký cùng chỗ với các instrumentation khác
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici';

declare function hostToService(origin: string): string;

registerInstrumentations({
  instrumentations: [
    new UndiciInstrumentation({
      // Bỏ qua health check và metrics scrape
      ignoreRequestHook: (req) =>
        req.path.startsWith('/healthz') || req.path.startsWith('/metrics'),
      // Gắn tên logic của downstream để service graph gom đúng node
      startSpanHook: (req) => {
        const peer = hostToService(req.origin);
        return { 'service.peer.name': peer, 'peer.service': peer };
      },
    }),
  ],
});

Attribute này mô tả tên logic của service phía bên kia, nhờ nó service graph hiện payment-gateway chứ không phải api-gw-3.internal:8443. Lưu ý trạng thái: peer.service đã Deprecated, thay bằng service.peer.name (hiện ở mức Development, tên còn có thể đổi). Nhưng backend đang chạy vẫn đọc tên cũ — connector servicegraph của Collector dựng "virtual node" cho hệ thống bên ngoài dựa trên virtual_node_peer_attributes, mặc định là peer.service, db.name, db.system. Giai đoạn chuyển tiếp cứ ghi cả hai tên.

Với instrumentation-http, hai option tương ứng là ignoreIncomingRequestHookignoreOutgoingRequestHook. Luôn loại /healthz, /readyz, /metrics — chúng chiếm phần lớn span rác (xem Phần 10). Tên option của từng instrumentation có thể đổi giữa các version, đối chiếu README của package.

Database

Thư viện Package instrumentation Attribute quan trọng sinh ra Option đáng chú ý
pg @opentelemetry/instrumentation-pg db.system.name, db.namespace, db.query.text, db.operation.name, server.address/server.port, error.type enhancedDatabaseReporting, requireParentSpan, requestHook/responseHook, addSqlCommenterCommentToQueries, ignoreConnectSpans
mysql2 @opentelemetry/instrumentation-mysql2 db.system.name, db.namespace, db.query.text, server.address/server.port maskStatement, maskStatementHook, responseHook, addSqlCommenterCommentToQueries
Prisma @prisma/instrumentation (do Prisma phát hành, không thuộc contrib) Span prisma:client:operation, prisma:client:serialize, prisma:engine:* Tracing GA từ Prisma 6.1.0; bản từ 4.2.0 đến trước 6.1.0 phải bật preview feature tracing trong schema.prisma
TypeORM Không có instrumentation trong opentelemetry-js-contrib (chỉ có package cộng đồng ngoài repo) Span đến từ driver bên dưới (pg / mysql2) Bọc repository/service bằng withSpan để có tên nghiệp vụ
MongoDB @opentelemetry/instrumentation-mongodb db.system.name, db.namespace, db.collection.name, db.operation.name, db.query.text enhancedDatabaseReporting, dbStatementSerializer, responseHook, requireParentSpan (mặc định true)
Mongoose @opentelemetry/instrumentation-mongoose Span ở tầng model/method Dùng kèm instrumentation-mongodb
ioredis @opentelemetry/instrumentation-ioredis db.system.name, db.query.text, db.operation.name, server.address/server.port dbStatementSerializer, requestHook/responseHook, requireParentSpan (mặc định true)
import { PrismaInstrumentation } from '@prisma/instrumentation';
import { registerInstrumentations } from '@opentelemetry/instrumentation';

registerInstrumentations({ instrumentations: [new PrismaInstrumentation()] });

Các bản instrumentation DB gần đây đã phát bộ tên Stable mặc định (db.system.name, db.namespace, db.query.text, db.operation.name); bản cũ hơn còn phát tên nay đã deprecated (db.system, db.name, db.statement, db.operation). Nếu dashboard hay query đang bám tên cũ, nâng package rồi dùng OTEL_SEMCONV_STABILITY_OPT_IN=database/dup để phát song song hai bộ trong lúc chuyển đổi (xem Phần 9).

db.query.text và rủi ro PII

db.query.text là Stable, nhưng semconv quy định query không tham số hóa PHẢI được sanitize trước khi ghi. Với query có tham số ($1, ?), instrumentation chỉ ghi câu SQL còn giá trị nằm ở tham số — an toàn. Rủi ro nằm ở hai chỗ:

  • SQL nối chuỗi: WHERE email = 'nguyen@example.com' đi thẳng vào span rồi vào Jaeger.
  • enhancedDatabaseReporting: true: option này thêm giá trị tham số vào span. Rất tiện khi debug, nhưng đồng nghĩa email, số điện thoại, địa chỉ khách hàng nằm trong trace store — nơi thường không có kiểm soát truy cập ngang tầm database và retention lại dài. Không bật ở production trừ khi đã có redaction ở Collector.

Ba lớp phòng thủ theo thứ tự ưu tiên: (1) luôn dùng parameterized query; (2) cắt ngay tại SDK để dữ liệu nhạy cảm không rời process — dbStatementSerializer (mongodb, ioredis), maskStatement / maskStatementHook (mysql2), hoặc responseHook tự viết; (3) processor redaction / transform ở Collector làm lưới chắn cuối (xem Phần 7 và Phần 10).

// tracing.ts
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';

registerInstrumentations({
  instrumentations: [
    new IORedisInstrumentation({
      dbStatementSerializer: (cmdName) => cmdName, // chỉ giữ tên lệnh, bỏ hết argument
      requireParentSpan: true,                     // mặc định đã true — bỏ span Redis mồ côi
    }),
  ],
});

SQLCommenter: nối trace với slow query log

Option addSqlCommenterCommentToQueries: true (có ở @opentelemetry/instrumentation-pg-mysql2) chèn trace context vào chính câu SQL dưới dạng comment:

SELECT id, total FROM orders WHERE id = $1 /*traceparent='00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'*/

Tác dụng: DBA đọc slow query log hoặc bảng thống kê query nhìn thấy trace id và mở thẳng trace tương ứng trên Jaeger. Đây là chiều ngược lại của log correlation ở mục dưới, và là mảnh ghép hay thiếu nhất khi nút thắt nằm trong database — đúng kịch bản của ví dụ xuyên suốt ở Phần 1 và Phần 8.

Ba đánh đổi phải biết trước khi bật:

  • Phá cache theo văn bản câu lệnh. Mỗi request sinh một chuỗi SQL khác nhau (trace id khác nhau), nên mọi cache khoá theo text — prepared statement cache của driver, statement cache của pooler như PgBouncer — mất tác dụng.
  • Công cụ gom nhóm theo text sẽ vỡ nhóm. Slow query log, nhiều dashboard APM và một số proxy coi mỗi request là một câu query mới. Riêng pg_stat_statements tính queryid từ cây parse nên comment thường không làm vỡ nhóm, nhưng đừng mặc định điều đó cho mọi version và mọi công cụ — kiểm chứng trên đúng hệ đang chạy.
  • Query dài thêm khoảng 60 byte mỗi câu, đáng kể ở hệ thống bắn hàng chục nghìn query mỗi giây.

Đó là lý do option này mặc định tắt. Khuyến nghị: bật ở staging, và bật có chọn lọc trong lúc điều tra một nhóm query cụ thể ở production; hệ tải cao thì cân nhắc kỹ. Kiểm chứng bằng cách chạy một request rồi grep đúng trace id đó trong log của Postgres.

Tự viết instrumentation cho thư viện chưa được hỗ trợ

Bọc tay bằng withSpan giải quyết được phần lớn trường hợp. Tự viết hẳn một instrumentation package chỉ đáng công khi bọc tay không giải quyết được.

Tình huống Chọn gì
Vài chục call site, code gọi sửa được withSpan
Chỉ cần thêm attribute cho span đã có sẵn requestHook/responseHook của instrumentation hiện có
SDK nội bộ dùng chung nhiều repo, muốn có span mà không repo nào phải sửa call site Tự viết instrumentation
Thư viện bên thứ ba, không sửa được chỗ gọi Tự viết instrumentation
Cần span cho code chạy trước khi app khởi tạo xong (module-level, connection pool) Tự viết instrumentation

Trước khi viết, tra opentelemetry-js-contrib xem đã có package hoặc PR đang mở chưa — danh sách hỗ trợ dài hơn nhiều người tưởng (amqplib, cassandra-driver, dataloader, knex, lru-memoizer, oracledb, socket.io, tedious…).

// src/observability/instrumentation-shipper.ts
import { SpanKind, SpanStatusCode, context, trace } from '@opentelemetry/api';
import {
  InstrumentationBase,
  type InstrumentationConfig,
  InstrumentationNodeModuleDefinition,
} from '@opentelemetry/instrumentation';

// Module giả định: 'shipper-sdk' export hàm createShipment(payload)
type ShipperModule = {
  createShipment: (payload: { orderId: string }) => Promise<{ code: string }>;
};

export class ShipperInstrumentation extends InstrumentationBase {
  constructor(config: InstrumentationConfig = {}) {
    super('hasaki-instrumentation-shipper', '1.0.0', config);
  }

  protected init() {
    return new InstrumentationNodeModuleDefinition(
      'shipper-sdk',
      ['>=2.0.0 <4'],                        // dải version đã thật sự kiểm chứng
      (moduleExports: ShipperModule) => {    // patch
        this._wrap(moduleExports, 'createShipment', this._patchCreate());
        return moduleExports;
      },
      (moduleExports: ShipperModule) => {    // unpatch
        this._unwrap(moduleExports, 'createShipment');
      },
    );
  }

  private _patchCreate() {
    const tracer = this.tracer;
    return (original: ShipperModule['createShipment']): ShipperModule['createShipment'] =>
      function patched(this: unknown, payload) {
        const span = tracer.startSpan('shipper createShipment', {
          kind: SpanKind.CLIENT,
          attributes: {
            'server.address': 'api.shipper.vn',
            'hasaki.order.id': payload.orderId,
          },
        });
        return context.with(trace.setSpan(context.active(), span), async () => {
          try {
            return await original.call(this, payload);
          } catch (err) {
            const e = err as Error;
            span.recordException(e);
            span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
            span.setAttribute('error.type', e.name);
            throw err;
          } finally {
            span.end();
          }
        });
      };
  }
}

Đăng ký y như mọi instrumentation khác — registerInstrumentations({ instrumentations: [new ShipperInstrumentation()] }) — và vẫn phải chạy trước khi shipper-sdk được require/import.

Bốn điều dễ làm sai:

  • Chỉ phụ thuộc @opentelemetry/api@opentelemetry/instrumentation. Không import gói SDK nào vào instrumentation (quy tắc ở Phần 2 §2.2): một thư viện kéo theo SDK sẽ tạo provider thứ hai hoặc xung đột version của @opentelemetry/api.
  • Dùng _wrap/_unwrapInstrumentationBase đã bọc sẵn từ shimmer, thay vì gán đè thủ công — nếu không thì disable() và unpatch không còn hoạt động.
  • ESM chỉ patch được khi có loader hook @opentelemetry/instrumentation/hook.mjs (Phần 5A). Thiếu hook thì instrumentation nạp bình thường, không báo lỗi, và không patch được gì.
  • Khai đúng dải version. Để ['*'] rồi thư viện đổi chữ ký ở major mới là gãy im lặng; khai hẹp thì OTEL_LOG_LEVEL=debug sẽ nói rõ module bị bỏ qua vì không khớp version.

Message queue

Kafka với kafkajs

@opentelemetry/instrumentation-kafkajs tạo span PRODUCER khi gửi và inject traceparent vào message header; phía consumer tạo span CONSUMER rồi extract header đó. Toàn bộ attribute messaging.* hiện ở mức Development (xem Phần 9), tên có thể đổi ở minor release.

Quy tắc chọn remote parent hay Link:

Tình huống Cách nối Lý do
Consumer xử lý một message (eachMessage) Được phép dùng message context làm parent Spec cho phép ngoại lệ này khi chỉ có một message
Consumer xử lý batch (eachBatch) Link — một span CONSUMER cho cả lô, mỗi message một link Một span chỉ có đúng một parent, không thể có N cha
Producer gửi batch Span PRODUCER cho cả lô, link tới từng message context Cùng lý do
Queue có độ trễ rất lớn (job chạy sau nhiều giờ) Link, không parent Tránh trace kéo dài nhiều giờ làm hỏng tail sampling (xem Phần 4)
import { propagation, ROOT_CONTEXT, SpanKind, trace, type Link } from '@opentelemetry/api';
import type { EachBatchPayload, KafkaMessage } from 'kafkajs';

declare function handleMessage(message: KafkaMessage): Promise<void>;

const kafkaTracer = trace.getTracer('hasaki.kafka');

// Header Kafka là Buffer -> cần getter riêng cho propagation.extract
const bufferGetter = {
  keys: (carrier: Record<string, unknown>) => Object.keys(carrier),
  get: (carrier: Record<string, unknown>, key: string) => {
    const v = carrier[key];
    return Buffer.isBuffer(v) ? v.toString('utf8') : (v as string | undefined);
  },
};

export async function handleBatch({ batch }: EachBatchPayload) {
  // eachBatch: MỘT span CONSUMER cho cả lô, nối từng message bằng Link
  const links: Link[] = batch.messages
    .map((m) => trace.getSpanContext(propagation.extract(ROOT_CONTEXT, m.headers ?? {}, bufferGetter)))
    .filter((sc): sc is NonNullable<typeof sc> => sc !== undefined)
    .map((sc) => ({ context: sc }));

  await kafkaTracer.startActiveSpan(
    'process order.created',
    {
      kind: SpanKind.CONSUMER,
      links,
      attributes: {
        'messaging.destination.name': batch.topic,
        'messaging.batch.message_count': batch.messages.length,
      },
    },
    async (span) => {
      try {
        for (const message of batch.messages) await handleMessage(message);
      } finally {
        span.end();
      }
    },
  );
}

BullMQ: propagation thủ công

BullMQ không có auto-instrumentation propagation: job đi qua Redis dưới dạng JSON, không có "header" nào để instrumentation ghi vào. Phải tự inject và extract.

flowchart LR
  A["Span PRODUCER<br/>queue.add()"] -->|"propagation.inject → job.data._otel"| B["Redis<br/>(BullMQ job payload)"]
  B -->|"propagation.extract ở Worker"| C["Span CONSUMER<br/>remote parent"]
// producer.ts
import { context, propagation, SpanKind, trace } from '@opentelemetry/api';
import { Queue } from 'bullmq';

const queue = new Queue('order-events', { connection: { host: 'redis', port: 6379 } });
const tracer = trace.getTracer('hasaki.jobs');

export async function enqueueOrderCreated(orderId: string) {
  return tracer.startActiveSpan(
    'publish order.created',
    { kind: SpanKind.PRODUCER, attributes: { 'hasaki.order.id': orderId } },
    async (span) => {
      try {
        // Ghi traceparent (và tracestate/baggage nếu propagator có) vào carrier
        const carrier: Record<string, string> = {};
        propagation.inject(context.active(), carrier);
        await queue.add('order.created', { orderId, _otel: carrier });
      } finally {
        span.end();
      }
    },
  );
}
// worker.ts
import { context, propagation, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api';
import { Worker } from 'bullmq';

declare function handleOrderCreated(data: { orderId: string }): Promise<void>;

const tracer = trace.getTracer('hasaki.jobs');

new Worker(
  'order-events',
  async (job) => {
    const carrier = (job.data?._otel ?? {}) as Record<string, string>;
    // Cha nằm ở process khác -> extract ra một context mới
    const parentCtx = propagation.extract(context.active(), carrier);

    return tracer.startActiveSpan(
      'process order.created',
      {
        kind: SpanKind.CONSUMER,
        attributes: {
          'hasaki.order.id': job.data.orderId,
          'hasaki.job.attempt': job.attemptsMade,
        },
      },
      parentCtx, // overload 4 tham số: chỉ định context cha tường minh
      async (span) => {
        try {
          await handleOrderCreated(job.data);
        } catch (err) {
          const e = err as Error;
          span.recordException(e);
          span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
          throw err;
        } finally {
          span.end();
        }
      },
    );
  },
  { connection: { host: 'redis', port: 6379 } },
);

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí