1use std::net::ToSocketAddrs as _;
2use std::sync::Arc;
3use std::sync::LazyLock;
4use std::time::Duration;
5
6use agent_data_plane_config::domains;
7use async_trait::async_trait;
8use axum::body::Bytes;
9use otlp_protos::opentelemetry::proto::collector::logs::v1::ExportLogsServiceRequest;
10use otlp_protos::opentelemetry::proto::collector::metrics::v1::ExportMetricsServiceRequest;
11use otlp_protos::opentelemetry::proto::collector::trace::v1::ExportTraceServiceRequest;
12use otlp_protos::opentelemetry::proto::logs::v1::ResourceLogs as OtlpResourceLogs;
13use otlp_protos::opentelemetry::proto::trace::v1::ResourceSpans as OtlpResourceSpans;
14use prost::Message;
15use saluki_common::collections::FastHashSet;
16use saluki_common::sync::shutdown::{ShutdownCoordinator, ShutdownHandle};
17use saluki_context::tags::{SharedTagSet, TagSet};
18use saluki_context::ContextResolver;
19use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
20use saluki_core::runtime;
21use saluki_core::topology::interconnect::BufferedDispatcher;
22use saluki_core::{
23 components::{
24 sources::{Source, SourceBuilder, SourceContext},
25 BuildContext,
26 },
27 data_model::event::EventType,
28 topology::{EventsBuffer, OutputDefinition},
29};
30use saluki_env::WorkloadProvider;
31use saluki_error::ErrorContext as _;
32use saluki_error::{generic_error, GenericError};
33use saluki_io::net::{server::http::Http2Config, ListenAddress};
34use stringtheory::MetaString;
35use tokio::pin;
36use tokio::select;
37use tokio::sync::mpsc;
38use tokio::time::{interval, MissedTickBehavior};
39use tracing::{debug, error};
40
41use crate::common::otlp::{
42 build_metrics, resolve_grpc_http2_config, CorsConfiguration, Metrics, OtlpHandler, OtlpServerConfiguration,
43 OtlpTlsConfiguration,
44};
45
46mod logs;
47mod metrics;
48mod resolver;
49use self::logs::translator::OtlpLogsTranslator;
50use self::metrics::translator::OtlpMetricsTranslator;
51use self::resolver::build_context_resolver;
52use crate::common::otlp::origin::OtlpOriginTagResolver;
53use crate::common::otlp::traces::translator::OtlpTracesTranslator;
54
55fn parse_configured_metric_tags(raw: &str) -> SharedTagSet {
59 let mut tags = TagSet::default();
60 for tag in raw.split(',') {
61 let tag = tag.trim();
62 if !tag.is_empty() {
63 tags.insert_tag(tag);
64 }
65 }
66 tags.into_shared()
67}
68
69fn cors_configuration(cors: &domains::otlp::Cors) -> CorsConfiguration {
71 CorsConfiguration {
72 allowed_origins: cors.allowed_origins.clone(),
73 allowed_headers: cors.allowed_headers.clone(),
74 exposed_headers: cors.exposed_headers.clone(),
75 max_age: cors.max_age,
76 }
77}
78
79fn build_tls_config(tls: &domains::otlp::Tls) -> Result<Option<OtlpTlsConfiguration>, GenericError> {
91 match (tls.cert_file.is_empty(), tls.key_file.is_empty()) {
92 (true, true) => {
93 if !tls.ca_file.is_empty() {
94 Err(generic_error!(
95 "OTLP receiver TLS `ca_file` is set but `cert_file` and `key_file` are empty. All three must \
96 be provided together, or `ca_file` must be omitted when TLS is disabled."
97 ))
98 } else {
99 Ok(None)
100 }
101 }
102 (false, false) => {
103 let mut config = OtlpTlsConfiguration::new(tls.cert_file.clone().into(), tls.key_file.clone().into());
104 if !tls.ca_file.is_empty() {
105 config = config.with_ca_file(tls.ca_file.clone().into());
106 }
107 Ok(Some(config))
108 }
109 (true, false) => Err(generic_error!(
110 "OTLP receiver TLS `key_file` is set but `cert_file` is empty. Both must be provided to enable TLS."
111 )),
112 (false, true) => Err(generic_error!(
113 "OTLP receiver TLS `cert_file` is set but `key_file` is empty. Both must be provided to enable TLS."
114 )),
115 }
116}
117
118fn apply_static_metric_tags(otlp: &mut domains::otlp::Domain, static_tags: Vec<String>) {
120 if !static_tags.is_empty() {
121 otlp.metrics.tags = static_tags.join(",");
122 }
123}
124
125pub struct OtlpConfiguration {
127 default_hostname: MetaString,
128
129 otlp: domains::otlp::Domain,
131
132 workload_provider: Arc<dyn WorkloadProvider + Send + Sync>,
134}
135
136impl OtlpConfiguration {
137 pub fn from_configuration<W>(otlp: &domains::otlp::Domain, workload_provider: W) -> Self
139 where
140 W: WorkloadProvider + Send + Sync + 'static,
141 {
142 Self {
143 default_hostname: MetaString::default(),
144 otlp: otlp.clone(),
145 workload_provider: Arc::new(workload_provider),
146 }
147 }
148
149 pub fn with_static_metric_tags(mut self, static_tags: Vec<String>) -> Self {
151 apply_static_metric_tags(&mut self.otlp, static_tags);
152 self
153 }
154
155 fn metrics_translator_config(&self) -> metrics::config::OtlpMetricsTranslatorConfig {
156 let mut config = metrics::config::OtlpMetricsTranslatorConfig::default()
157 .with_summary_mode(self.otlp.metrics.summaries.mode)
158 .with_histogram_mode(self.otlp.metrics.histogram_mode)
159 .with_send_histogram_aggregations(self.otlp.metrics.send_histogram_aggregations)
160 .with_cumulative_monotonic_mode(self.otlp.metrics.sums.cumulative_monotonic_mode)
161 .with_initial_cumulative_monotonic_value(self.otlp.metrics.sums.initial_cumulative_monotonic_value)
162 .with_resource_attributes_as_tags(self.otlp.metrics.resource_attributes_as_tags)
163 .with_instrumentation_scope_metadata_as_tags(self.otlp.metrics.instrumentation_scope_metadata_as_tags)
164 .with_delta_ttl(self.otlp.metrics.delta_ttl);
165 config.tag_cardinality = self.otlp.metrics.tag_cardinality;
166 config
167 }
168
169 pub fn with_default_hostname(mut self, hostname: impl Into<MetaString>) -> Self {
171 self.default_hostname = hostname.into();
172 self
173 }
174}
175
176#[async_trait]
177impl SourceBuilder for OtlpConfiguration {
178 fn outputs(&self) -> &[OutputDefinition<EventType>] {
179 static OUTPUTS: LazyLock<Vec<OutputDefinition<EventType>>> = LazyLock::new(|| {
180 vec![
181 OutputDefinition::named_output("metrics", EventType::Metric),
182 OutputDefinition::named_output("logs", EventType::Log),
183 OutputDefinition::named_output("traces", EventType::Trace),
184 ]
185 });
186
187 &OUTPUTS
188 }
189
190 async fn build(&self, context: BuildContext) -> Result<Box<dyn Source + Send>, GenericError> {
191 if !self.otlp.receiver.metrics_enabled && !self.otlp.receiver.logs_enabled && !self.otlp.traces.enabled {
192 return Err(generic_error!(
193 "OTLP metrics, logs and traces support is disabled. Please enable at least one of them."
194 ));
195 }
196
197 let grpc_listen_str = format!(
198 "{}://{}",
199 self.otlp.receiver.grpc.transport.as_str(),
200 self.otlp.receiver.grpc.endpoint
201 );
202 let grpc_endpoint = ListenAddress::try_from(grpc_listen_str.as_str())
203 .map_err(|e| generic_error!("Invalid gRPC endpoint address '{}': {}", grpc_listen_str, e))?;
204
205 let http_endpoint_str = &self.otlp.receiver.http.endpoint;
206 let http_socket_addr = http_endpoint_str
207 .to_socket_addrs()
208 .map_err(|e| generic_error!("Invalid HTTP endpoint address '{}': {}", http_endpoint_str, e))?
209 .next()
210 .ok_or_else(|| generic_error!("No addresses resolved for HTTP endpoint '{}'", http_endpoint_str))?;
211
212 let origin_tag_resolver = OtlpOriginTagResolver::new(Arc::clone(&self.workload_provider));
213
214 let context_resolver = build_context_resolver(&self.otlp.contexts, context.component_context(), None)?;
217 let metrics_translator_config = self.metrics_translator_config();
218
219 let metric_tags = parse_configured_metric_tags(&self.otlp.metrics.tags);
220 let traces_translator = OtlpTracesTranslator::new(self.otlp.traces.clone());
221 let grpc_max_recv_msg_size_bytes = self.otlp.receiver.grpc.max_recv_msg_size_mib as usize * 1024 * 1024;
222 let grpc_http2_config = resolve_grpc_http2_config(
223 &self.otlp.receiver.grpc.keepalive,
224 self.otlp.receiver.grpc.max_concurrent_streams,
225 );
226 let http_max_request_body_size = self.otlp.receiver.http.max_request_body_size;
227 let cors = cors_configuration(&self.otlp.receiver.http.cors);
228 let http_tls_config = build_tls_config(&self.otlp.receiver.http.tls)?;
229 let grpc_tls_config = build_tls_config(&self.otlp.receiver.grpc.tls)?;
230 let metrics = build_metrics(context.component_context());
231 let translator_metrics =
232 metrics::telemetry::OtlpMetricsTranslatorMetrics::from_component_context(context.component_context());
233
234 Ok(Box::new(Otlp {
235 context_resolver,
236 origin_tag_resolver,
237 grpc_endpoint,
238 http_endpoint: ListenAddress::Tcp(http_socket_addr),
239 grpc_max_recv_msg_size_bytes,
240 grpc_http2_config,
241 http_max_request_body_size,
242 metrics_translator_config,
243 metric_tags,
244 default_hostname: self.default_hostname.clone(),
245 traces_translator,
246 cors,
247 http_tls_config,
248 grpc_tls_config,
249 metrics,
250 translator_metrics,
251 }))
252 }
253}
254
255impl MemoryBounds for OtlpConfiguration {
256 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
257 builder
258 .minimum()
259 .with_single_value::<Otlp>("source struct")
260 .with_single_value::<SourceHandler>("source handler");
261 }
262}
263
264pub struct Otlp {
265 context_resolver: ContextResolver,
266 origin_tag_resolver: OtlpOriginTagResolver,
267 grpc_endpoint: ListenAddress,
268 http_endpoint: ListenAddress,
269 grpc_max_recv_msg_size_bytes: usize,
270 grpc_http2_config: Http2Config,
271 http_max_request_body_size: u64,
272 metrics_translator_config: metrics::config::OtlpMetricsTranslatorConfig,
273 metric_tags: SharedTagSet,
274 default_hostname: MetaString,
275 traces_translator: OtlpTracesTranslator,
276 cors: CorsConfiguration,
277 http_tls_config: Option<OtlpTlsConfiguration>,
278 grpc_tls_config: Option<OtlpTlsConfiguration>,
279 metrics: Metrics, translator_metrics: metrics::telemetry::OtlpMetricsTranslatorMetrics,
281}
282
283#[async_trait]
284impl Source for Otlp {
285 async fn run(self: Box<Self>, mut context: SourceContext) -> Result<(), GenericError> {
286 let Self {
287 context_resolver,
288 origin_tag_resolver,
289 grpc_endpoint,
290 http_endpoint,
291 grpc_max_recv_msg_size_bytes,
292 grpc_http2_config,
293 http_max_request_body_size,
294 metrics_translator_config,
295 metric_tags,
296 default_hostname,
297 traces_translator,
298 cors,
299 http_tls_config,
300 grpc_tls_config,
301 metrics,
302 translator_metrics,
303 } = *self;
304
305 let global_shutdown = context.take_shutdown_handle();
306 pin!(global_shutdown);
307
308 let mut health = context.take_health_handle();
309 let memory_limiter = context.topology_context().memory_limiter();
310
311 let (tx, rx) = mpsc::channel::<OtlpSignal>(1024);
313
314 let metrics_translator = OtlpMetricsTranslator::new(
315 metrics_translator_config,
316 default_hostname,
317 context_resolver,
318 origin_tag_resolver.clone(),
319 metric_tags,
320 translator_metrics,
321 )?;
322
323 let handler = SourceHandler::new(tx, metrics.clone());
325 let mut server_config =
326 OtlpServerConfiguration::new(http_endpoint, grpc_endpoint, grpc_max_recv_msg_size_bytes)
327 .with_cors(cors)
328 .with_grpc_http2_config(grpc_http2_config)
329 .with_http_max_request_body_size(http_max_request_body_size);
330
331 if let Some(tls) = http_tls_config {
332 server_config = server_config.with_http_tls(tls);
333 }
334 if let Some(tls) = grpc_tls_config {
335 server_config = server_config.with_grpc_tls(tls);
336 }
337
338 server_config
339 .build(
340 handler,
341 memory_limiter.clone(),
342 metrics.clone(),
343 context.topology_context().global_thread_pool(),
344 )
345 .await?;
346
347 let converter_context = context.clone();
349
350 let mut converter_shutdown_coordinator = ShutdownCoordinator::default();
351 let converter_shutdown = converter_shutdown_coordinator.register();
352
353 runtime::worker(
354 "resource_converter",
355 run_converter(
356 rx,
357 converter_context,
358 origin_tag_resolver,
359 converter_shutdown,
360 metrics_translator,
361 metrics,
362 traces_translator,
363 ),
364 )
365 .on_runtime(context.topology_context().global_thread_pool().clone())
366 .spawn();
367
368 health.mark_ready();
369 debug!("OTLP source started.");
370
371 loop {
373 select! {
374 _ = &mut global_shutdown => {
375 debug!("Received shutdown signal.");
376 break
377 },
378 _ = health.live() => continue,
379 }
380 }
381
382 debug!("Stopping OTLP source...");
383
384 converter_shutdown_coordinator.shutdown_and_wait().await;
385
386 debug!("OTLP source stopped.");
387
388 Ok(())
389 }
390}
391
392enum OtlpSignal {
393 Metrics(ExportMetricsServiceRequest),
394 Logs(OtlpResourceLogs),
395 Traces(OtlpResourceSpans),
396}
397
398struct SourceHandler {
400 tx: mpsc::Sender<OtlpSignal>,
401 metrics: Metrics,
402}
403
404impl SourceHandler {
405 fn new(tx: mpsc::Sender<OtlpSignal>, metrics: Metrics) -> Self {
406 Self { tx, metrics }
407 }
408}
409
410#[async_trait]
411impl OtlpHandler for SourceHandler {
412 async fn handle_metrics(&self, body: Bytes) -> Result<(), GenericError> {
413 let request = ExportMetricsServiceRequest::decode(body).map_err(|e| {
414 self.metrics.metrics_errors_decode().increment(1);
415 generic_error!("Failed to decode metrics export request: {}", e)
416 })?;
417
418 self.tx.send(OtlpSignal::Metrics(request)).await.map_err(|e| {
422 self.metrics.metrics_errors_channel().increment(1);
423 generic_error!("Failed to send metrics request to converter: channel is closed: {}", e)
424 })?;
425 Ok(())
426 }
427
428 async fn handle_logs(&self, body: Bytes) -> Result<(), GenericError> {
429 let request = ExportLogsServiceRequest::decode(body).error_context("Failed to decode logs export request.")?;
430
431 for resource_logs in request.resource_logs {
432 self.tx
433 .send(OtlpSignal::Logs(resource_logs))
434 .await
435 .error_context("Failed to send resource logs to converter: channel is closed.")?;
436 }
437 Ok(())
438 }
439
440 async fn handle_traces(&self, body: Bytes) -> Result<(), GenericError> {
441 let request =
442 ExportTraceServiceRequest::decode(body).error_context("Failed to decode trace export request.")?;
443
444 for resource_spans in request.resource_spans {
445 self.tx
446 .send(OtlpSignal::Traces(resource_spans))
447 .await
448 .error_context("Failed to send resource spans to converter: channel is closed.")?;
449 }
450 Ok(())
451 }
452}
453
454async fn run_converter(
455 mut receiver: mpsc::Receiver<OtlpSignal>, source_context: SourceContext,
456 origin_tag_resolver: OtlpOriginTagResolver, shutdown_handle: ShutdownHandle,
457 mut metrics_translator: OtlpMetricsTranslator, metrics: Metrics, mut traces_translator: OtlpTracesTranslator,
458) {
459 pin!(shutdown_handle);
460
461 debug!("OTLP resource converter task started.");
462
463 let mut buffer_flush = interval(Duration::from_millis(100));
466 buffer_flush.set_missed_tick_behavior(MissedTickBehavior::Delay);
467
468 let mut metrics_dispatcher: Option<BufferedDispatcher<'_, EventsBuffer>> = None;
469 let mut logs_dispatcher: Option<BufferedDispatcher<'_, EventsBuffer>> = None;
470 let mut traces_dispatcher: Option<BufferedDispatcher<'_, EventsBuffer>> = None;
471
472 loop {
473 select! {
474 Some(otlp_signal) = receiver.recv() => {
475 match otlp_signal {
476 OtlpSignal::Metrics(request) => {
477 let mut detected_languages = FastHashSet::default();
478
479 for resource_metrics in request.resource_metrics {
480 match metrics_translator.translate_metrics(resource_metrics, &metrics) {
481 Ok((events, languages)) => {
482 detected_languages.extend(languages);
483 for event in events {
484 let dispatcher = metrics_dispatcher.get_or_insert_with(|| {
485 source_context
486 .dispatcher()
487 .buffered_named("metrics")
488 .expect("metrics output should exist")
489 });
490 if let Err(e) = dispatcher.push(event).await {
491 error!(error = %e, "Failed to dispatch metric event.");
492 metrics.metrics_errors_dispatch().increment(1);
493 }
494 }
495 }
496 Err(e) => {
497 error!(error = %e, "Failed to handle resource metrics.");
498 }
499 }
500 }
501
502 for event in metrics_translator.emit_usage_beacons(detected_languages) {
504 let dispatcher = metrics_dispatcher.get_or_insert_with(|| {
505 source_context
506 .dispatcher()
507 .buffered_named("metrics")
508 .expect("metrics output should exist")
509 });
510 if let Err(e) = dispatcher.push(event).await {
511 error!(error = %e, "Failed to dispatch usage beacon metric event.");
512 }
513 }
514 }
515 OtlpSignal::Logs(resource_logs) => {
516 let translator = OtlpLogsTranslator::from_resource_logs(resource_logs, &origin_tag_resolver);
517 for log_event in translator {
518 metrics.logs_received().increment(1);
519
520 let dispatcher = logs_dispatcher.get_or_insert_with(|| {
521 source_context
522 .dispatcher()
523 .buffered_named("logs")
524 .expect("logs output should exist")
525 });
526 if let Err(e) = dispatcher.push(log_event).await {
527 error!(error = %e, "Failed to dispatch log event.");
528 }
529 }
530 }
531 OtlpSignal::Traces(resource_spans) => {
532 for trace_event in traces_translator.translate_spans(resource_spans, &metrics) {
533 let dispatcher = traces_dispatcher.get_or_insert_with(|| {
534 source_context
535 .dispatcher()
536 .buffered_named("traces")
537 .expect("traces output should exist")
538 });
539 if let Err(e) = dispatcher.push(trace_event).await {
540 error!(error = %e, "Failed to dispatch trace event.");
541 }
542 }
543 }
544 }
545 },
546 _ = buffer_flush.tick() => {
547 if let Some(dispatcher) = metrics_dispatcher.take() {
548 if let Err(e) = dispatcher.flush().await {
549 error!(error = %e, "Failed to flush metric events.");
550 metrics.metrics_errors_flush().increment(1);
551 }
552 }
553 if let Some(dispatcher) = logs_dispatcher.take() {
554 if let Err(e) = dispatcher.flush().await {
555 error!(error = %e, "Failed to flush log events.");
556 }
557 }
558 if let Some(dispatcher) = traces_dispatcher.take() {
559 if let Err(e) = dispatcher.flush().await {
560 error!(error = %e, "Failed to flush trace events.");
561 }
562 }
563 },
564 _ = &mut shutdown_handle => {
565 debug!("Converter task received shutdown signal.");
566 break;
567 }
568 }
569 }
570
571 if let Some(dispatcher) = metrics_dispatcher.take() {
572 if let Err(e) = dispatcher.flush().await {
573 error!(error = %e, "Failed to flush metric events.");
574 metrics.metrics_errors_flush().increment(1);
575 }
576 }
577 if let Some(dispatcher) = logs_dispatcher.take() {
578 if let Err(e) = dispatcher.flush().await {
579 error!(error = %e, "Failed to flush log events.");
580 }
581 }
582 if let Some(dispatcher) = traces_dispatcher.take() {
583 if let Err(e) = dispatcher.flush().await {
584 error!(error = %e, "Failed to flush trace events.");
585 }
586 }
587
588 debug!("OTLP resource converter task stopped.");
589}
590
591#[cfg(test)]
592mod tests {
593 use std::time::Duration;
594
595 use agent_data_plane_config::domains;
596 use agent_data_plane_config::domains::otlp::{
597 CumulativeMonotonicMode, HistogramMode, InitialCumulativeMonotonicValue, SummaryMode,
598 };
599 use prost::Message;
600 use saluki_core::components::ComponentContext;
601 use saluki_metrics::test::TestRecorder;
602
603 use super::{apply_static_metric_tags, parse_configured_metric_tags, OtlpConfiguration};
604 use crate::common::otlp::{build_metrics, OtlpHandler};
605
606 fn tags(raw: &str) -> Vec<String> {
607 parse_configured_metric_tags(raw)
608 .into_iter()
609 .map(|t| t.to_string())
610 .collect()
611 }
612
613 fn config_with_metrics(metrics: domains::otlp::Metrics) -> OtlpConfiguration {
614 let otlp = domains::otlp::Domain {
615 metrics,
616 ..Default::default()
617 };
618 OtlpConfiguration::from_configuration(&otlp, saluki_env::workload::providers::NoopWorkloadProvider)
619 }
620
621 #[test]
622 fn empty_static_tags_preserve_explicit_otlp_metric_tags() {
623 let mut otlp = domains::otlp::Domain::default();
624 otlp.metrics.tags = "configured:true".to_string();
625
626 apply_static_metric_tags(&mut otlp, Vec::new());
627
628 assert_eq!(otlp.metrics.tags, "configured:true");
629 }
630
631 #[test]
632 fn static_metric_tags_replace_explicit_otlp_metric_tags() {
633 let mut otlp = domains::otlp::Domain::default();
634 otlp.metrics.tags = "configured:true".to_string();
635
636 apply_static_metric_tags(&mut otlp, vec!["provider_kind:autopilot".to_string()]);
637
638 assert_eq!(otlp.metrics.tags, "provider_kind:autopilot");
639 }
640
641 #[test]
642 fn histogram_mode_flows_to_metrics_translator() {
643 for mode in [
644 HistogramMode::NoBuckets,
645 HistogramMode::Counters,
646 HistogramMode::Distributions,
647 ] {
648 let config = config_with_metrics(domains::otlp::Metrics {
649 histogram_mode: mode,
650 ..Default::default()
651 });
652
653 assert_eq!(config.metrics_translator_config().hist_mode, mode);
654 }
655 }
656
657 #[test]
658 fn summary_mode_flows_to_metrics_translator() {
659 for (mode, expected_quantiles) in [(SummaryMode::Gauges, true), (SummaryMode::NoQuantiles, false)] {
661 let config = config_with_metrics(domains::otlp::Metrics {
662 summaries: domains::otlp::Summaries { mode },
663 ..Default::default()
664 });
665
666 assert_eq!(config.metrics_translator_config().quantiles, expected_quantiles);
667 }
668 }
669
670 #[test]
671 fn histogram_aggregation_flows_to_metrics_translator() {
672 for send in [false, true] {
673 let config = config_with_metrics(domains::otlp::Metrics {
674 send_histogram_aggregations: send,
675 ..Default::default()
676 });
677
678 assert_eq!(config.metrics_translator_config().send_histogram_aggregations, send);
679 }
680 }
681
682 #[test]
683 fn nobuckets_with_histogram_aggregations_is_valid() {
684 let config = config_with_metrics(domains::otlp::Metrics {
685 histogram_mode: HistogramMode::NoBuckets,
686 send_histogram_aggregations: true,
687 ..Default::default()
688 });
689
690 assert!(config.metrics_translator_config().validate().is_ok());
691 }
692
693 #[test]
694 fn nobuckets_without_histogram_aggregations_is_invalid() {
695 let config = config_with_metrics(domains::otlp::Metrics {
697 histogram_mode: HistogramMode::NoBuckets,
698 send_histogram_aggregations: false,
699 ..Default::default()
700 });
701
702 assert!(config.metrics_translator_config().validate().is_err());
703 }
704
705 #[test]
706 fn cumulative_monotonic_sum_mode_defaults_to_delta_conversion() {
707 assert_eq!(
708 config_with_metrics(domains::otlp::Metrics::default())
709 .metrics_translator_config()
710 .cumulative_monotonic_mode,
711 CumulativeMonotonicMode::ToDelta
712 );
713 }
714
715 #[test]
716 fn cumulative_monotonic_mode_flows_to_metrics_translator() {
717 for mode in [CumulativeMonotonicMode::ToDelta, CumulativeMonotonicMode::RawValue] {
718 let config = config_with_metrics(domains::otlp::Metrics {
719 sums: domains::otlp::Sums {
720 cumulative_monotonic_mode: mode,
721 ..Default::default()
722 },
723 ..Default::default()
724 });
725
726 assert_eq!(config.metrics_translator_config().cumulative_monotonic_mode, mode);
727 }
728 }
729
730 #[test]
731 fn initial_cumulative_monotonic_value_flows_to_metrics_translator() {
732 for value in [
733 InitialCumulativeMonotonicValue::Auto,
734 InitialCumulativeMonotonicValue::Drop,
735 InitialCumulativeMonotonicValue::Keep,
736 ] {
737 let config = config_with_metrics(domains::otlp::Metrics {
738 sums: domains::otlp::Sums {
739 initial_cumulative_monotonic_value: value,
740 ..Default::default()
741 },
742 ..Default::default()
743 });
744
745 assert_eq!(
746 config.metrics_translator_config().initial_cumulative_monotonic_value,
747 value
748 );
749 }
750 }
751
752 #[test]
753 fn delta_ttl_flows_to_metrics_translator() {
754 let config = config_with_metrics(domains::otlp::Metrics {
756 delta_ttl: Duration::from_secs(7200),
757 ..Default::default()
758 });
759
760 assert_eq!(config.metrics_translator_config().delta_ttl, Duration::from_secs(7200));
761 }
762
763 #[test]
764 fn delta_ttl_defaults_to_3600s() {
765 assert_eq!(
766 config_with_metrics(domains::otlp::Metrics::default())
767 .metrics_translator_config()
768 .delta_ttl,
769 Duration::from_secs(3600)
770 );
771 }
772
773 #[test]
774 fn instrumentation_scope_metadata_as_tags_defaults_to_true() {
775 assert!(
776 config_with_metrics(domains::otlp::Metrics::default())
777 .metrics_translator_config()
778 .instrumentation_scope_metadata_as_tags
779 );
780 }
781
782 #[test]
783 fn instrumentation_scope_metadata_as_tags_flows_to_metrics_translator() {
784 let config = config_with_metrics(domains::otlp::Metrics {
785 instrumentation_scope_metadata_as_tags: false,
786 ..Default::default()
787 });
788
789 assert!(
790 !config
791 .metrics_translator_config()
792 .instrumentation_scope_metadata_as_tags
793 );
794 }
795
796 #[test]
797 fn empty_configuration_yields_no_tags() {
798 assert!(tags("").is_empty());
799 }
800
801 #[test]
802 fn single_tag_is_parsed() {
803 assert_eq!(tags("env:prod"), vec!["env:prod".to_string()]);
804 }
805
806 #[test]
807 fn multiple_tags_are_split_on_comma() {
808 assert_eq!(
809 tags("env:prod,team:core"),
810 vec!["env:prod".to_string(), "team:core".to_string()]
811 );
812 }
813
814 #[test]
815 fn duplicate_tags_are_deduplicated() {
816 assert_eq!(tags("env:prod,env:prod"), vec!["env:prod".to_string()]);
817 }
818
819 #[test]
820 fn whitespace_around_commas_is_stripped() {
821 assert_eq!(
822 tags("env:prod, team:core"),
823 vec!["env:prod".to_string(), "team:core".to_string()]
824 );
825 }
826
827 #[test]
828 fn trailing_and_doubled_commas_produce_no_empty_tags() {
829 assert_eq!(tags("env:prod,"), vec!["env:prod".to_string()]);
830 assert_eq!(
831 tags("env:prod,,team:core"),
832 vec!["env:prod".to_string(), "team:core".to_string()]
833 );
834 }
835
836 #[tokio::test]
841 async fn source_handler_increments_decode_error_on_malformed_body() {
842 let recorder = TestRecorder::default();
843 let _recorder_guard = metrics::set_default_local_recorder(&recorder);
844
845 let metrics = build_metrics(&ComponentContext::test_source("otlp_test"));
846 let (tx, _rx) = tokio::sync::mpsc::channel::<super::OtlpSignal>(1);
847 let handler = super::SourceHandler::new(tx, metrics);
848
849 let result = handler.handle_metrics(bytes::Bytes::from_static(b"not protobuf")).await;
851 assert!(result.is_err());
852
853 let tags: &[(&str, &str)] = &[
854 ("component_id", "otlp_test"),
855 ("component_type", "source"),
856 ("reason", "decode"),
857 ];
858 assert_eq!(recorder.counter(("component_errors_total", tags)), Some(1));
859 }
860
861 #[tokio::test]
862 async fn source_handler_increments_channel_error_on_closed_channel() {
863 let recorder = TestRecorder::default();
864 let _recorder_guard = metrics::set_default_local_recorder(&recorder);
865
866 let metrics = build_metrics(&ComponentContext::test_source("otlp_test"));
867 let (tx, rx) = tokio::sync::mpsc::channel::<super::OtlpSignal>(1);
869 drop(rx);
870 let handler = super::SourceHandler::new(tx, metrics);
871
872 let request = otlp_protos::opentelemetry::proto::collector::metrics::v1::ExportMetricsServiceRequest::default();
874 let body = bytes::Bytes::from(request.encode_to_vec());
875 let result = handler.handle_metrics(body).await;
876 assert!(result.is_err());
877
878 let tags: &[(&str, &str)] = &[
879 ("component_id", "otlp_test"),
880 ("component_type", "source"),
881 ("reason", "channel"),
882 ];
883 assert_eq!(recorder.counter(("component_errors_total", tags)), Some(1));
884
885 let decode_tags: &[(&str, &str)] = &[
887 ("component_id", "otlp_test"),
888 ("component_type", "source"),
889 ("reason", "decode"),
890 ];
891 assert_eq!(recorder.counter(("component_errors_total", decode_tags)), Some(0));
892 }
893}