OpenTelemetry for Rust: Traces, Metrics & Logs with OpenObserve
Instrument Rust apps with the OpenTelemetry SDK and tracing crate — traces, metrics, and logs via OTLP to OpenObserve. Setup, code, and troubleshooting.
Rust occupies an interesting position in OpenTelemetry: the language that OpenObserve, the OpenTelemetry Collector’s config translator, and a growing share of infrastructure software are written in also has an OpenTelemetry SDK with an unusual maturity profile — logs and metrics went stable before traces. Add the fact that most Rust libraries instrument themselves with the tracing crate rather than the OpenTelemetry API, and the setup looks different from every other language on this hub. This guide walks through instrumenting a Rust service for traces, metrics, and logs with the current (0.32-line) crates, bridging the tracing ecosystem, and shipping everything to OpenObserve over OTLP.
If you are new to OpenTelemetry itself — the signals, the collector, OTLP — start with What is OpenTelemetry? A Complete Guide and come back here for the Rust specifics.
Why OpenTelemetry for Rust
Rust services usually exist because performance matters — proxies, ingestion pipelines, data planes, latency-critical APIs. That makes observability overhead a real design constraint, and it is exactly where the Rust SDK’s design pays off:
- Vendor-neutral instrumentation. Instrument once, export anywhere OTLP is accepted. No proprietary SDK compiled into your binary.
- A
tracing-native story. The Rust ecosystem standardized on thetracingcrate years before OpenTelemetry matured. Rather than fighting that, OpenTelemetry bridges it: your existingtracingspans and events become OTel spans and log records with two subscriber layers. - Low, predictable overhead. Batch processors run on a dedicated background thread, instruments are lock-light, and there is no garbage collector to interact badly with telemetry buffers.
Signal stability in Rust
Be precise about what “supported” means. As of mid-2026, with the crates on the 0.32 release line, the status is:
| Signal | API | SDK | OTLP exporter | Notes |
|---|---|---|---|---|
| Logs | Stable | Stable | RC | Stabilized in the 0.28–0.29 releases; the recommended path is the tracing bridge |
| Metrics | Stable | Stable | RC | API stable since 0.28, SDK stable since 0.30 |
| Traces | Beta | Beta | Beta | Fully functional and widely used, but the API can still change between releases |
Two things follow from that table. First, this is the reverse of Go, Java, and most other languages, where traces stabilized first — do not assume the maturity order transfers. Second, every crate is still pre-1.0: even the stable signals live in 0.x versions, breaking changes land in minor releases (0.28 was a large one), and all the first-party crates version in lockstep. Pin exact versions and upgrade the whole family together.
Prerequisites
- Rust 1.75 or later. That is the minimum supported Rust version for the current SDK; the project tracks the three most recent stable releases.
- An OpenObserve instance. Either an OpenObserve Cloud account or a self-hosted instance. For local testing, a single container is enough:
docker run -d --name openobserve \
-p 5080:5080 -p 5081:5081 \
-e ZO_ROOT_USER_EMAIL="root@example.com" \
-e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
public.ecr.aws/zinclabs/openobserve:latest
- Your OpenObserve credentials. OTLP ingestion uses HTTP Basic auth. Generate the token once:
echo -n 'root@example.com:Complexpass#123' | base64
This prints cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM= — the value you will pass in the Authorization: Basic header.
In OpenObserve Cloud, the ingestion page under Data Sources shows your organization-specific endpoint and a ready-made Basic auth token, so you rarely need to construct it by hand.
Zero-code instrumentation: not available in Rust
Rust has no runtime agent. A Rust program is a statically compiled native binary — there is no bytecode to rewrite and no interpreter to monkey-patch. If you are coming from Java, .NET, or Python, this is the mental shift: in Rust, you wire up the SDK in main().
What Rust has instead is arguably better for the long run: the tracing ecosystem. Most of the async web stack — axum, tower, tonic, hyper middleware, sqlx, reqwest middleware crates — either emits tracing spans natively or has a thin companion crate that does. Once you install the tracing-opentelemetry layer (below), all of those spans flow into OpenTelemetry without touching the libraries themselves. There is also eBPF-based zero-code capture (the OpenTelemetry eBPF Instrumentation project) that can observe unmodified binaries at the kernel level on Linux, but it is pre-1.0 and coarse-grained — useful for services you cannot rebuild, not a substitute for SDK instrumentation.
The rest of this guide covers the production path: explicit SDK setup, the OTLP exporter, and the two tracing bridges. The examples are framework-neutral so nothing here depends on axum, actix, or anything else.
Manual instrumentation
Add the current crates to Cargo.toml. All first-party OpenTelemetry crates must share the same minor version; tracing-opentelemetry is versioned separately (it lives in the tokio-rs organization) and 0.33 is the release that pairs with OpenTelemetry 0.32:
[dependencies]
opentelemetry = "0.32"
opentelemetry_sdk = "0.32"
opentelemetry-otlp = { version = "0.32", features = ["trace", "metrics", "logs"] }
opentelemetry-appender-tracing = "0.32"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["registry", "fmt", "env-filter"] }
tracing-opentelemetry = "0.33"
By default opentelemetry-otlp uses OTLP/HTTP with protobuf encoding and a blocking reqwest client (http-proto + reqwest-blocking-client features). That default matters: because the batch processors run on their own background thread since 0.28, this configuration needs no async runtime at all — it works in a plain synchronous binary. If you prefer gRPC, enable the grpc-tonic feature instead, and note that tonic requires an active tokio runtime; the examples below use HTTP.
Traces
The pattern mirrors every other OpenTelemetry SDK: build an exporter, attach it to a provider with a batch processor and a Resource, register the provider globally, and shut it down explicitly on exit.
use opentelemetry::trace::{Span, Tracer, TracerProvider};
use opentelemetry::{global, KeyValue};
use opentelemetry_otlp::SpanExporter;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::trace::SdkTracerProvider;
use opentelemetry_sdk::Resource;
fn init_traces() -> SdkTracerProvider {
// Reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS
// from the environment — see the OpenObserve section below.
let exporter = SpanExporter::builder()
.with_http()
.build()
.expect("failed to build OTLP span exporter");
let provider = SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(
// No with_service_name here — that would overwrite OTEL_SERVICE_NAME.
// Only set attributes in code that should NOT be env-overridable.
Resource::builder()
.with_attribute(KeyValue::new("service.version", "1.4.2"))
.build(),
)
.build();
// W3C Trace Context propagation for distributed traces.
global::set_text_map_propagator(TraceContextPropagator::new());
global::set_tracer_provider(provider.clone());
provider
}
fn main() {
let tracer_provider = init_traces();
let tracer = global::tracer("checkout-service");
// in_span makes the new span "active" for the closure, so nested
// spans parent correctly without passing a context by hand.
tracer.in_span("checkout", |_cx| {
tracer.in_span("process-payment", |cx| {
let span = cx.span();
span.set_attribute(KeyValue::new("payment.provider", "stripe"));
span.set_attribute(KeyValue::new("cart.items", 3));
// ... do the work ...
});
});
// Flush remaining spans before exit — skipping this drops the last batch.
if let Err(err) = tracer_provider.shutdown() {
eprintln!("tracer provider shutdown error: {err:?}");
}
}
Two details matter more than anything else in this file:
Resource::builder()(notbuilder_empty()) pre-populates from the environment detectors (OTEL_SERVICE_NAME,OTEL_RESOURCE_ATTRIBUTES), then applies the attributes you add in code. In the 0.32 SDK,mergeis last-write-wins, so everywith_service_name/with_attributecall overwrites the env-detected value for that key — code-level attributes win, and the matching env var is ignored. That means a hardcoded.with_service_name("checkout-service")silently defeatsOTEL_SERVICE_NAME. The example above deliberately omitsservice.namefrom code soOTEL_SERVICE_NAMEtakes effect; onlyservice.version(which you may equally move toOTEL_RESOURCE_ATTRIBUTES) is set in code. If you want full env control with no code overrides at all, useResource::builder_empty()and the env detectors will not run either — so preferbuilder()and just avoid re-declaring env-overridable keys in code.shutdown()is not optional. The batch processor buffers spans on its background thread; a process that exits without shutting the provider down silently loses its final batch. This is doubly important for CLIs and short-lived jobs.
Bridging the tracing ecosystem
Hand-writing tracer.in_span everywhere is not how real Rust services get instrumented. The idiomatic move is to keep using the tracing crate — #[tracing::instrument], info_span!, and the spans your frameworks already emit — and install tracing-opentelemetry as a subscriber layer that converts every tracing span into an OpenTelemetry span on the same provider:
use opentelemetry::trace::TracerProvider;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
fn init_tracing_subscriber(tracer_provider: &opentelemetry_sdk::trace::SdkTracerProvider) {
let tracer = tracer_provider.tracer("checkout-service");
tracing_subscriber::registry()
// tracing spans -> OpenTelemetry spans
.with(tracing_opentelemetry::layer().with_tracer(tracer))
// human-readable output on stdout for local dev
.with(tracing_subscriber::fmt::layer())
.init();
}
#[tracing::instrument(fields(payment.provider = "stripe"))]
fn process_payment(order_id: &str, amount_cents: u64) {
tracing::info!(order_id, amount_cents, "processing payment");
// ... work ...
}
Once this layer is installed, an axum handler wrapped in tower’s trace middleware, a #[tracing::instrument]-annotated function, and a manual info_span! all show up as properly nested OpenTelemetry spans — no OpenTelemetry types in your business code at all.
Metrics
Metrics are one of Rust’s two stable signals. The provider takes a periodic exporter that collects and pushes on an interval (60 s by default):
use opentelemetry::{global, KeyValue};
use opentelemetry_otlp::MetricExporter;
use opentelemetry_sdk::metrics::SdkMeterProvider;
use opentelemetry_sdk::Resource;
fn init_metrics() -> SdkMeterProvider {
let exporter = MetricExporter::builder()
.with_http()
.build()
.expect("failed to build OTLP metric exporter");
let provider = SdkMeterProvider::builder()
.with_periodic_exporter(exporter) // default 60s interval
.with_resource(
// No with_service_name — let OTEL_SERVICE_NAME control it.
Resource::builder()
.build(),
)
.build();
global::set_meter_provider(provider.clone());
provider
}
fn main() {
let meter_provider = init_metrics();
let meter = global::meter("checkout-service");
// Create instruments once (e.g. in a struct or OnceLock), not per request.
let orders = meter
.u64_counter("orders.processed")
.with_description("Number of orders processed")
.with_unit("{order}")
.build();
let latency = meter
.f64_histogram("payment.duration")
.with_description("Payment processing duration")
.with_unit("ms")
.build();
let start = std::time::Instant::now();
// ... process a payment ...
orders.add(1, &[KeyValue::new("payment.provider", "stripe")]);
latency.record(start.elapsed().as_secs_f64() * 1000.0, &[]);
if let Err(err) = meter_provider.shutdown() {
eprintln!("meter provider shutdown error: {err:?}");
}
}
Note the instrument builders end in .build() — older tutorials show .init(), which was removed during the pre-stable churn. Instruments are cheap to record on but should be created once; stash them in a struct or a std::sync::OnceLock rather than calling u64_counter(...) on the hot path.
Logs
Logs are the other stable signal, and the design is deliberate: there is no user-facing OpenTelemetry logging API. You log with the tracing crate (the maintainers’ recommendation; a log-crate appender also exists), and opentelemetry-appender-tracing forwards each event into the OpenTelemetry logs pipeline as another subscriber layer:
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::LogExporter;
use opentelemetry_sdk::logs::SdkLoggerProvider;
use opentelemetry_sdk::Resource;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};
fn main() {
let exporter = LogExporter::builder()
.with_http()
.build()
.expect("failed to build OTLP log exporter");
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(
// No with_service_name — let OTEL_SERVICE_NAME control it.
Resource::builder()
.build(),
)
.build();
// Filter out the SDK's own internal logs (emitted via tracing) so an
// export error cannot generate a log that itself gets exported — the
// classic telemetry-induced-telemetry loop.
let filter = EnvFilter::new("info")
.add_directive("opentelemetry=off".parse().unwrap())
.add_directive("hyper=off".parse().unwrap())
.add_directive("tonic=off".parse().unwrap())
.add_directive("reqwest=off".parse().unwrap());
let otel_log_layer =
OpenTelemetryTracingBridge::new(&logger_provider).with_filter(filter);
tracing_subscriber::registry()
.with(otel_log_layer)
.with(tracing_subscriber::fmt::layer())
.init();
tracing::info!(order_id = "ord_8412", amount_cents = 4599, "order processed");
tracing::error!(reason = "insufficient_funds", "payment declined");
if let Err(err) = logger_provider.shutdown() {
eprintln!("logger provider shutdown error: {err:?}");
}
}
The filter block is not decorative. The exporter stack (reqwest, hyper, and the SDK itself) logs through tracing, so without those directives a transient export failure produces error events that re-enter the bridge and get exported, amplifying the original problem. The official examples ship the same suppression list.
In a real service you install all three providers and both bridge layers (tracing_opentelemetry::layer() for spans, OpenTelemetryTracingBridge for logs) on one registry. When a tracing::info! fires inside an instrumented span, the exported log record carries the active trace_id and span_id, which is what lets you pivot from a slow trace to its exact log lines in OpenObserve.
Sending data to OpenObserve
OpenObserve ingests OTLP natively — no collector required in between, although putting one in the path is a fine architecture too. Two transports are supported:
- OTLP/HTTP at
<host>/api/<org>/v1/traces,/v1/metrics, and/v1/logs(port5080self-hosted). This is what the defaultwith_http()exporters above speak; the signal path is appended automatically. - OTLP/gRPC on port
5081(self-hosted), which additionally requires anorganizationheader alongsideAuthorization, and — in Rust — a tokio runtime for the tonic client.
Stick with HTTP unless you have a reason not to; it needs the least configuration and no async runtime.
The exporters in the code above take no hardcoded endpoint on purpose. opentelemetry-otlp honors the standard environment variables (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_PROTOCOL, plus the signal-specific OTEL_EXPORTER_OTLP_TRACES_* variants), so the same binary runs against local, staging, and cloud backends.
OpenObserve Cloud:
export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/<your-org-name>"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20<your-base64-token>"
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=production"
cargo run --release
Self-hosted OpenObserve:
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=dev"
cargo run
Three rules that prevent 90% of setup failures:
- No trailing slash on the endpoint. The exporter appends
/v1/traces(or/v1/metrics,/v1/logs) itself..../api/default/produces.../api/default//v1/tracesand a 404. - Percent-encode the space in the header value.
OTEL_EXPORTER_OTLP_HEADERSuses akey=value,key=valueformat in which the value must be URL-encoded — henceBasic%20<token>, notBasic <token>. - The organization is part of the URL path.
/api/defaulttargets thedefaultorganization; replace it with your org name in OpenObserve Cloud (visible on the ingestion/Data Sources page, which also shows the exact endpoint and token to copy).
If you would rather configure in code — common when credentials come from a secret manager — every exporter builder accepts explicit options. Note that with_endpoint on the HTTP builder takes the full signal URL, unlike the environment variable, which takes the base:
use opentelemetry_otlp::{SpanExporter, WithExportConfig, WithHttpConfig};
use std::collections::HashMap;
let exporter = SpanExporter::builder()
.with_http()
.with_endpoint("http://localhost:5080/api/default/v1/traces")
.with_headers(HashMap::from([(
"Authorization".to_string(),
"Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=".to_string(), // literal space here
)]))
.build()?;
For gRPC, switch the feature flag to grpc-tonic, point at port 5081, run inside #[tokio::main], and add the organization header alongside Authorization (via OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20<token>,organization=default" or the builder’s metadata option). For a broader tour of exporter configuration, see A Beginner’s Guide to OTLP Exporters.
Verifying data arrival in OpenObserve
Run the service, exercise a few code paths, then open the OpenObserve UI (http://localhost:5080 self-hosted, or your cloud URL):
- Streams — you should see a
defaulttraces stream plus streams for your metrics and logs. If a stream exists, ingestion is working end to end. - Traces — pick
checkout-serviceon the Traces page and open a trace. You should see thecheckoutspan withprocess-paymentnested under it (or your#[tracing::instrument]span names), along with the attributes you set. - Logs — query the log stream and confirm records carry
trace_idandspan_idfields when they were emitted inside a span; that confirms the appender bridge is correlating correctly. - Metrics —
orders.processedandpayment.durationappear as metric streams you can chart or attach to dashboards and alerts. Remember the periodic reader’s default interval is 60 s, so metrics lag traces on first run.
If nothing appears within ~60 seconds, jump to Troubleshooting below — and temporarily remove the opentelemetry=off filter directive (or run with RUST_LOG=opentelemetry=debug on the fmt layer only) so you can see the SDK’s own export errors.
Production tips
- Keep the batch exporter (never the simple exporter) in production.
with_batch_exporterbuffers and ships spans off the hot path on a dedicated background thread;with_simple_exporterblocks on every span end and exists for examples and tests. TheOTEL_BSP_*variables (schedule delay, queue size, batch size) tune it without a rebuild. - Sample at the head, keep parents authoritative. For high-traffic services, ratio sampling with parent-based override is the standard start — set it in code with
.with_sampler(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(0.1)))), or leave code untouched and useOTEL_TRACES_SAMPLER="parentbased_traceidratio"withOTEL_TRACES_SAMPLER_ARG="0.1". The parent-based wrapper is what keeps distributed traces complete instead of fragmenting them. - Invest in resource attributes.
service.name,service.version, anddeployment.environmentare the dimensions you will filter by in every OpenObserve query.Resource::builder()pre-populates from the env detectors, butmergeis last-write-wins, so any attribute you also set in code overwrites the env value for that key — a hardcoded.with_service_name(...)makesOTEL_SERVICE_NAMEa no-op. To make per-environment overrides work, do not re-declare env-overridable keys in code: setservice.versionanddeployment.environmentviaOTEL_RESOURCE_ATTRIBUTES, and leaveservice.nametoOTEL_SERVICE_NAME. UseResource::builder_empty()only if you want to opt out of env detection entirely. - Pin the whole crate family and upgrade it together.
opentelemetry,opentelemetry_sdk,opentelemetry-otlp, andopentelemetry-appender-tracingare released in lockstep;tracing-opentelemetryis versioned ahead of the core SDK in its own numbering (tracing-opentelemetry 0.33 pairs with opentelemetry 0.32). Mixing versions produces baffling trait-bound compile errors, and pre-1.0 minors are allowed to break APIs — the 0.28 release was a near-rewrite of provider construction. Read the release notes every bump. - Mind spans across
tokio::spawn. A spawned task does not inherit the caller’s span. With thetracingbridge, attach it explicitly:tokio::spawn(work().instrument(tracing::Span::current()))(fromtracing::Instrument). Otherwise the task’s spans and logs land in a separate, orphaned trace. - Shut down every provider on exit, in the right order. Tracer, meter, and logger providers each buffer data; call
shutdown()on all three before returning frommain. Shut the logger provider down last if other shutdown paths still log. For short-lived jobs,force_flush()after each unit of work is cheaper than losing tail data. - Record failures on spans, not only in logs. With the
tracingbridge, anerror!event inside a span marks it; with the raw API, usespan.record_error(&err)plusspan.set_status(Status::error(...))so failed requests are filterable in the Traces UI.
Troubleshooting
Symptom: exporter reports 404 Not Found and no data arrives.
Fix: almost always a URL problem. Remove any trailing slash from OTEL_EXPORTER_OTLP_ENDPOINT and confirm the path includes your organization (/api/default, not just the host). If you set the endpoint in code with with_endpoint, remember it needs the full signal path (.../v1/traces) on the HTTP builder — the base-URL convention applies only to the environment variable.
Symptom: 401 Unauthorized on every export.
Fix: regenerate the token with echo -n 'email:password' | base64 (the -n matters — a trailing newline corrupts the token), and make sure the header value in OTEL_EXPORTER_OTLP_HEADERS is Authorization=Basic%20<token> with the space percent-encoded. In code via with_headers, use a literal space instead — the encoding rule applies only to the environment variable.
Symptom: trait-bound or type-mismatch compile errors after adding or upgrading crates.
Fix: version skew. All opentelemetry* crates must be on the same minor (0.32 across the board), and tracing-opentelemetry must be the matching release (0.33 for OpenTelemetry 0.32). cargo tree -i opentelemetry shows whether two versions are being pulled in; cargo update alone will not fix a Cargo.toml that pins mismatched minors.
Symptom: the gRPC exporter fails at startup with a runtime/reactor panic.
Fix: the grpc-tonic transport requires an active tokio runtime — build your providers inside #[tokio::main] (or hand tonic a runtime). If the service is synchronous, use the default OTLP/HTTP transport instead, which needs no runtime, and point it at port 5080 rather than 5081.
Symptom: the program exits cleanly but the last spans, logs, or any metrics at all are missing.
Fix: the providers were never shut down. shutdown() flushes the batch processors and forces a final metric collection; without it, anything still buffered is dropped, and a short-lived process may exit before the first 60 s metric export ever fires. Wire all three shutdown() calls into your exit path.
Symptom: log volume explodes, or export errors seem to multiply themselves.
Fix: telemetry-induced telemetry. The SDK and its HTTP stack log through tracing, and those events are being fed back into the OTLP log bridge. Apply the filter shown in the Logs section (opentelemetry=off, hyper=off, tonic=off, reqwest=off) to the OpenTelemetryTracingBridge layer — keep the fmt layer unfiltered locally if you still want to see SDK errors on stderr.
With the SDK wired up once in main(), the tracing layers turning your existing spans and events into OpenTelemetry data, and OTLP pointed at OpenObserve, a Rust service gets correlated traces, metrics, and logs in a single backend — with the overhead profile Rust services are built for and no vendor-specific code anywhere in your application.
Frequently asked questions
Does Rust have automatic (zero-code) OpenTelemetry instrumentation like Java?
No. Rust compiles to a native binary, so there is no runtime agent that can attach and rewrite code the way the Java or Python agents do. The standard approach is explicit SDK setup in main plus the tracing ecosystem: most Rust web frameworks and libraries already emit tracing spans, and the tracing-opentelemetry layer converts those into OpenTelemetry spans, which gets you broad coverage with very little per-callsite work.
Which OpenTelemetry signals are stable in Rust?
Unusually, logs and metrics reached stability before traces. As of the 0.32 release line, the Logs API and SDK and the Metrics API and SDK are Stable, while the Traces API and SDK are still Beta, and the OTLP exporters range from RC (logs, metrics) to Beta (traces). Everything works in practice, but the crates are still pre-1.0, so pin exact versions and read the changelog before upgrading.
What endpoint do I use to send OTLP data from Rust to OpenObserve?
Set OTEL_EXPORTER_OTLP_ENDPOINT to your OpenObserve base URL including the organization path, with no trailing slash. For OpenObserve Cloud that is https://api.openobserve.ai/api/your-org-name, and for a self-hosted instance it is http://localhost:5080/api/default. The opentelemetry-otlp HTTP exporter automatically appends /v1/traces, /v1/metrics, and /v1/logs.
How do I authenticate Rust OTLP exports to OpenObserve?
OpenObserve uses HTTP Basic authentication. Base64-encode your email and password (or ingestion token) as email:password, then send it as an Authorization header. With environment variables, set OTEL_EXPORTER_OTLP_HEADERS to Authorization=Basic%20<base64-token> — the space after Basic must be percent-encoded as %20. In code, pass the header through the exporter builder's with_headers option instead, using a literal space.
Do I need the tokio runtime to use OpenTelemetry in Rust?
Not always. Since the 0.28 release the batch processors run on their own dedicated background thread, and the default OTLP/HTTP transport uses a blocking reqwest client, so a plain synchronous binary works with no async runtime at all. You do need an active tokio runtime if you choose the grpc-tonic transport or the async reqwest-client feature — building a tonic exporter outside a tokio runtime fails at runtime.
Should I use the OpenTelemetry API directly or the tracing crate?
For most Rust codebases, use the tracing crate as your instrumentation API. The OpenTelemetry Rust maintainers recommend tracing for logging, the opentelemetry-appender-tracing bridge forwards tracing events to the OpenTelemetry logs pipeline, and the tracing-opentelemetry layer converts tracing spans into OpenTelemetry spans. The OpenTelemetry API remains the right tool for metrics and for cases where you need precise control over span semantics.
Keep reading
Ship your OpenTelemetry data somewhere better
OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.