Destino Dev

Blog

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:

  1. Logs que não correlacionam requests across services;
  2. Métricas agregadas erradas por trace duplication;
  3. 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.

  1. Mock ClientProxy no teste para capturar envelope;
  2. 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