Skip to main content

Configurando Open Telemetry | Dia a Dia | Rust

  • Configurando Open Telemetry | Dia a Dia | Rust

Configurar uma API Rust com actix-web para enviar traces para o OpenTelemetry Collector usando o padrão OTLP.

O fluxo final será:

API Rust
-> tracing
-> tracing-opentelemetry
-> opentelemetry-otlp
-> OpenTelemetry Collector
-> backend de observabilidade

Exemplos de backend:

Jaeger
Grafana Tempo
Datadog
New Relic
Elastic APM
SigNoz
OpenObserve

Conceitos principais

TermoDescrição
OpenTelemetryPadrão aberto para instrumentação, coleta e exportação de traces, métricas e logs
OTLPOpenTelemetry Protocol, protocolo usado para enviar telemetry
CollectorServiço intermediário que recebe, processa e exporta telemetry
TraceRepresenta uma operação distribuída ponta a ponta
SpanUma unidade dentro de um trace, por exemplo uma request HTTP ou uma query no banco
ExporterComponente que envia telemetry para outro destino
ReceiverComponente do Collector que recebe telemetry
ProcessorComponente que processa telemetry antes de exportar

O OTLP usa por padrão a porta 4317 para gRPC e 4318 para HTTP. A especificação oficial define 4317 como porta padrão para OTLP/gRPC e 4318 para OTLP/HTTP. (OpenTelemetry)

Quando usar OpenTelemetry

Use OpenTelemetry quando você precisa responder perguntas como:

Qual rota está lenta?
Qual chamada no banco está demorando?
Qual serviço gerou o erro?
Quanto tempo uma request levou entre API, banco e fila?
A falha veio da API, gateway, banco ou serviço externo?

Em uma API simples, logs estruturados já ajudam bastante. Em sistemas com múltiplos serviços, OpenTelemetry passa a ser mais importante.

Dependências

No Cargo.toml, adicione:

[dependencies]
actix-cors = "0.7"
actix-web = "4"
actix-web-httpauth = "0.8"
tracing-actix-web = "0.7"

sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }

tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }

opentelemetry = "0.32"
opentelemetry_sdk = { version = "0.32", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.32", features = ["grpc-tonic"] }
tracing-opentelemetry = "0.33"

O crate tracing é usado para instrumentação estruturada no Rust. Ele coleta informações diagnósticas baseadas em eventos e spans. (Docs.rs)

O crate opentelemetry-otlp é o exporter responsável por enviar spans, métricas ou logs usando OTLP.

Criando o módulo de telemetry

Crie o arquivo:

src/telemetry.rs

Conteúdo:

use opentelemetry::trace::TracerProvider as _;
use opentelemetry::KeyValue;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{
propagation::TraceContextPropagator,
trace::SdkTracerProvider,
Resource,
};
use tracing_subscriber::{
layer::SubscriberExt,
util::SubscriberInitExt,
EnvFilter,
};

pub fn init_telemetry() -> SdkTracerProvider {
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());

let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:4317".to_string());

let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(endpoint)
.build()
.expect("falha ao criar OTLP span exporter");

let resource = Resource::builder()
.with_service_name("global-rising-sun-catalog-api")
.with_attributes([
KeyValue::new(
"deployment.environment",
std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "development".to_string()),
),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
])
.build();

let tracer_provider = SdkTracerProvider::builder()
.with_resource(resource)
.with_batch_exporter(exporter)
.build();

let tracer = tracer_provider.tracer("global-rising-sun-catalog-api");

let otel_layer = tracing_opentelemetry::layer()
.with_tracer(tracer);

let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));

let fmt_layer = tracing_subscriber::fmt::layer()
.json()
.with_current_span(true)
.with_span_list(true);

tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer)
.with(otel_layer)
.init();

tracer_provider
}

Explicação do módulo

TraceContextPropagator

opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());

Configura propagação de contexto entre serviços.

Isso permite carregar informações como:

trace_id
span_id
parent_span_id

Assim, quando uma request passa por múltiplos serviços, os spans conseguem pertencer ao mesmo trace.

OTEL_EXPORTER_OTLP_ENDPOINT

let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:4317".to_string());

Define para onde a aplicação vai enviar os spans.

Exemplos:

OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

Dentro de Docker Compose:

OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317

Dentro de Docker Swarm:

OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317

A configuração padrão do OTLP exporter usa http://localhost:4317 para gRPC e http://localhost:4318/v1/traces para HTTP. (OpenTelemetry)

Resource

