OpenTelemetry no Node.js: instrumentação automática e manual na prática

1. Fundamentos do OpenTelemetry no Ecossistema Node.js

OpenTelemetry é um framework de observabilidade open-source que se tornou o padrão da indústria para coleta de telemetria em aplicações distribuídas. No ecossistema Node.js, ele oferece uma abordagem unificada para capturar traces, métricas e logs, permitindo que desenvolvedores entendam o comportamento de suas aplicações em produção.

Os componentes principais do OpenTelemetry incluem:

  • API: Define interfaces para criar spans, métricas e logs sem depender de implementações específicas
  • SDK: Implementação concreta que gerencia o ciclo de vida dos spans e a exportação dos dados
  • Propagadores: Mecanismos para transmitir contexto entre serviços (ex.: W3C Trace Context)
  • Exporters: Componentes que enviam os dados coletados para backends como Jaeger, Zipkin ou Grafana Tempo

Neste artigo, focaremos principalmente em traces, que representam o caminho de uma requisição através de múltiplos serviços e operações.

2. Configuração Inicial e Instrumentação Automática

A instrumentação automática é a forma mais rápida de começar. Com poucas linhas de código, você pode rastrear automaticamente requisições HTTP, chamadas a bancos de dados e operações em filas.

npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node
// tracing.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-node');

const sdk = new NodeSDK({
  traceExporter: new ConsoleSpanExporter(),
  instrumentations: [getNodeAutoInstrumentations()]
});

sdk.start();

Com essa configuração, todas as requisições HTTP recebidas e enviadas pela sua aplicação serão automaticamente rastreadas. Por exemplo, um endpoint Express simples como:

const express = require('express');
const app = express();

app.get('/api/users', async (req, res) => {
  const users = await fetchUsersFromDatabase();
  res.json(users);
});

Gerará spans automaticamente para a requisição HTTP, a chamada ao banco de dados (se estiver usando um ORM instrumentado) e a resposta.

3. Instrumentação Manual: Criando Spans e Contextos Personalizados

Para operações específicas do seu domínio, a instrumentação manual oferece controle granular sobre o que é rastreado.

const { trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('meu-servico');

async function processarPedido(pedidoId) {
  const span = tracer.startSpan('processarPedido', {
    attributes: {
      'pedido.id': pedidoId,
      'pedido.tipo': 'ecommerce'
    }
  });

  try {
    // Simulando operações assíncronas
    await validarEstoque(pedidoId);
    await processarPagamento(pedidoId);

    span.addEvent('pedido.processado', {
      'pedido.status': 'sucesso',
      'timestamp': Date.now()
    });

    span.setStatus({ code: SpanStatusCode.OK });
  } catch (error) {
    span.recordException(error);
    span.setStatus({
      code: SpanStatusCode.ERROR,
      message: error.message
    });
    throw error;
  } finally {
    span.end();
  }
}

Para propagar contexto entre funções assíncronas, use context.with():

const { context, trace } = require('@opentelemetry/api');

async function operacaoParalela() {
  const parentSpan = tracer.startSpan('operacaoPrincipal');

  await context.with(trace.setSpan(context.active(), parentSpan), async () => {
    const [resultado1, resultado2] = await Promise.all([
      tracer.startActiveSpan('subtarefa1', async (span) => {
        // Lógica da subtarefa
        span.end();
        return 'resultado1';
      }),
      tracer.startActiveSpan('subtarefa2', async (span) => {
        // Lógica da subtarefa
        span.end();
        return 'resultado2';
      })
    ]);
  });

  parentSpan.end();
}

4. Integração com Frameworks e Bibliotecas Populares

A instrumentação automática cobre muitos casos, mas operações específicas exigem spans manuais. Veja exemplos com Redis e filas:

const redis = require('redis');
const client = redis.createClient();

async function buscarCache(chave) {
  const span = tracer.startSpan('cache.consulta', {
    attributes: {
      'cache.chave': chave,
      'cache.tipo': 'redis'
    }
  });

  try {
    const valor = await client.get(chave);
    span.setAttribute('cache.hit', valor !== null);
    return valor;
  } finally {
    span.end();
  }
}

Para filas Bull:

const Queue = require('bull');
const emailQueue = new Queue('email');

emailQueue.process(async (job) => {
  const span = tracer.startSpan('email.enviar', {
    attributes: {
      'email.destinatario': job.data.email,
      'job.id': job.id
    }
  });

  try {
    await enviarEmail(job.data);
    span.setStatus({ code: SpanStatusCode.OK });
  } catch (error) {
    span.setStatus({ code: SpanStatusCode.ERROR });
    throw error;
  } finally {
    span.end();
  }
});

5. Exportação de Dados e Configuração de Backends

Para produção, você precisará de um backend de tracing. O exemplo abaixo configura exportação via OTLP para um collector OpenTelemetry:

const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base');

const exporter = new OTLPTraceExporter({
  url: 'http://localhost:4318/v1/traces',
});

const sdk = new NodeSDK({
  spanProcessor: new BatchSpanProcessor(exporter, {
    maxExportBatchSize: 512,
    scheduledDelayMillis: 5000
  }),
  instrumentations: [getNodeAutoInstrumentations()]
});

O BatchSpanProcessor agrupa spans em lotes antes de enviá-los, reduzindo o número de requisições HTTP e melhorando a performance.

6. Boas Práticas e Padrões em Aplicações Reais

Estruture seus spans em camadas seguindo a arquitetura da aplicação:

// controller → service → repository
async function criarUsuario(req, res) {
  return tracer.startActiveSpan('controller.criarUsuario', async (span) => {
    try {
      const usuario = await usuarioService.criar(req.body);
      span.setAttribute('usuario.id', usuario.id);
      res.status(201).json(usuario);
    } catch (error) {
      span.setStatus({ code: SpanStatusCode.ERROR });
      span.recordException(error);
      res.status(500).json({ error: error.message });
    }
  });
}

Para tratamento de erros, sempre registre exceções e defina status ERROR:

try {
  // operação
} catch (error) {
  span.recordException(error);
  span.setStatus({
    code: SpanStatusCode.ERROR,
    message: error.message
  });
}

7. Monitoramento de Performance e Troubleshooting

Com traces bem estruturados, você pode derivar métricas importantes:

  • Latência por endpoint: Calcule a duração média dos spans de controller
  • Taxa de erro: Conte spans com status ERROR dividido pelo total
  • Gargalos: Identifique spans com duração anormalmente alta

Problemas comuns e soluções:

Problema Causa Solução
Spans perdidos Contexto não propagado em callbacks Usar context.bind() ou async_hooks
Exportação lenta Muitos spans individuais Aumentar maxExportBatchSize
Memória alta Spans não finalizados Verificar span.end() em todos os caminhos

8. Próximos Passos e Considerações para Produção

Para ambientes de produção, considere:

Sampling head-based: Amostra decisões no início do trace:

const { ParentBasedSampler, TraceIdRatioBasedSampler } = require('@opentelemetry/sdk-trace-node');

const sdk = new NodeSDK({
  sampler: new ParentBasedSampler({
    root: new TraceIdRatioBasedSampler(0.1) // 10% dos traces
  })
});

Correlação com logs: Inclua traceId em todas as suas mensagens de log:

const { trace } = require('@opentelemetry/api');
const spanContext = trace.getSpan(context.active())?.spanContext();
console.log(`[traceId: ${spanContext?.traceId}] Usuário criado com sucesso`);

O futuro do OpenTelemetry inclui a Logs Bridge API, que permitirá correlação nativa entre logs, traces e métricas, unificando completamente a observabilidade.

Referências