#![doc(
html_logo_url = "https://raw.githubusercontent.com/getsentry/relay/master/artwork/relay-icon.png",
html_favicon_url = "https://raw.githubusercontent.com/getsentry/relay/master/artwork/relay-icon.png"
)]
#![warn(missing_docs)]
use std::sync::OnceLock;
use relay_base_schema::project::ProjectId;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const SLUG_LENGTH: usize = 50;
const ENVIRONMENT_LENGTH: usize = 64;
#[derive(Debug, thiserror::Error)]
pub enum ProcessCheckInError {
#[error("failed to deserialize check in")]
Json(#[from] serde_json::Error),
#[error("the monitor slug is empty or invalid")]
EmptySlug,
#[error("the environment is invalid")]
InvalidEnvironment,
}
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckInStatus {
Ok,
Error,
InProgress,
Missed,
#[serde(other)]
Unknown,
}
fn uuid_simple<S>(uuid: &Uuid, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
uuid.as_simple().serialize(serializer)
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[serde(tag = "type")]
enum Schedule {
Crontab { value: String },
Interval { value: u64, unit: IntervalName },
}
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
enum IntervalName {
Year,
Month,
Week,
Day,
Hour,
Minute,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct MonitorConfig {
schedule: Schedule,
#[serde(default, skip_serializing_if = "Option::is_none")]
checkin_margin: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_runtime: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timezone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
failure_issue_threshold: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
recovery_threshold: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
owner: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CheckInTrace {
#[serde(serialize_with = "uuid_simple")]
trace_id: Uuid,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CheckInContexts {
#[serde(default, skip_serializing_if = "Option::is_none")]
trace: Option<CheckInTrace>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CheckIn {
#[serde(default, serialize_with = "uuid_simple")]
pub check_in_id: Uuid,
#[serde(default)]
pub monitor_slug: String,
pub status: CheckInStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub monitor_config: Option<MonitorConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contexts: Option<CheckInContexts>,
}
pub struct ProcessedCheckInResult {
pub routing_hint: Uuid,
pub payload: Vec<u8>,
}
pub fn process_check_in(
payload: &[u8],
project_id: ProjectId,
) -> Result<ProcessedCheckInResult, ProcessCheckInError> {
let mut check_in = serde_json::from_slice::<CheckIn>(payload)?;
if check_in.status == CheckInStatus::Missed {
check_in.status = CheckInStatus::Unknown;
}
trim_slug(&mut check_in.monitor_slug);
if check_in.monitor_slug.is_empty() {
return Err(ProcessCheckInError::EmptySlug);
}
if check_in
.environment
.as_ref()
.is_some_and(|e| e.chars().count() > ENVIRONMENT_LENGTH)
{
return Err(ProcessCheckInError::InvalidEnvironment);
}
static NAMESPACE: OnceLock<Uuid> = OnceLock::new();
let namespace = NAMESPACE
.get_or_init(|| Uuid::new_v5(&Uuid::NAMESPACE_URL, b"https://sentry.io/crons/#did"));
let slug = &check_in.monitor_slug;
let project_id_slug_key = format!("{project_id}:{slug}");
let routing_hint = Uuid::new_v5(namespace, project_id_slug_key.as_bytes());
Ok(ProcessedCheckInResult {
routing_hint,
payload: serde_json::to_vec(&check_in)?,
})
}
fn trim_slug(slug: &mut String) {
if let Some((overflow, _)) = slug.char_indices().nth(SLUG_LENGTH) {
slug.truncate(overflow);
}
}
#[cfg(test)]
mod tests {
use similar_asserts::assert_eq;
use super::*;
#[test]
fn truncate_basic() {
let mut test1 = "test_".repeat(50);
trim_slug(&mut test1);
assert_eq!("test_test_test_test_test_test_test_test_test_test_", test1,);
let mut test2 = "🦀".repeat(SLUG_LENGTH + 10);
trim_slug(&mut test2);
assert_eq!("🦀".repeat(SLUG_LENGTH), test2);
}
#[test]
fn serialize_json_roundtrip() {
let json = r#"{
"check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
"monitor_slug": "my-monitor",
"status": "in_progress",
"environment": "production",
"duration": 21.0,
"contexts": {
"trace": {
"trace_id": "8f431b7aa08441bbbd5a0100fd91f9fe"
}
}
}"#;
let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
let serialized = serde_json::to_string_pretty(&check_in).unwrap();
assert_eq!(json, serialized);
}
#[test]
fn serialize_with_upsert_short() {
let json = r#"{
"check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
"monitor_slug": "my-monitor",
"status": "in_progress",
"monitor_config": {
"schedule": {
"type": "crontab",
"value": "0 * * * *"
}
}
}"#;
let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
let serialized = serde_json::to_string_pretty(&check_in).unwrap();
assert_eq!(json, serialized);
}
#[test]
fn serialize_with_upsert_interval() {
let json = r#"{
"check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
"monitor_slug": "my-monitor",
"status": "in_progress",
"monitor_config": {
"schedule": {
"type": "interval",
"value": 5,
"unit": "day"
},
"checkin_margin": 5,
"max_runtime": 10,
"timezone": "America/Los_Angles",
"failure_issue_threshold": 3,
"recovery_threshold": 1
}
}"#;
let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
let serialized = serde_json::to_string_pretty(&check_in).unwrap();
assert_eq!(json, serialized);
}
#[test]
fn serialize_with_upsert_full() {
let json = r#"{
"check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
"monitor_slug": "my-monitor",
"status": "in_progress",
"monitor_config": {
"schedule": {
"type": "crontab",
"value": "0 * * * *"
},
"checkin_margin": 5,
"max_runtime": 10,
"timezone": "America/Los_Angles",
"failure_issue_threshold": 3,
"recovery_threshold": 1,
"owner": "user:123"
}
}"#;
let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
let serialized = serde_json::to_string_pretty(&check_in).unwrap();
assert_eq!(json, serialized);
}
#[test]
fn process_simple() {
let json = r#"{"check_in_id":"a460c25ff2554577b920fcfacae4e5eb","monitor_slug":"my-monitor","status":"ok"}"#;
let result = process_check_in(json.as_bytes(), ProjectId::new(1));
let expected_uuid = Uuid::parse_str("66e5c5fa-b1b9-5980-8d85-432c1874521a").unwrap();
if let Ok(processed_result) = result {
assert_eq!(String::from_utf8(processed_result.payload).unwrap(), json);
assert_eq!(processed_result.routing_hint, expected_uuid);
} else {
panic!("Failed to process check-in")
}
}
#[test]
fn process_empty_slug() {
let json = r#"{
"check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
"monitor_slug": "",
"status": "in_progress"
}"#;
let result = process_check_in(json.as_bytes(), ProjectId::new(1));
assert!(matches!(result, Err(ProcessCheckInError::EmptySlug)));
}
#[test]
fn process_invalid_environment() {
let json = r#"{
"check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
"monitor_slug": "test",
"status": "in_progress",
"environment": "1234567890123456789012345678901234567890123456789012345678901234567890"
}"#;
let result = process_check_in(json.as_bytes(), ProjectId::new(1));
assert!(matches!(
result,
Err(ProcessCheckInError::InvalidEnvironment)
));
}
}