saluki_components/forwarders/otlp/
mod.rs1use std::time::Duration;
2
3use agent_data_plane_config::domains;
4use async_trait::async_trait;
5use otlp_protos::opentelemetry::proto::collector::logs::v1::logs_service_client::LogsServiceClient;
6use otlp_protos::opentelemetry::proto::collector::logs::v1::ExportLogsServiceRequest;
7use otlp_protos::opentelemetry::proto::collector::metrics::v1::metrics_service_client::MetricsServiceClient;
8use otlp_protos::opentelemetry::proto::collector::metrics::v1::ExportMetricsServiceRequest;
9use otlp_protos::opentelemetry::proto::collector::trace::v1::{
10 trace_service_client::TraceServiceClient, ExportTraceServiceRequest,
11};
12use prost::Message;
13use saluki_common::buf::FrozenChunkedBytesBuffer;
14use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
15use saluki_core::data_model::payload::Payload;
16use saluki_core::{
17 components::{forwarders::*, ComponentContext},
18 data_model::payload::PayloadType,
19};
20use saluki_error::ErrorContext as _;
21use saluki_error::GenericError;
22use stringtheory::MetaString;
23use tokio::select;
24use tonic::transport::Channel;
25use tracing::{debug, error, warn};
26
27use crate::common::otlp::{OTLP_LOGS_GRPC_SERVICE_PATH, OTLP_METRICS_GRPC_SERVICE_PATH, OTLP_TRACES_GRPC_SERVICE_PATH};
28
29#[derive(Clone)]
33pub struct OtlpForwarderConfiguration {
34 core_agent_otlp_grpc_endpoint: String,
35 core_agent_traces_internal_port: u16,
36}
37
38impl OtlpForwarderConfiguration {
39 pub fn from_configuration(traces: &domains::otlp::Traces, core_agent_otlp_grpc_endpoint: String) -> Self {
41 Self {
42 core_agent_otlp_grpc_endpoint,
43 core_agent_traces_internal_port: traces.internal_port,
44 }
45 }
46}
47
48#[async_trait]
49impl ForwarderBuilder for OtlpForwarderConfiguration {
50 fn input_payload_type(&self) -> PayloadType {
51 PayloadType::Grpc
52 }
53
54 async fn build(&self, _context: ComponentContext) -> Result<Box<dyn Forwarder + Send>, GenericError> {
55 let trace_agent_endpoint = format!("http://localhost:{}", self.core_agent_traces_internal_port);
56 let trace_agent_channel = Channel::from_shared(trace_agent_endpoint.clone())
57 .error_context("Failed to construct gRPC channel due to an invalid endpoint.")?
58 .connect_lazy();
59 let trace_agent_client = TraceServiceClient::new(trace_agent_channel);
60
61 let normalized_endpoint = normalize_endpoint(&self.core_agent_otlp_grpc_endpoint);
62 let core_agent_grpc_channel = Channel::from_shared(normalized_endpoint)
63 .error_context("Failed to construct gRPC channel due to an invalid endpoint.")?
64 .connect_timeout(Duration::from_secs(5))
65 .connect_lazy();
66
67 let core_agent_metrics_client = MetricsServiceClient::new(core_agent_grpc_channel.clone());
68 let core_agent_logs_client = LogsServiceClient::new(core_agent_grpc_channel);
69
70 Ok(Box::new(OtlpForwarder {
71 trace_agent_client,
72 core_agent_metrics_client,
73 core_agent_logs_client,
74 }))
75 }
76}
77
78impl MemoryBounds for OtlpForwarderConfiguration {
79 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
80 builder.minimum().with_single_value::<OtlpForwarder>("component struct");
81 }
82}
83
84struct OtlpForwarder {
85 trace_agent_client: TraceServiceClient<Channel>,
86 core_agent_metrics_client: MetricsServiceClient<Channel>,
87 core_agent_logs_client: LogsServiceClient<Channel>,
88}
89
90#[async_trait]
91impl Forwarder for OtlpForwarder {
92 async fn run(mut self: Box<Self>, mut context: ForwarderContext) -> Result<(), GenericError> {
93 let Self {
94 mut trace_agent_client,
95 mut core_agent_metrics_client,
96 mut core_agent_logs_client,
97 } = *self;
98
99 let mut health = context.take_health_handle();
100
101 health.mark_ready();
102 debug!("OTLP forwarder started.");
103
104 loop {
105 select! {
106 _ = health.live() => continue,
107 maybe_payload = context.payloads().next() => match maybe_payload {
108 Some(payload) => match payload {
109 Payload::Grpc(grpc_payload) => {
110 let (_, endpoint, service_path, body) = grpc_payload.into_parts();
113 match &service_path {
114 path if *path == OTLP_TRACES_GRPC_SERVICE_PATH => {
115 export_traces(&mut trace_agent_client, &endpoint, &service_path, body).await;
116 }
117 path if *path == OTLP_METRICS_GRPC_SERVICE_PATH => {
118 export_metrics(&mut core_agent_metrics_client, &endpoint, &service_path, body).await;
119 }
120 path if *path == OTLP_LOGS_GRPC_SERVICE_PATH => {
121 export_logs(&mut core_agent_logs_client, &endpoint, &service_path, body).await;
122 }
123 _ => {
124 warn!(service_path = %service_path, "Received gRPC payload with unknown service path. Skipping.");
125 continue;
126 }
127 }
128
129 },
130 _ => continue,
131 },
132 None => break,
133 },
134 }
135 }
136
137 debug!("OTLP forwarder stopped.");
138
139 Ok(())
140 }
141}
142
143async fn export_traces(
144 trace_agent_client: &mut TraceServiceClient<Channel>, endpoint: &MetaString, service_path: &MetaString,
145 body: FrozenChunkedBytesBuffer,
146) {
147 let body = body.into_bytes();
154 let request = match ExportTraceServiceRequest::decode(body) {
155 Ok(req) => req,
156 Err(e) => {
157 error!(error = %e, "Failed to decode trace export request from payload.");
158 return;
159 }
160 };
161
162 match trace_agent_client.export(request).await {
163 Ok(response) => {
164 let resp = response.into_inner();
165 if let Some(partial_success) = resp.partial_success {
166 if partial_success.rejected_spans > 0 {
167 warn!(
168 rejected_spans = partial_success.rejected_spans,
169 error = %partial_success.error_message,
170 "Trace export partially failed."
171 );
172 }
173 }
174 }
175 Err(e) => {
176 error!(error = %e, %endpoint, %service_path, "Failed to export traces to Trace Agent.");
177 }
178 }
179}
180
181async fn export_metrics(
182 core_agent_grpc_client: &mut MetricsServiceClient<Channel>, endpoint: &MetaString, service_path: &MetaString,
183 body: FrozenChunkedBytesBuffer,
184) {
185 let body = body.into_bytes();
192
193 let request = match ExportMetricsServiceRequest::decode(body) {
194 Ok(req) => req,
195 Err(e) => {
196 error!(error = %e, "Failed to decode metrics or logs export request from payload.");
197 return;
198 }
199 };
200
201 match core_agent_grpc_client.export(request).await {
202 Ok(response) => {
203 let resp = response.into_inner();
204 if let Some(partial_success) = resp.partial_success {
205 if partial_success.rejected_data_points > 0 {
206 warn!(
207 rejected_data_points = partial_success.rejected_data_points,
208 error = %partial_success.error_message,
209 "Metrics export partially failed."
210 );
211 }
212 }
213 }
214 Err(e) => {
215 error!(error = %e, %endpoint, %service_path, "Failed to export metrics to Core Agent.");
216 }
217 }
218}
219
220async fn export_logs(
221 core_agent_grpc_client: &mut LogsServiceClient<Channel>, endpoint: &MetaString, service_path: &MetaString,
222 body: FrozenChunkedBytesBuffer,
223) {
224 let body = body.into_bytes();
231
232 let request = match ExportLogsServiceRequest::decode(body) {
233 Ok(req) => req,
234 Err(e) => {
235 error!(error = %e, "Failed to decode logs export request from payload.");
236 return;
237 }
238 };
239
240 match core_agent_grpc_client.export(request).await {
241 Ok(response) => {
242 let resp = response.into_inner();
243 if let Some(partial_success) = resp.partial_success {
244 if partial_success.rejected_log_records > 0 {
245 warn!(
246 rejected_log_records = partial_success.rejected_log_records,
247 error = %partial_success.error_message,
248 "Trace export partially failed."
249 );
250 }
251 }
252 }
253 Err(e) => {
254 error!(error = %e, %endpoint, %service_path, "Failed to export metrics to Core Agent.");
255 }
256 }
257}
258
259fn normalize_endpoint(endpoint: &str) -> String {
260 if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
261 endpoint.to_string()
262 } else {
263 format!("https://{}", endpoint)
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::normalize_endpoint;
270
271 #[test]
272 fn normalize_endpoint_prepends_https_only_when_scheme_missing() {
273 let cases = [
276 ("http://localhost:4317", "http://localhost:4317"),
277 ("https://api.datadoghq.com:443", "https://api.datadoghq.com:443"),
278 ("localhost:4317", "https://localhost:4317"),
279 ("127.0.0.1:5003", "https://127.0.0.1:5003"),
280 ];
281
282 for (input, expected) in cases {
283 assert_eq!(expected, normalize_endpoint(input), "normalize_endpoint({input:?})");
284 }
285 }
286}