let resource = Resource::builder()
.with_service_name("global-rising-sun-catalog-api")
.with_attributes([
KeyValue::new("deployment.environment", "development"),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
])
.build();

O Resource identifica a aplicação que gerou a telemetry.

Campos úteis:

CampoExemplo
service.nameglobal-rising-sun-catalog-api
service.version0.1.0
deployment.environmentdevelopment, staging, production

Sem service.name, fica difícil filtrar a aplicação dentro do Jaeger, Tempo ou outro backend.

tracing_opentelemetry

let otel_layer = tracing_opentelemetry::layer()
.with_tracer(tracer);

Essa camada converte spans do tracing em spans OpenTelemetry.

Ajustando o main.rs

Exemplo baseado na sua API:

use actix_cors::Cors;
use actix_web::{get, web, App, HttpResponse, HttpServer, Responder};
use actix_web_httpauth::middleware::HttpAuthentication;
use sqlx::PgPool;
use std::io;
use tracing::{error, info};
use tracing_actix_web::TracingLogger;

mod api;
mod db;
mod middlewares;
mod models;
mod telemetry;

#[get("/health-database")]
#[tracing::instrument(
name = "Health database",
skip(db)
)]
async fn health_database(db: web::Data<PgPool>) -> impl Responder {
tracing::info!("health check database executado");

let result = sqlx::query("SELECT 1")
.execute(db.get_ref())
.await;

match result {
Ok(_) => HttpResponse::Ok().body("conectado no postgres"),
Err(error) => {
error!(error = ?error, "Falha no health check do banco");
HttpResponse::InternalServerError().body(format!("erro: {error}"))
}
}
}

#[get("/health-check")]
#[tracing::instrument(name = "Health check")]
async fn health() -> impl Responder {
tracing::info!("health check executado");
HttpResponse::Ok().finish()
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
let tracer_provider = telemetry::init_telemetry();

info!("Servidor rodando em: http://0.0.0.0:9009");

let auth = HttpAuthentication::bearer(api::authentication::token_service::validar_jwt);

let pool = db::connection::connect_db()
.await
.map_err(|error| io::Error::other(format!("Falha ao conectar no banco: {error}")))?;

let server_result = HttpServer::new(move || {
App::new()
.wrap(middlewares::error_handlers::error_handlers())
.wrap(TracingLogger::default())
.wrap(
Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header(),
)
.app_data(web::Data::new(pool.clone()))
.service(health)
.service(health_database)
.service(
web::scope("/api")
.wrap(auth.clone())
.configure(api::routes::catalog::init)
.configure(api::routes::product_variant::init)
.configure(api::routes::raw_material::init)
.configure(api::routes::stock_raw_material::init)
.configure(api::routes::variant_composition::init),
)
})
.bind(("0.0.0.0", 9009))?
.run()
.await;

tracer_provider
.shutdown()
.expect("falha ao finalizar tracer provider");

server_result
}

Ordem dos middlewares

Use esta ordem:

App::new()
.wrap(middlewares::error_handlers::error_handlers())
.wrap(TracingLogger::default())
.wrap(cors)

No seu caso, isso evita erro de tipo como:

expected ServiceResponse<BoxBody>
found ServiceResponse<StreamSpan<BoxBody>>

Esse erro acontece porque o TracingLogger altera o tipo do body da response. Se o ErrorHandlers estiver na ordem errada, ele pode esperar BoxBody, mas receber StreamSpan<BoxBody>.

Instrumentando handlers

Use #[tracing::instrument] nas rotas importantes.

Exemplo:

#[get("/health-check")]
#[tracing::instrument(name = "Health check")]
async fn health() -> impl Responder {
tracing::info!("health check executado");
HttpResponse::Ok().finish()
}

Exemplo com banco:

#[get("/health-database")]
#[tracing::instrument(
name = "Health database",
skip(db)
)]
async fn health_database(db: web::Data<PgPool>) -> impl Responder {
tracing::info!("health check database executado");

let result = sqlx::query("SELECT 1")
.execute(db.get_ref())
.await;

match result {
Ok(_) => HttpResponse::Ok().body("conectado no postgres"),
Err(error) => {
tracing::error!(error = ?error, "Falha no health check do banco");
HttpResponse::InternalServerError().body(format!("erro: {error}"))
}
}
}

Quando usar skip

Use skip para não tentar serializar/logar objetos grandes ou sensíveis.

Exemplo:

#[tracing::instrument(
name = "Criar produto",
skip(pool, payload)
)]
async fn criar_produto(
pool: web::Data<PgPool>,
payload: web::Json<CriarProdutoRequest>,
) -> impl Responder {
// ...
}

