Skip to main content

mira/
receiver.rs

1//! OTLP receivers.
2//!
3//! Two listeners, because OTLP is two protocols. 4317 is OTLP/gRPC and is served
4//! by tonic. 4318 is OTLP/HTTP — a plain HTTP/1.1 POST of protobuf to
5//! `/v1/{traces,metrics,logs}` — which tonic cannot serve; that one is axum,
6//! already in the tree via tonic's `router` feature, so it costs no dependency.
7
8use axum::Router;
9use axum::body::Bytes;
10use axum::http::{HeaderMap, StatusCode, header};
11use axum::response::{IntoResponse, Response};
12use axum::routing::post;
13use prost::Message;
14use tonic::codec::CompressionEncoding;
15use tonic::{Request, Status};
16use tonic_types::{ErrorDetails, StatusExt};
17
18use mira_proto::collector::logs::v1::logs_service_server::{LogsService, LogsServiceServer};
19use mira_proto::collector::logs::v1::{ExportLogsServiceRequest, ExportLogsServiceResponse};
20use mira_proto::collector::metrics::v1::metrics_service_server::{
21    MetricsService, MetricsServiceServer,
22};
23use mira_proto::collector::metrics::v1::{
24    ExportMetricsServiceRequest, ExportMetricsServiceResponse,
25};
26use mira_proto::collector::trace::v1::trace_service_server::{TraceService, TraceServiceServer};
27use mira_proto::collector::trace::v1::{ExportTraceServiceRequest, ExportTraceServiceResponse};
28
29use crate::pipeline::{Ingest, Rejected};
30
31/// One write handle per signal. The type parameter is what keeps a metrics
32/// request from being handed to the logs flusher; `Ingest` is generic precisely
33/// so that the mistake is a compile error rather than a corrupt block.
34#[derive(Clone)]
35pub struct Receivers {
36    pub logs: Ingest<ExportLogsServiceRequest>,
37    pub traces: Ingest<ExportTraceServiceRequest>,
38    pub metrics: Ingest<ExportMetricsServiceRequest>,
39    /// The largest export either listener will decode, in bytes.
40    ///
41    /// One number for both, because a batch that a collector sends happily over
42    /// 4317 and that fails over 4318 is the worst kind of bug to be handed: it
43    /// depends on a transport nobody changed. Applied three times — as the
44    /// axum body limit, as tonic's `max_decoding_message_size`, and as the
45    /// ceiling on what a gzip body may inflate to.
46    pub max_request_bytes: usize,
47}
48
49/// Map a rejection onto a gRPC status.
50///
51/// Overload is never a partial success: the OTLP spec says the client MUST NOT
52/// retry a partial success, so using it for backpressure destroys the data and
53/// records the failure as the sender's fault. It is always a status code, and
54/// always with `RetryInfo` attached — `grpc-retry-pushback-ms` alone is only
55/// honoured by clients that configured a gRPC retry policy, which OTLP
56/// exporters do not.
57///
58/// The retryable set is closed and `INTERNAL` is not in it, so it is reserved
59/// for the one refusal that really is permanent. A write that failed on a full
60/// disk answered `INTERNAL` would have the exporter drop the batch it is holding
61/// rather than send it again a second later, which is data loss chosen by a
62/// status code.
63fn status_for(r: Rejected) -> Status {
64    // 250ms for the queue, which drains in one block age; a second for a failed
65    // write, which is usually a disk needing longer than that anyway.
66    let retry = |ms| ErrorDetails::with_retry_info(Some(std::time::Duration::from_millis(ms)));
67    match r {
68        Rejected::Busy => {
69            Status::with_error_details(tonic::Code::Unavailable, "ingest queue full", retry(250))
70        }
71        Rejected::Closed => Status::unavailable("shutting down"),
72        Rejected::Unavailable(e) => {
73            Status::with_error_details(tonic::Code::Unavailable, e, retry(1_000))
74        }
75        Rejected::Failed(e) => Status::internal(e),
76    }
77}
78
79/// `accept_compressed` on every service, because the spec says MUST and the
80/// stock exporter says default.
81///
82/// Without it tonic answers a compressed request with `UNIMPLEMENTED`, and
83/// OTLP calls that a permanent failure: the exporter drops the batch instead of
84/// retrying it. `send_compressed` is deliberately absent — the response is an
85/// empty message, and gzipping nothing costs a round of deflate per export.
86///
87/// `max_decoding_message_size` is set from the same field the HTTP listener
88/// uses; tonic's own default is 4 MiB and axum's is 2 MiB, and leaving the two
89/// listeners disagreeing means a batch size that works on one port and fails
90/// on the other.
91impl Receivers {
92    pub fn logs_server(&self) -> LogsServiceServer<Self> {
93        LogsServiceServer::new(self.clone())
94            .accept_compressed(CompressionEncoding::Gzip)
95            .max_decoding_message_size(self.max_request_bytes)
96    }
97    pub fn traces_server(&self) -> TraceServiceServer<Self> {
98        TraceServiceServer::new(self.clone())
99            .accept_compressed(CompressionEncoding::Gzip)
100            .max_decoding_message_size(self.max_request_bytes)
101    }
102    pub fn metrics_server(&self) -> MetricsServiceServer<Self> {
103        MetricsServiceServer::new(self.clone())
104            .accept_compressed(CompressionEncoding::Gzip)
105            .max_decoding_message_size(self.max_request_bytes)
106    }
107}
108
109#[tonic::async_trait]
110impl LogsService for Receivers {
111    async fn export(
112        &self,
113        request: Request<ExportLogsServiceRequest>,
114    ) -> Result<tonic::Response<ExportLogsServiceResponse>, Status> {
115        self.logs
116            .submit(request.into_inner())
117            .await
118            .map_err(status_for)?;
119        // No partial_success: everything we accepted is durable by now, and
120        // anything we could not accept was reported as a status above.
121        Ok(tonic::Response::new(ExportLogsServiceResponse::default()))
122    }
123}
124
125#[tonic::async_trait]
126impl TraceService for Receivers {
127    async fn export(
128        &self,
129        request: Request<ExportTraceServiceRequest>,
130    ) -> Result<tonic::Response<ExportTraceServiceResponse>, Status> {
131        self.traces
132            .submit(request.into_inner())
133            .await
134            .map_err(status_for)?;
135        Ok(tonic::Response::new(ExportTraceServiceResponse::default()))
136    }
137}
138
139#[tonic::async_trait]
140impl MetricsService for Receivers {
141    async fn export(
142        &self,
143        request: Request<ExportMetricsServiceRequest>,
144    ) -> Result<tonic::Response<ExportMetricsServiceResponse>, Status> {
145        self.metrics
146            .submit(request.into_inner())
147            .await
148            .map_err(status_for)?;
149        Ok(tonic::Response::new(ExportMetricsServiceResponse::default()))
150    }
151}
152
153/// OTLP/HTTP on 4318.
154pub fn http_router(r: Receivers) -> Router {
155    // Each route is the same three lines with a different field and response
156    // type; a macro says that once instead of three times.
157    macro_rules! signal {
158        ($field:ident, $resp:ty, $json:path) => {
159            post(
160                |axum::extract::State(r): axum::extract::State<Receivers>,
161                 h: HeaderMap,
162                 b: Bytes| async move {
163                    let max = r.max_request_bytes;
164                    export(&r.$field, &h, b, max, <$resp>::default(), $json).await
165                },
166            )
167        };
168    }
169    let max = r.max_request_bytes;
170    Router::new()
171        // OTLP/HTTP log export. Protobuf or JSON, gzip either way.
172        .route(
173            "/v1/logs",
174            signal!(logs, ExportLogsServiceResponse, crate::json::logs),
175        )
176        // OTLP/HTTP trace export. Protobuf or JSON, gzip either way.
177        .route(
178            "/v1/traces",
179            signal!(traces, ExportTraceServiceResponse, crate::json::traces),
180        )
181        // OTLP/HTTP metric export. Protobuf or JSON, gzip either way.
182        .route(
183            "/v1/metrics",
184            signal!(metrics, ExportMetricsServiceResponse, crate::json::metrics),
185        )
186        // Explicit, because axum's default is 2 MiB and that is below what a
187        // stock collector's batch processor produces at its own default of
188        // 8192 records. The exporter treats 413 as permanent and drops the
189        // batch, so a limit set too low is data loss rather than a slow start.
190        .layer(axum::extract::DefaultBodyLimit::max(max))
191        .with_state(r)
192}
193
194/// Which body encoding the client sent.
195///
196/// OTLP/HTTP names exactly two, and a request that claims a third has to be
197/// refused rather than guessed at: `415` tells the exporter to stop, where
198/// mis-decoding it as protobuf produces a `400` that reads like corrupt data.
199/// A missing `content-type` is treated as protobuf, which is what every
200/// pre-JSON client that omitted it meant.
201enum Encoding {
202    Protobuf,
203    Json,
204}
205
206fn encoding(h: &HeaderMap) -> Option<Encoding> {
207    let Some(ct) = h.get(header::CONTENT_TYPE) else {
208        return Some(Encoding::Protobuf);
209    };
210    // Compare the media type only; charset and boundary parameters are the
211    // client's business.
212    let ct = ct.to_str().unwrap_or_default();
213    match ct.split(';').next().unwrap_or_default().trim() {
214        "application/x-protobuf" | "application/protobuf" | "" => Some(Encoding::Protobuf),
215        "application/json" => Some(Encoding::Json),
216        _ => None,
217    }
218}
219
220/// Undo `Content-Encoding` before anything tries to parse the body.
221///
222/// Returning the body untouched for a missing or `identity` header is the
223/// common path. An encoding we do not implement is `415`, not a decode failure:
224/// the exporter has to be told to stop offering it, and a protobuf parser fed
225/// deflate reports "invalid wire type" — a message that sends whoever reads it
226/// looking for corruption instead of a header.
227///
228/// `max` caps what comes *out*, which the body limit does not: a few kilobytes
229/// of gzipped zeros expand to gigabytes and `read_to_end` will allocate every
230/// one of them. It is the same number as the body limit rather than a multiple
231/// of it, because that makes one configured value mean one thing — the largest
232/// export Mira will decode — however it arrived. A ratio would be the obvious
233/// alternative and does not work: a real batch, the same attribute keys over
234/// and over, reaches about 35:1, and there is no ratio above that which a bomb
235/// cannot also sit under.
236fn inflate(h: &HeaderMap, body: Bytes, max: usize) -> Result<Bytes, (StatusCode, String)> {
237    let ce = h
238        .get(header::CONTENT_ENCODING)
239        .map(|v| v.to_str().unwrap_or_default().trim().to_ascii_lowercase());
240    match ce.as_deref() {
241        None | Some("") | Some("identity") => Ok(body),
242        Some("gzip") | Some("x-gzip") => {
243            use std::io::Read;
244            let mut out = Vec::new();
245            // `take` is the whole defence: one byte past the cap and the read
246            // stops, so the refusal costs the cap and not the bomb.
247            flate2::read::GzDecoder::new(&body[..])
248                .take(max as u64 + 1)
249                .read_to_end(&mut out)
250                .map_err(|e| {
251                    (
252                        StatusCode::BAD_REQUEST,
253                        format!("failed to decompress gzip body: {e}"),
254                    )
255                })?;
256            if out.len() > max {
257                return Err((
258                    StatusCode::PAYLOAD_TOO_LARGE,
259                    format!(
260                        "gzip body inflates past the {max} byte limit; \
261                         raise ingest.max_request_bytes or lower the sender's batch size"
262                    ),
263                ));
264            }
265            Ok(out.into())
266        }
267        Some(other) => Err((
268            StatusCode::UNSUPPORTED_MEDIA_TYPE,
269            format!("unsupported content-encoding {other}; expected gzip or identity"),
270        )),
271    }
272}
273
274/// Decode, submit, and answer. Generic over the signal because the three
275/// endpoints differ only in which types they name.
276///
277/// The response is echoed back in the request's own encoding, which the spec
278/// requires: a JSON client gets `{}`, not a protobuf empty message that its
279/// parser will choke on.
280async fn export<R: Message + Default, T: Message>(
281    ingest: &Ingest<R>,
282    headers: &HeaderMap,
283    body: Bytes,
284    max: usize,
285    ok: T,
286    from_json: fn(&yaml_rust2::Yaml) -> Result<R, String>,
287) -> Response {
288    let json = match encoding(headers) {
289        Some(Encoding::Protobuf) => false,
290        Some(Encoding::Json) => true,
291        None => {
292            return (
293                StatusCode::UNSUPPORTED_MEDIA_TYPE,
294                "expected application/x-protobuf or application/json",
295            )
296                .into_response();
297        }
298    };
299
300    let body = match inflate(headers, body, max) {
301        Ok(b) => b,
302        Err((code, e)) => return fail(json, code, &e),
303    };
304
305    let decoded = if json {
306        std::str::from_utf8(&body)
307            .map_err(|e| e.to_string())
308            .and_then(crate::api::parse)
309            .and_then(|doc| from_json(&doc))
310    } else {
311        R::decode(body).map_err(|e| e.to_string())
312    };
313    let req = match decoded {
314        Ok(r) => r,
315        Err(e) => return fail(json, StatusCode::BAD_REQUEST, &e),
316    };
317
318    match ingest.submit(req).await {
319        Ok(()) if json => (
320            StatusCode::OK,
321            [(header::CONTENT_TYPE, "application/json")],
322            "{}",
323        )
324            .into_response(),
325        Ok(()) => (
326            StatusCode::OK,
327            [(header::CONTENT_TYPE, "application/x-protobuf")],
328            ok.encode_to_vec(),
329        )
330            .into_response(),
331        Err(Rejected::Busy) => (
332            [(header::RETRY_AFTER, "1")],
333            fail(json, StatusCode::SERVICE_UNAVAILABLE, "ingest queue full"),
334        )
335            .into_response(),
336        Err(Rejected::Closed) => fail(json, StatusCode::SERVICE_UNAVAILABLE, "shutting down"),
337        // 503 and not 500 for the same reason as `UNAVAILABLE` above: 500 is
338        // outside OTLP/HTTP's retryable set, so it drops the batch.
339        Err(Rejected::Unavailable(e)) => (
340            [(header::RETRY_AFTER, "1")],
341            fail(json, StatusCode::SERVICE_UNAVAILABLE, &e),
342        )
343            .into_response(),
344        Err(Rejected::Failed(e)) => fail(json, StatusCode::INTERNAL_SERVER_ERROR, &e),
345    }
346}
347
348/// An error in the encoding the client asked for.
349///
350/// OTLP wants a `google.rpc.Status`; for the JSON case that is a two-field
351/// object, and hand-writing it costs less than a serializer. The protobuf case
352/// keeps returning text — encoding a `Status` there means another generated type
353/// for a path no exporter parses.
354fn fail(json: bool, code: StatusCode, message: &str) -> Response {
355    if !json {
356        return (code, message.to_owned()).into_response();
357    }
358    // The only characters a message here can contain that JSON forbids.
359    let escaped = message.replace('\\', "\\\\").replace('"', "\\\"");
360    (
361        code,
362        [(header::CONTENT_TYPE, "application/json")],
363        // code 2 is UNKNOWN in google.rpc.Code; the HTTP status carries the
364        // detail an exporter actually branches on.
365        format!(r#"{{"code":2,"message":"{escaped}"}}"#),
366    )
367        .into_response()
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    use axum::body::Body;
375    use axum::http::{HeaderValue, Request};
376    use mira_core::wal;
377    use tokio::sync::mpsc;
378    use tower::ServiceExt;
379
380    /// Which encoding `encoding` picked, as something a table can compare.
381    /// `Encoding` deliberately has no `PartialEq` — nothing in production
382    /// compares two of them — so the test names them instead of widening the
383    /// type's API for its own convenience.
384    fn picked(h: &HeaderMap) -> &'static str {
385        match encoding(h) {
386            Some(Encoding::Protobuf) => "protobuf",
387            Some(Encoding::Json) => "json",
388            None => "refused",
389        }
390    }
391
392    /// The `content-type` is what decides whether the body is parsed as
393    /// protobuf, parsed as KYAML, or refused with a 415 — and getting it wrong
394    /// is never a clean failure: a JSON body handed to `prost` reports "invalid
395    /// wire type", which reads like corruption rather than a header. The
396    /// missing header in particular has to mean protobuf, because every OTLP
397    /// client that predates the JSON encoding omits it, and refusing those is a
398    /// silent outage for the oldest exporters in the fleet.
399    #[test]
400    fn the_content_type_decides_the_parser_and_a_missing_one_means_protobuf() {
401        let with = |v: Option<&[u8]>| {
402            let mut h = HeaderMap::new();
403            if let Some(v) = v {
404                h.insert(header::CONTENT_TYPE, HeaderValue::from_bytes(v).unwrap());
405            }
406            h
407        };
408        for (sent, want) in [
409            (None, "protobuf"),
410            (Some(&b"application/x-protobuf"[..]), "protobuf"),
411            (Some(&b"application/protobuf"[..]), "protobuf"),
412            (Some(&b""[..]), "protobuf"),
413            // The parameters are the client's business, not ours.
414            (Some(&b"application/json; charset=utf-8"[..]), "json"),
415            (Some(&b" application/json "[..]), "json"),
416            (Some(&b"text/plain"[..]), "refused"),
417            (Some(&b"application/x-thrift"[..]), "refused"),
418            // A header that is not UTF-8 is unreadable, not unknown: it is
419            // treated as absent rather than crashing the handler on `to_str`.
420            (Some(&[0xff][..]), "protobuf"),
421        ] {
422            assert_eq!(picked(&with(sent)), want, "content-type {sent:?}");
423        }
424    }
425
426    /// A queue in the state named, with no flusher behind it. Both of these
427    /// refusals are decided by `submit` before anything is dequeued, so a real
428    /// pipeline is not needed to reach them — and a queue filled by wedging a
429    /// live flusher would be a race rather than a state.
430    async fn refusing<R>(closed: bool) -> (Ingest<R>, Box<dyn std::any::Any>)
431    where
432        R: prost::Message + 'static,
433    {
434        let (tx, rx) = mpsc::channel(1);
435        // Capacity one, and the one permit taken: `try_reserve` then reports
436        // `Full`, which is what an ingest queue that is not draining looks like.
437        let held = tx.clone().reserve_owned().await.ok();
438        let ingest = Ingest {
439            tx: [tx].into(),
440            turn: std::sync::Arc::default(),
441            rejects: &crate::pipeline::REJECTS[0],
442            wal: None,
443            signal: wal::Signal::Logs,
444        };
445        // Closed is the flusher being gone: drop both ends of the queue and
446        // keep nothing alive.
447        let keep: Box<dyn std::any::Any> = if closed {
448            drop((rx, held));
449            Box::new(())
450        } else {
451            Box::new((rx, held))
452        };
453        (ingest, keep)
454    }
455
456    /// Neither refusal is the sender's fault, and OTLP/HTTP's retryable set is
457    /// closed: anything outside 429/502/503/504 has the exporter drop the batch
458    /// it is holding. A full queue answered 500 — or 200 — is data loss chosen
459    /// by a status code, and the `retry-after` is what stops a shedding node
460    /// being hammered by every exporter at once while it drains.
461    #[tokio::test]
462    async fn a_queue_that_cannot_take_an_export_answers_503_and_keeps_the_batch_alive() {
463        for (closed, want_body, want_retry) in [
464            (false, "ingest queue full", true),
465            (true, "shutting down", false),
466        ] {
467            let (logs, _keep) = refusing(closed).await;
468            let (traces, _keep_t) = refusing(closed).await;
469            let (metrics, _keep_m) = refusing(closed).await;
470            let app = http_router(Receivers {
471                logs,
472                traces,
473                metrics,
474                max_request_bytes: 1 << 20,
475            });
476            let res = app
477                .oneshot(
478                    Request::builder()
479                        .method("POST")
480                        .uri("/v1/logs")
481                        .header("content-type", "application/x-protobuf")
482                        .body(Body::from(
483                            ExportLogsServiceRequest::default().encode_to_vec(),
484                        ))
485                        .unwrap(),
486                )
487                .await
488                .unwrap();
489
490            assert_eq!(
491                res.status(),
492                StatusCode::SERVICE_UNAVAILABLE,
493                "closed={closed} must stay inside the retryable set"
494            );
495            assert_eq!(
496                res.headers().get(header::RETRY_AFTER).is_some(),
497                want_retry,
498                "closed={closed} pushback"
499            );
500            let body = axum::body::to_bytes(res.into_body(), 1 << 16)
501                .await
502                .unwrap();
503            assert_eq!(
504                String::from_utf8_lossy(&body),
505                want_body,
506                "the reason has to name which of the two it was"
507            );
508        }
509    }
510
511    /// The status is the retry policy. OTLP names the retryable codes exactly —
512    /// `UNAVAILABLE` is in the set, `INTERNAL` is not — so an exporter handed
513    /// `INTERNAL` for a full disk drops the batch instead of resending it thirty
514    /// seconds later when the disk has room. `RetryInfo` rides along because the
515    /// bare code leaves the backoff to the client.
516    #[test]
517    fn a_write_that_failed_is_retryable_and_an_impossible_export_is_not() {
518        for r in [Rejected::Busy, Rejected::Unavailable("no space".into())] {
519            let s = status_for(r);
520            assert_eq!(s.code(), tonic::Code::Unavailable);
521            assert!(
522                s.get_error_details().retry_info().is_some(),
523                "an exporter with no retry policy needs the pushback"
524            );
525        }
526        assert_eq!(
527            status_for(Rejected::Closed).code(),
528            tonic::Code::Unavailable,
529            "a draining node must be retried elsewhere, not written off"
530        );
531        assert_eq!(
532            status_for(Rejected::Failed("too wide".into())).code(),
533            tonic::Code::Internal,
534            "an export no block can hold must not be retried forever"
535        );
536    }
537}