Skip to content

OpenTelemetry

First-class OpenTelemetry for nestrs — traces, logs and metrics, W3C propagation, OTLP export — wired by importing one module.

nest-rs-opentelemetry is the OpenTelemetry layer of the framework. It installs a tracing subscriber, batches traces / logs / metrics through the official opentelemetry + opentelemetry_sdk + opentelemetry-otlp crates, follows the opentelemetry-semantic-conventions for resource and HTTP attributes, and links the tracing span tree to its OTel counterpart through tracing-opentelemetry.

Three signal types ship in one crate, behind one import:

  • Traces — every request, job and scheduled tick opens a span; W3C traceparent propagation, sampling, and the per-request HTTP interceptor stitch distributed traces together.
  • Logs — tracing::info! and friends flow to the console and to OTel logs (via opentelemetry-appender-tracing) with structured fields, configurable level per layer, and JSON for prod.
  • Metrics — inject Arc<OpenTelemetryMeter> and build counters / histograms / gauges; metrics batch on a PeriodicReader and export through the same OTLP endpoint as the traces.
Terminal window
cargo add nest-rs --features opentelemetry

A headless binary (a queue worker) stops there; an app serving HTTP adds the http feature for the per-request interceptor — see Feature flags below.

apps/auth/src/main.rs (from the demo)
use anyhow::Result;
use nest_rs::config::Environment;
use nest_rs::core::App;
use nest_rs::opentelemetry::OpenTelemetry;
use auth::AuthModule;
#[tokio::main]
async fn main() -> Result<()> {
let _environment = Environment::init();
let _otel = OpenTelemetry::init("auth")?;
App::builder()
.module::<AuthModule>()
.build()
.await?
.run()
.await
}
apps/auth/src/module.rs (from the demo, abridged)
#[module(
imports = [
ConfigModule::for_root(),
OpenTelemetryModule,
HttpModule::for_root(HttpConfig { port: 3001, ..Default::default() }),
],
)]
pub struct AuthModule;

Two pieces, one rule. OpenTelemetry::init(name)? installs the global subscriber and must run before App::builder(). The returned guard flushes batch exporters on Drop, so it must live until main returns. OpenTelemetryModule then mounts the per-request HTTP interceptor and exposes the meter as a provider.

Every option of OpenTelemetryConfig is settable via env or via the pinned struct — the framework-wide config dual path. OpenTelemetry::init(name) reads env; OpenTelemetry::init_with(cfg) takes a pinned OpenTelemetryConfig.

Env varFieldDefault
NESTRS_OPENTELEMETRY__SERVICE_NAMEservice_nameargument to init
NESTRS_OPENTELEMETRY__SERVICE_VERSIONservice_versionNone
NESTRS_OPENTELEMETRY__SERVICE_ENVIRONMENTdeployment_environmentNone
NESTRS_OPENTELEMETRY__SERVICE_INSTANCE_IDservice_instance_idfresh UUIDv7 per process
NESTRS_LOGlog_filterinfo (the framework-wide logging family)
NESTRS_LOG_FORMATlog_formattext in debug, json in release
NESTRS_LOG_SOURCE_LOCATIONlog_source_locationfalse
NESTRS_OPENTELEMETRY__OTLP_ENDPOINTotlp_endpointNone (console-only)
NESTRS_OPENTELEMETRY__SAMPLE_RATIOtrace_sample_ratio1.0

The OTLP exporter wires up only when otlp_endpoint is set. Without it, the subscriber stays console-only; the W3C propagator is still installed so an inbound traceparent is continued behind a trusted proxy and the response carries traceresponse.

Terminal window
NESTRS_OPENTELEMETRY__OTLP_ENDPOINT=http://otel-collector:4318 \
NESTRS_OPENTELEMETRY__SERVICE_VERSION=2026.06.0 \
NESTRS_OPENTELEMETRY__SERVICE_ENVIRONMENT=production \
NESTRS_OPENTELEMETRY__SAMPLE_RATIO=0.1 \
NESTRS_LOG=info \
nestrs run start api

The exporter uses HTTP/protobuf via reqwest (no tonic, no openssl) — the batch processors export from a dedicated non-Tokio thread so the blocking reqwest client is the right pick, the upstream-recommended pairing.

nest-rs-opentelemetry ships two features of its own. You never name them: the umbrella’s opentelemetry feature turns on otlp, and the umbrella’s http feature turns on this crate’s http — so the two lines below differ only in whether the binary serves HTTP at all.

  • otlp (default) — pulls the opentelemetry-* crates, installs the OTLP exporters for traces / metrics / logs, and bridges tracing events into OTel logs. Without it, the subscriber stays a pure tracing-subscriber setup — useful for a tiny binary that should not link the OTel SDK.

  • http — pulls poem + nest-rs-http + nest-rs-interceptors so the per-request interceptor compiles: the remote parent link and the sampling decision. A headless app (a queue worker) uses otlp without http and stays free of the HTTP transport. Requires otlp — the interceptor reads the trace id off the OTel context.

    Without it you still get the request span, the access log and the whole W3C trace context — trace_id, span_id, traceparent across a queue: those are the transport’s, and they do not wait on this crate.

Cargo.toml (headless binary)
nest-rs = { workspace = true, features = ["opentelemetry"] }
apps/auth/Cargo.toml (from the demo)
nest-rs = { workspace = true, features = ["http", "opentelemetry"] }
Terminal window
thread 'main' panicked at 'OpenTelemetryModule was imported without calling
`OpenTelemetry::init` first — the global tracer and meter are no-ops, so
traces and metrics would be silently dropped. Add `let _otel =
nest_rs::opentelemetry::OpenTelemetry::init("<service>")?;` at the top of
`main`, before building the app.'

Forgetting OpenTelemetry::init is the failure mode that hurts in production: the backend stays quiet, the team thinks observability is on, and the first time someone needs a trace there’s nothing to grep. The boot guard makes it a deterministic startup error instead — visible the first time you run the binary locally.

apps/api/tests/e2e/users.rs
use nest_rs::testing::TestApp;
#[tokio::test]
async fn users_list_returns_a_page() {
let app = TestApp::builder()
.module::<ApiModule>()
.with_test_telemetry()
.build()
.await
.unwrap();
app.http().get("/users").send().await.assert_status_is_ok();
}

with_test_telemetry calls OpenTelemetry::init_for_tests — a console-only init honouring NESTRS_LOG then RUST_LOG (default warn for noise control). It is idempotent; the first test to run wins, the rest no-op. It is gated by the opentelemetry feature on nest-rs-testing.

A test that does not import OpenTelemetryModule does not need it.

  • crates/nest-rs-opentelemetry/ — OpenTelemetry::init, OpenTelemetryConfig, OpenTelemetryModule, OpenTelemetryMeter, LogFormat.
  • crates/nest-rs-server-timing/ — the Server-Timing header middleware.
  • Traces — the spans this exporter ships, and what each edge opens.
  • Logs — the record shape, and the ids every line carries.
  • Correlation — the trace context underneath, which waits on no crate.
  • Server-Timing — the companion header summarising a request’s main spans.