Use skip para:

PgPool
Payloads grandes
Senhas
Tokens
Connection strings
Objetos sem Debug

Adicionando campos no span

Exemplo:

#[tracing::instrument(
name = "Buscar produto por id",
skip(pool),
fields(product_id = %id)
)]
async fn buscar_produto(
pool: web::Data<PgPool>,
id: web::Path<i32>,
) -> impl Responder {
// ...
}

O campo product_id passa a aparecer no span.

Collector mínimo

Crie:

otel-collector-config.yml

Conteúdo:

receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318

processors:
batch:

exporters:
debug:
verbosity: detailed

service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [debug]

A configuração do Collector é baseada em pipelines. Cada pipeline tem receivers, processors e exporters. (OpenTelemetry)

Docker Compose do Collector

services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel-collector-config.yml"]
volumes:
- ./otel-collector-config.yml:/etc/otel-collector-config.yml
ports:
- "4317:4317"
- "4318:4318"

Subir:

docker compose up

Docker Compose com API e Collector

services:
api:
build: .
environment:
RUST_LOG: info
APP_ENVIRONMENT: development
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
ports:
- "9009:9009"
depends_on:
- otel-collector

otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel-collector-config.yml"]
volumes:
- ./otel-collector-config.yml:/etc/otel-collector-config.yml
ports:
- "4317:4317"
- "4318:4318"

Testando

Suba o Collector:

docker compose up

Rode a API:

RUST_LOG=info \
APP_ENVIRONMENT=development \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
cargo run

Chame uma rota:

curl http://localhost:9009/health-check

Ou:

curl http://localhost:9009/health-database

No log do Collector, você deve ver spans chegando no exporter debug.

Enviando para Jaeger

Collector:

receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318

processors:
batch:

exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true

service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/jaeger]

Compose:

services:
api:
build: .
environment:
RUST_LOG: info
APP_ENVIRONMENT: development
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
ports:
- "9009:9009"
depends_on:
- otel-collector

otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel-collector-config.yml"]
volumes:
- ./otel-collector-config.yml:/etc/otel-collector-config.yml
ports:
- "4317:4317"
- "4318:4318"
depends_on:
- jaeger

jaeger:
image: jaegertracing/all-in-one:latest
environment:
COLLECTOR_OTLP_ENABLED: "true"
ports:
- "16686:16686"
- "4317"
- "4318"

Acesse:

http://localhost:16686

Enviando para Grafana Tempo

Collector:

receivers:
otlp:
protocols:
grpc:
http:

processors:
batch:

exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true

service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/tempo]

Variáveis importantes

VariávelExemploDescrição
RUST_LOGinfoDefine o nível dos logs/spans
APP_ENVIRONMENTproductionDefine o ambiente da aplicação
OTEL_EXPORTER_OTLP_ENDPOINThttp://otel-collector:4317Endpoint OTLP/gRPC do Collector
OTEL_SERVICE_NAMEcatalog-apiNome do serviço, caso você prefira usar variável

Níveis de log

NívelUso
traceDiagnóstico muito detalhado
debugDiagnóstico em desenvolvimento
infoEventos normais da aplicação
warnSituações anormais, mas recuperáveis
errorFalhas reais

Exemplo:

tracing::info!("produto criado com sucesso");

tracing::warn!(
product_id = %id,
"produto não encontrado"
);

tracing::error!(
error = ?error,
"falha ao salvar produto"
);

Boas práticas

PráticaMotivo
Usar tracing, não println!Gera telemetry estruturada
Usar #[tracing::instrument]Cria spans automaticamente
Usar skip em objetos grandesEvita log pesado e vazamento de dados
Informar service.nameFacilita busca no backend
Usar OTLP para o CollectorEvita acoplamento direto com vendor
Usar batch no CollectorReduz overhead de exportação
Fazer shutdown() do providerGarante flush dos spans antes da aplicação encerrar
Não logar token/senhaSegurança

Erros comuns

ErroCausaSolução
Nada aparece no CollectorEndpoint erradoVerifique OTEL_EXPORTER_OTLP_ENDPOINT
Erro de conexão gRPCPorta erradaUse 4317 para gRPC
Erro usando HTTPEndpoint incompletoPara HTTP, normalmente use /v1/traces
Erro ServiceResponse<BoxBody>Ordem dos middlewaresColoque error_handlers antes do TracingLogger
Spans sem nome do serviçoResource não configuradoConfigure service.name
Logs aparecem, traces nãoFalta tracing-opentelemetryVerifique se o layer OTEL foi registrado