use axum::Router;
use axum::body::Bytes;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use prost::Message;
use tonic::codec::CompressionEncoding;
use tonic::{Request, Status};
use tonic_types::{ErrorDetails, StatusExt};
use mira_proto::collector::logs::v1::logs_service_server::{LogsService, LogsServiceServer};
use mira_proto::collector::logs::v1::{ExportLogsServiceRequest, ExportLogsServiceResponse};
use mira_proto::collector::metrics::v1::metrics_service_server::{
MetricsService, MetricsServiceServer,
};
use mira_proto::collector::metrics::v1::{
ExportMetricsServiceRequest, ExportMetricsServiceResponse,
};
use mira_proto::collector::trace::v1::trace_service_server::{TraceService, TraceServiceServer};
use mira_proto::collector::trace::v1::{ExportTraceServiceRequest, ExportTraceServiceResponse};
use crate::pipeline::{Ingest, Rejected};
#[derive(Clone)]
pub struct Receivers {
pub logs: Ingest<ExportLogsServiceRequest>,
pub traces: Ingest<ExportTraceServiceRequest>,
pub metrics: Ingest<ExportMetricsServiceRequest>,
pub max_request_bytes: usize,
}
fn status_for(r: Rejected) -> Status {
let retry = |ms| ErrorDetails::with_retry_info(Some(std::time::Duration::from_millis(ms)));
match r {
Rejected::Busy => {
Status::with_error_details(tonic::Code::Unavailable, "ingest queue full", retry(250))
}
Rejected::Closed => Status::unavailable("shutting down"),
Rejected::Unavailable(e) => {
Status::with_error_details(tonic::Code::Unavailable, e, retry(1_000))
}
Rejected::Failed(e) => Status::internal(e),
}
}
impl Receivers {
pub fn logs_server(&self) -> LogsServiceServer<Self> {
LogsServiceServer::new(self.clone())
.accept_compressed(CompressionEncoding::Gzip)
.max_decoding_message_size(self.max_request_bytes)
}
pub fn traces_server(&self) -> TraceServiceServer<Self> {
TraceServiceServer::new(self.clone())
.accept_compressed(CompressionEncoding::Gzip)
.max_decoding_message_size(self.max_request_bytes)
}
pub fn metrics_server(&self) -> MetricsServiceServer<Self> {
MetricsServiceServer::new(self.clone())
.accept_compressed(CompressionEncoding::Gzip)
.max_decoding_message_size(self.max_request_bytes)
}
}
#[tonic::async_trait]
impl LogsService for Receivers {
async fn export(
&self,
request: Request<ExportLogsServiceRequest>,
) -> Result<tonic::Response<ExportLogsServiceResponse>, Status> {
self.logs
.submit(request.into_inner())
.await
.map_err(status_for)?;
Ok(tonic::Response::new(ExportLogsServiceResponse::default()))
}
}
#[tonic::async_trait]
impl TraceService for Receivers {
async fn export(
&self,
request: Request<ExportTraceServiceRequest>,
) -> Result<tonic::Response<ExportTraceServiceResponse>, Status> {
self.traces
.submit(request.into_inner())
.await
.map_err(status_for)?;
Ok(tonic::Response::new(ExportTraceServiceResponse::default()))
}
}
#[tonic::async_trait]
impl MetricsService for Receivers {
async fn export(
&self,
request: Request<ExportMetricsServiceRequest>,
) -> Result<tonic::Response<ExportMetricsServiceResponse>, Status> {
self.metrics
.submit(request.into_inner())
.await
.map_err(status_for)?;
Ok(tonic::Response::new(ExportMetricsServiceResponse::default()))
}
}
pub fn http_router(r: Receivers) -> Router {
macro_rules! signal {
($field:ident, $resp:ty, $json:path) => {
post(
|axum::extract::State(r): axum::extract::State<Receivers>,
h: HeaderMap,
b: Bytes| async move {
let max = r.max_request_bytes;
export(&r.$field, &h, b, max, <$resp>::default(), $json).await
},
)
};
}
let max = r.max_request_bytes;
Router::new()
.route(
"/v1/logs",
signal!(logs, ExportLogsServiceResponse, crate::json::logs),
)
.route(
"/v1/traces",
signal!(traces, ExportTraceServiceResponse, crate::json::traces),
)
.route(
"/v1/metrics",
signal!(metrics, ExportMetricsServiceResponse, crate::json::metrics),
)
.layer(axum::extract::DefaultBodyLimit::max(max))
.with_state(r)
}
enum Encoding {
Protobuf,
Json,
}
fn encoding(h: &HeaderMap) -> Option<Encoding> {
let Some(ct) = h.get(header::CONTENT_TYPE) else {
return Some(Encoding::Protobuf);
};
let ct = ct.to_str().unwrap_or_default();
match ct.split(';').next().unwrap_or_default().trim() {
"application/x-protobuf" | "application/protobuf" | "" => Some(Encoding::Protobuf),
"application/json" => Some(Encoding::Json),
_ => None,
}
}
fn inflate(h: &HeaderMap, body: Bytes, max: usize) -> Result<Bytes, (StatusCode, String)> {
let ce = h
.get(header::CONTENT_ENCODING)
.map(|v| v.to_str().unwrap_or_default().trim().to_ascii_lowercase());
match ce.as_deref() {
None | Some("") | Some("identity") => Ok(body),
Some("gzip") | Some("x-gzip") => {
use std::io::Read;
let mut out = Vec::new();
flate2::read::GzDecoder::new(&body[..])
.take(max as u64 + 1)
.read_to_end(&mut out)
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("failed to decompress gzip body: {e}"),
)
})?;
if out.len() > max {
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
format!(
"gzip body inflates past the {max} byte limit; \
raise ingest.max_request_bytes or lower the sender's batch size"
),
));
}
Ok(out.into())
}
Some(other) => Err((
StatusCode::UNSUPPORTED_MEDIA_TYPE,
format!("unsupported content-encoding {other}; expected gzip or identity"),
)),
}
}
async fn export<R: Message + Default, T: Message>(
ingest: &Ingest<R>,
headers: &HeaderMap,
body: Bytes,
max: usize,
ok: T,
from_json: fn(&yaml_rust2::Yaml) -> Result<R, String>,
) -> Response {
let json = match encoding(headers) {
Some(Encoding::Protobuf) => false,
Some(Encoding::Json) => true,
None => {
return (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"expected application/x-protobuf or application/json",
)
.into_response();
}
};
let body = match inflate(headers, body, max) {
Ok(b) => b,
Err((code, e)) => return fail(json, code, &e),
};
let decoded = if json {
std::str::from_utf8(&body)
.map_err(|e| e.to_string())
.and_then(crate::api::parse)
.and_then(|doc| from_json(&doc))
} else {
R::decode(body).map_err(|e| e.to_string())
};
let req = match decoded {
Ok(r) => r,
Err(e) => return fail(json, StatusCode::BAD_REQUEST, &e),
};
match ingest.submit(req).await {
Ok(()) if json => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
"{}",
)
.into_response(),
Ok(()) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/x-protobuf")],
ok.encode_to_vec(),
)
.into_response(),
Err(Rejected::Busy) => (
[(header::RETRY_AFTER, "1")],
fail(json, StatusCode::SERVICE_UNAVAILABLE, "ingest queue full"),
)
.into_response(),
Err(Rejected::Closed) => fail(json, StatusCode::SERVICE_UNAVAILABLE, "shutting down"),
Err(Rejected::Unavailable(e)) => (
[(header::RETRY_AFTER, "1")],
fail(json, StatusCode::SERVICE_UNAVAILABLE, &e),
)
.into_response(),
Err(Rejected::Failed(e)) => fail(json, StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
fn fail(json: bool, code: StatusCode, message: &str) -> Response {
if !json {
return (code, message.to_owned()).into_response();
}
let escaped = message.replace('\\', "\\\\").replace('"', "\\\"");
(
code,
[(header::CONTENT_TYPE, "application/json")],
format!(r#"{{"code":2,"message":"{escaped}"}}"#),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{HeaderValue, Request};
use mira_core::wal;
use tokio::sync::mpsc;
use tower::ServiceExt;
fn picked(h: &HeaderMap) -> &'static str {
match encoding(h) {
Some(Encoding::Protobuf) => "protobuf",
Some(Encoding::Json) => "json",
None => "refused",
}
}
#[test]
fn the_content_type_decides_the_parser_and_a_missing_one_means_protobuf() {
let with = |v: Option<&[u8]>| {
let mut h = HeaderMap::new();
if let Some(v) = v {
h.insert(header::CONTENT_TYPE, HeaderValue::from_bytes(v).unwrap());
}
h
};
for (sent, want) in [
(None, "protobuf"),
(Some(&b"application/x-protobuf"[..]), "protobuf"),
(Some(&b"application/protobuf"[..]), "protobuf"),
(Some(&b""[..]), "protobuf"),
(Some(&b"application/json; charset=utf-8"[..]), "json"),
(Some(&b" application/json "[..]), "json"),
(Some(&b"text/plain"[..]), "refused"),
(Some(&b"application/x-thrift"[..]), "refused"),
(Some(&[0xff][..]), "protobuf"),
] {
assert_eq!(picked(&with(sent)), want, "content-type {sent:?}");
}
}
async fn refusing<R>(closed: bool) -> (Ingest<R>, Box<dyn std::any::Any>)
where
R: prost::Message + 'static,
{
let (tx, rx) = mpsc::channel(1);
let held = tx.clone().reserve_owned().await.ok();
let ingest = Ingest {
tx: [tx].into(),
turn: std::sync::Arc::default(),
rejects: &crate::pipeline::REJECTS[0],
wal: None,
signal: wal::Signal::Logs,
};
let keep: Box<dyn std::any::Any> = if closed {
drop((rx, held));
Box::new(())
} else {
Box::new((rx, held))
};
(ingest, keep)
}
#[tokio::test]
async fn a_queue_that_cannot_take_an_export_answers_503_and_keeps_the_batch_alive() {
for (closed, want_body, want_retry) in [
(false, "ingest queue full", true),
(true, "shutting down", false),
] {
let (logs, _keep) = refusing(closed).await;
let (traces, _keep_t) = refusing(closed).await;
let (metrics, _keep_m) = refusing(closed).await;
let app = http_router(Receivers {
logs,
traces,
metrics,
max_request_bytes: 1 << 20,
});
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/logs")
.header("content-type", "application/x-protobuf")
.body(Body::from(
ExportLogsServiceRequest::default().encode_to_vec(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::SERVICE_UNAVAILABLE,
"closed={closed} must stay inside the retryable set"
);
assert_eq!(
res.headers().get(header::RETRY_AFTER).is_some(),
want_retry,
"closed={closed} pushback"
);
let body = axum::body::to_bytes(res.into_body(), 1 << 16)
.await
.unwrap();
assert_eq!(
String::from_utf8_lossy(&body),
want_body,
"the reason has to name which of the two it was"
);
}
}
#[test]
fn a_write_that_failed_is_retryable_and_an_impossible_export_is_not() {
for r in [Rejected::Busy, Rejected::Unavailable("no space".into())] {
let s = status_for(r);
assert_eq!(s.code(), tonic::Code::Unavailable);
assert!(
s.get_error_details().retry_info().is_some(),
"an exporter with no retry policy needs the pushback"
);
}
assert_eq!(
status_for(Rejected::Closed).code(),
tonic::Code::Unavailable,
"a draining node must be retried elsewhere, not written off"
);
assert_eq!(
status_for(Rejected::Failed("too wide".into())).code(),
tonic::Code::Internal,
"an export no block can hold must not be retried forever"
);
}
}