1#![doc(
14 html_logo_url = "https://raw.githubusercontent.com/getsentry/relay/master/artwork/relay-icon.png",
15 html_favicon_url = "https://raw.githubusercontent.com/getsentry/relay/master/artwork/relay-icon.png"
16)]
17#![warn(missing_docs)]
18
19use std::sync::OnceLock;
20
21use relay_base_schema::project::ProjectId;
22use relay_event_schema::protocol::{EventId, TraceId};
23use serde::{Deserialize, Serialize};
24use uuid::Uuid;
25
26const SLUG_LENGTH: usize = 50;
28
29const ENVIRONMENT_LENGTH: usize = 64;
31
32#[derive(Debug, thiserror::Error)]
34pub enum ProcessCheckInError {
35 #[error("failed to deserialize check in")]
37 Json(#[from] serde_json::Error),
38
39 #[error("the monitor slug is empty or invalid")]
41 EmptySlug,
42
43 #[error("the environment is invalid")]
45 InvalidEnvironment,
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
50#[serde(rename_all = "snake_case")]
51pub enum CheckInStatus {
52 Ok,
54 Error,
56 InProgress,
58 Missed,
60 #[serde(other)]
62 Unknown,
63}
64
65#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
66#[serde(rename_all = "snake_case")]
67#[serde(tag = "type")]
68enum Schedule {
69 Crontab { value: String },
70 Interval { value: u64, unit: IntervalName },
71}
72
73#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
74#[serde(rename_all = "snake_case")]
75enum IntervalName {
76 Year,
77 Month,
78 Week,
79 Day,
80 Hour,
81 Minute,
82}
83
84#[derive(Debug, Deserialize, Serialize)]
86pub struct MonitorConfig {
87 schedule: Schedule,
89
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 checkin_margin: Option<u64>,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
98 max_runtime: Option<u64>,
99
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 timezone: Option<String>,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 failure_issue_threshold: Option<u64>,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 recovery_threshold: Option<u64>,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
117 owner: Option<String>,
118}
119
120#[derive(Debug, Deserialize, Serialize)]
122pub struct CheckInTrace {
123 trace_id: TraceId,
125}
126
127#[derive(Debug, Deserialize, Serialize)]
129pub struct CheckInContexts {
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 trace: Option<CheckInTrace>,
133}
134
135#[derive(Debug, Deserialize, Serialize)]
137pub struct CheckIn {
138 #[serde(default = "EventId::nil")]
140 pub check_in_id: EventId,
141
142 #[serde(default)]
144 pub monitor_slug: String,
145
146 pub status: CheckInStatus,
148
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub environment: Option<String>,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub duration: Option<f64>,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub monitor_config: Option<MonitorConfig>,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub contexts: Option<CheckInContexts>,
165}
166
167pub struct ProcessedCheckInResult {
169 pub routing_hint: Uuid,
174
175 pub payload: Vec<u8>,
177}
178
179pub fn process_check_in(
181 payload: &[u8],
182 project_id: ProjectId,
183) -> Result<ProcessedCheckInResult, ProcessCheckInError> {
184 let mut check_in = serde_json::from_slice::<CheckIn>(payload)?;
185
186 if check_in.status == CheckInStatus::Missed {
188 check_in.status = CheckInStatus::Unknown;
189 }
190
191 trim_slug(&mut check_in.monitor_slug);
192
193 if check_in.monitor_slug.is_empty() {
194 return Err(ProcessCheckInError::EmptySlug);
195 }
196
197 if check_in
198 .environment
199 .as_ref()
200 .is_some_and(|e| e.chars().count() > ENVIRONMENT_LENGTH)
201 {
202 return Err(ProcessCheckInError::InvalidEnvironment);
203 }
204
205 static NAMESPACE: OnceLock<Uuid> = OnceLock::new();
206 let namespace = NAMESPACE
207 .get_or_init(|| Uuid::new_v5(&Uuid::NAMESPACE_URL, b"https://sentry.io/crons/#did"));
208
209 let slug = &check_in.monitor_slug;
223 let environment = match check_in.environment.as_deref() {
224 Some(environment) if !environment.is_empty() => environment,
225 _ => "production",
226 };
227 let routing_key = format!("{project_id}:{slug}:{environment}");
228
229 let routing_hint = Uuid::new_v5(namespace, routing_key.as_bytes());
230
231 Ok(ProcessedCheckInResult {
232 routing_hint,
233 payload: serde_json::to_vec(&check_in)?,
234 })
235}
236
237fn trim_slug(slug: &mut String) {
238 if let Some((overflow, _)) = slug.char_indices().nth(SLUG_LENGTH) {
239 slug.truncate(overflow);
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use similar_asserts::assert_eq;
246
247 use super::*;
248
249 #[test]
250 fn truncate_basic() {
251 let mut test1 = "test_".repeat(50);
252 trim_slug(&mut test1);
253 assert_eq!("test_test_test_test_test_test_test_test_test_test_", test1,);
254
255 let mut test2 = "🦀".repeat(SLUG_LENGTH + 10);
256 trim_slug(&mut test2);
257 assert_eq!("🦀".repeat(SLUG_LENGTH), test2);
258 }
259
260 #[test]
261 fn serialize_json_roundtrip() {
262 let json = r#"{
263 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
264 "monitor_slug": "my-monitor",
265 "status": "in_progress",
266 "environment": "production",
267 "duration": 21.0,
268 "contexts": {
269 "trace": {
270 "trace_id": "8f431b7aa08441bbbd5a0100fd91f9fe"
271 }
272 }
273}"#;
274
275 let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
276 let serialized = serde_json::to_string_pretty(&check_in).unwrap();
277
278 assert_eq!(json, serialized);
279 }
280
281 #[test]
282 fn serialize_with_upsert_short() {
283 let json = r#"{
284 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
285 "monitor_slug": "my-monitor",
286 "status": "in_progress",
287 "monitor_config": {
288 "schedule": {
289 "type": "crontab",
290 "value": "0 * * * *"
291 }
292 }
293}"#;
294
295 let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
296 let serialized = serde_json::to_string_pretty(&check_in).unwrap();
297
298 assert_eq!(json, serialized);
299 }
300
301 #[test]
302 fn serialize_with_upsert_interval() {
303 let json = r#"{
304 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
305 "monitor_slug": "my-monitor",
306 "status": "in_progress",
307 "monitor_config": {
308 "schedule": {
309 "type": "interval",
310 "value": 5,
311 "unit": "day"
312 },
313 "checkin_margin": 5,
314 "max_runtime": 10,
315 "timezone": "America/Los_Angles",
316 "failure_issue_threshold": 3,
317 "recovery_threshold": 1
318 }
319}"#;
320
321 let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
322 let serialized = serde_json::to_string_pretty(&check_in).unwrap();
323
324 assert_eq!(json, serialized);
325 }
326
327 #[test]
328 fn serialize_with_upsert_full() {
329 let json = r#"{
330 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
331 "monitor_slug": "my-monitor",
332 "status": "in_progress",
333 "monitor_config": {
334 "schedule": {
335 "type": "crontab",
336 "value": "0 * * * *"
337 },
338 "checkin_margin": 5,
339 "max_runtime": 10,
340 "timezone": "America/Los_Angles",
341 "failure_issue_threshold": 3,
342 "recovery_threshold": 1,
343 "owner": "user:123"
344 }
345}"#;
346
347 let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
348 let serialized = serde_json::to_string_pretty(&check_in).unwrap();
349
350 assert_eq!(json, serialized);
351 }
352
353 #[test]
354 fn process_simple() {
355 let json = r#"{"check_in_id":"a460c25ff2554577b920fcfacae4e5eb","monitor_slug":"my-monitor","status":"ok"}"#;
356
357 let result = process_check_in(json.as_bytes(), ProjectId::new(1));
358
359 let expected_uuid = Uuid::parse_str("9aa99731-a8e3-5594-9f00-c3e8a62c2b11").unwrap();
361
362 if let Ok(processed_result) = result {
363 assert_eq!(String::from_utf8(processed_result.payload).unwrap(), json);
364 assert_eq!(processed_result.routing_hint, expected_uuid);
365 } else {
366 panic!("Failed to process check-in")
367 }
368 }
369
370 #[test]
371 fn routing_hint_splits_environments() {
372 let hint = |env: &str| {
373 let json = format!(
374 r#"{{"check_in_id":"a460c25ff2554577b920fcfacae4e5eb","monitor_slug":"my-monitor","environment":"{env}","status":"ok"}}"#
375 );
376 process_check_in(json.as_bytes(), ProjectId::new(1))
377 .unwrap()
378 .routing_hint
379 };
380
381 assert_ne!(hint("prod"), hint("dev"));
384 assert_eq!(hint("prod"), hint("prod"));
385 assert_eq!(
386 hint("prod"),
387 Uuid::parse_str("f97ad155-c5c6-57f4-b748-03a301a14e54").unwrap()
388 );
389 }
390
391 #[test]
392 fn routing_hint_treats_missing_environment_as_production() {
393 let hint = |env: Option<&str>| {
394 let json = match env {
395 Some(env) => format!(
396 r#"{{"check_in_id":"a460c25ff2554577b920fcfacae4e5eb","monitor_slug":"my-monitor","environment":"{env}","status":"ok"}}"#
397 ),
398 None => r#"{"check_in_id":"a460c25ff2554577b920fcfacae4e5eb","monitor_slug":"my-monitor","status":"ok"}"#.to_owned(),
399 };
400 process_check_in(json.as_bytes(), ProjectId::new(1))
401 .unwrap()
402 .routing_hint
403 };
404
405 assert_eq!(hint(None), hint(Some("")));
408 assert_eq!(hint(None), hint(Some("production")));
409 }
410
411 #[test]
412 fn process_empty_slug() {
413 let json = r#"{
414 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
415 "monitor_slug": "",
416 "status": "in_progress"
417 }"#;
418
419 let result = process_check_in(json.as_bytes(), ProjectId::new(1));
420 assert!(matches!(result, Err(ProcessCheckInError::EmptySlug)));
421 }
422
423 #[test]
424 fn process_invalid_environment() {
425 let json = r#"{
426 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
427 "monitor_slug": "test",
428 "status": "in_progress",
429 "environment": "1234567890123456789012345678901234567890123456789012345678901234567890"
430 }"#;
431
432 let result = process_check_in(json.as_bytes(), ProjectId::new(1));
433 assert!(matches!(
434 result,
435 Err(ProcessCheckInError::InvalidEnvironment)
436 ));
437 }
438}