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}