ESC
Type to search...

Observability

Structured logging and OpenTelemetry trace export with the otel feature flag

Rapina has two observability layers: structured logging built on tracing, and distributed tracing exported over OTLP to any OpenTelemetry collector such as Jaeger, Grafana Tempo, or Datadog.

Structured logging

with_tracing installs a global tracing subscriber that formats logs to stdout:

use rapina::prelude::*;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    Rapina::new()
        .with_tracing(TracingConfig::default())
        .router(router)
        .listen("127.0.0.1:3000")
        .await
}

TracingConfig controls the output format:

FieldDefaultDescription
jsonfalseJSON output instead of plain text
levelLevel::INFOMinimum level to log
with_targettrueInclude the module path
with_filefalseInclude the source file name
with_line_numberfalseInclude the source line number

Each field has a builder method:

TracingConfig::new()
    .json()
    .level(Level::DEBUG)
    .with_file(true)
    .with_line_number(true)

When the RUST_LOG environment variable is set, it takes precedence over level and supports the full EnvFilter syntax (RUST_LOG=info,my_api=debug).

OpenTelemetry trace export

Enable the otel feature flag:

[dependencies]
rapina = { version = "0.12.0", features = ["otel"] }

Then configure the exporter with with_telemetry:

use rapina::prelude::*;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    Rapina::new()
        .with_telemetry(TelemetryConfig {
            endpoint: "http://localhost:4317".into(),
            service_name: "my-api".into(),
            sample_rate: 1.0,
        })
        .router(router)
        .listen("127.0.0.1:3000")
        .await
}

TelemetryConfig fields and defaults:

FieldDefaultDescription
endpointhttp://localhost:4317OTLP gRPC endpoint of the collector
service_namerapinaReported as the service.name resource attribute
sample_rate1.0Fraction of traces to sample, 0.0 (none) to 1.0 (all)

The same builder style works here:

TelemetryConfig::new()
    .endpoint("http://jaeger:4317")
    .service_name("my-api")
    .sample_rate(0.25)

Export is plaintext OTLP gRPC by default. For an https:// collector enable the otel-tls feature, which adds TLS with the system root certificates.

with_telemetry and with_tracing compose: when both are set, logs go to stdout and spans go to the collector through a single subscriber. Spans are exported in batches and flushed during graceful shutdown, so in-flight traces are not dropped.

Sampling

The sampler is parent-based. When a request carries a propagated trace context, the upstream sampling decision is honored; otherwise the trace id is sampled at sample_rate. Out-of-range values are clamped to [0.0, 1.0].

Request spans

With telemetry configured, every request gets a server span following the OpenTelemetry HTTP semantic conventions. The span is named {method} {route} using the matched route template (GET /users/:id, not GET /users/42), falling back to the bare method when no route matches.

Recorded attributes:

AttributeExample
http.request.methodGET
http.route/users/:id
url.path/users/42
http.response.status_code200

Responses with a 5xx status mark the span as an error. Log lines emitted inside a request carry otel_trace_id and otel_span_id fields, so logs correlate with the exported trace.

Distributed tracing

Incoming requests with a W3C traceparent header continue the upstream trace: the request span is linked to the remote parent, and spans exported from your service appear in the same trace as the calling service.

Trying it locally

Run Jaeger all-in-one:

docker run --rm -p 4317:4317 -p 16686:16686 jaegertracing/all-in-one

Start your app with the otel feature and default TelemetryConfig, send a few requests, and open the Jaeger UI at http://localhost:16686. The repository ships a runnable example: cargo run --example telemetry --features otel.