Por que tracing e não só logs
Você tem um endpoint que demora 800ms. Os logs mostram que a request chegou, foi processada e respondeu. Mas onde ficaram esses 800ms?
Foi no banco? Em qual query? Foi numa chamada externa? Foram múltiplas chamadas em sequência que poderiam ser paralelas?
Logs estruturados respondem "o que aconteceu". Tracing distribuído responde "onde o tempo foi gasto" — e em sistemas com múltiplos serviços, "em qual serviço e em qual operação".
OpenTelemetry (OTel) é o padrão open-source para instrumentação de observabilidade: traces, metrics e logs. Um único SDK, múltiplos backends (Jaeger, Tempo, Datadog, Honeycomb, OTLP).
Conceitos essenciais
Trace: representa uma operação de ponta a ponta. Uma request HTTP que passa por 3 serviços é um único trace.
Span: uma unidade de trabalho dentro do trace. Cada operação (query SQL, chamada HTTP, processamento de mensagem) é um span. Spans têm pai: formam uma árvore.
Context propagation: como o trace ID viaja entre serviços. Tipicamente via headers HTTP (traceparent).
Trace: a1b2c3d4
├── Span: HTTP POST /orders (200ms) → orders-service
│ ├── Span: validate input (5ms)
│ ├── Span: SELECT users (15ms) → PostgreSQL
│ ├── Span: POST /payments (150ms) → payments-service
│ │ ├── Span: SELECT accounts (20ms) → PostgreSQL
│ │ └── Span: INSERT transactions (10ms)
│ └── Span: INSERT orders (20ms) → PostgreSQL
Esse gráfico (chamado de Gantt ou flame graph no Jaeger/Tempo) mostra exatamente onde o tempo foi gasto.
Setup no Node.js
npm install @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-otlp-httpO arquivo de instrumentação deve ser o primeiro a carregar:
// instrumentation.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: 'orders-service',
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION ?? '0.0.1',
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318/v1/traces',
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false }, // muito ruído
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0));
});Para carregar antes de qualquer outro módulo:
// package.json
{
"scripts": {
"start": "node --require ./dist/instrumentation.js dist/main.js"
}
}Com getNodeAutoInstrumentations, você instrumenta automaticamente: HTTP (http/https), Express, Fastify, NestJS, pg, mysql2, redis, ioredis, fetch, axios, gRPC — sem mudar uma linha do código existente.
Spans customizados
A auto-instrumentação cobre I/O. Para sua lógica de negócio, você cria spans manualmente:
import { trace, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('orders-service');
async function processOrder(orderId: string) {
return tracer.startActiveSpan('process-order', async (span) => {
span.setAttribute('order.id', orderId);
try {
const order = await loadOrder(orderId);
span.setAttribute('order.user_id', order.userId);
span.setAttribute('order.item_count', order.items.length);
const result = await applyBusinessRules(order);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: (error as Error).message });
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
}startActiveSpan cria o span e o coloca no contexto ativo. Qualquer operação de I/O que acontecer dentro desse escopo (queries, chamadas HTTP) será automaticamente filha desse span.
Integrando com NestJS
No NestJS, um interceptor propaga o contexto para cada request:
// tracing.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { Observable } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
@Injectable()
export class TracingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const span = trace.getActiveSpan();
if (span) {
span.setAttribute('http.route', request.route?.path ?? request.url);
span.setAttribute('user.id', request.user?.id ?? 'anonymous');
}
return next.handle().pipe(
catchError((error) => {
span?.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
throw error;
}),
);
}
}Propagação entre serviços
A auto-instrumentação do fetch/axios/undici injeta automaticamente o header traceparent nas chamadas HTTP de saída. O servidor receptor precisa ler esse header para continuar o trace.
Com Express/NestJS e auto-instrumentação, isso acontece automaticamente. Mas se você usar node:http diretamente, precisa propagar manualmente:
import { context, propagation } from '@opentelemetry/api';
import { request } from 'node:http';
const headers: Record<string, string> = {};
propagation.inject(context.active(), headers); // injeta traceparent
const req = request({
hostname: 'payments-service',
path: '/charge',
method: 'POST',
headers,
});Atributos semânticos importantes
O OTel tem convenções para atributos. Usar os atributos certos permite que ferramentas como Jaeger e Grafana Tempo entendam os dados automaticamente:
import {
ATTR_DB_SYSTEM,
ATTR_DB_STATEMENT,
ATTR_HTTP_REQUEST_METHOD,
ATTR_URL_FULL,
} from '@opentelemetry/semantic-conventions';
span.setAttribute(ATTR_DB_SYSTEM, 'postgresql');
span.setAttribute(ATTR_DB_STATEMENT, 'SELECT * FROM orders WHERE user_id = ?');
span.setAttribute(ATTR_HTTP_REQUEST_METHOD, 'POST');Para atributos de negócio, use namespaces próprios:
span.setAttribute('app.order.id', orderId);
span.setAttribute('app.payment.provider', 'stripe');
span.setAttribute('app.retry.count', retryCount);Coleta e visualização local
Para desenvolvimento, suba um Jaeger:
# docker-compose.yml
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # UI
- "4318:4318" # OTLP HTTP
environment:
- COLLECTOR_OTLP_ENABLED=trueConfigure o exporter para apontar para http://localhost:4318/v1/traces e acesse http://localhost:16686 para ver os traces.
Em produção, o OTLP Collector é o padrão: recebe os dados, pode fazer sampling, e envia para múltiplos backends simultaneamente.
Sampling
Enviar 100% dos traces para produção pode ser caro. O OTel suporta sampling:
import { TraceIdRatioBasedSampler, ParentBasedSampler } from '@opentelemetry/sdk-trace-base';
const sdk = new NodeSDK({
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(0.1), // 10% das traces raiz
}),
// ...
});ParentBasedSampler respeita a decisão do serviço pai: se o upstream decidiu amostrar o trace, o serviço filho amostra também — garantindo traces completos.
Para sistemas críticos, considere tail-based sampling no OTel Collector: você coleta tudo, mas só persiste os traces com erro ou com latência acima de um threshold. Isso dá cobertura total nos casos que importam sem o custo de armazenar tudo.
O que você ganha na prática
- Diagnóstico de latência: ver o Gantt chart de um trace lento e identificar em qual operação o tempo foi gasto — sem adicionar logs temporários e reimplantar
- Rastreio de erros: quando uma exception ocorre, o trace mostra todo o caminho que levou até ela, incluindo as queries anteriores
- Detecção de N+1: spans de banco de dados repetidos no mesmo trace são N+1 queries visíveis sem precisar analisar logs
- Correlação entre serviços: trace ID único que vai de ponta a ponta, liga logs de múltiplos serviços para o mesmo request
Instrumentação retroativa com auto-instrumentação leva menos de uma hora e entrega a maior parte do valor sem mudar o código existente.