jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Node.js Production Engineering 20 — Observability, SLO và OpenTelemetry

Xây observability từ user journey: SLI/SLO, error budget, structured logs, metrics cardinality, distributed traces, sampling, Collector topology và incident workflow.

02:07 sáng, alert “CPU 92%” đánh thức on-call. CPU cao ở một pod, nhưng checkout vẫn bình thường. Hai ngày sau, payment p99 tăng gấp mười vì một query lock; CPU không cao nên không có alert. Team có rất nhiều metric nhưng chưa đo điều người dùng thực sự cần.

Observability không bắt đầu bằng việc cài dashboard. Nó bắt đầu bằng câu hỏi: hệ thống hứa điều gì với người dùng, tín hiệu nào chứng minh lời hứa đang được giữ, và khi lời hứa bị đe dọa thì kỹ sư có lần được từ triệu chứng tới nguyên nhân không?

Sau bài này, bạn sẽ có thể:

  • định nghĩa SLI, SLO và error budget theo user journey;
  • chọn logs, metrics, traces và profiles cho đúng loại câu hỏi;
  • instrument Node.js bằng OpenTelemetry mà không tạo cardinality/overhead mất kiểm soát;
  • truyền trace context qua HTTP, gRPC và message queue;
  • thiết kế head/tail sampling và Collector topology;
  • biến alert thành một incident workflow có evidence và action rõ.

Baseline: Node.js 24 LTS và OpenTelemetry JavaScript hiện đại. Theo tài liệu OpenTelemetry JS tại thời điểm cập nhật, traces và metrics ổn định; logs SDK vẫn đang ở trạng thái development. Kiểm release/status trước khi chuẩn hóa pipeline log thuần OTel.


1. Monitoring và observability là một vòng phản hồi

Monitoring theo dõi những điều ta đã biết cần quan sát: error rate, queue age, CPU. Observability là khả năng suy luận trạng thái bên trong từ output của hệ thống để trả lời cả câu hỏi chưa được dự đoán trước.

Không nên biến chúng thành hai phe. Một hệ vận hành tốt cần vòng lặp:

SLO/alert phát hiện user impact


metric khoanh vùng thời gian + service


trace tìm dependency/span bất thường


structured log + profile xác nhận nguyên nhân


mitigate → verify SLI hồi phục → postmortem → cải thiện signal/runbook

Telemetry chỉ có giá trị khi rút ngắn thời gian phát hiện, chẩn đoán hoặc xác minh. Một dashboard không ai dùng khi incident là chi phí lưu trữ, không phải observability.

2. Đi từ user journey đến SLI/SLO

SLI: cách đo một outcome tốt

Service Level Indicator thường là tỷ lệ event tốt trên event hợp lệ:

availability SLI = successful eligible requests / all eligible requests
latency SLI      = requests hoàn tất dưới 300 ms / successful requests
freshness SLI    = updates tới client dưới 5 s / all expected updates

“Eligible” phải được định nghĩa. Request bị client cancel có tính không? 400 do input sai có tính lỗi service không? Nếu không ghi, hai dashboard có thể cho hai availability khác nhau.

Ví dụ cho các phần trước:

User journeySLI khả dụng
Checkouttỷ lệ order hợp lệ được confirm không duplicate
Background emailtỷ lệ email giao dịch hoàn tất trong 5 phút
GraphQL article screentỷ lệ operation có dữ liệu bắt buộc dưới 500 ms
Realtime order statustỷ lệ client thấy trạng thái mới trong 5 giây, kể cả reconnect

SLO: mục tiêu trong một cửa sổ

99,9% availability trong 30 ngày cho phép 0,1% event không tốt. Với service liên tục, 0,1% của 30 ngày tương đương khoảng 43 phút 12 giây — nhưng event-based SLO nên tính theo request/event thực, không chỉ đổi ra downtime.

SLO không phải lời hứa marketing. Nó là ngưỡng ra quyết định: tốc độ release, ưu tiên reliability work, capacity và on-call.

Error budget và burn rate

Error budget là phần lỗi SLO cho phép. Nếu SLO là 99,9%, budget là 0,1%. Burn rate cho biết đang tiêu budget nhanh gấp bao nhiêu tốc độ bền vững.

burn rate = observed bad-event ratio / allowed bad-event ratio

Burn rate 10 nghĩa là nếu giữ tốc độ hiện tại, budget sẽ cạn nhanh gấp 10. Multi-window burn-rate alert kết hợp cửa sổ nhanh và chậm để vừa bắt sự cố lớn sớm, vừa tránh đánh thức vì spike ngắn vô hại.

