Skip to main content

rustlavel_otel/
plugin.rs

1//! Turning OpenTelemetry on.
2//!
3//! ```ignore
4//! App::new()?
5//!     .routes(routes::web::routes)
6//!     .plugin(OpenTelemetry::default())
7//!     .serve()
8//!     .await
9//! ```
10//!
11//! One line, and the request span, its children, and the metrics all follow.
12//! The configuration is read from the environment variables the OpenTelemetry
13//! specification defines, because anyone deploying this already has them set
14//! for their other services and a framework that insists on its own names makes
15//! them keep two copies of the same fact.
16
17use crate::exporter::{Exporter, Protocol, Settings, signal_endpoint};
18use crate::metrics;
19use crate::resource::{Resource, Value, percent_decode};
20use crate::logs::{LogRecord, LogSettings};
21use crate::trace::{Span, SpanContext, SpanKind, SpanStatus};
22use rustlavel_core::events::{Event, Subscriber, subscribe};
23use rustlavel_core::log::Level;
24use rustlavel_core::{Config, env};
25use rustlavel_http::plugin::{Plugin, Setup};
26use rustlavel_http::{Next, Request, Response, Router};
27use std::time::{Duration, Instant, SystemTime};
28
29/// Where a collector listens when nothing says otherwise: the OTLP/HTTP port,
30/// on this machine, which is where a sidecar or an agent normally is.
31pub const DEFAULT_ENDPOINT: &str = "http://localhost:4318";
32
33/// The OpenTelemetry plugin.
34///
35/// Every setting can come from configuration or the environment instead, so a
36/// deployment tunes it without recompiling. A builder call always wins over
37/// configuration, and configuration over the environment: a value written in
38/// `main.rs` is a decision, a config key is a default, and an environment
39/// variable is whatever the platform happened to inject.
40///
41/// | Config key           | Environment                      | Meaning                                   |
42/// |----------------------|----------------------------------|-------------------------------------------|
43/// | `otel.enabled`       | `OTEL_SDK_DISABLED`              | Export at all. On by default.             |
44/// | `otel.endpoint`      | `OTEL_EXPORTER_OTLP_ENDPOINT`    | Collector base URL.                       |
45/// | `otel.service`       | `OTEL_SERVICE_NAME`              | `service.name`. Falls back to `app.name`. |
46/// | `otel.headers`       | `OTEL_EXPORTER_OTLP_HEADERS`     | `k=v,k=v`, percent-decoded.               |
47/// | `otel.protocol`      | `OTEL_EXPORTER_OTLP_PROTOCOL`    | `http/protobuf` or `http/json`.           |
48/// | `otel.interval_ms`   |                                  | Flush interval (5000).                    |
49/// | `otel.batch`         |                                  | Spans per request (512).                  |
50/// | `otel.queue`         |                                  | Spans held before dropping (2048).        |
51/// | `otel.retries`       |                                  | Retries per batch (3).                    |
52/// | `otel.logs.enabled`  | `OTEL_LOGS_EXPORTER=otlp`        | Export logs too. Off by default.          |
53/// | `otel.logs.level`    |                                  | Lowest level exported (`info`).           |
54/// | `otel.logs.queue`    |                                  | Log records held before dropping (2048).  |
55///
56/// `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` points the log signal at a full URL of
57/// its own; without it logs go to `<endpoint>/v1/logs`.
58#[derive(Default)]
59pub struct OpenTelemetry {
60    endpoint: Option<String>,
61    service: Option<String>,
62    protocol: Option<Protocol>,
63    headers: Vec<(String, String)>,
64    attributes: Vec<(String, Value)>,
65    interval: Option<Duration>,
66    max_batch: Option<usize>,
67    max_queue: Option<usize>,
68    max_retries: Option<u32>,
69    logs: Option<bool>,
70    log_level: Option<Level>,
71    enabled: Option<bool>,
72    client: Option<rustlavel_client::Client>,
73}
74
75impl OpenTelemetry {
76    pub fn new() -> Self {
77        OpenTelemetry::default()
78    }
79
80    /// The collector's base URL. `/v1/traces` and `/v1/metrics` are appended,
81    /// which is what the specification says a base endpoint means over HTTP.
82    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
83        self.endpoint = Some(endpoint.into());
84        self
85    }
86
87    pub fn service(mut self, name: impl Into<String>) -> Self {
88        self.service = Some(name.into());
89        self
90    }
91
92    pub fn protocol(mut self, protocol: Protocol) -> Self {
93        self.protocol = Some(protocol);
94        self
95    }
96
97    /// A header on every export — an API key for a hosted collector, usually.
98    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
99        self.headers.push((name.into(), value.into()));
100        self
101    }
102
103    /// A resource attribute: `deployment.environment.name`, `service.version`,
104    /// the pod name, anything that identifies this process rather than this
105    /// request.
106    pub fn attribute(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
107        self.attributes.push((key.into(), value.into()));
108        self
109    }
110
111    pub fn flush_every(mut self, interval: Duration) -> Self {
112        self.interval = Some(interval);
113        self
114    }
115
116    pub fn batch_size(mut self, spans: usize) -> Self {
117        self.max_batch = Some(spans);
118        self
119    }
120
121    /// How many spans may wait before the newest are dropped.
122    pub fn queue_limit(mut self, spans: usize) -> Self {
123        self.max_queue = Some(spans);
124        self
125    }
126
127    pub fn retries(mut self, retries: u32) -> Self {
128        self.max_retries = Some(retries);
129        self
130    }
131
132    /// Export logs as well as traces and metrics. Off unless asked for: logs
133    /// can carry anything an application prints.
134    pub fn logs(mut self, enabled: bool) -> Self {
135        self.logs = Some(enabled);
136        self
137    }
138
139    /// The lowest level exported as a log record. `info` by default.
140    pub fn log_level(mut self, level: Level) -> Self {
141        self.log_level = Some(level);
142        self
143    }
144
145    pub fn enabled(mut self, enabled: bool) -> Self {
146        self.enabled = Some(enabled);
147        self
148    }
149
150    /// Export through a caller-supplied client. Tests fake it; production might
151    /// use it to carry a proxy or a client certificate.
152    pub fn with_client(mut self, client: rustlavel_client::Client) -> Self {
153        self.client = Some(client);
154        self
155    }
156
157    fn base_endpoint(&self, config: &Config) -> String {
158        self.endpoint
159            .clone()
160            .unwrap_or_else(|| {
161                config.string(
162                    "otel.endpoint",
163                    &env::env_or("OTEL_EXPORTER_OTLP_ENDPOINT", DEFAULT_ENDPOINT),
164                )
165            })
166            .trim_end_matches('/')
167            .to_string()
168    }
169
170    /// Where logs go: the signal's own URL if the environment names one, which
171    /// the specification says is used as given, else `<endpoint>/v1/logs`.
172    fn logs_settings(&self, config: &Config) -> Option<LogSettings> {
173        let on = self.logs.unwrap_or_else(|| {
174            config.bool(
175                "otel.logs.enabled",
176                env::env_or("OTEL_LOGS_EXPORTER", "none").eq_ignore_ascii_case("otlp"),
177            )
178        });
179        if !on {
180            return None;
181        }
182
183        let endpoint = match env::env_or("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "") {
184            full if !full.is_empty() => full,
185            _ => signal_endpoint(&self.base_endpoint(config), "logs"),
186        };
187        let min_level = self
188            .log_level
189            .or_else(|| Level::parse(&config.string("otel.logs.level", "info")))
190            .unwrap_or(Level::Info);
191        let max_queue = config.int("otel.logs.queue", 2_048).clamp(1, 1_000_000) as usize;
192
193        Some(LogSettings { endpoint, min_level, max_queue })
194    }
195
196    fn resolve(&self, config: &Config) -> (Settings, Resource) {
197        let endpoint = self.base_endpoint(config);
198
199        let protocol = self.protocol.unwrap_or_else(|| {
200            Protocol::parse(&config.string(
201                "otel.protocol",
202                &env::env_or("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"),
203            ))
204            .unwrap_or_default()
205        });
206
207        let mut headers = parse_headers(&config.string(
208            "otel.headers",
209            &env::env_or("OTEL_EXPORTER_OTLP_HEADERS", ""),
210        ));
211        headers.extend(self.headers.iter().cloned());
212
213        let settings = Settings {
214            traces_endpoint: signal_endpoint(&endpoint, "traces"),
215            metrics_endpoint: signal_endpoint(&endpoint, "metrics"),
216            protocol,
217            headers,
218            timeout: Duration::from_millis(
219                config.int("otel.timeout_ms", 10_000).clamp(100, 120_000) as u64
220            ),
221            interval: self.interval.unwrap_or_else(|| {
222                Duration::from_millis(config.int("otel.interval_ms", 5_000).clamp(50, 600_000) as u64)
223            }),
224            max_batch: self
225                .max_batch
226                .unwrap_or_else(|| config.int("otel.batch", 512).clamp(1, 10_000) as usize),
227            max_queue: self
228                .max_queue
229                .unwrap_or_else(|| config.int("otel.queue", 2_048).clamp(1, 1_000_000) as usize),
230            max_retries: self
231                .max_retries
232                .unwrap_or_else(|| config.int("otel.retries", 3).clamp(0, 10) as u32),
233        };
234
235        let service = self.service.clone().unwrap_or_else(|| {
236            config.string(
237                "otel.service",
238                &env::env_or("OTEL_SERVICE_NAME", &config.string("app.name", "rustlavel")),
239            )
240        });
241
242        let mut resource = Resource::new(&service)
243            .with_pairs(&env::env_or("OTEL_RESOURCE_ATTRIBUTES", ""))
244            // Written after the environment so a value set in code wins, and
245            // last of all so it also wins over `OTEL_RESOURCE_ATTRIBUTES`.
246            .with("deployment.environment.name", config.environment());
247        for (key, value) in &self.attributes {
248            resource = resource.with(key.clone(), value.clone());
249        }
250
251        (settings, resource)
252    }
253
254    /// Whether exporting is switched on.
255    ///
256    /// Unlike Telescope, the default is on in every environment: an exporter
257    /// leaks nothing — it talks to a collector the operator configured — and
258    /// production is the environment where the traces are actually wanted.
259    fn allowed(&self, config: &Config) -> bool {
260        if let Some(enabled) = self.enabled {
261            return enabled;
262        }
263        // `OTEL_SDK_DISABLED` is the specification's own kill switch, and it
264        // has to work without a rebuild or a config file.
265        let disabled = env::env_or("OTEL_SDK_DISABLED", "false").eq_ignore_ascii_case("true");
266        config.bool("otel.enabled", !disabled)
267    }
268
269    /// Install onto a router directly.
270    ///
271    /// Returns the [`Exporter`] when it mounted. Applications go through
272    /// [`Plugin`]; this is for tests and for an application that builds its own
273    /// router.
274    pub fn install(self, router: &mut Router, config: &Config) -> Option<Exporter> {
275        if !self.allowed(config) {
276            rustlavel_core::debug!("otel: export is disabled");
277            return None;
278        }
279
280        let (settings, resource) = self.resolve(config);
281        let endpoint = settings.traces_endpoint.clone();
282        let service = resource.service_name().to_string();
283
284        let exporter = match self.client.clone() {
285            Some(client) => Exporter::with_client(settings, resource, client),
286            None => Exporter::new(settings, resource),
287        };
288
289        if let Some(logs) = self.logs_settings(config) {
290            let target = logs.endpoint.clone();
291            exporter.enable_logs(logs);
292            rustlavel_core::info!("otel: exporting logs to {target}");
293        }
294
295        router.middleware(trace_requests(exporter.clone()));
296        subscribe(Collector { exporter: exporter.clone() });
297
298        // `start` spawns onto the current runtime. An application that builds
299        // its router outside one — a test, or a CLI assembling routes to list
300        // them — gets a working exporter that simply has no background loop
301        // until something flushes it, rather than a panic during boot.
302        if tokio::runtime::Handle::try_current().is_ok() {
303            exporter.start();
304        } else {
305            rustlavel_core::debug!(
306                "otel: no tokio runtime during setup, so nothing is flushing yet; \
307                 call `Exporter::start` once the runtime is up"
308            );
309        }
310
311        rustlavel_core::info!("otel: exporting `{service}` to {endpoint}");
312        Some(exporter)
313    }
314}
315
316impl Plugin for OpenTelemetry {
317    fn name(&self) -> &'static str {
318        "otel"
319    }
320
321    fn register(self: Box<Self>, setup: &mut Setup<'_>) {
322        if let Some(exporter) = (*self).install(setup.router, setup.config) {
323            // Registered as state so a handler can record its own spans and
324            // metrics, and so an application can flush on shutdown.
325            setup.state(exporter);
326        }
327    }
328}
329
330/// Global middleware that opens a server span around every request.
331///
332/// This is middleware rather than another bus subscriber for two reasons the
333/// bus cannot give: the incoming `traceparent` is only visible here, and the
334/// span has to be *open* while the handler runs so that queries inside it can
335/// find their parent. The `http.request` event arrives after the response is
336/// finished, which is too late for either.
337fn trace_requests(exporter: Exporter) -> impl rustlavel_http::Middleware {
338    move |request: Request, next: Next| {
339        let exporter = exporter.clone();
340        async move { trace_request(exporter, request, next).await }
341    }
342}
343
344async fn trace_request(exporter: Exporter, request: Request, next: Next) -> Response {
345    let incoming = request.header("traceparent").and_then(SpanContext::parse_traceparent);
346    let context = match incoming {
347        Some(parent) => parent.child(),
348        None => SpanContext::root(),
349    };
350
351    let method = request.method().as_str();
352    // The route pattern, not the path. A span name is what a backend groups by,
353    // and `/orders/1` as a name makes one group per order.
354    let route = request.route().map(str::to_string);
355    let name = match &route {
356        Some(route) => format!("{method} {route}"),
357        None => method.to_string(),
358    };
359    let path = request.path().to_string();
360    let user_agent = request.header("user-agent").map(str::to_string);
361    let client_address = request.ip();
362
363    let start = SystemTime::now();
364    let elapsed = Instant::now();
365    let response = crate::trace::in_span(context, next.run(request)).await;
366    // The end is derived from the monotonic clock rather than read from the
367    // wall clock again, so an NTP step during the request cannot produce a span
368    // that ends before it started.
369    let end = start + elapsed.elapsed();
370
371    let mut span = Span::new(context, name, SpanKind::Server)
372        .between(start, end)
373        .with("http.request.method", method)
374        .with("url.path", path)
375        .with("http.response.status_code", response.status.code());
376    if let Some(parent) = incoming {
377        span = span.parent(parent.span_id);
378    }
379    if let Some(route) = route {
380        span = span.with("http.route", route);
381    }
382    if let Some(agent) = user_agent {
383        span = span.with("user_agent.original", agent);
384    }
385    if let Some(address) = client_address {
386        span = span.with("client.address", address);
387    }
388    // Only a 5xx is the server's fault. A 404 or a 422 is the span working
389    // exactly as intended, and marking those as errors turns an error rate into
390    // a measure of how many people mistype URLs.
391    if response.status.code() >= 500 {
392        span = span.status(SpanStatus::Error(response.status.reason().to_string()));
393    }
394
395    exporter.record_span(span);
396
397    // Returned so a caller — a browser, a load test, the service that made the
398    // request — can look up the trace for the response it is holding.
399    response.with_header("traceparent", context.traceparent())
400}
401
402/// Turns framework events into metrics, and into child spans of whatever
403/// request is in scope.
404///
405/// Metrics are recorded for every event; spans only for events that happened
406/// inside a span. Work with no request around it — a queue worker, a scheduled
407/// job, anything a handler moved onto a `tokio::spawn` — is therefore measured
408/// but not traced, because a span with no trace to belong to is noise. Wrap
409/// that work in [`crate::trace::in_span`] to give it one.
410struct Collector {
411    exporter: Exporter,
412}
413
414impl Subscriber for Collector {
415    fn interested_in(&self, kind: &str) -> bool {
416        if kind == "log" {
417            return self.exporter.logs_enabled();
418        }
419        matches!(
420            kind,
421            "http.request"
422                | "http.client"
423                | "db.query"
424                | "ai.call"
425                | "mcp.call"
426                | "queue.processed"
427                | "queue.failed"
428        )
429    }
430
431    fn handle(&self, event: &Event) {
432        // The exporter ships over HTTP, and the client reports that request on
433        // this same bus. Measuring it would make every flush generate the data
434        // the next flush reports.
435        if crate::exporter::is_exporting() {
436            return;
437        }
438
439        // A log line is not work that took time, so it is neither a span nor a
440        // metric. It is a record, stamped with the span in scope so a collector
441        // can show it beside the request that wrote it.
442        if event.kind == "log" {
443            if let Some(record) = LogRecord::from_event(event, crate::trace::current()) {
444                self.exporter.record_log(record);
445            }
446            return;
447        }
448
449        metrics::record_event(self.exporter.meter(), event);
450
451        // `http.request` has no child span: the middleware above already
452        // recorded the request, with the response and the incoming
453        // `traceparent` in hand.
454        if let Some(parent) = crate::trace::current()
455            && parent.sampled
456            && let Some(span) = crate::trace::span_from_event(event, parent)
457        {
458            self.exporter.record_span(span);
459        }
460    }
461}
462
463/// `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value`, values
464/// percent-decoded so a token containing a comma survives.
465fn parse_headers(raw: &str) -> Vec<(String, String)> {
466    raw.split(',')
467        .filter_map(|pair| {
468            let (name, value) = pair.trim().split_once('=')?;
469            let name = name.trim();
470            (!name.is_empty())
471                .then(|| (name.to_ascii_lowercase(), percent_decode(value.trim())))
472        })
473        .collect()
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use rustlavel_client::fake::{Fake, FakeResponse};
480    use rustlavel_client::Client;
481    use rustlavel_core::events::clear_subscribers;
482    use rustlavel_http::TestClient;
483    use std::sync::Arc;
484
485    /// The instrumentation bus and the environment are both process-wide, so
486    /// these tests run one at a time. Rule six: serialise when the state really
487    /// is global.
488    ///
489    /// A Tokio mutex because the tests await while holding it, which a `std`
490    /// guard must never do.
491    static BUS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
492
493    fn config() -> Config {
494        let config = Config::new();
495        config.set("app.name", "checkout");
496        config.set("app.env", "testing");
497        // Pin every environment-backed setting so a developer's own
498        // OTEL_* variables cannot change what these assert.
499        config.set("otel.endpoint", "http://collector.test");
500        config.set("otel.protocol", "http/protobuf");
501        config.set("otel.headers", "");
502        config.set("otel.service", "checkout");
503        // Logs are pinned off whatever OTEL_LOGS_EXPORTER says on this machine;
504        // a test that wants them says so with `.logs(true)`.
505        config.set("otel.logs.enabled", "false");
506        config
507    }
508
509    fn faking() -> (OpenTelemetry, Arc<Fake>) {
510        let client = Client::new().faking(Fake::new().fallback(FakeResponse::text("")));
511        let fake = client.fake().expect("faked").clone();
512        (OpenTelemetry::new().with_client(client), fake)
513    }
514
515    async fn mounted(plugin: OpenTelemetry, router: &mut Router) -> Exporter {
516        plugin.install(router, &config()).expect("mounted")
517    }
518
519    #[tokio::test]
520    async fn a_request_becomes_a_server_span_with_the_route_as_its_name() {
521        let _guard = BUS.lock().await;
522        clear_subscribers();
523
524        let mut router = Router::new();
525        router.get("/orders/{id}", |_req: Request| async { "order" });
526        let (plugin, fake) = faking();
527        let exporter = mounted(plugin, &mut router).await;
528
529        TestClient::new(router).get("/orders/7").await.assert_ok();
530        exporter.shutdown().await;
531        clear_subscribers();
532
533        let body = fake
534            .recorded()
535            .into_iter()
536            .find(|r| r.url.ends_with("/v1/traces"))
537            .expect("a trace export")
538            .body;
539        // The pattern, not the path — otherwise every order id is its own span
540        // name and the trace view is useless.
541        assert!(contains(&body, b"GET /orders/{id}"), "span name missing");
542        assert!(!contains(&body, b"GET /orders/7"), "the path leaked into the name");
543        assert!(contains(&body, b"http.route"), "the route attribute is missing");
544        assert!(contains(&body, b"/orders/7"), "url.path should still carry the real path");
545    }
546
547    #[tokio::test]
548    async fn an_incoming_traceparent_continues_the_trace_and_the_response_carries_one_back() {
549        let _guard = BUS.lock().await;
550        clear_subscribers();
551
552        let mut router = Router::new();
553        router.get("/orders", |_req: Request| async { "orders" });
554        let (plugin, fake) = faking();
555        let exporter = mounted(plugin, &mut router).await;
556
557        let upstream = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
558        let response = TestClient::new(router)
559            .send(
560                Request::new(rustlavel_http::Method::Get, "/orders")
561                    .with_header("traceparent", upstream),
562            )
563            .await;
564        exporter.shutdown().await;
565        clear_subscribers();
566
567        let returned = response.header("traceparent").expect("a traceparent on the way out");
568        let context = SpanContext::parse_traceparent(returned).expect("valid");
569        assert_eq!(context.trace_id.to_hex(), "4bf92f3577b34da6a3ce929d0e0e4736");
570        // A new span in the same trace, not the upstream span repeated.
571        assert_ne!(context.span_id.to_hex(), "00f067aa0ba902b7");
572
573        let body = fake
574            .recorded()
575            .into_iter()
576            .find(|r| r.url.ends_with("/v1/traces"))
577            .expect("a trace export")
578            .body;
579        // The upstream ids have to appear as raw bytes: the trace id on the
580        // span, the upstream span id as its parent.
581        assert!(contains(&body, context.trace_id.as_bytes()), "the trace was not continued");
582        assert!(
583            contains(&body, crate::trace::SpanId::from_hex("00f067aa0ba902b7").unwrap().as_bytes()),
584            "the parent span id is missing"
585        );
586    }
587
588    #[tokio::test]
589    async fn an_unsampled_upstream_stops_this_service_recording_too() {
590        let _guard = BUS.lock().await;
591        clear_subscribers();
592
593        let mut router = Router::new();
594        router.get("/orders", |_req: Request| async { "orders" });
595        let (plugin, fake) = faking();
596        let exporter = mounted(plugin, &mut router).await;
597
598        TestClient::new(router)
599            .send(Request::new(rustlavel_http::Method::Get, "/orders").with_header(
600                "traceparent",
601                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00",
602            ))
603            .await;
604        exporter.shutdown().await;
605        clear_subscribers();
606
607        assert_eq!(exporter.pending(), 0);
608        fake.assert_not_sent("/v1/traces");
609    }
610
611    #[tokio::test]
612    async fn a_query_inside_a_request_becomes_a_child_of_the_request_span() {
613        let _guard = BUS.lock().await;
614        clear_subscribers();
615
616        let mut router = Router::new();
617        router.get("/orders", |_req: Request| async {
618            // What the database driver dispatches from inside the handler.
619            Event::new("db.query")
620                .with("sql", "select * from orders")
621                .with("ok", true)
622                .took(Duration::from_millis(3))
623                .dispatch();
624            "orders"
625        });
626        let (plugin, _fake) = faking();
627        let exporter = mounted(plugin, &mut router).await;
628
629        TestClient::new(router).get("/orders").await.assert_ok();
630        clear_subscribers();
631
632        let queued: Vec<Span> = exporter_queue(&exporter);
633        assert_eq!(queued.len(), 2, "expected the query span and the request span");
634
635        let query = queued.iter().find(|s| s.name == "SELECT orders").expect("a query span");
636        let request = queued.iter().find(|s| s.name.starts_with("GET")).expect("a request span");
637
638        assert_eq!(query.context.trace_id, request.context.trace_id, "not in the same trace");
639        assert_eq!(query.parent, Some(request.context.span_id), "not parented to the request");
640        // And the request is a root within this service.
641        assert_eq!(request.parent, None);
642    }
643
644    #[tokio::test]
645    async fn a_five_hundred_is_an_error_and_a_four_hundred_is_not() {
646        let _guard = BUS.lock().await;
647        clear_subscribers();
648
649        let mut router = Router::new();
650        router.get("/broken", |_req: Request| async {
651            Response::new(rustlavel_http::Status::INTERNAL_ERROR)
652        });
653        router.get("/missing", |_req: Request| async { Response::not_found() });
654        let (plugin, _fake) = faking();
655        let exporter = mounted(plugin, &mut router).await;
656
657        let client = TestClient::new(router);
658        client.get("/broken").await;
659        client.get("/missing").await;
660        clear_subscribers();
661
662        let queued = exporter_queue(&exporter);
663        let broken = queued.iter().find(|s| s.name.contains("/broken")).expect("a span");
664        let missing = queued.iter().find(|s| s.name.contains("/missing")).expect("a span");
665
666        assert!(matches!(broken.status, SpanStatus::Error(_)));
667        assert_eq!(missing.status, SpanStatus::Unset);
668    }
669
670    #[tokio::test]
671    async fn requests_are_measured_as_well_as_traced() {
672        let _guard = BUS.lock().await;
673        clear_subscribers();
674
675        let mut router = Router::new();
676        router.get("/orders/{id}", |_req: Request| async { "order" });
677        let (plugin, _fake) = faking();
678        let exporter = mounted(plugin, &mut router).await;
679
680        let client = TestClient::new(router);
681        client.get("/orders/1").await.assert_ok();
682        client.get("/orders/2").await.assert_ok();
683        clear_subscribers();
684
685        let key = vec![
686            ("http.request.method".to_string(), Value::from("GET")),
687            ("http.route".to_string(), Value::from("/orders/{id}")),
688            ("http.response.status_code".to_string(), Value::Int(200)),
689        ];
690        assert_eq!(
691            exporter.meter().observations(metrics::instruments::HTTP_SERVER_DURATION, &key),
692            2
693        );
694    }
695
696    #[tokio::test]
697    async fn the_exporters_own_traffic_is_not_measured_as_application_traffic() {
698        let _guard = BUS.lock().await;
699        clear_subscribers();
700
701        let mut router = Router::new();
702        let (plugin, _fake) = faking();
703        let exporter = mounted(plugin, &mut router).await;
704
705        exporter.meter().add(metrics::instruments::QUEUE_JOBS, Vec::new(), 1);
706        exporter.flush().await;
707        exporter.flush().await;
708        clear_subscribers();
709
710        // Two flushes, each posting metrics, and not one outbound-request
711        // measurement to show for it — otherwise the exporter would report on
712        // itself for ever.
713        assert_eq!(
714            exporter.meter().observations(metrics::instruments::HTTP_CLIENT_DURATION, &Vec::new()),
715            0
716        );
717    }
718
719    #[tokio::test]
720    async fn disabling_it_mounts_nothing_and_subscribes_to_nothing() {
721        let _guard = BUS.lock().await;
722        clear_subscribers();
723
724        let mut router = Router::new();
725        let mounted = OpenTelemetry::new().enabled(false).install(&mut router, &config());
726
727        assert!(mounted.is_none());
728        assert!(!rustlavel_core::events::has_subscribers());
729        clear_subscribers();
730    }
731
732    #[test]
733    fn configuration_supplies_the_endpoint_service_and_limits() {
734        let config = config();
735        config.set("otel.endpoint", "http://collector.internal:4318/");
736        config.set("otel.service", "billing");
737        config.set("otel.queue", 10);
738        config.set("otel.batch", 5);
739        config.set("otel.interval_ms", 250);
740
741        let (settings, resource) = OpenTelemetry::new().resolve(&config);
742
743        assert_eq!(settings.traces_endpoint, "http://collector.internal:4318/v1/traces");
744        assert_eq!(settings.metrics_endpoint, "http://collector.internal:4318/v1/metrics");
745        assert_eq!(settings.max_queue, 10);
746        assert_eq!(settings.max_batch, 5);
747        assert_eq!(settings.interval, Duration::from_millis(250));
748        assert_eq!(resource.service_name(), "billing");
749    }
750
751    #[test]
752    fn a_builder_call_wins_over_configuration() {
753        let config = config();
754        config.set("otel.queue", 10);
755        config.set("otel.service", "from-config");
756
757        let (settings, resource) =
758            OpenTelemetry::new().queue_limit(99).service("from-code").resolve(&config);
759
760        assert_eq!(settings.max_queue, 99);
761        assert_eq!(resource.service_name(), "from-code");
762    }
763
764    #[test]
765    fn the_service_name_falls_back_to_the_application_name() {
766        let config = Config::new();
767        config.set("app.name", "checkout");
768
769        let (_, resource) = OpenTelemetry::new().resolve(&config);
770
771        // Only meaningful when the developer's shell has not set the variable.
772        if std::env::var("OTEL_SERVICE_NAME").is_err() {
773            assert_eq!(resource.service_name(), "checkout");
774        }
775    }
776
777    #[test]
778    fn nonsense_limits_are_clamped_rather_than_trusted() {
779        let config = config();
780        config.set("otel.queue", 0);
781        config.set("otel.batch", -5);
782        config.set("otel.retries", 500);
783
784        let (settings, _) = OpenTelemetry::new().resolve(&config);
785
786        assert_eq!(settings.max_queue, 1);
787        assert_eq!(settings.max_batch, 1);
788        assert_eq!(settings.max_retries, 10);
789    }
790
791    #[test]
792    fn headers_are_parsed_and_percent_decoded() {
793        let parsed = parse_headers("api-key=secret,X-Tenant=a%2Cb, spaced = value ");
794
795        assert_eq!(parsed[0], ("api-key".to_string(), "secret".to_string()));
796        // Header names are lower-cased so a later `.header()` call replaces
797        // rather than duplicating one that came from the environment.
798        assert_eq!(parsed[1], ("x-tenant".to_string(), "a,b".to_string()));
799        assert_eq!(parsed[2], ("spaced".to_string(), "value".to_string()));
800        assert!(parse_headers("").is_empty());
801        assert!(parse_headers("nonsense").is_empty());
802    }
803
804    #[test]
805    fn the_environment_is_recorded_on_the_resource() {
806        let (_, resource) = OpenTelemetry::new().resolve(&config());
807
808        assert!(
809            resource
810                .attributes
811                .iter()
812                .any(|(k, v)| k == "deployment.environment.name" && *v == Value::from("testing")),
813            "{:?}",
814            resource.attributes
815        );
816    }
817
818    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
819        haystack.windows(needle.len()).any(|window| window == needle)
820    }
821
822    fn exporter_queue(exporter: &Exporter) -> Vec<Span> {
823        // Reaching into the queue rather than flushing: these tests are about
824        // what was recorded, and a flush would only prove the fake was called.
825        exporter.drain_for_test()
826    }
827
828    #[tokio::test]
829    async fn logs_are_not_collected_unless_asked_for() {
830        let _guard = BUS.lock().await;
831        clear_subscribers();
832
833        let mut router = Router::new();
834        let (plugin, fake) = faking();
835        let exporter = mounted(plugin, &mut router).await;
836
837        rustlavel_core::info!("nobody asked for this to leave the machine");
838        exporter.shutdown().await;
839        clear_subscribers();
840
841        assert!(!exporter.logs_enabled());
842        assert_eq!(exporter.pending_logs(), 0);
843        assert!(
844            fake.recorded().iter().all(|r| !r.url.ends_with("/v1/logs")),
845            "nothing may be sent to the log endpoint when the signal is off"
846        );
847    }
848
849    #[tokio::test]
850    async fn a_log_line_is_exported_to_v1_logs_with_the_level_and_the_message() {
851        let _guard = BUS.lock().await;
852        clear_subscribers();
853
854        let mut router = Router::new();
855        let (plugin, fake) = faking();
856        let exporter = mounted(plugin.logs(true), &mut router).await;
857
858        rustlavel_core::warn!("the disk is nearly full");
859        rustlavel_core::debug!("below the default level");
860        exporter.shutdown().await;
861        clear_subscribers();
862
863        let sent = fake.recorded().into_iter().find(|r| r.url.ends_with("/v1/logs")).expect("logs sent");
864        assert_eq!(sent.url, "http://collector.test/v1/logs");
865        assert_eq!(sent.headers.get("content-type"), Some("application/x-protobuf"));
866        assert!(contains(&sent.body, b"the disk is nearly full"));
867        assert!(!contains(&sent.body, b"below the default level"));
868        assert!(contains(&sent.body, b"WARN"));
869    }
870
871    #[tokio::test]
872    async fn a_line_logged_inside_a_request_carries_that_requests_trace_id() {
873        let _guard = BUS.lock().await;
874        clear_subscribers();
875
876        let mut router = Router::new();
877        router.get("/work", |_req: Request| async {
878            rustlavel_core::info!("working on it");
879            "done"
880        });
881        let (plugin, fake) = faking();
882        let exporter = mounted(plugin.logs(true).protocol(Protocol::Json), &mut router).await;
883
884        TestClient::new(router)
885            .send(
886                Request::new(rustlavel_http::Method::Get, "/work").with_header(
887                    "traceparent",
888                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
889                ),
890            )
891            .await
892            .assert_ok();
893        exporter.shutdown().await;
894        clear_subscribers();
895
896        let body = fake
897            .recorded()
898            .into_iter()
899            .find(|r| r.url.ends_with("/v1/logs"))
900            .map(|r| r.body_text())
901            .expect("logs sent");
902        assert!(body.contains("working on it"), "{body}");
903        assert!(
904            body.contains("4bf92f3577b34da6a3ce929d0e0e4736"),
905            "the log record must carry the trace the request continued: {body}"
906        );
907    }
908
909    #[tokio::test]
910    async fn a_collector_that_refuses_logs_does_not_feed_on_its_own_complaints() {
911        let _guard = BUS.lock().await;
912        clear_subscribers();
913
914        let client = Client::new().faking(
915            Fake::new()
916                .on("/v1/logs", FakeResponse::text("no logs pipeline here").status(400))
917                .fallback(FakeResponse::text("")),
918        );
919        let fake = client.fake().expect("faked").clone();
920        let mut router = Router::new();
921        let exporter = mounted(OpenTelemetry::new().with_client(client).logs(true), &mut router).await;
922
923        rustlavel_core::info!("one line");
924        for _ in 0..4 {
925            exporter.flush().await;
926        }
927        clear_subscribers();
928
929        // The claim is that the exporter's own complaints never become records.
930        // Counting requests or records would also count whatever other tests log
931        // through the same process-wide bus while this one runs, so it looks for
932        // the complaints themselves in everything that was sent.
933        let sent: Vec<Vec<u8>> = fake
934            .recorded()
935            .into_iter()
936            .filter(|r| r.url.ends_with("/v1/logs"))
937            .map(|r| r.body)
938            .collect();
939        assert!(!sent.is_empty(), "the line was exported at least once");
940        for body in &sent {
941            assert!(
942                !contains(body, b"rejected the payload") && !contains(body, b"log record(s)"),
943                "the exporter shipped its own complaint about the collector"
944            );
945        }
946        assert_eq!(exporter.pending_logs(), 0);
947        assert!(exporter.dropped_logs() >= 1, "the refused batch is counted, not retried for ever");
948    }
949}