saluki_components/sources/otlp/
mod.rs

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
55/// Parses `otlp_config.metrics.tags` into a set of tags added to every emitted metric.
56///
57/// The value is a comma-separated list. An empty configuration yields no tags.
58fn 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
69/// Builds component-owned CORS settings from the resolved configuration model.
70fn 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
79/// Builds an `OtlpTlsConfiguration` from resolved TLS settings, if TLS is enabled.
80///
81/// TLS is enabled when both `cert_file` and `key_file` are non-empty. When `ca_file` is also non-empty, the server
82/// requests client certificates and verifies them against the CA certificates in that file, but does not require a
83/// client certificate (optional verification).
84///
85/// # Errors
86///
87/// Returns an error if any TLS field is set without the others required to form a valid TLS configuration. Both
88/// `cert_file` and `key_file` must be provided together to enable TLS, and `ca_file` must not be set without them.
89/// Setting only a subset is treated as a configuration error rather than silently downgrading to plaintext.
90fn 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
118/// Applies resolved static tags using replacement semantics.
119fn 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
125/// Configuration for the OTLP source.
126pub struct OtlpConfiguration {
127    default_hostname: MetaString,
128
129    /// Resolved OTLP domain slice.
130    otlp: domains::otlp::Domain,
131
132    /// Workload provider to utilize for origin detection/enrichment.
133    workload_provider: Arc<dyn WorkloadProvider + Send + Sync>,
134}
135
136impl OtlpConfiguration {
137    /// Creates a new `OtlpConfiguration` from the resolved OTLP configuration and workload provider.
138    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    /// Replaces the configured metric tags when static tags are required.
150    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    /// Sets the default hostname used when OTLP metrics do not carry a resource hostname.
170    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        // Metrics resolve their full OTLP entity list at the resource boundary. Keep the context resolver free of an
215        // origin resolver so it cannot apply the legacy RawOrigin-only lookup a second time. Logs retain that resolver.
216        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, // Telemetry metrics, not DD native metrics.
280    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        // Create the internal channel for decoupling the servers from the converter.
312        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        // Build our gRPC and HTTP servers and spawn them.
324        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        // Run the converter task on the worker pool: translating OTLP resources is highly compute-bound.
348        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        // Wait for the global shutdown signal, then notify converter to shutdown.
372        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
398/// Handler that decodes OTLP bytes and sends resources to the converter.
399struct 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        // Send the entire request as a single channel message so the converter processes it
419        // atomically. This preserves the request boundary for usage beacon emission without
420        // needing control markers or shared state across concurrent requests.
421        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    // Set a buffer flush interval of 100ms, which will ensure we always flush buffered events at least every 100ms if
464    // we're otherwise idle and not receiving packets from the client.
465    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                        // Emit usage beacon metrics for the completed request.
503                        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        // `gauges` emits one gauge per quantile (quantiles on); `noquantiles` omits them.
660        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        // Match the Agent: `nobuckets` without aggregation metrics emits nothing and is invalid.
696        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        // An explicit TTL overrides the 3600s default end-to-end into the translator config.
755        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    // -----------------------------------------------------------------------------------------------
837    // Self-telemetry: server-level decode and channel error counters.
838    // -----------------------------------------------------------------------------------------------
839
840    #[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        // Invalid protobuf bytes cause decode to fail.
850        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        // Create a channel with no receiver, then drop the receiver so the send fails.
868        let (tx, rx) = tokio::sync::mpsc::channel::<super::OtlpSignal>(1);
869        drop(rx);
870        let handler = super::SourceHandler::new(tx, metrics);
871
872        // A valid (empty) request that decodes fine but can't be sent because the channel is closed.
873        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        // The decode counter should not have been incremented.
886        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}