3. Bốn loại tín hiệu, bốn loại câu hỏi

SignalTối ưu choKhông tối ưu cho
Metricsxu hướng, aggregate, alert rẻchi tiết một request cụ thể
Logsevent rời rạc, audit, ngữ cảnh lỗipercentile/aggregate lớn nếu phải scan text
Traceshành trình và causal path một requestđếm chính xác toàn traffic khi sampling
Profilescode path tiêu CPU/allocation theo thời gianbusiness outcome

“Ba trụ cột” là mental model hữu ích, không phải giới hạn. Continuous profiling và domain events cũng có thể là bằng chứng quan trọng.

Kiến trúc telemetry production thường là:

Node process
  ├─ structured logs ───────────────┐
  ├─ OTel metrics/traces ── OTLP ───┼─▶ Collector agent/gateway ─▶ backends
  └─ runtime/profile signal ────────┘          │
                                               ├─ batch/retry
                                               ├─ filter/redact
                                               ├─ sample/route
                                               └─ queue + export

Ứng dụng không nên block business request chờ telemetry backend. SDK/Collector cần batch, queue hữu hạn và fail-open có kiểm soát: observability backend lỗi không được kéo sập checkout.

4. Structured logs: event có schema, không phải câu văn ngẫu nhiên

import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import { trace } from '@opentelemetry/api';
import pino from 'pino';

type RequestContext = { requestId: string };
const requestContext = new AsyncLocalStorage<RequestContext>();

const baseLogger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  redact: {
    paths: [
      'req.headers.authorization',
      'password',
      'accessToken',
      'refreshToken',
    ],
    censor: '[REDACTED]',
  },
});

export function logger() {
  const spanContext = trace.getActiveSpan()?.spanContext();
  return baseLogger.child({
    requestId: requestContext.getStore()?.requestId,
    traceId: spanContext?.traceId,
    spanId: spanContext?.spanId,
  });
}

app.use((req, res, next) => {
  const requestId = req.get('x-request-id') || randomUUID();
  res.setHeader('x-request-id', requestId);
  requestContext.run({ requestId }, next);
});

Log business event bằng field ổn định:

logger().info(
  {
    event: 'order.confirmed',
    orderId: order.id,
    actorType: 'user',
    durationMs,
  },
  'order confirmed'
);

Log level là contract vận hành

  • debug: điều tra tạm thời, thường tắt ở production;
  • info: state transition đáng quan tâm, không phải mọi dòng code;
  • warn: bất thường hệ thống tự hồi phục hoặc gần ngưỡng;
  • error: operation thất bại cần điều tra/hành động.

Không log cùng exception ở controller, service và global handler ba lần. Log một lần ở boundary có đủ context. Sampling/rate-limit repeated logs để dependency lỗi không tạo log storm và hóa đơn telemetry lớn.

PII/secret policy phải có allowlist, retention và access control. Redaction theo vài key phổ biến không bắt được token nằm trong URL hoặc nested payload; tốt nhất không đưa raw request body vào log từ đầu.

5. Metrics: label hữu hạn quan trọng hơn số lượng metric

Khung RED cho request/service:

  • Rate: request/job/message mỗi giây;
  • Errors: tỷ lệ outcome xấu theo contract;
  • Duration: histogram latency.

USE cho resource:

  • Utilization: CPU, connection đang dùng;
  • Saturation: queue, event-loop delay, pool wait;
  • Errors: OOM, connection reset, disk error.

Node-specific signals đáng có: event-loop delay/utilization, heap/RSS, GC pause, active handles, libuv/thread-pool symptom, connection-pool wait và worker queue age.

Cardinality budget

Metric label tạo một time series cho mỗi tổ hợp giá trị. userId, requestId, raw URL, SQL text hoặc error message tạo cardinality gần vô hạn.

// ❌ /users/123, /users/456... tạo series mới
httpDuration.record(seconds, { path: req.originalUrl, userId: actor.id });

// ✅ route template + tập giá trị hữu hạn
httpDuration.record(seconds, {
  method: req.method,
  route: '/users/:id',
  statusCode: String(res.statusCode),
});

ID chi tiết thuộc trace/log. Metric dành cho aggregate. Mỗi label mới cần owner, use case và ước lượng cardinality trước khi ship.

Histogram bucket phải bám SLO: nếu mục tiêu 300 ms nhưng bucket chỉ có 100 ms và 1 giây, bạn không tính được tỷ lệ dưới ngưỡng chính xác. Dùng seconds thống nhất với semantic convention/backend.

