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