Skip to main content

laminar_connectors/otel/
server.rs

1//! Tonic gRPC server implementing OTLP trace, metrics, and logs services.
2//!
3//! Each receiver converts its respective protobuf request to Arrow
4//! `RecordBatch`, splits oversized batches, and forwards them through
5//! a bounded channel to the `OtelSource` connector.
6
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9use std::time::Duration;
10
11use arrow_array::RecordBatch;
12use arrow_schema::SchemaRef;
13use crossfire::{mpsc, MAsyncTx};
14use tokio::sync::Notify;
15use tonic::{Request, Response, Status};
16
17use opentelemetry_proto::tonic::collector::logs::v1::logs_service_server::LogsService;
18use opentelemetry_proto::tonic::collector::logs::v1::{
19    ExportLogsServiceRequest, ExportLogsServiceResponse,
20};
21use opentelemetry_proto::tonic::collector::metrics::v1::metrics_service_server::MetricsService;
22use opentelemetry_proto::tonic::collector::metrics::v1::{
23    ExportMetricsServiceRequest, ExportMetricsServiceResponse,
24};
25use opentelemetry_proto::tonic::collector::trace::v1::trace_service_server::TraceService;
26use opentelemetry_proto::tonic::collector::trace::v1::{
27    ExportTraceServiceRequest, ExportTraceServiceResponse,
28};
29
30use crate::connector::ConnectorTaskGuard;
31
32use super::convert::{logs_request_to_batch, metrics_request_to_batch, trace_request_to_batch};
33
34const DOWNSTREAM_SEND_TIMEOUT: Duration = Duration::from_secs(5);
35
36// ── Shared helpers ──
37
38/// Current wall-clock time as nanoseconds since Unix epoch.
39fn now_nanos() -> i64 {
40    std::time::SystemTime::now()
41        .duration_since(std::time::UNIX_EPOCH)
42        .map_or(0, |d| {
43            // Nanos from epoch fits in i64 until ~2262.
44            #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
45            {
46                d.as_nanos() as i64
47            }
48        })
49}
50
51/// Split a batch into chunks of at most `batch_size` rows and send each
52/// through the channel. Returns the total number of rows sent.
53async fn split_and_send(
54    batch: RecordBatch,
55    batch_size: usize,
56    tx: &MAsyncTx<mpsc::Array<RecordBatch>>,
57    notify: &Notify,
58    timeout: Duration,
59) -> Result<usize, Status> {
60    let total = batch.num_rows();
61    if total == 0 {
62        return Ok(0);
63    }
64
65    let mut offset = 0;
66    while offset < total {
67        let len = (total - offset).min(batch_size);
68        let chunk = batch.slice(offset, len);
69
70        match tokio::time::timeout(timeout, tx.send(chunk)).await {
71            Ok(Ok(())) => {
72                notify.notify_one();
73                offset += len;
74            }
75            Ok(Err(_)) => {
76                return Err(Status::unavailable("OTel receiver is shutting down"));
77            }
78            Err(_) => {
79                tracing::warn!(
80                    sent = offset,
81                    remaining = total - offset,
82                    "OTel batch send timed out — downstream backpressure"
83                );
84                return Err(Status::resource_exhausted(
85                    "pipeline backpressure: retry with backoff",
86                ));
87            }
88        }
89    }
90
91    Ok(total)
92}
93
94// ── Receiver struct (shared across all signal types) ──
95
96/// OTLP receiver that converts protobuf to Arrow and pushes to a channel.
97///
98/// One instance is created per signal type; the tonic service trait
99/// (`TraceService`, `MetricsService`, `LogsService`) is impl'd below.
100pub struct OtelReceiver {
101    batch_tx: MAsyncTx<mpsc::Array<RecordBatch>>,
102    schema: SchemaRef,
103    data_ready: Arc<Notify>,
104    records_received: Arc<AtomicU64>,
105    requests_received: Arc<AtomicU64>,
106    send_timeout: Duration,
107    batch_size: usize,
108    // Generated tonic services retain the receiver in an Arc for every
109    // accepted connection and RPC future. This guard therefore outlives all
110    // tonic work, including early failure of the parent serve future.
111    _terminal_guard: ConnectorTaskGuard,
112}
113
114impl OtelReceiver {
115    /// Create a new receiver.
116    pub fn new(
117        batch_tx: MAsyncTx<mpsc::Array<RecordBatch>>,
118        schema: SchemaRef,
119        data_ready: Arc<Notify>,
120        records_received: Arc<AtomicU64>,
121        requests_received: Arc<AtomicU64>,
122        batch_size: usize,
123        terminal_guard: ConnectorTaskGuard,
124    ) -> Self {
125        Self {
126            batch_tx,
127            schema,
128            data_ready,
129            records_received,
130            requests_received,
131            send_timeout: DOWNSTREAM_SEND_TIMEOUT,
132            batch_size,
133            _terminal_guard: terminal_guard,
134        }
135    }
136
137    /// Common export path: convert → split → send → count.
138    async fn handle_batch(&self, batch: Option<RecordBatch>) -> Result<usize, Status> {
139        let Some(batch) = batch else {
140            return Ok(0);
141        };
142        let n = split_and_send(
143            batch,
144            self.batch_size,
145            &self.batch_tx,
146            &self.data_ready,
147            self.send_timeout,
148        )
149        .await?;
150        #[allow(clippy::cast_possible_truncation)]
151        self.records_received.fetch_add(n as u64, Ordering::Relaxed);
152        Ok(n)
153    }
154}
155
156// ── Trait impls ──
157
158#[tonic::async_trait]
159impl TraceService for OtelReceiver {
160    async fn export(
161        &self,
162        request: Request<ExportTraceServiceRequest>,
163    ) -> Result<Response<ExportTraceServiceResponse>, Status> {
164        self.requests_received.fetch_add(1, Ordering::Relaxed);
165        let batch = trace_request_to_batch(&request.into_inner(), &self.schema, now_nanos())
166            .map_err(|e| Status::internal(format!("batch conversion failed: {e}")))?;
167        self.handle_batch(batch).await?;
168        Ok(Response::new(ExportTraceServiceResponse {
169            partial_success: None,
170        }))
171    }
172}
173
174#[tonic::async_trait]
175impl MetricsService for OtelReceiver {
176    async fn export(
177        &self,
178        request: Request<ExportMetricsServiceRequest>,
179    ) -> Result<Response<ExportMetricsServiceResponse>, Status> {
180        self.requests_received.fetch_add(1, Ordering::Relaxed);
181        let batch = metrics_request_to_batch(&request.into_inner(), &self.schema, now_nanos())
182            .map_err(|e| Status::internal(format!("batch conversion failed: {e}")))?;
183        self.handle_batch(batch).await?;
184        Ok(Response::new(ExportMetricsServiceResponse {
185            partial_success: None,
186        }))
187    }
188}
189
190#[tonic::async_trait]
191impl LogsService for OtelReceiver {
192    async fn export(
193        &self,
194        request: Request<ExportLogsServiceRequest>,
195    ) -> Result<Response<ExportLogsServiceResponse>, Status> {
196        self.requests_received.fetch_add(1, Ordering::Relaxed);
197        let batch = logs_request_to_batch(&request.into_inner(), &self.schema, now_nanos())
198            .map_err(|e| Status::internal(format!("batch conversion failed: {e}")))?;
199        self.handle_batch(batch).await?;
200        Ok(Response::new(ExportLogsServiceResponse {
201            partial_success: None,
202        }))
203    }
204}