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
| Termo | Descrição |
|---|---|
| OpenTelemetry | Padrão aberto para instrumentação, coleta e exportação de traces, métricas e logs |
| OTLP | OpenTelemetry Protocol, protocolo usado para enviar telemetry |
| Collector | Serviço intermediário que recebe, processa e exporta telemetry |
| Trace | Representa uma operação distribuída ponta a ponta |
| Span | Uma unidade dentro de um trace, por exemplo uma request HTTP ou uma query no banco |
| Exporter | Componente que envia telemetry para outro destino |
| Receiver | Componente do Collector que recebe telemetry |
| Processor | Componente 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:
| Campo | Exemplo |
|---|---|
service.name | global-rising-sun-catalog-api |
service.version | 0.1.0 |
deployment.environment | development, 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ável | Exemplo | Descrição |
|---|---|---|
RUST_LOG | info | Define o nível dos logs/spans |
APP_ENVIRONMENT | production | Define o ambiente da aplicação |
OTEL_EXPORTER_OTLP_ENDPOINT | http://otel-collector:4317 | Endpoint OTLP/gRPC do Collector |
OTEL_SERVICE_NAME | catalog-api | Nome do serviço, caso você prefira usar variável |
Níveis de log
| Nível | Uso |
|---|---|
trace | Diagnóstico muito detalhado |
debug | Diagnóstico em desenvolvimento |
info | Eventos normais da aplicação |
warn | Situações anormais, mas recuperáveis |
error | Falhas 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ática | Motivo |
|---|---|
Usar tracing, não println! | Gera telemetry estruturada |
Usar #[tracing::instrument] | Cria spans automaticamente |
Usar skip em objetos grandes | Evita log pesado e vazamento de dados |
Informar service.name | Facilita busca no backend |
| Usar OTLP para o Collector | Evita acoplamento direto com vendor |
Usar batch no Collector | Reduz overhead de exportação |
Fazer shutdown() do provider | Garante flush dos spans antes da aplicação encerrar |
| Não logar token/senha | Segurança |
Erros comuns
| Erro | Causa | Solução |
|---|---|---|
| Nada aparece no Collector | Endpoint errado | Verifique OTEL_EXPORTER_OTLP_ENDPOINT |
| Erro de conexão gRPC | Porta errada | Use 4317 para gRPC |
| Erro usando HTTP | Endpoint incompleto | Para HTTP, normalmente use /v1/traces |
Erro ServiceResponse<BoxBody> | Ordem dos middlewares | Coloque error_handlers antes do TracingLogger |
| Spans sem nome do serviço | Resource não configurado | Configure service.name |
| Logs aparecem, traces não | Falta tracing-opentelemetry | Verifique se o layer OTEL foi registrado |