# otel-bootstrap
[](https://github.com/brefwiz/otel-bootstrap/actions/workflows/ci.yml)
[](https://crates.io/crates/otel-bootstrap)
[](https://docs.rs/otel-bootstrap)
[](LICENSE)
[](https://www.rust-lang.org)
One-call OpenTelemetry bootstrap for Rust services — traces, metrics, and logs over OTLP with sensible defaults.
The standard `opentelemetry` + `opentelemetry-otlp` + `tracing-subscriber` wiring is the same in every service. This crate does it once: call `init_telemetry("my-service")`, keep the handle alive, and drop it to flush.
## Features
- **One call to wire it all up** — traces, metrics, and logs over OTLP, `tracing-subscriber` configured, W3C TraceContext + Baggage propagators registered.
- **gRPC or HTTP/protobuf** — select transport via cargo feature.
- **Env-var configuration** — follows the OpenTelemetry spec (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_SERVICE_NAME`, `OTEL_TRACES_SAMPLER`, …).
- **Builder API** — version, environment, sampler, custom meter setup, extra subscriber layers.
- **Optional axum middleware** — `OtelTraceLayer` for inbound HTTP trace propagation.
- **Generic `enduser.*` span enrichment** — implement `EnrichSpan` on your context type and plug it into `SpanEnricherLayer<T>` with no brefwiz dependencies required.
- **Graceful shutdown** — drop `TelemetryHandles` to flush and shut down both providers.
## Quick start
```toml
[dependencies]
otel-bootstrap = "2"
tokio = { version = "1", features = ["full"] }
```
```rust
use otel_bootstrap::init_telemetry;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _telemetry = init_telemetry("my-service")?;
tracing::info!("service started");
// ... run your server ...
Ok(())
// _telemetry dropped here → SDK flushes and shuts down
}
```
For more control, use the builder:
```rust
use otel_bootstrap::{LogFormat, Telemetry};
let _telemetry = Telemetry::builder("my-service")
.with_version(env!("CARGO_PKG_VERSION"))
.with_environment("production")
.with_log_filter("info,opentelemetry_sdk=warn")
.with_log_format(LogFormat::Json)
.init()?;
```
`with_log_filter` and `with_log_format` take precedence over environment-backed
logging defaults, so typed configuration sources can configure logging without
mutating process-global environment.
## Cargo features
| `grpc` | ✅ | OTLP/gRPC transport via `tonic` |
| `http` | ❌ | OTLP/HTTP-protobuf transport via `reqwest` |
| `axum` | ❌ | `OtelTraceLayer` + `SpanEnricherLayer<T>` for axum servers |
| `tonic-tracing` | ❌ | `grpc_client_layer()` + `grpc_server_layer()` for raw tonic gRPC clients/servers |
| `testing` | ❌ | In-memory exporters for unit tests |
At least one of `grpc` or `http` must be enabled (enforced at compile time).
## Configuration
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` (gRPC) / `http://localhost:4318` (HTTP) |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` |
| `OTEL_EXPORTER_OTLP_TIMEOUT` | `10000` ms |
| `OTEL_SERVICE_NAME` | overridden by the `service_name` argument |
| `OTEL_TRACES_SAMPLER` | `parentbased_always_on` |
| `OTEL_TRACES_SAMPLER_ARG` | sampler-specific (e.g. ratio for `traceidratio`) |
Full reference: [`OTEL_TRACES_SAMPLER` values](https://opentelemetry.io/docs/concepts/sdk-configuration/general-sdk-configuration/#otel_traces_sampler).
## Axum middleware
```toml
otel-bootstrap = { version = "2", features = ["axum"] }
```
```rust
use otel_bootstrap::axum_layer;
let app = Router::new()
.route("/", get(handler))
.layer(axum_layer());
```
This layer reads the `traceparent` / `tracestate` headers, starts a server span, and propagates context to all child spans.
## `enduser.*` span enrichment
```toml
otel-bootstrap = { version = "2", features = ["axum"] }
```
Implement `EnrichSpan` on your context type, then plug it into `SpanEnricherLayer`:
```rust
use otel_bootstrap::span_enrichment::{EnrichSpan, span_enricher_layer};
use tracing::Span;
#[derive(Clone)]
struct MyContext { user_id: String }
impl EnrichSpan for MyContext {
fn enrich(&self, span: &Span) {
span.record("enduser.id", &self.user_id.as_str());
}
}
let app = Router::new()
.route("/", get(handler))
.layer(span_enricher_layer::<MyContext>());
```
Routes with no `MyContext` extension are silently skipped.
## Span-field log propagation
When `with_logs(true)` is set, otel-bootstrap replaces the upstream log bridge with `SpanAwareLogBridge`. It propagates span-level attributes into every OTLP log record so that `request.id`, `enduser.*`, and `http.*` fields appear as Loki labels alongside the trace context.
Two propagation paths are supported:
- **Tracing fields** declared at span creation (`info_span!("req", "request.id" = id)`) — captured automatically for names in [`PROPAGATED_SPAN_FIELDS`].
- **Post-creation attributes** set via `record_span_log_attr_on` — use this from middleware or `EnrichSpan::enrich_span` for fields that arrive after span creation.
```rust
use otel_bootstrap::{span_enrichment::emit_request_id, record_span_log_attr_on};
use opentelemetry::{Key, logs::AnyValue};
// In your request handler / middleware:
emit_request_id(&request_id); // → appears in traces + logs
// In EnrichSpan::enrich_span for enduser fields:
span.set_attribute("enduser.id", user_id.clone());
record_span_log_attr_on(span, Key::new("enduser.id"), AnyValue::String(user_id.into()));
```
Override the captured field set per service:
```rust
const FIELDS: &[&str] = &["request.id", "enduser.id", "tenant.id"];
let _telemetry = Telemetry::builder("my-service")
.with_logs(true)
.with_propagated_span_fields(FIELDS)
.init()?;
```
## Continuous profiling
Profiling is the fourth OTLP signal alongside traces, metrics, and logs
(ADR platform/0201). It ships as an explicit, off-by-default feature:
| `profiling` | ❌ | Enables `Telemetry::builder(...).with_profiling(endpoint)` |
| `profiling-bridge-pyroscope-rs` | ❌ | Implies `profiling`; wires the pyroscope-rs exporter |
```rust
let _telemetry = otel_bootstrap::Telemetry::builder("my-service")
.with_profiling("http://localhost:4040")
.init()?;
```
**Single export plane.** There is no direct-to-backend pyroscope client
path. `pyroscope-rs` hardcodes its own HTTP client with no TLS/identity
hook, so profiles are pushed over plain HTTP to a **localhost-only
SPIFFE-terminating sidecar** that carries the workload identity upstream
(ADR platform/0203). The `with_profiling` endpoint is validated at init
time to be loopback-only (`127.0.0.1`, `::1`, `localhost`, or a unix
socket) — anything else is rejected. This bridge is a temporary,
sunset-bound exception (ADR platform/0202, Tracks #40) pending a native Rust OTLP
profiles exporter; every profile carries `trace_id`/`span_id` tags for
correlation with the trace pillar.
**SVID rotation requires a pod restart.** Like the rest of the telemetry
channel, mTLS material for the profiling sidecar path is read once at
`init()` time and held for the process lifetime. SPIFFE SVID rotation is
not hot-reloaded into an already-running telemetry channel — a rotated
SVID only takes effect on the next process start (pod restart). This is a
standing platform constraint, not a profiling-specific bug.
## Examples
- [`basic_setup`](examples/basic_setup.rs) — minimal init
- [`shutdown_handling`](examples/shutdown_handling.rs) — explicit graceful flush
- [`custom_config`](examples/custom_config.rs) — builder API with version, environment, sampler
- [`axum_span_enricher`](examples/axum_span_enricher.rs) — axum + generic `EnrichSpan` enrichment
- [`telemetry_profiling`](examples/telemetry_profiling.rs) — continuous profiling via the builder
## License
MIT — see [LICENSE](LICENSE).