Propagando contexto de requisição e trace IDs entre microservices com NestJS
Por John Pires · 09 de março de 2026

Propagando contexto de requisição e trace IDs entre microservices com NestJS
Análise profunda do problema
O que exatamente precisa ser propagado
Em ambientes distribuídos, cada requisição que atravessa vários serviços precisa transportar contexto mínimo para correlação: um trace ID (correlação), metadata (user-id, tenant, flags de feature) e, às vezes, informações de autenticação parcial. O problema é garantir que esse contexto sobreviva a:
- Chamadas HTTP síncronas e assíncronas;
- Mensageria (RabbitMQ, Kafka, Redis) onde headers nem sempre são padronizados;
- Operações assíncronas internas (timers, promises, db callbacks) sem perder o contexto por causa de trocas de execução.
Sem propagação robusta, logs e métricas ficam desconexos, tracing não funciona e debugging torna-se caro em tempo humano.
Casos reais e impactos práticos
Em produção já vi duas falhas típicas: 1) sistemas que geram trace IDs novos em cada serviço (resultando em múltiplos traces para a mesma operação) e 2) trace ID mantido apenas como variável local (perdido em callbacks ou quando a operação passa por queues).
Ambos resultam em:
- Logs que não correlacionam requests across services;
- Métricas agregadas erradas por trace duplication;
- Dificuldade em instrumentar SLOs e SLIs baseados em traces.
Arquitetura e decisões de design
Princípios adotados
Decisões técnicas claras evitam soluções frágeis. Aqui os princípios aplicados:
- Source of truth local: cada serviço gera um traceId caso não exista e o propaga;
- Propagação explícita: enviar metadata no payload quando transporte não garante headers;
- Contexto de execução: usar AsyncLocalStorage (Node >= 12.17+) para manter contexto por request;
- Abstração cliente: encapsular clientes de transporte (HTTP/ClientProxy) para automaticamente injetarem traceId.
Essa arquitetura equilibra confiabilidade, portabilidade entre transportes e simplicidade operacional.
Decisões para compatibilidade com NestJS
Escolhi usar:
- Middleware para gerar/ler trace ID em HTTP;
- Interceptor para vincular contextos e adicionar trace em respostas/saídas;
- RequestContextService que encapsula AsyncLocalStorage;
- Client wrappers para microsserviços (ClientProxy) que adicionam traceId no payload ou headers;
- Guard simples para validar presença de trace em entradas RPC quando obrigatório.
Essas decisões coexistem bem com módulos NestJS, DI e tornam testes e instrumentação mais fáceis.
Implementação com foco em escalabilidade
1) Middleware para HTTP: gerar/ler traceId
Este middleware lê o header X-Trace-Id se existir; caso contrário gera um UUID v4. Em seguida coloca o valor no objeto request e no contexto AsyncLocalStorage.
O código abaixo cria o middleware e demonstra registro no request para consumidores NestJS (controllers, interceptors, guards).
import { Injectable, NestMiddleware } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { Request, Response, NextFunction } from 'express';
import { RequestContextService } from './request-context.service';
@Injectable()
export class TraceIdMiddleware implements NestMiddleware {
constructor(private readonly ctx: RequestContextService) {}
use(req: Request, res: Response, next: NextFunction) {
const header = req.headers['x-trace-id'] as string | undefined;
const traceId = header && header.trim() ? header : uuidv4();
// armazena no request (útil para middlewares express/third-party)
(req as any).traceId = traceId;
// inicia um contexto AsyncLocalStorage para essa requisição
this.ctx.run({ traceId }, () => {
// adiciona header de resposta
res.setHeader('x-trace-id', traceId);
next();
});
}
}
Depois de executar, qualquer código dentro do fluxo HTTP que acessar RequestContextService.get() terá o traceId.
2) RequestContextService com AsyncLocalStorage
RequestContextService encapsula AsyncLocalStorage e fornece APIs simples: run(context, fn), get(key), set(key, value).
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
type Ctx = Record<string, any>;
@Injectable()
export class RequestContextService {
private als = new AsyncLocalStorage<Ctx>();
run(context: Ctx, callback: (...args: any[]) => any) {
return this.als.run(context, callback);
}
get<T = any>(key: string): T | undefined {
const store = this.als.getStore();
return store ? (store[key] as T) : undefined;
}
set(key: string, value: any) {
const store = this.als.getStore();
if (store) store[key] = value;
}
getAll(): Ctx | undefined {
return this.als.getStore();
}
}
Contexto preservado mesmo durante awaits, timers e callbacks que mantenham a cadeia de execução do Node (AsyncLocalStorage resolve muitos problemas clássicos).
3) Interceptor para logs e propagação em respostas
Interceptor lê traceId do RequestContextService e adiciona em logs, além de garantir que qualquer resposta HTTP contenha o header. Também mostra um padrão para instrumentar chamadas externas.
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { RequestContextService } from './request-context.service';
@Injectable()
export class TraceInterceptor implements NestInterceptor {
constructor(private readonly ctx: RequestContextService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const traceId = this.ctx.get('traceId');
const now = Date.now();
return next.handle().pipe(
tap(() => {
const elapsed = Date.now() - now;
// log minimalista com traceId
console.log(`[trace:${traceId}] ${context.getClass().name}#${context.getHandler().name} ${elapsed}ms`);
}),
);
}
}
Depois deste interceptor, qualquer log simples pode incluir traceId. Para produção, injete um logger que sempre lê traceId do RequestContextService.
4) Propagando para microservices: Client wrapper
Nem todo transporte preserva headers. A prática mais portável é padronizar um envelope para mensagens. Abaixo, um ClientProducerService que encapsula ClientProxy e injeta meta.traceId automaticamente.
import { Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { RequestContextService } from './request-context.service';
@Injectable()
export class ClientProducerService {
constructor(
private readonly client: ClientProxy,
private readonly ctx: RequestContextService,
) {}
// send padrão: adiciona meta.traceId no payload
async send(pattern: any, payload: any) {
const traceId = this.ctx.get('traceId') || null;
const envelope = { meta: { traceId }, payload };
return this.client.send(pattern, envelope).toPromise();
}
// emit para fire-and-forget
emit(pattern: any, payload: any) {
const traceId = this.ctx.get('traceId') || null;
const envelope = { meta: { traceId }, payload };
this.client.emit(pattern, envelope);
}
}
Ao padronizar o envelope, consumidores podem extrair traceId mesmo em transportes que não preservam headers.
5) Handler RPC que extrai traceId
No lado do consumidor (microservice), reutilize RequestContextService para executar o handler dentro do contexto correto. Exemplo de um controlador RPC que extrai traceId do envelope:
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { RequestContextService } from './request-context.service';
@Controller()
export class OrdersController {
constructor(private readonly ctx: RequestContextService) {}
@MessagePattern('order_created')
async handleOrderCreated(@Payload() message: any) {
// envelope: { meta: { traceId }, payload }
const traceId = message?.meta?.traceId || null;
return this.ctx.run({ traceId }, async () => {
// dentro daqui RequestContextService.get('traceId') funciona
// executar lógica de negócio
return { ok: true };
});
}
}
Esse padrão coloca o handler inteiro dentro do contexto AsyncLocalStorage, unificando logs e métricas.
Benchmarks e métricas
Como medir impacto da AsyncLocalStorage
AsyncLocalStorage tem overhead. Medir é imprescindível quando se escala para milhares de RPS. Método direto: simular requisições e comparar latência com/sem contexto. O snippet abaixo é um benchmark simples usando superagent para gerar requisições paralelas.
import * as superagent from 'superagent';
async function bench(url: string, concurrency = 100, requests = 1000) {
const start = process.hrtime.bigint();
const tasks: Promise<any>[] = [];
for (let i = 0; i < requests; i++) {
if (tasks.length >= concurrency) tasks.shift();
tasks.push(superagent.get(url));
}
await Promise.all(tasks);
const end = process.hrtime.bigint();
const ms = Number(end - start) / 1_000_000;
console.log(`total ${requests} requests in ${ms.toFixed(2)}ms, avg ${(ms / requests).toFixed(2)}ms`);
}
bench('http://localhost:3000/health', 200, 2000).catch(console.error);
Compare resultados com AsyncLocalStorage ativo e com uma versão do middleware que apenas anexa header sem usar als.run. Em minha experiência, overhead é pequeno (<5-7% latência) e compensa pela correlação que possibilita.
Métricas práticas para coletar
Colete as seguintes métricas:
- Percentil 50/95/99 de latência por endpoint (com traceId);
- Taxa de análise de traces válidos (requests com traceId propagado);
- Taxa de mensagens com meta.traceId em filas;
- Erro por traceId (agrupar logs por traceId para entender falhas end-to-end).
Para cada métrica, implemente labels: service, env, endpoint e trace-propagation (ok/fail).
Padrões avançados e extensibilidade
1) Integração com OpenTelemetry / Jaeger
Se já estiver usando OpenTelemetry, sincronize traceId gerado internamente com o span. Exemplo rápido: recuperar traceId do span atual e injetar no RequestContextService. Assim, traceId do serviço e o traceId do tracer ficam sempre alinhados.
Pseudo-código de integração:
// ao inicializar tracer
const span = tracer.startSpan('request');
ctx.set('traceId', span.context().traceId);
Com isso, logs locais e traces distribuídos ficam ligados.
2) Estratégia para retries e idempotência
TraceId deve persistir em requeues e retries para não fragmentar o trace. Em mensagens, envie também um requestId único se a operação precisa ser idempotente. Assim o consumidor pode deduplicar com requestId enquanto o traceId permite correlação.
3) Extensibilidade do envelope
Evite inserir tudo dentro de meta arbitrariamente. Defina um schema mínimo (meta.traceId, meta.requestId, meta.userId). Para evolução, mantenha meta ignorável por consumidores que não entendem campos adicionais.
Erros comuns, armadilhas e dicas práticas
Dica: AsyncLocalStorage não funciona se você usa processos child (child_process.fork) sem repropagar o contexto manualmente.
Aqui alguns erros que aparecem em código real:
- Gerar um novo traceId em cada microserviço sem aceitar o legado no header/payload;
- Confiar em headers HTTP sem fallback para payload metadata quando a mensagem passa por brokers que limpam headers;
- Usar variáveis globais para armazenar traceId — isso falha com concorrência;
- Não envolver handlers RPC com a mesma AsyncLocalStorage; logs do microservice ficam sem traceId;
- Esperar que bibliotecas externas (ORMs, SDKs) preservem context automaticamente — às vezes é preciso fornecer context manual para o logger do ORM.
Evite esses problemas padronizando o envelope e encapsulando a lógica de propagação em serviços/DIs.
Exemplos de integração e testes
Testando unidade do RequestContextService
Teste para garantir que context persiste durante awaits:
import { RequestContextService } from './request-context.service';
const service = new RequestContextService();
service.run({ traceId: 'abc' }, async () => {
await new Promise((r) => setTimeout(r, 10));
console.assert(service.get('traceId') === 'abc');
});
Teste simples, mas cobre o comportamento crítico do ALS.
Testando integração end-to-end (HTTP + MQ)
Cenário: serviço A recebe HTTP, chama serviço B via ClientProxy e B responde. Verifique que o traceId em logs e resposta final é o mesmo.
- Mock ClientProxy no teste para capturar envelope;
- Assert que envelope.meta.traceId === request.header.x-trace-id;
Isso valida a integração entre middleware, RequestContextService e ClientProducerService.
Conclusão prática
Propagar trace IDs entre microservices no NestJS exige três peças coordenadas: geração/extração no ponto de entrada, contexto de execução persistente (AsyncLocalStorage) e propagação explícita no transporte (envelope ou headers).
Recomendações finais:
- Padronize um envelope com meta.traceId para cobrir todos os transportes;
- Encapsule ClientProxy para injetar traceId automaticamente;
- Use AsyncLocalStorage via um RequestContextService para logs e métricas coerentes;
- Integre com tracing (OpenTelemetry) para obter visibilidade completa.
Implementações práticas e testes automatizados garantem que o padrão sobrevive a mudanças de infra: trocar RabbitMQ por Kafka, adicionar retrys ou escalar instâncias não deve quebrar correlação. A abordagem mostrada é aplicável em projetos reais e escaláveis.
Para próximos passos: adicione um logger estruturado que leia traceId do RequestContextService, envie traces para Jaeger/Zipkin e implemente dashboards com percentis por traceId. Esses passos completam a observabilidade end-to-end.
Assuntos: NestJS, microservices, async-local-storage, tracing, interceptors
Leia também
Rate limiting por usuário no NestJS com Guard + Redis (sliding window)
Aprenda a implementar rate limiting por usuário no NestJS usando Guard + Redis com sliding window. Guia passo a passo, exemplos práticos (Lua + ioredis)…
Testando Guards e Interceptors no NestJS com Jest e SuperTest — do jeito errado ao certo
Guards controlam acesso e Interceptors transformam respostas. Aprenda a testar ambos no NestJS: mostro o jeito errado, corrijo e explico testes unitários e…
API REST com NestJS, Prisma e PostgreSQL: JWT, testes e otimização de performance
Guia avançado para APIs com NestJS, Prisma e PostgreSQL. Cobre autenticação JWT, testes automáticos (unit/e2e) e otimizações de performance para produção…
NestJS: criar e testar microservice TCP e gateway HTTP passo a passo
Passo a passo prático para criar e testar um microservice TCP em NestJS e um gateway HTTP. Mostro o jeito errado, o jeito certo, exemplos funcionais e como…
Interceptor global de logs e tempo no NestJS: implementar e testar
Implemente um interceptor global no NestJS para logs e medição de tempo, registre-o no bootstrap e teste com Jest e supertest. Artigo prático passo a passo…
Implementando Circuit Breaker e Bulkhead em microservices NestJS com Cockatiel
Guia prático para integrar circuit breaker e bulkhead com Cockatiel em NestJS: módulo centralizado, wrapper Http resiliente, tratamento de erros e otimizações…