Skip to main content

relay_monitors/
lib.rs

1//! Monitors protocol and processing for Sentry.
2//!
3//! [Monitors] allow you to monitor the uptime and performance of any scheduled, recurring job in
4//! Sentry. Once implemented, it'll allow you to get alerts and metrics to help you solve errors,
5//! detect timeouts, and prevent disruptions to your service.
6//!
7//! # API
8//!
9//! The public API documentation is available on [Sentry Docs](https://docs.sentry.io/api/crons/).
10//!
11//! [monitors]: https://docs.sentry.io/product/crons/
12
13#![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
29/// Maximum length of monitor slugs.
30const SLUG_LENGTH: usize = 50;
31
32/// Maximum length of environment names.
33const ENVIRONMENT_LENGTH: usize = 64;
34
35/// Error returned during monitor normalization/processing.
36#[derive(Debug, thiserror::Error)]
37pub enum ProcessCheckInError {
38    /// Failed to deserialize the payload.
39    #[error("failed to deserialize check in")]
40    Json(#[from] serde_json::Error),
41
42    /// Monitor slug was empty after slugification.
43    #[error("the monitor slug is empty or invalid")]
44    EmptySlug,
45
46    /// Environment name was invalid.
47    #[error("the environment is invalid")]
48    InvalidEnvironment,
49
50    /// Relay error (somehow, an envelope was missing a check-in).
51    #[error("envelope missing a check-in")]
52    MissingCheckIn,
53}
54
55/// Describes the status of the incoming CheckIn.
56#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
57#[serde(rename_all = "snake_case")]
58pub enum CheckInStatus {
59    /// Check-in had no issues during execution.
60    Ok,
61    /// Check-in failed or otherwise had some issues.
62    Error,
63    /// Check-in is expectred to complete.
64    InProgress,
65    /// Monitor did not check in on time.
66    Missed,
67    /// No status was passed.
68    #[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/// The monitor configuration payload for upserting monitors during check-in
92#[derive(Debug, Deserialize, Serialize)]
93pub struct MonitorConfig {
94    /// The monitor schedule configuration
95    schedule: Schedule,
96
97    /// How long (in minutes) after the expected checkin time will we wait until we consider the
98    /// checkin to have been missed.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    checkin_margin: Option<u64>,
101
102    /// How long (in minutes) is the check-in allowed to run for in in_progress before it is
103    /// considered failed.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    max_runtime: Option<u64>,
106
107    /// tz database style timezone string
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    timezone: Option<String>,
110
111    /// How many consecutive failed check-ins it takes to create an issue.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    failure_issue_threshold: Option<u64>,
114
115    /// How many consecutive OK check-ins it takes to resolve an issue.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    recovery_threshold: Option<u64>,
118
119    /// Who the owner of the monitor should be. Uses the ActorTuple [0]
120    /// identifier format.
121    ///
122    /// [0]: https://github.com/getsentry/sentry/blob/3644f5c4f2a99073bf925181b5237a6e05c1d6c2/src/sentry/utils/actor.py#L17
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    owner: Option<String>,
125
126    /// Unknown fields, for forwards-compatibility.
127    #[serde(flatten, default)]
128    pub other: BTreeMap<String, Value>,
129}
130
131/// The trace context sent with a check-in.
132#[derive(Debug, Deserialize, Serialize)]
133pub struct CheckInTrace {
134    /// Trace-ID of the check-in.
135    trace_id: TraceId,
136
137    /// Unknown fields, for forwards-compatibility.
138    #[serde(flatten, default)]
139    pub other: BTreeMap<String, Value>,
140}
141
142/// Any contexts sent in the check-in payload.
143#[derive(Debug, Deserialize, Serialize)]
144pub struct CheckInContexts {
145    /// Trace context sent with a check-in.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    trace: Option<CheckInTrace>,
148
149    /// Unknown fields, for forwards-compatibility.
150    #[serde(flatten, default)]
151    pub other: BTreeMap<String, Value>,
152}
153
154/// The monitor check-in payload.
155#[derive(Debug, Deserialize, Serialize)]
156pub struct CheckIn {
157    /// Unique identifier of this check-in.
158    #[serde(default = "EventId::nil")]
159    pub check_in_id: EventId,
160
161    /// Identifier of the monitor for this check-in.
162    #[serde(default)]
163    pub monitor_slug: String,
164
165    /// Status of this check-in. Defaults to `"unknown"`.
166    pub status: CheckInStatus,
167
168    /// The environment to associate the check-in with
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub environment: Option<String>,
171
172    /// Duration of this check since it has started in seconds.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub duration: Option<f64>,
175
176    /// monitor configuration to support upserts.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub monitor_config: Option<MonitorConfig>,
179
180    /// Contexts describing the associated environment of the job run.
181    /// Only supports trace for now.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub contexts: Option<CheckInContexts>,
184
185    /// Unknown fields, for forwards-compatibility.
186    #[serde(flatten, default)]
187    pub other: BTreeMap<String, Value>,
188}
189
190/// Normalizes a monitor check-in payload.
191pub fn normalize(check_in: &mut CheckIn) -> Result<(), ProcessCheckInError> {
192    // Missed status cannot be ingested, this is computed on the server.
193    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
214/// Produce a routing hint UUID from a checkin and project id.
215pub 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    // Use the project_id + monitor_slug + monitor env as the routing key hint. This helps ensure
221    // monitor check-ins are processed in order by consistently routing check-ins from the same
222    // monitor + env combo.
223    //
224    // Keep this in sync with `CheckinItem.processing_key` in Sentry
225    // https://github.com/getsentry/sentry/blob/master/src/sentry/monitors/types.py
226    //
227    // Also keep the environment in sync with Sentry's `ensure_environment`
228    // https://github.com/getsentry/sentry/blob/master/src/sentry/monitors/models.py
229    // We translate empty environments to `production`. This needs to be consistent here or we can
230    // end up with checkins for the same monitor/env routed to different partitions.
231
232    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
242/// Slugifies the monitor slug in the same way as Sentry.
243///
244/// Keep this in sync with `slugify_monitor_slug` in Sentry, which applies Django's `slugify`,
245/// truncates to 50 characters and strips `-`:
246/// <https://github.com/getsentry/sentry/blob/master/src/sentry/monitors/types.py>
247pub fn slugify_monitor_slug(slug: &str) -> String {
248    // Python's `\s` for ASCII, which also matches the separators `\x1c` to `\x1f`.
249    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        // Expected values come from Sentry's `slugify_monitor_slug`.
287        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        // The routing_hint should be consistent for the (project_id, monitor_slug, environment)
445        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        // The consumer groups on (project, slug, environment) and only guarantees order within a
461        // group, so environments of one monitor do not need to share a partition.
462        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        // Sentry resolves all three to the same monitor environment, so they have to share a
484        // partition or their check-ins can be processed out of order.
485        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}