6. OpenTelemetry Node.js: khởi tạo trước application code

Auto-instrumentation patch/wrap module khi chúng được load. Nếu Express/database được import trước SDK, span tự động có thể thiếu.

npm install @opentelemetry/api @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/sdk-metrics \
  @opentelemetry/exporter-trace-otlp-proto \
  @opentelemetry/exporter-metrics-otlp-proto
// instrumentation.ts — phải chạy trước server.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';

export const telemetrySdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter(),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter(),
    exportIntervalMillis: 30_000,
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      // fs spans thường rất ồn; chỉ bật khi có use case.
      '@opentelemetry/instrumentation-fs': { enabled: false },
    }),
  ],
});

telemetrySdk.start();

Cấu hình resource/exporter qua environment để cùng image chạy nhiều môi trường:

OTEL_SERVICE_NAME=order-service
OTEL_RESOURCE_ATTRIBUTES=service.version=2026.07.11,deployment.environment.name=production
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-agent:4318

Boot command phụ thuộc CJS/ESM và cách build. Tài liệu OTel hiện dùng preload (--import) và lưu ý ESM có thể cần instrumentation loader hook. Invariant cần kiểm bằng integration test: instrumentation chạy trước app và một request thật tạo đủ HTTP/framework/dependency spans. Đừng copy --require CJS vào dự án ESM rồi giả định auto-instrumentation hoạt động.

7. Manual span: instrument business boundary, không trace mọi function

Auto-instrumentation thấy HTTP/DB; nó không hiểu “reserve inventory” hay “calculate risk”. Tạo manual span cho bước có ý nghĩa chẩn đoán:

import { SpanStatusCode, trace } from '@opentelemetry/api';

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

async function reserveInventory(order: Order): Promise<Reservation> {
  return tracer.startActiveSpan('inventory.reserve', async (span) => {
    span.setAttributes({
      'app.order.item_count': order.items.length,
      'app.inventory.strategy': 'hold',
    });

    try {
      return await inventory.reserve(order.items, {
        signal: AbortSignal.timeout(800),
      });
    } catch (error) {
      if (error instanceof Error) span.recordException(error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw error;
    } finally {
      span.end();
    }
  });
}

Không đặt email, token, full SQL hay payload vào attribute. Span name phải có cardinality thấp (GET /users/:id, không GET /users/42).

8. Context propagation qua message queue

HTTP/gRPC instrumentation thường tự inject/extract W3C Trace Context. Queue/job payload tự thiết kế cần truyền carrier rõ:

import { context, propagation, ROOT_CONTEXT, trace } from '@opentelemetry/api';

// Producer
const traceContext: Record<string, string> = {};
propagation.inject(context.active(), traceContext);

await emailQueue.add('order-receipt.v1', {
  orderId,
  traceContext,
});

// Consumer
const parent = propagation.extract(
  ROOT_CONTEXT,
  job.data.traceContext as Record<string, string>
);

await context.with(parent, () =>
  tracer.startActiveSpan('email.order_receipt process', async (span) => {
    try {
      await sendReceipt(job.data.orderId);
    } finally {
      span.end();
    }
  })
);

Với job có thể chờ hàng giờ, nối parent-child trực tiếp có thể tạo trace kéo dài khó đọc. Một số hệ dùng span link từ consumer tới producer context để biểu diễn causal relationship mà không giả định cùng một synchronous trace. Ghi rõ convention và test propagation qua retry/replay.

Không dùng trace như audit log bền: sampling/retention có thể bỏ trace. Business audit cần storage riêng.

9. Sampling là quyết định chi phí và bằng chứng

Trace toàn bộ traffic có thể quá đắt. Hai nhóm:

  • Head sampling quyết định sớm từ trace bắt đầu: rẻ, nhất quán, nhưng chưa biết trace sẽ lỗi/chậm.
  • Tail sampling đợi thấy nhiều/toàn trace: giữ lỗi/latency đặc biệt tốt hơn, đổi lại Collector cần buffer, routing và memory đáng kể.

Một policy có thể giữ:

  • 100% trace lỗi hoặc latency vượt ngưỡng;
  • tỷ lệ cao cho canary/operation quan trọng;
  • 1–5% traffic bình thường tùy volume/cost;
  • parent-based decision để không tạo trace thủng.

Tỷ lệ trên chỉ minh họa; capacity và compliance quyết định giá trị thật. Tail sampling cần các span cùng trace về đúng sampling tier; deployment topology phải hỗ trợ điều đó.

Metrics SLI không được suy ra từ trace đã sampling nếu cần tỷ lệ chính xác. Metrics đếm toàn traffic; trace giải thích mẫu cụ thể.

10. Collector là reliability boundary của telemetry

Gửi trực tiếp từ từng service tới vendor nhanh cho local, nhưng production thường dùng Collector để batch, retry, filter/redact, sample và tách credential backend khỏi application.

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
  batch: {}

exporters:
  otlphttp/backend:
    endpoint: ${env:OBSERVABILITY_ENDPOINT}

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/backend]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/backend]

