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::collections::BTreeMap;
20use std::sync::OnceLock;
21
22use relay_base_schema::project::ProjectId;
23use relay_event_schema::protocol::{EventId, TraceId};
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26use unicode_normalization::UnicodeNormalization;
27use uuid::Uuid;
28
29const SLUG_LENGTH: usize = 50;
31
32const ENVIRONMENT_LENGTH: usize = 64;
34
35#[derive(Debug, thiserror::Error)]
37pub enum ProcessCheckInError {
38 #[error("failed to deserialize check in")]
40 Json(#[from] serde_json::Error),
41
42 #[error("the monitor slug is empty or invalid")]
44 EmptySlug,
45
46 #[error("the environment is invalid")]
48 InvalidEnvironment,
49
50 #[error("envelope missing a check-in")]
52 MissingCheckIn,
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
57#[serde(rename_all = "snake_case")]
58pub enum CheckInStatus {
59 Ok,
61 Error,
63 InProgress,
65 Missed,
67 #[serde(other)]
69 Unknown,
70}
71
72#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
73#[serde(rename_all = "snake_case")]
74#[serde(tag = "type")]
75enum Schedule {
76 Crontab { value: String },
77 Interval { value: u64, unit: IntervalName },
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
81#[serde(rename_all = "snake_case")]
82enum IntervalName {
83 Year,
84 Month,
85 Week,
86 Day,
87 Hour,
88 Minute,
89}
90
91#[derive(Debug, Deserialize, Serialize)]
93pub struct MonitorConfig {
94 schedule: Schedule,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 checkin_margin: Option<u64>,
101
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 max_runtime: Option<u64>,
106
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 timezone: Option<String>,
110
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 failure_issue_threshold: Option<u64>,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 recovery_threshold: Option<u64>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
124 owner: Option<String>,
125
126 #[serde(flatten, default)]
128 pub other: BTreeMap<String, Value>,
129}
130
131#[derive(Debug, Deserialize, Serialize)]
133pub struct CheckInTrace {
134 trace_id: TraceId,
136
137 #[serde(flatten, default)]
139 pub other: BTreeMap<String, Value>,
140}
141
142#[derive(Debug, Deserialize, Serialize)]
144pub struct CheckInContexts {
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 trace: Option<CheckInTrace>,
148
149 #[serde(flatten, default)]
151 pub other: BTreeMap<String, Value>,
152}
153
154#[derive(Debug, Deserialize, Serialize)]
156pub struct CheckIn {
157 #[serde(default = "EventId::nil")]
159 pub check_in_id: EventId,
160
161 #[serde(default)]
163 pub monitor_slug: String,
164
165 pub status: CheckInStatus,
167
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub environment: Option<String>,
171
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub duration: Option<f64>,
175
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub monitor_config: Option<MonitorConfig>,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub contexts: Option<CheckInContexts>,
184
185 #[serde(flatten, default)]
187 pub other: BTreeMap<String, Value>,
188}
189
190pub fn normalize(check_in: &mut CheckIn) -> Result<(), ProcessCheckInError> {
192 if check_in.status == CheckInStatus::Missed {
194 check_in.status = CheckInStatus::Unknown;
195 }
196
197 trim_slug(&mut check_in.monitor_slug);
198
199 if check_in.monitor_slug.is_empty() {
200 return Err(ProcessCheckInError::EmptySlug);
201 }
202
203 if check_in
204 .environment
205 .as_ref()
206 .is_some_and(|e| e.chars().count() > ENVIRONMENT_LENGTH)
207 {
208 return Err(ProcessCheckInError::InvalidEnvironment);
209 }
210
211 Ok(())
212}
213
214pub fn routing_hint(check_in: &CheckIn, project_id: &ProjectId) -> Uuid {
216 static NAMESPACE: OnceLock<Uuid> = OnceLock::new();
217 let namespace = NAMESPACE
218 .get_or_init(|| Uuid::new_v5(&Uuid::NAMESPACE_URL, b"https://sentry.io/crons/#did"));
219
220 let slug = slugify_monitor_slug(&check_in.monitor_slug);
233 let environment = match check_in.environment.as_deref() {
234 Some(environment) if !environment.is_empty() => environment,
235 _ => "production",
236 };
237 let routing_key = format!("{project_id}:{slug}:{environment}");
238
239 Uuid::new_v5(namespace, routing_key.as_bytes())
240}
241
242pub fn slugify_monitor_slug(slug: &str) -> String {
248 fn is_space(c: char) -> bool {
250 matches!(c, '\t'..='\r' | '\x1c'..='\x1f' | ' ')
251 }
252
253 let mut slugified = String::with_capacity(slug.len());
254 let mut in_separator = false;
255 for c in slug.nfkd().filter(char::is_ascii) {
256 if c == '-' || is_space(c) {
257 if !in_separator {
258 slugified.push('-');
259 in_separator = true;
260 }
261 } else if c.is_ascii_alphanumeric() || c == '_' {
262 slugified.push(c.to_ascii_lowercase());
263 in_separator = false;
264 }
265 }
266
267 let mut slugified = slugified.trim_matches(['-', '_']).to_owned();
268 slugified.truncate(SLUG_LENGTH);
269 slugified.trim_end_matches('-').to_owned()
270}
271
272fn trim_slug(slug: &mut String) {
273 if let Some((overflow, _)) = slug.char_indices().nth(SLUG_LENGTH) {
274 slug.truncate(overflow);
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use similar_asserts::assert_eq;
281
282 use super::*;
283
284 #[test]
285 fn slugify_matches_sentry() {
286 let long_hyphen = format!("{}-b", "a".repeat(49));
288 let long_underscore = format!("{}_b", "a".repeat(49));
289 let cases = [
290 ("my-monitor", "my-monitor"),
291 ("My Monitor", "my-monitor"),
292 ("MyJob", "myjob"),
293 ("my_job", "my_job"),
294 (" leading and trailing ", "leading-and-trailing"),
295 ("a - ! - b", "a-b"),
296 ("--a--b--", "a-b"),
297 ("_-_a_-_", "a"),
298 ("a\tb\nc", "a-b-c"),
299 ("a\x1cb", "a-b"),
300 ("Café Crème", "cafe-creme"),
301 ("straße", "strae"),
302 ("🦀 crab job", "crab-job"),
303 ("🦀🦀", ""),
304 ("日本語", ""),
305 ("file", "file"),
306 ("Hello, World!", "hello-world"),
307 ("a.b.c", "abc"),
308 ("ÀÉÎÕÜ", "aeiou"),
309 (&long_hyphen, &"a".repeat(49)),
310 (&long_underscore, &format!("{}_", "a".repeat(49))),
311 (&"x".repeat(60), &"x".repeat(50)),
312 ("-", ""),
313 ("", ""),
314 ];
315
316 for (input, expected) in cases {
317 assert_eq!(slugify_monitor_slug(input), expected, "input: {input:?}");
318 }
319 }
320
321 #[test]
322 fn routing_hint_uses_slugified_slug() {
323 let check_in = |slug: &str| {
324 let json = format!(r#"{{"monitor_slug":"{slug}","status":"ok"}}"#);
325 serde_json::from_str::<CheckIn>(&json).unwrap()
326 };
327
328 assert_eq!(
329 routing_hint(&check_in("My Job"), &ProjectId::new(1)),
330 routing_hint(&check_in("my-job"), &ProjectId::new(1)),
331 );
332 }
333
334 #[test]
335 fn truncate_basic() {
336 let mut test1 = "test_".repeat(50);
337 trim_slug(&mut test1);
338 assert_eq!("test_test_test_test_test_test_test_test_test_test_", test1,);
339
340 let mut test2 = "🦀".repeat(SLUG_LENGTH + 10);
341 trim_slug(&mut test2);
342 assert_eq!("🦀".repeat(SLUG_LENGTH), test2);
343 }
344
345 #[test]
346 fn serialize_json_roundtrip() {
347 let json = r#"{
348 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
349 "monitor_slug": "my-monitor",
350 "status": "in_progress",
351 "environment": "production",
352 "duration": 21.0,
353 "contexts": {
354 "trace": {
355 "trace_id": "8f431b7aa08441bbbd5a0100fd91f9fe"
356 }
357 }
358}"#;
359
360 let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
361 let serialized = serde_json::to_string_pretty(&check_in).unwrap();
362
363 assert_eq!(json, serialized);
364 }
365
366 #[test]
367 fn serialize_with_upsert_short() {
368 let json = r#"{
369 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
370 "monitor_slug": "my-monitor",
371 "status": "in_progress",
372 "monitor_config": {
373 "schedule": {
374 "type": "crontab",
375 "value": "0 * * * *"
376 }
377 }
378}"#;
379
380 let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
381 let serialized = serde_json::to_string_pretty(&check_in).unwrap();
382
383 assert_eq!(json, serialized);
384 }
385
386 #[test]
387 fn serialize_with_upsert_interval() {
388 let json = r#"{
389 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
390 "monitor_slug": "my-monitor",
391 "status": "in_progress",
392 "monitor_config": {
393 "schedule": {
394 "type": "interval",
395 "value": 5,
396 "unit": "day"
397 },
398 "checkin_margin": 5,
399 "max_runtime": 10,
400 "timezone": "America/Los_Angles",
401 "failure_issue_threshold": 3,
402 "recovery_threshold": 1
403 }
404}"#;
405
406 let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
407 let serialized = serde_json::to_string_pretty(&check_in).unwrap();
408
409 assert_eq!(json, serialized);
410 }
411
412 #[test]
413 fn serialize_with_upsert_full() {
414 let json = r#"{
415 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
416 "monitor_slug": "my-monitor",
417 "status": "in_progress",
418 "monitor_config": {
419 "schedule": {
420 "type": "crontab",
421 "value": "0 * * * *"
422 },
423 "checkin_margin": 5,
424 "max_runtime": 10,
425 "timezone": "America/Los_Angles",
426 "failure_issue_threshold": 3,
427 "recovery_threshold": 1,
428 "owner": "user:123"
429 }
430}"#;
431
432 let check_in = serde_json::from_str::<CheckIn>(json).unwrap();
433 let serialized = serde_json::to_string_pretty(&check_in).unwrap();
434
435 assert_eq!(json, serialized);
436 }
437
438 #[test]
439 fn process_simple() {
440 let json = r#"{"check_in_id":"a460c25ff2554577b920fcfacae4e5eb","monitor_slug":"my-monitor","status":"ok"}"#;
441 let check_in = serde_json::from_str(json).unwrap();
442 let rh = routing_hint(&check_in, &ProjectId::new(1));
443
444 let expected_uuid = Uuid::parse_str("9aa99731-a8e3-5594-9f00-c3e8a62c2b11").unwrap();
446
447 assert_eq!(rh, expected_uuid);
448 }
449
450 #[test]
451 fn routing_hint_splits_environments() {
452 let hint = |env: &str| {
453 let json = format!(
454 r#"{{"check_in_id":"a460c25ff2554577b920fcfacae4e5eb","monitor_slug":"my-monitor","environment":"{env}","status":"ok"}}"#
455 );
456 let check_in = serde_json::from_str(&json).unwrap();
457 routing_hint(&check_in, &ProjectId::new(1))
458 };
459
460 assert_ne!(hint("prod"), hint("dev"));
463 assert_eq!(hint("prod"), hint("prod"));
464 assert_eq!(
465 hint("prod"),
466 Uuid::parse_str("f97ad155-c5c6-57f4-b748-03a301a14e54").unwrap()
467 );
468 }
469
470 #[test]
471 fn routing_hint_treats_missing_environment_as_production() {
472 let hint = |env: Option<&str>| {
473 let json = match env {
474 Some(env) => format!(
475 r#"{{"check_in_id":"a460c25ff2554577b920fcfacae4e5eb","monitor_slug":"my-monitor","environment":"{env}","status":"ok"}}"#
476 ),
477 None => r#"{"check_in_id":"a460c25ff2554577b920fcfacae4e5eb","monitor_slug":"my-monitor","status":"ok"}"#.to_owned(),
478 };
479 let check_in = serde_json::from_str(&json).unwrap();
480 routing_hint(&check_in, &ProjectId::new(1))
481 };
482
483 assert_eq!(hint(None), hint(Some("")));
486 assert_eq!(hint(None), hint(Some("production")));
487 }
488
489 #[test]
490 fn process_empty_slug() {
491 let json = r#"{
492 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
493 "monitor_slug": "",
494 "status": "in_progress"
495 }"#;
496 let mut check_in = serde_json::from_str(json).unwrap();
497
498 let result = normalize(&mut check_in);
499 assert!(matches!(result, Err(ProcessCheckInError::EmptySlug)));
500 }
501
502 #[test]
503 fn process_invalid_environment() {
504 let json = r#"{
505 "check_in_id": "a460c25ff2554577b920fcfacae4e5eb",
506 "monitor_slug": "test",
507 "status": "in_progress",
508 "environment": "1234567890123456789012345678901234567890123456789012345678901234567890"
509 }"#;
510 let mut check_in = serde_json::from_str(json).unwrap();
511
512 let result = normalize(&mut check_in);
513 assert!(matches!(
514 result,
515 Err(ProcessCheckInError::InvalidEnvironment)
516 ));
517 }
518}