Skip to main content

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_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
56/// Parses `otlp_config.metrics.tags` into a set of tags added to every emitted metric.
57///
58/// The value is a comma-separated list. An empty configuration yields no tags.
59fn 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
70/// Builds component-owned CORS settings from the resolved configuration model.
71fn 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
80/// Builds an `OtlpTlsConfiguration` from resolved TLS settings, if TLS is enabled.
81///
82/// TLS is enabled when both `cert_file` and `key_file` are non-empty. When `ca_file` is also non-empty, the server
83/// requests client certificates and verifies them against the CA certificates in that file, but does not require a
84/// client certificate (optional verification).
85///
86/// # Errors
87///
88/// Returns an error if any TLS field is set without the others required to form a valid TLS configuration. Both
89/// `cert_file` and `key_file` must be provided together to enable TLS, and `ca_file` must not be set without them.
90/// Setting only a subset is treated as a configuration error rather than silently downgrading to plaintext.
91fn 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
119/// Applies resolved static tags using replacement semantics.
120fn 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
126/// Configuration for the OTLP source.
127pub struct OtlpConfiguration {
128    default_hostname: MetaString,
129
130    /// Resolved OTLP domain slice.
131    otlp: domains::otlp::Domain,
132
133    /// Maximum length of a span's resource name, in bytes.
134    ///
135    /// Defaults to `usize::MAX`, meaning resource names are not truncated.
136    max_resource_len: usize,
137
138    /// Supplies attribute mappings for trace translation. Defaults to embedded mappings without remote updates.
139    semantic_registry: SemanticRegistryProvider,
140
141    /// Workload provider to utilize for origin detection/enrichment.
142    workload_provider: Arc<dyn WorkloadProvider + Send + Sync>,
143}
144
145impl OtlpConfiguration {
146    /// Creates a new `OtlpConfiguration` from the resolved OTLP configuration and workload provider.
147    ///
148    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    /// Replaces the configured metric tags when static tags are required.
162    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    /// Sets the default hostname used when OTLP metrics do not carry a resource hostname.
190    pub fn with_default_hostname(mut self, hostname: impl Into<MetaString>) -> Self {
191        self.default_hostname = hostname.into();
192        self
193    }
194
195    /// Sets the maximum length of a span's resource name, in bytes.
196    ///
197    /// Resource names longer than this limit are truncated to the limit, on a UTF-8 boundary. Defaults to `usize::MAX`
198    /// (unbounded), which matches the source's behavior before resource name truncation was introduced.
199    ///
200    /// If set to `0`, every resource name is truncated to an empty string. Pass through the configured value
201    /// unmodified, including `0`, so the configured behavior is always honored.
202    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    /// Sets the provider of attribute mappings used by registry-based trace lookups.
208    ///
209    /// Pass a subscribed provider to receive remote mapping updates. The translator reads one registry snapshot per
210    /// `ResourceSpans` batch, so an update cannot change mappings midway through that batch. Metrics and logs are
211    /// unaffected.
212    ///
213    /// Defaults to embedded mappings without remote updates.
214    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        // Metrics resolve their full OTLP entity list at the resource boundary. Keep the context resolver free of an
259        // origin resolver so it cannot apply the legacy RawOrigin-only lookup a second time. Logs retain that resolver.
260        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, // Telemetry metrics, not DD native metrics.
324    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        // Create the internal channel for decoupling the servers from the converter.
356        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        // Build our gRPC and HTTP servers and spawn them.
368        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        // Run the converter task on the worker pool: translating OTLP resources is highly compute-bound.
392        //
393        // The converter stops once the servers exit and its channel closes, which can take longer than the default
394        // timeout of a closure-based child. Thus, it has no timeout of its own, and is halted with the source if the
395        // component's shutdown timeout expires.
396        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        // When this body returns, the scope stops the servers, and the converter drains what they accepted.
417        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
439/// Handler that decodes OTLP bytes and sends resources to the converter.
440struct 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        // Send the entire request as a single channel message so the converter processes it
460        // atomically. This preserves the request boundary for usage beacon emission without
461        // needing control markers or shared state across concurrent requests.
462        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    // Set a buffer flush interval of 100ms, which will ensure we always flush buffered events at least every 100ms if
503    // we're otherwise idle and not receiving packets from the client.
504    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                        // Emit usage beacon metrics for the completed request.
547                        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        // `gauges` emits one gauge per quantile (quantiles on); `noquantiles` omits them.
723        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        // Match the Agent: `nobuckets` without aggregation metrics emits nothing and is invalid.
759        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        // An explicit TTL overrides the 3600s default end-to-end into the translator config.
818        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    // -----------------------------------------------------------------------------------------------
900    // Self-telemetry: server-level decode and channel error counters.
901    // -----------------------------------------------------------------------------------------------
902
903    #[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        // Invalid protobuf bytes cause decode to fail.
913        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        // Create a channel with no receiver, then drop the receiver so the send fails.
931        let (tx, rx) = tokio::sync::mpsc::channel::<super::OtlpSignal>(1);
932        drop(rx);
933        let handler = super::SourceHandler::new(tx, metrics);
934
935        // A valid (empty) request that decodes fine but can't be sent because the channel is closed.
936        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        // The decode counter should not have been incremented.
949        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        // Many more records than two event buffers hold, so that the converter blocks on the output with most of the
960        // requests still queued.
961        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        // The configuration translator resolves a zero keepalive to these defaults.
969        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}