saluki_components/relays/otlp/
mod.rs1use 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
24fn 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
34fn 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#[derive(Default)]
75pub struct OtlpRelayConfiguration {
76 receiver: domains::otlp::Receiver,
77}
78
79impl OtlpRelayConfiguration {
80 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
144pub 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 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 GrpcPayload::new(
285 PayloadMetadata::from_event_count(1),
286 MetaString::empty(),
287 service_path,
288 FrozenChunkedBytesBuffer::from(self.data),
289 )
290 }
291}
292
293struct 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 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 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}