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::ComponentContext;
10use saluki_core::data_model::payload::{GrpcPayload, Payload, PayloadMetadata, PayloadType};
11use saluki_core::topology::OutputDefinition;
12use saluki_error::{ErrorContext as _, GenericError};
13use saluki_io::net::ListenAddress;
14use stringtheory::MetaString;
15use tokio::sync::mpsc;
16use tokio::{pin, select};
17use tracing::{debug, error};
18
19use crate::common::otlp::{
20    build_metrics, CorsConfiguration, Metrics, OtlpHandler, OtlpServerBuilder, OTLP_LOGS_GRPC_SERVICE_PATH,
21    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/// Configuration for the OTLP relay.
35#[derive(Default)]
36pub struct OtlpRelayConfiguration {
37    receiver: domains::otlp::Receiver,
38}
39
40impl OtlpRelayConfiguration {
41    /// Creates relay configuration from typed OTLP receiver settings.
42    pub fn from_configuration(receiver: &domains::otlp::Receiver) -> Self {
43        Self {
44            receiver: receiver.clone(),
45        }
46    }
47
48    fn http_endpoint(&self) -> ListenAddress {
49        let address = format!("{}://{}", self.receiver.http.transport, self.receiver.http.endpoint);
50        ListenAddress::try_from(address).expect("valid HTTP endpoint")
51    }
52
53    fn grpc_endpoint(&self) -> ListenAddress {
54        let address = format!(
55            "{}://{}",
56            self.receiver.grpc.transport.as_str(),
57            self.receiver.grpc.endpoint
58        );
59        ListenAddress::try_from(address).expect("valid gRPC endpoint")
60    }
61
62    fn grpc_max_recv_msg_size_bytes(&self) -> usize {
63        (self.receiver.grpc.max_recv_msg_size_mib * 1024 * 1024) as usize
64    }
65}
66
67impl MemoryBounds for OtlpRelayConfiguration {
68    fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
69}
70
71#[async_trait]
72impl RelayBuilder for OtlpRelayConfiguration {
73    fn outputs(&self) -> &[OutputDefinition<PayloadType>] {
74        static OUTPUTS: LazyLock<Vec<OutputDefinition<PayloadType>>> = LazyLock::new(|| {
75            vec![
76                OutputDefinition::named_output("metrics", PayloadType::Grpc),
77                OutputDefinition::named_output("logs", PayloadType::Grpc),
78                OutputDefinition::named_output("traces", PayloadType::Grpc),
79            ]
80        });
81        &OUTPUTS
82    }
83
84    async fn build(&self, context: ComponentContext) -> Result<Box<dyn Relay + Send>, GenericError> {
85        Ok(Box::new(OtlpRelay {
86            http_endpoint: self.http_endpoint(),
87            grpc_endpoint: self.grpc_endpoint(),
88            grpc_max_recv_msg_size_bytes: self.grpc_max_recv_msg_size_bytes(),
89            cors: cors_configuration(&self.receiver.http.cors),
90            metrics: build_metrics(&context),
91        }))
92    }
93}
94
95/// OTLP relay.
96///
97/// Receives OTLP metrics and logs via gRPC and HTTP, outputting payloads for downstream processing.
98pub struct OtlpRelay {
99    http_endpoint: ListenAddress,
100    grpc_endpoint: ListenAddress,
101    grpc_max_recv_msg_size_bytes: usize,
102    cors: CorsConfiguration,
103    metrics: Metrics,
104}
105
106#[async_trait]
107impl Relay for OtlpRelay {
108    async fn run(self: Box<Self>, mut context: RelayContext) -> Result<(), GenericError> {
109        let Self {
110            http_endpoint,
111            grpc_endpoint,
112            grpc_max_recv_msg_size_bytes,
113            cors,
114            metrics,
115        } = *self;
116
117        let global_shutdown = context.take_shutdown_handle();
118        pin!(global_shutdown);
119
120        let mut health = context.take_health_handle();
121        let memory_limiter = context.topology_context().memory_limiter().clone();
122
123        let (payload_tx, mut payload_rx) = mpsc::channel(1024);
124
125        // Build our gRPC and HTTP servers and spawn them.
126        let handler = RelayHandler::new(payload_tx);
127        let server_builder = OtlpServerBuilder::new(
128            http_endpoint.clone(),
129            grpc_endpoint.clone(),
130            grpc_max_recv_msg_size_bytes,
131        )
132        .with_cors(cors);
133
134        server_builder
135            .build(handler, memory_limiter, metrics, context.spawner())
136            .await?;
137
138        health.mark_ready();
139        debug!(%http_endpoint, %grpc_endpoint, "OTLP relay started.");
140
141        loop {
142            select! {
143                _ = &mut global_shutdown => {
144                    debug!("Received shutdown signal.");
145                    break
146                },
147                Some(otlp_payload) = payload_rx.recv() => {
148                    let output_name = otlp_payload.signal_type.as_str();
149                    let payload = Payload::Grpc(otlp_payload.into_grpc_payload());
150                    if let Err(e) = context.dispatcher().dispatch_named(output_name, payload).await {
151                        error!(error = %e, output = output_name, "Failed to dispatch OTLP payload.");
152                    }
153                },
154                _ = health.live() => continue,
155            }
156        }
157
158        debug!("Stopping OTLP relay...");
159        debug!("OTLP relay stopped.");
160
161        Ok(())
162    }
163}
164
165enum OtlpSignalType {
166    Metrics,
167    Logs,
168    Traces,
169}
170
171impl OtlpSignalType {
172    fn as_str(&self) -> &'static str {
173        match self {
174            OtlpSignalType::Metrics => "metrics",
175            OtlpSignalType::Logs => "logs",
176            OtlpSignalType::Traces => "traces",
177        }
178    }
179}
180
181struct OtlpPayload {
182    signal_type: OtlpSignalType,
183    data: Bytes,
184}
185
186impl OtlpPayload {
187    fn metrics(data: Bytes) -> Self {
188        Self {
189            signal_type: OtlpSignalType::Metrics,
190            data,
191        }
192    }
193
194    fn logs(data: Bytes) -> Self {
195        Self {
196            signal_type: OtlpSignalType::Logs,
197            data,
198        }
199    }
200
201    fn traces(data: Bytes) -> Self {
202        Self {
203            signal_type: OtlpSignalType::Traces,
204            data,
205        }
206    }
207
208    fn into_grpc_payload(self) -> GrpcPayload {
209        let service_path = match self.signal_type {
210            OtlpSignalType::Metrics => OTLP_METRICS_GRPC_SERVICE_PATH,
211            OtlpSignalType::Logs => OTLP_LOGS_GRPC_SERVICE_PATH,
212            OtlpSignalType::Traces => OTLP_TRACES_GRPC_SERVICE_PATH,
213        };
214
215        // We provide an empty endpoint because we want any consuming components to fill that in for themselves.
216        GrpcPayload::new(
217            PayloadMetadata::from_event_count(1),
218            MetaString::empty(),
219            service_path,
220            FrozenChunkedBytesBuffer::from(self.data),
221        )
222    }
223}
224
225/// Handler that forwards OTLP payloads to a channel for downstream processing.
226struct RelayHandler {
227    tx: mpsc::Sender<OtlpPayload>,
228}
229
230impl RelayHandler {
231    fn new(tx: mpsc::Sender<OtlpPayload>) -> Self {
232        Self { tx }
233    }
234}
235
236#[async_trait]
237impl OtlpHandler for RelayHandler {
238    async fn handle_metrics(&self, body: Bytes) -> Result<(), GenericError> {
239        self.tx
240            .send(OtlpPayload::metrics(body))
241            .await
242            .error_context("Failed to send OTLP metrics payload to relay dispatcher: channel closed.")
243    }
244
245    async fn handle_logs(&self, body: Bytes) -> Result<(), GenericError> {
246        self.tx
247            .send(OtlpPayload::logs(body))
248            .await
249            .error_context("Failed to send OTLP logs payload to relay dispatcher: channel closed.")
250    }
251
252    async fn handle_traces(&self, body: Bytes) -> Result<(), GenericError> {
253        self.tx
254            .send(OtlpPayload::traces(body))
255            .await
256            .error_context("Failed to send OTLP traces payload to relay dispatcher: channel closed.")
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use agent_data_plane_config::domains;
263    use agent_data_plane_config::domains::otlp::GrpcTransport;
264
265    use super::OtlpRelayConfiguration;
266
267    fn relay(receiver: domains::otlp::Receiver) -> OtlpRelayConfiguration {
268        OtlpRelayConfiguration::from_configuration(&receiver)
269    }
270
271    #[test]
272    fn endpoints_combine_transport_and_address() {
273        let config = relay(domains::otlp::Receiver {
274            grpc: domains::otlp::GrpcReceiver {
275                endpoint: "0.0.0.0:4317".to_string(),
276                transport: GrpcTransport::Tcp,
277                max_recv_msg_size_mib: 4,
278            },
279            http: domains::otlp::HttpReceiver {
280                endpoint: "0.0.0.0:4318".to_string(),
281                transport: "tcp".to_string(),
282                cors: Default::default(),
283            },
284            ..Default::default()
285        });
286
287        assert_eq!(config.grpc_endpoint().to_string(), "tcp://0.0.0.0:4317");
288        assert_eq!(config.http_endpoint().to_string(), "tcp://0.0.0.0:4318");
289    }
290
291    #[test]
292    fn grpc_max_recv_msg_size_converts_mib_to_bytes() {
293        let config = relay(domains::otlp::Receiver {
294            grpc: domains::otlp::GrpcReceiver {
295                max_recv_msg_size_mib: 8,
296                ..Default::default()
297            },
298            ..Default::default()
299        });
300
301        assert_eq!(config.grpc_max_recv_msg_size_bytes(), 8 * 1024 * 1024);
302    }
303}