1use 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
29pub const DEFAULT_ENDPOINT: &str = "http://localhost:4318";
32
33#[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 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 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 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 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 pub fn logs(mut self, enabled: bool) -> Self {
135 self.logs = Some(enabled);
136 self
137 }
138
139 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 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 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 .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 fn allowed(&self, config: &Config) -> bool {
260 if let Some(enabled) = self.enabled {
261 return enabled;
262 }
263 let disabled = env::env_or("OTEL_SDK_DISABLED", "false").eq_ignore_ascii_case("true");
266 config.bool("otel.enabled", !disabled)
267 }
268
269 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 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 setup.state(exporter);
326 }
327 }
328}
329
330fn 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 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 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 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 response.with_header("traceparent", context.traceparent())
400}
401
402struct 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 if crate::exporter::is_exporting() {
436 return;
437 }
438
439 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 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
463fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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}