Đây là skeleton, không phải config production hoàn chỉnh. Component stability/config thay đổi theo distribution; validate bằng otelcol validate/công cụ của distribution.

Deployment pattern:

  • agent/sidecar/DaemonSet gần app: network hop ngắn, offload nhanh;
  • gateway tập trung: routing, credential, tail sampling, policy;
  • agent + gateway: nhiều moving part hơn nhưng tách local collection và central processing.

Collector cũng cần SLO, memory limit, queue, backpressure và alert. Nếu exporter down, quyết định drop/block phải bảo vệ application trước, đồng thời làm rõ telemetry loss trên dashboard.

11. Alert theo triệu chứng, dashboard theo nguyên nhân

Page on-call khi có user impact hoặc error budget bị đe dọa:

  • checkout bad-event ratio/burn rate;
  • p99 vượt SLO đủ lâu;
  • oldest critical job age vượt deadline;
  • realtime recovery SLI giảm.

CPU, heap, replica count thường là diagnostic/capacity signals; chỉ page khi chúng gắn với action khẩn cấp rõ. Alert phải có owner, severity, runbook, dashboard link, recent deploy và điều kiện tự resolve.

Một alert tốt trả lời:

What: checkout availability burn rate 14× trong 10 phút
Impact: khoảng 8% order hợp lệ thất bại ở region ap-southeast
Since: 02:03 UTC, bắt đầu 4 phút sau deploy 2026.07.11-3
Action: mở runbook checkout-availability; cân nhắc rollback nếu 5m window > 10×
Evidence: dashboard + exemplar trace + change link

12. Từ alert đến nguyên nhân: một incident walkthrough

Giả sử p99 checkout tăng:

  1. SLO dashboard xác nhận latency bad-event ratio và region bị ảnh hưởng.
  2. Deploy marker cho thấy không có release mới; loại bớt hypothesis.
  3. Service dependency metric cho thấy Postgres pool wait tăng, CPU bình thường.
  4. Exemplar trace chậm có span UPDATE inventory 8 giây.
  5. Log cùng traceId cho thấy retry transaction ba lần với 40001.
  6. DB view/lock analysis xác nhận một batch job giữ lock theo thứ tự ngược.
  7. Mitigate: pause batch worker; SLI hồi phục trong hai window.
  8. Follow-up: thống nhất lock order, concurrency test, alert pool wait và runbook.

Telemetry không thay tư duy điều tra. Nó làm hypothesis rẻ hơn và bằng chứng liên kết được.

13. Failure modes của chính observability

Anti-patternHậu quảThiết kế lại
synchronous exporter trong requesttelemetry backend kéo tăng latencybatch async + Collector
userId/raw URL làm metric labelcardinality/cost bùng nổroute template, id vào trace/log
log toàn request bodylộ PII/secretallowlist field + redaction
span mọi function/resolveroverhead và noisebusiness/dependency boundary
trace 100% không budgethóa đơn hoặc drop hỗn loạnsampling policy + capacity
chỉ dashboard CPUbỏ lỡ user impactSLI/SLO trước resource
health endpoint luôn 200orchestrator route traffic vào app hỏngreadiness kiểm critical local state có giới hạn
không flush lúc shutdownmất tail telemetry deploy/incidentdrain app rồi shutdown SDK/logger

Observability code cũng là production code: dependency version, security, load test và rollback plan đều áp dụng.

14. Graceful shutdown giữ lại telemetry cuối

Thứ tự quan trọng: ngừng nhận traffic, drain request/worker, rồi flush telemetry. Nếu shutdown SDK trước, spans của request đang drain bị mất.

function closeHttpServer(): Promise<void> {
  return new Promise((resolve, reject) => {
    httpServer.close((error) => (error ? reject(error) : resolve()));
  });
}

function flushLogger(): Promise<void> {
  return new Promise((resolve) => baseLogger.flush(resolve));
}

