Skip to main content

saluki_components/relays/otlp/
mod.rs

1use std::sync::LazyLock;
2
3use agent_data_plane_config::domains;
4use async_trait::async_trait;
5use axum::body::Bytes;
6use saluki_common::buf::FrozenChunkedBytesBuffer;
7use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
8use saluki_core::components::relays::{Relay, RelayBuilder, RelayContext};
9use saluki_core::components::BuildContext;
10use saluki_core::data_model::payload::{GrpcPayload, Payload, PayloadMetadata, PayloadType};
11use saluki_core::topology::OutputDefinition;
12use saluki_error::{generic_error, ErrorContext as _, GenericError};
13use saluki_io::net::{server::http::Http2Config, ListenAddress};
14use stringtheory::MetaString;
15use tokio::select;
16use tokio::sync::mpsc;
17use tracing::{debug, error};
18
19use crate::common::otlp::{
20    build_metrics, resolve_grpc_http2_config, CorsConfiguration, Metrics, OtlpHandler, OtlpServerConfiguration,
21    OtlpTlsConfiguration, OTLP_LOGS_GRPC_SERVICE_PATH, OTLP_METRICS_GRPC_SERVICE_PATH, OTLP_TRACES_GRPC_SERVICE_PATH,
22};
23
24/// Builds component-owned CORS settings from the resolved configuration model.
25fn cors_configuration(cors: &domains::otlp::Cors) -> CorsConfiguration {
26    CorsConfiguration {
27        allowed_origins: cors.allowed_origins.clone(),
28        allowed_headers: cors.allowed_headers.clone(),
29        exposed_headers: cors.exposed_headers.clone(),
30        max_age: cors.max_age,
31    }
32}
33
34/// Builds an `OtlpTlsConfiguration` from resolved TLS settings, if TLS is enabled.
35///
36/// TLS is enabled when both `cert_file` and `key_file` are non-empty. When `ca_file` is also non-empty, the server
37/// requests client certificates and verifies them against the CA certificates in that file, but does not require a
38/// client certificate (optional verification).
39///
40/// # Errors
41///
42/// Returns an error if any TLS field is set without the others required to form a valid TLS configuration. Both
43/// `cert_file` and `key_file` must be provided together to enable TLS, and `ca_file` must not be set without them.
44/// Setting only a subset is treated as a configuration error rather than silently downgrading to plaintext.
45fn build_tls_config(tls: &domains::otlp::Tls) -> Result<Option<OtlpTlsConfiguration>, GenericError> {
46    match (tls.cert_file.is_empty(), tls.key_file.is_empty()) {
47        (true, true) => {
48            if !tls.ca_file.is_empty() {
49                Err(generic_error!(
50                    "OTLP receiver TLS `ca_file` is set but `cert_file` and `key_file` are empty. All three must \
51                     be provided together, or `ca_file` must be omitted when TLS is disabled."
52                ))
53            } else {
54                Ok(None)
55            }
56        }
57        (false, false) => {
58            let mut config = OtlpTlsConfiguration::new(tls.cert_file.clone().into(), tls.key_file.clone().into());
59            if !tls.ca_file.is_empty() {
60                config = config.with_ca_file(tls.ca_file.clone().into());
61            }
62            Ok(Some(config))
63        }
64        (true, false) => Err(generic_error!(
65            "OTLP receiver TLS `key_file` is set but `cert_file` is empty. Both must be provided to enable TLS."
66        )),
67        (false, true) => Err(generic_error!(
68            "OTLP receiver TLS `cert_file` is set but `key_file` is empty. Both must be provided to enable TLS."
69        )),
70    }
71}
72
73/// Configuration for the OTLP relay.
74#[derive(Default)]
75pub struct OtlpRelayConfiguration {
76    receiver: domains::otlp::Receiver,
77}
78
79impl OtlpRelayConfiguration {
80    /// Creates relay configuration from typed OTLP receiver settings.
81    pub fn from_configuration(receiver: &domains::otlp::Receiver) -> Self {
82        Self {
83            receiver: receiver.clone(),
84        }
85    }
86
87    fn http_endpoint(&self) -> ListenAddress {
88        let address = format!("{}://{}", self.receiver.http.transport, self.receiver.http.endpoint);
89        ListenAddress::try_from(address).expect("valid HTTP endpoint")
90    }
91
92    fn grpc_endpoint(&self) -> ListenAddress {
93        let address = format!(
94            "{}://{}",
95            self.receiver.grpc.transport.as_str(),
96            self.receiver.grpc.endpoint
97        );
98        ListenAddress::try_from(address).expect("valid gRPC endpoint")
99    }
100
101    fn grpc_max_recv_msg_size_bytes(&self) -> usize {
102        (self.receiver.grpc.max_recv_msg_size_mib * 1024 * 1024) as usize
103    }
104}
105
106impl MemoryBounds for OtlpRelayConfiguration {
107    fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
108}
109
110#[async_trait]
111impl RelayBuilder for OtlpRelayConfiguration {
112    fn outputs(&self) -> &[OutputDefinition<PayloadType>] {
113        static OUTPUTS: LazyLock<Vec<OutputDefinition<PayloadType>>> = LazyLock::new(|| {
114            vec![
115                OutputDefinition::named_output("metrics", PayloadType::Grpc),
116                OutputDefinition::named_output("logs", PayloadType::Grpc),
117                OutputDefinition::named_output("traces", PayloadType::Grpc),
118            ]
119        });
120        &OUTPUTS
121    }
122
123    async fn build(&self, context: BuildContext) -> Result<Box<dyn Relay + Send>, GenericError> {
124        let http_tls_config = build_tls_config(&self.receiver.http.tls)?;
125        let grpc_tls_config = build_tls_config(&self.receiver.grpc.tls)?;
126
127        Ok(Box::new(OtlpRelay {
128            http_endpoint: self.http_endpoint(),
129            grpc_endpoint: self.grpc_endpoint(),
130            grpc_max_recv_msg_size_bytes: self.grpc_max_recv_msg_size_bytes(),
131            grpc_http2_config: resolve_grpc_http2_config(
132                &self.receiver.grpc.keepalive,
133                self.receiver.grpc.max_concurrent_streams,
134            ),
135            http_max_request_body_size: self.receiver.http.max_request_body_size,
136            cors: cors_configuration(&self.receiver.http.cors),
137            http_tls_config,
138            grpc_tls_config,
139            metrics: build_metrics(context.component_context()),
140        }))
141    }
142}
143
144/// OTLP relay.
145///
146/// Receives OTLP metrics and logs via gRPC and HTTP, outputting payloads for downstream processing.
147pub struct OtlpRelay {
148    http_endpoint: ListenAddress,
149    grpc_endpoint: ListenAddress,
150    grpc_max_recv_msg_size_bytes: usize,
151    grpc_http2_config: Http2Config,
152    http_max_request_body_size: u64,
153    cors: CorsConfiguration,
154    http_tls_config: Option<OtlpTlsConfiguration>,
155    grpc_tls_config: Option<OtlpTlsConfiguration>,
156    metrics: Metrics,
157}
158
159#[async_trait]
160impl Relay for OtlpRelay {
161    async fn run(self: Box<Self>, mut context: RelayContext) -> Result<(), GenericError> {
162        let Self {
163            http_endpoint,
164            grpc_endpoint,
165            grpc_max_recv_msg_size_bytes,
166            grpc_http2_config,
167            http_max_request_body_size,
168            cors,
169            http_tls_config,
170            grpc_tls_config,
171            metrics,
172        } = *self;
173
174        let mut health = context.take_health_handle();
175        let memory_limiter = context.topology_context().memory_limiter().clone();
176
177        let (payload_tx, mut payload_rx) = mpsc::channel(1024);
178
179        // Build our gRPC and HTTP servers and spawn them. They stop when this relay is told to stop, and the channel
180        // closes once both exit. Thus, the loop below dispatches every payload that they accepted before it ends.
181        let handler = RelayHandler::new(payload_tx);
182        let mut server_config = OtlpServerConfiguration::new(
183            http_endpoint.clone(),
184            grpc_endpoint.clone(),
185            grpc_max_recv_msg_size_bytes,
186        )
187        .with_cors(cors)
188        .with_grpc_http2_config(grpc_http2_config)
189        .with_http_max_request_body_size(http_max_request_body_size);
190
191        if let Some(tls) = http_tls_config {
192            server_config = server_config.with_http_tls(tls);
193        }
194        if let Some(tls) = grpc_tls_config {
195            server_config = server_config.with_grpc_tls(tls);
196        }
197
198        server_config
199            .build(
200                handler,
201                memory_limiter,
202                metrics,
203                context.topology_context().global_thread_pool(),
204            )
205            .await?;
206
207        health.mark_ready();
208        debug!(%http_endpoint, %grpc_endpoint, "OTLP relay started.");
209
210        loop {
211            select! {
212                maybe_payload = payload_rx.recv() => {
213                    let Some(otlp_payload) = maybe_payload else {
214                        break;
215                    };
216
217                    let output_name = otlp_payload.signal_type.as_str();
218                    let payload = Payload::Grpc(otlp_payload.into_grpc_payload());
219                    if let Err(e) = context.dispatcher().dispatch_named(output_name, payload).await {
220                        error!(error = %e, output = output_name, "Failed to dispatch OTLP payload.");
221                    }
222                },
223                _ = health.live() => continue,
224            }
225        }
226
227        debug!("OTLP relay stopped.");
228
229        Ok(())
230    }
231}
232
233enum OtlpSignalType {
234    Metrics,
235    Logs,
236    Traces,
237}
238
239impl OtlpSignalType {
240    fn as_str(&self) -> &'static str {
241        match self {
242            OtlpSignalType::Metrics => "metrics",
243            OtlpSignalType::Logs => "logs",
244            OtlpSignalType::Traces => "traces",
245        }
246    }
247}
248
249struct OtlpPayload {
250    signal_type: OtlpSignalType,
251    data: Bytes,
252}
253
254impl OtlpPayload {
255    fn metrics(data: Bytes) -> Self {
256        Self {
257            signal_type: OtlpSignalType::Metrics,
258            data,
259        }
260    }
261
262    fn logs(data: Bytes) -> Self {
263        Self {
264            signal_type: OtlpSignalType::Logs,
265            data,
266        }
267    }
268
269    fn traces(data: Bytes) -> Self {
270        Self {
271            signal_type: OtlpSignalType::Traces,
272            data,
273        }
274    }
275
276    fn into_grpc_payload(self) -> GrpcPayload {
277        let service_path = match self.signal_type {
278            OtlpSignalType::Metrics => OTLP_METRICS_GRPC_SERVICE_PATH,
279            OtlpSignalType::Logs => OTLP_LOGS_GRPC_SERVICE_PATH,
280            OtlpSignalType::Traces => OTLP_TRACES_GRPC_SERVICE_PATH,
281        };
282
283        // We provide an empty endpoint because we want any consuming components to fill that in for themselves.
284        GrpcPayload::new(
285            PayloadMetadata::from_event_count(1),
286            MetaString::empty(),
287            service_path,
288            FrozenChunkedBytesBuffer::from(self.data),
289        )
290    }
291}
292
293/// Handler that forwards OTLP payloads to a channel for downstream processing.
294struct RelayHandler {
295    tx: mpsc::Sender<OtlpPayload>,
296}
297
298impl RelayHandler {
299    fn new(tx: mpsc::Sender<OtlpPayload>) -> Self {
300        Self { tx }
301    }
302}
303
304#[async_trait]
305impl OtlpHandler for RelayHandler {
306    async fn handle_metrics(&self, body: Bytes) -> Result<(), GenericError> {
307        self.tx
308            .send(OtlpPayload::metrics(body))
309            .await
310            .error_context("Failed to send OTLP metrics payload to relay dispatcher: channel closed.")
311    }
312
313    async fn handle_logs(&self, body: Bytes) -> Result<(), GenericError> {
314        self.tx
315            .send(OtlpPayload::logs(body))
316            .await
317            .error_context("Failed to send OTLP logs payload to relay dispatcher: channel closed.")
318    }
319
320    async fn handle_traces(&self, body: Bytes) -> Result<(), GenericError> {
321        self.tx
322            .send(OtlpPayload::traces(body))
323            .await
324            .error_context("Failed to send OTLP traces payload to relay dispatcher: channel closed.")
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use std::num::NonZeroUsize;
331    use std::time::Duration;
332
333    use agent_data_plane_config::domains;
334    use agent_data_plane_config::domains::otlp::GrpcTransport;
335    use otlp_protos::opentelemetry::proto::collector::logs::v1::{
336        logs_service_client::LogsServiceClient, ExportLogsServiceRequest,
337    };
338    use otlp_protos::opentelemetry::proto::logs::v1::ResourceLogs;
339    use saluki_core::components::test_util::TestComponentDriver;
340    use saluki_core::runtime::state::{DataspaceUpdate, IdentifierFilter};
341    use saluki_io::net::BoundListenAddress;
342
343    use super::OtlpRelayConfiguration;
344
345    fn relay(receiver: domains::otlp::Receiver) -> OtlpRelayConfiguration {
346        OtlpRelayConfiguration::from_configuration(&receiver)
347    }
348
349    #[test]
350    fn endpoints_combine_transport_and_address() {
351        let config = relay(domains::otlp::Receiver {
352            grpc: domains::otlp::GrpcReceiver {
353                endpoint: "0.0.0.0:4317".to_string(),
354                transport: GrpcTransport::Tcp,
355                max_recv_msg_size_mib: 4,
356                ..Default::default()
357            },
358            http: domains::otlp::HttpReceiver {
359                endpoint: "0.0.0.0:4318".to_string(),
360                transport: "tcp".to_string(),
361                cors: Default::default(),
362                ..Default::default()
363            },
364            ..Default::default()
365        });
366
367        assert_eq!(config.grpc_endpoint().to_string(), "tcp://0.0.0.0:4317");
368        assert_eq!(config.http_endpoint().to_string(), "tcp://0.0.0.0:4318");
369    }
370
371    #[test]
372    fn grpc_max_recv_msg_size_converts_mib_to_bytes() {
373        let config = relay(domains::otlp::Receiver {
374            grpc: domains::otlp::GrpcReceiver {
375                max_recv_msg_size_mib: 8,
376                ..Default::default()
377            },
378            ..Default::default()
379        });
380
381        assert_eq!(config.grpc_max_recv_msg_size_bytes(), 8 * 1024 * 1024);
382    }
383
384    #[tokio::test]
385    async fn accepted_payloads_are_dispatched_before_the_relay_stops() {
386        const EXPORTS: usize = 16;
387
388        let config = relay(domains::otlp::Receiver {
389            grpc: domains::otlp::GrpcReceiver {
390                endpoint: "127.0.0.1:0".to_string(),
391                max_recv_msg_size_mib: 4,
392                // The configuration translator resolves a zero keepalive to these defaults.
393                keepalive: domains::otlp::KeepaliveServerParameters {
394                    time: Duration::from_secs(2 * 60 * 60),
395                    timeout: Duration::from_secs(20),
396                    ..Default::default()
397                },
398                ..Default::default()
399            },
400            http: domains::otlp::HttpReceiver {
401                endpoint: "127.0.0.1:0".to_string(),
402                ..Default::default()
403            },
404            ..Default::default()
405        });
406
407        // The output holds one payload, so the relay still has the others queued when it is told to stop.
408        let (mut control, mut outputs) = TestComponentDriver::options()
409            .with_interconnect_capacity(NonZeroUsize::MIN)
410            .relay(config)
411            .await
412            .expect("relay should build");
413        let mut logs = outputs.take_named_output("logs");
414
415        let mut bound = control
416            .supervisor()
417            .dataspace()
418            .subscribe::<BoundListenAddress>(IdentifierFilter::exact("http-server-otlp-grpc"));
419        let address = match bound.recv().await {
420            Some(DataspaceUpdate::Asserted(_, BoundListenAddress::Tcp(address))) => address,
421            update => panic!("expected the gRPC server to bind, got {update:?}"),
422        };
423        let channel = tonic::transport::Endpoint::from_shared(format!("http://{address}"))
424            .expect("endpoint should parse")
425            .connect()
426            .await
427            .expect("should connect to the gRPC server");
428        let mut client = LogsServiceClient::new(channel);
429        for _ in 0..EXPORTS {
430            let request = ExportLogsServiceRequest {
431                resource_logs: vec![ResourceLogs::default()],
432            };
433            client.export(request).await.expect("export should succeed");
434        }
435
436        control.signal_shutdown();
437        let (result, payloads) = tokio::join!(control.wait(), logs.collect());
438        result.expect("relay should stop cleanly");
439        assert_eq!(payloads.len(), EXPORTS, "every accepted export must be dispatched");
440    }
441}