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::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
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 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 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 GrpcPayload::new(
288 PayloadMetadata::from_event_count(1),
289 MetaString::empty(),
290 service_path,
291 FrozenChunkedBytesBuffer::from(self.data),
292 )
293 }
294}
295
296struct 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}