async function shutdown(signal: NodeJS.Signals): Promise<void> {
  logger().info({ signal }, 'service shutting down');

  readiness.set(false);
  await closeHttpServer();
  await workers.close();
  await telemetrySdk.shutdown();
  await flushLogger();
}

Đoạn code là skeleton: thêm deadline/force exit và bảo vệ gọi hai lần như phần deployment. Test bằng SIGTERM dưới tải và kiểm span cuối xuất hiện.

15. Kiểm thử observability như một contract

  • Unit test redaction: token/password không xuất hiện trong serialized log.
  • Integration test: một request tạo trace với service/route/status attributes cần thiết.
  • Propagation test: HTTP → queue → worker giữ trace context/link.
  • Metric test: route động không tạo label động; histogram boundary bám SLO.
  • Failure test: Collector down không làm request fail hoặc memory tăng vô hạn.
  • Shutdown test: drain và flush trong termination budget.
  • Alert test: phát synthetic bad-event series, xác nhận burn-rate alert và runbook link.

Không assert exact auto-instrumentation payload quá chi tiết; upgrade package sẽ làm test giòn. Assert semantic contract mà dashboard/alert phụ thuộc.

16. Checklist trước khi ship

  • SLI đo user outcome, denominator/exclusion được ghi rõ.
  • SLO có window, owner và error-budget policy.
  • Alert dựa trên symptom/burn rate, có runbook và action.
  • Log có schema, correlation và redaction; không trùng lặp exception.
  • Metric label có cardinality budget; histogram bám ngưỡng SLO.
  • OTel khởi tạo trước app; auto-instrumentation được integration-test.
  • Manual span chỉ ở business/dependency boundary, không chứa PII.
  • Context truyền qua RPC/queue; retry/replay semantics được định nghĩa.
  • Sampling và Collector topology có capacity/failure plan.
  • Shutdown drain rồi flush; Collector down không kéo sập service.

17. Capstone: vận hành một incident từ đầu tới cuối

Dùng hệ thống Order/Notification từ Phần 16–19:

  1. Định nghĩa ba SLI: checkout availability, receipt dưới 5 phút, realtime status dưới 5 giây.
  2. Instrument HTTP, Postgres, BullMQ và WebSocket; propagate/link context qua outbox/job.
  3. Dashboard RED + queue age + realtime recovery; label cardinality có test.
  4. Collector nhận OTLP, batch và export; mô phỏng backend telemetry mất 10 phút.
  5. Cố ý đảo lock order tạo contention. Alert bằng burn rate, lần trace tới DB span, dùng log xác nhận retry.
  6. Mitigate, xác minh SLI hồi phục, viết postmortem gồm timeline, contributing factors và action có owner.
  7. Gửi SIGTERM trong lúc incident drill; xác nhận request drain và spans cuối không mất.

Capstone đạt khi một người không viết feature vẫn có thể đi từ alert tới nguyên nhân và phục hồi bằng dashboard/runbook — không cần SSH vào pod rồi đoán.

Nếu chỉ nhớ 5 điều

  1. Bắt đầu bằng user journey và SLO, không bằng danh sách dashboard.
  2. Metrics phát hiện, traces liên kết, logs xác nhận, profiles chỉ ra code path.
  3. Cardinality, sampling và retention là quyết định kiến trúc/cost.
  4. Context propagation phải đi qua cả message queue và reconnect boundary.
  5. Telemetry chỉ có giá trị khi dẫn tới một quyết định hoặc hành động nhanh hơn.

Tài liệu chính thức

Phần tiếp theo

Hai mươi phần đầu đi từ runtime tới vận hành hệ phân tán. Giá trị của lộ trình không nằm ở việc ghi nhớ nhiều API, mà ở một chuỗi phản xạ kỹ thuật:

hiểu cơ chế → xác định invariant → thiết kế boundary → dự đoán failure
→ đo production outcome → thay đổi có migration/rollback → học từ incident

Bạn không cần dùng mọi công nghệ trong series. Năng lực quan trọng hơn là biết constraint nào khiến một công nghệ đáng có, chi phí nào nó thêm vào, và bằng chứng nào cho thấy quyết định vẫn đúng sau khi hệ thống thay đổi.

Phần 21 bắt đầu volume thực chiến bằng một câu hỏi khó hơn: khi traffic vượt quá khả năng phục vụ, làm sao hệ thống từ chối có kiểm soát thay vì giữ mọi request đến lúc tất cả cùng timeout? Ta sẽ nối deadline, cancellation, concurrency budget, load shedding, retry và graceful shutdown thành một reliability contract.