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::sync::mpsc;
16use tokio::{pin, select};
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 global_shutdown = context.take_shutdown_handle();
175        pin!(global_shutdown);
176
177        let mut health = context.take_health_handle();
178        let memory_limiter = context.topology_context().memory_limiter().clone();
179
180        let (payload_tx, mut payload_rx) = mpsc::channel(1024);
181
182        // Build our gRPC and HTTP servers and spawn them.
183        let handler = RelayHandler::new(payload_tx);
184        let mut server_config = OtlpServerConfiguration::new(
185            http_endpoint.clone(),
186            grpc_endpoint.clone(),
187            grpc_max_recv_msg_size_bytes,
188        )
189        .with_cors(cors)
190        .with_grpc_http2_config(grpc_http2_config)
191        .with_http_max_request_body_size(http_max_request_body_size);
192
193        if let Some(tls) = http_tls_config {
194            server_config = server_config.with_http_tls(tls);
195        }
196        if let Some(tls) = grpc_tls_config {
197            server_config = server_config.with_grpc_tls(tls);
198        }
199
200        server_config
201            .build(
202                handler,
203                memory_limiter,
204                metrics,
205                context.topology_context().global_thread_pool(),
206            )
207            .await?;
208
209        health.mark_ready();
210        debug!(%http_endpoint, %grpc_endpoint, "OTLP relay started.");
211
212        loop {
213            select! {
214                _ = &mut global_shutdown => {
215                    debug!("Received shutdown signal.");
216                    break
217                },
218                Some(otlp_payload) = payload_rx.recv() => {
219                    let output_name = otlp_payload.signal_type.as_str();
220                    let payload = Payload::Grpc(otlp_payload.into_grpc_payload());
221                    if let Err(e) = context.dispatcher().dispatch_named(output_name, payload).await {
222                        error!(error = %e, output = output_name, "Failed to dispatch OTLP payload.");
223                    }
224                },
225                _ = health.live() => continue,
226            }
227        }
228
229        debug!("Stopping OTLP relay...");
230        debug!("OTLP relay stopped.");
231
232        Ok(())
233    }
234}
235
236enum OtlpSignalType {
237    Metrics,
238    Logs,
239    Traces,
240}
241
242impl OtlpSignalType {
243    fn as_str(&self) -> &'static str {
244        match self {
245            OtlpSignalType::Metrics => "metrics",
246            OtlpSignalType::Logs => "logs",
247            OtlpSignalType::Traces => "traces",
248        }
249    }
250}
251
252struct OtlpPayload {
253    signal_type: OtlpSignalType,
254    data: Bytes,
255}
256
257impl OtlpPayload {
258    fn metrics(data: Bytes) -> Self {
259        Self {
260            signal_type: OtlpSignalType::Metrics,
261            data,
262        }
263    }
264
265    fn logs(data: Bytes) -> Self {
266        Self {
267            signal_type: OtlpSignalType::Logs,
268            data,
269        }
270    }
271
272    fn traces(data: Bytes) -> Self {
273        Self {
274            signal_type: OtlpSignalType::Traces,
275            data,
276        }
277    }
278
279    fn into_grpc_payload(self) -> GrpcPayload {
280        let service_path = match self.signal_type {
281            OtlpSignalType::Metrics => OTLP_METRICS_GRPC_SERVICE_PATH,
282            OtlpSignalType::Logs => OTLP_LOGS_GRPC_SERVICE_PATH,
283            OtlpSignalType::Traces => OTLP_TRACES_GRPC_SERVICE_PATH,
284        };
285
286        // We provide an empty endpoint because we want any consuming components to fill that in for themselves.
287        GrpcPayload::new(
288            PayloadMetadata::from_event_count(1),
289            MetaString::empty(),
290            service_path,
291            FrozenChunkedBytesBuffer::from(self.data),
292        )
293    }
294}
295
296/// Handler that forwards OTLP payloads to a channel for downstream processing.
297struct RelayHandler {
298    tx: mpsc::Sender<OtlpPayload>,
299}
300
301impl RelayHandler {
302    fn new(tx: mpsc::Sender<OtlpPayload>) -> Self {
303        Self { tx }
304    }
305}
306
307#[async_trait]
308impl OtlpHandler for RelayHandler {
309    async fn handle_metrics(&self, body: Bytes) -> Result<(), GenericError> {
310        self.tx
311            .send(OtlpPayload::metrics(body))
312            .await
313            .error_context("Failed to send OTLP metrics payload to relay dispatcher: channel closed.")
314    }
315
316    async fn handle_logs(&self, body: Bytes) -> Result<(), GenericError> {
317        self.tx
318            .send(OtlpPayload::logs(body))
319            .await
320            .error_context("Failed to send OTLP logs payload to relay dispatcher: channel closed.")
321    }
322
323    async fn handle_traces(&self, body: Bytes) -> Result<(), GenericError> {
324        self.tx
325            .send(OtlpPayload::traces(body))
326            .await
327            .error_context("Failed to send OTLP traces payload to relay dispatcher: channel closed.")
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use agent_data_plane_config::domains;
334    use agent_data_plane_config::domains::otlp::GrpcTransport;
335
336    use super::OtlpRelayConfiguration;
337
338    fn relay(receiver: domains::otlp::Receiver) -> OtlpRelayConfiguration {
339        OtlpRelayConfiguration::from_configuration(&receiver)
340    }
341
342    #[test]
343    fn endpoints_combine_transport_and_address() {
344        let config = relay(domains::otlp::Receiver {
345            grpc: domains::otlp::GrpcReceiver {
346                endpoint: "0.0.0.0:4317".to_string(),
347                transport: GrpcTransport::Tcp,
348                max_recv_msg_size_mib: 4,
349                ..Default::default()
350            },
351            http: domains::otlp::HttpReceiver {
352                endpoint: "0.0.0.0:4318".to_string(),
353                transport: "tcp".to_string(),
354                cors: Default::default(),
355                ..Default::default()
356            },
357            ..Default::default()
358        });
359
360        assert_eq!(config.grpc_endpoint().to_string(), "tcp://0.0.0.0:4317");
361        assert_eq!(config.http_endpoint().to_string(), "tcp://0.0.0.0:4318");
362    }
363
364    #[test]
365    fn grpc_max_recv_msg_size_converts_mib_to_bytes() {
366        let config = relay(domains::otlp::Receiver {
367            grpc: domains::otlp::GrpcReceiver {
368                max_recv_msg_size_mib: 8,
369                ..Default::default()
370            },
371            ..Default::default()
372        });
373
374        assert_eq!(config.grpc_max_recv_msg_size_bytes(), 8 * 1024 * 1024);
375    }
376}