use serde::Deserialize;
const MAX_REASON_CHARS: usize = 200;
#[derive(Debug, Clone, Copy)]
struct JsonInt(u64);
impl<'de> Deserialize<'de> for JsonInt {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Reader;
impl serde::de::Visitor<'_> for Reader {
type Value = JsonInt;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a decimal int64, quoted or bare")
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<JsonInt, E> {
value.parse::<u64>().map(JsonInt).map_err(E::custom)
}
fn visit_u64<E: serde::de::Error>(self, value: u64) -> Result<JsonInt, E> {
Ok(JsonInt(value))
}
fn visit_i64<E: serde::de::Error>(self, value: i64) -> Result<JsonInt, E> {
u64::try_from(value).map(JsonInt).map_err(E::custom)
}
}
deserializer.deserialize_any(Reader)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PartialSuccess {
#[serde(default)]
rejected_data_points: Option<JsonInt>,
#[serde(default)]
error_message: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ExportReply {
#[serde(default)]
partial_success: Option<PartialSuccess>,
}
fn points(count: u64) -> &'static str {
if count == 1 {
"data point"
} else {
"data points"
}
}
pub fn rejection(body: &str) -> Option<String> {
let trimmed = body.trim();
if trimmed.is_empty() {
return None;
}
let Ok(reply) = serde_json::from_str::<ExportReply>(trimmed) else {
return Some("the collector answered something that was not OTLP/JSON".to_owned());
};
let partial = reply.partial_success?;
let dropped = partial
.rejected_data_points
.map(|count| count.0)
.unwrap_or(0);
let (sanitised, _stripped) =
crate::terminal_safe::sanitise(partial.error_message.unwrap_or_default().trim());
let message = sanitised.trim();
if dropped == 0 && message.is_empty() {
return None;
}
let reason = match (dropped, message.is_empty()) {
(0, false) => message.to_owned(),
(count, true) => format!("{count} {} rejected", points(count)),
(count, false) => format!("{count} {} rejected: {message}", points(count)),
};
Some(reason.chars().take(MAX_REASON_CHARS).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_answer_that_names_nothing_means_the_collector_took_everything() {
for body in [
"",
" \n ",
"{}",
r#"{"partialSuccess":null}"#,
r#"{"partialSuccess":{}}"#,
r#"{"partialSuccess":{"rejectedDataPoints":"0"}}"#,
r#"{"partialSuccess":{"errorMessage":""}}"#,
] {
assert_eq!(rejection(body), None, "{body}");
}
}
#[test]
fn a_partial_success_names_what_the_collector_dropped() {
for (body, want) in [
(
r#"{"partialSuccess":{"rejectedDataPoints":"1"}}"#,
"1 data point rejected",
),
(
r#"{"partialSuccess":{"rejectedDataPoints":"3"}}"#,
"3 data points rejected",
),
(
r#"{"partialSuccess":{"rejectedDataPoints":"1","errorMessage":"bad point"}}"#,
"1 data point rejected: bad point",
),
(
r#"{"partialSuccess":{"errorMessage":"disk full"}}"#,
"disk full",
),
(
r#"{"partialSuccess":{"rejectedDataPoints":2}}"#,
"2 data points rejected",
),
(
r#"{"partialSuccess":{"rejectedDataPoints":"1","future":[1]},"futureToo":true}"#,
"1 data point rejected",
),
] {
assert_eq!(rejection(body).as_deref(), Some(want), "{body}");
}
}
#[test]
fn an_answer_that_is_not_otlp_json_is_reported_rather_than_ignored() {
for body in [
"Gateway Timeout",
"null",
r#"{"partialSuccess":"1 data point dropped"}"#,
] {
assert_eq!(
rejection(body).as_deref(),
Some("the collector answered something that was not OTLP/JSON"),
"{body}"
);
}
}
#[test]
fn a_reason_is_sanitised_and_cut_before_it_reaches_the_log() {
let long = "x".repeat(10 * MAX_REASON_CHARS);
let reason = rejection(&format!(
"{{\"partialSuccess\":{{\"errorMessage\":\"{long}\"}}}}"
))
.unwrap();
assert_eq!(reason.chars().count(), MAX_REASON_CHARS);
let control = rejection(
r#"{"partialSuccess":{"rejectedDataPoints":"1","errorMessage":"a\u001b[31mb"}}"#,
)
.unwrap();
assert_eq!(control, "1 data point rejected: a[31mb");
assert!(!control.chars().any(char::is_control), "{control}");
}
}