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::sync::OnceLock;
20
21use relay_base_schema::project::ProjectId;
22use relay_event_schema::protocol::{EventId, TraceId};
23use serde::{Deserialize, Serialize};
24use uuid::Uuid;
25
26/// Maximum length of monitor slugs.
27const SLUG_LENGTH: usize = 50;
28
29/// Maximum length of environment names.
30const ENVIRONMENT_LENGTH: usize = 64;
31
32/// Error returned from [`process_check_in`].
33#[derive(Debug, thiserror::Error)]
34pub enum ProcessCheckInError {
35    /// Failed to deserialize the payload.
36    #[error("failed to deserialize check in")]
37    Json(#[from] serde_json::Error),
38
39    /// Monitor slug was empty after slugification.
40    #[error("the monitor slug is empty or invalid")]
41    EmptySlug,
42
43    /// Environment name was invalid.
44    #[error("the environment is invalid")]
45    InvalidEnvironment,
46}
47
48/// Describes the status of the incoming CheckIn.
49#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
50#[serde(rename_all = "snake_case")]
51pub enum CheckInStatus {
52    /// Check-in had no issues during execution.
53    Ok,
54    /// Check-in failed or otherwise had some issues.
55    Error,
56    /// Check-in is expectred to complete.
57    InProgress,
58    /// Monitor did not check in on time.
59    Missed,
60    /// No status was passed.
61    #[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/// The monitor configuration payload for upserting monitors during check-in
85#[derive(Debug, Deserialize, Serialize)]
86pub struct MonitorConfig {
87    /// The monitor schedule configuration
88    schedule: Schedule,
89
90    /// How long (in minutes) after the expected checkin time will we wait until we consider the
91    /// checkin to have been missed.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    checkin_margin: Option<u64>,
94
95    /// How long (in minutes) is the check-in allowed to run for in in_progress before it is
96    /// considered failed.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    max_runtime: Option<u64>,
99
100    /// tz database style timezone string
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    timezone: Option<String>,
103
104    /// How many consecutive failed check-ins it takes to create an issue.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    failure_issue_threshold: Option<u64>,
107
108    /// How many consecutive OK check-ins it takes to resolve an issue.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    recovery_threshold: Option<u64>,
111
112    /// Who the owner of the monitor should be. Uses the ActorTuple [0]
113    /// identifier format.
114    ///
115    /// [0]: https://github.com/getsentry/sentry/blob/3644f5c4f2a99073bf925181b5237a6e05c1d6c2/src/sentry/utils/actor.py#L17
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    owner: Option<String>,
118}
119
120/// The trace context sent with a check-in.
121#[derive(Debug, Deserialize, Serialize)]
122pub struct CheckInTrace {
123    /// Trace-ID of the check-in.
124    trace_id: TraceId,
125}
126
127/// Any contexts sent in the check-in payload.
128#[derive(Debug, Deserialize, Serialize)]
129pub struct CheckInContexts {
130    /// Trace context sent with a check-in.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    trace: Option<CheckInTrace>,
133}
134
135/// The monitor check-in payload.
136#[derive(Debug, Deserialize, Serialize)]
137pub struct CheckIn {
138    /// Unique identifier of this check-in.
139    #[serde(default = "EventId::nil")]
140    pub check_in_id: EventId,
141
142    /// Identifier of the monitor for this check-in.
143    #[serde(default)]
144    pub monitor_slug: String,
145
146    /// Status of this check-in. Defaults to `"unknown"`.
147    pub status: CheckInStatus,
148
149    /// The environment to associate the check-in with
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub environment: Option<String>,
152
153    /// Duration of this check since it has started in seconds.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub duration: Option<f64>,
156
157    /// monitor configuration to support upserts.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub monitor_config: Option<MonitorConfig>,
160
161    /// Contexts describing the associated environment of the job run.
162    /// Only supports trace for now.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub contexts: Option<CheckInContexts>,
165}
166
167/// The result from calling process_check_in
168pub struct ProcessedCheckInResult {
169    /// The routing key to be used for the check-in payload.
170    ///
171    /// Important to help ensure monitor check-ins are processed in order by routing check-ins from
172    /// the same monitor to the same place.
173    pub routing_hint: Uuid,
174
175    /// The JSON payload of the processed check-in.
176    pub payload: Vec<u8>,
177}
178
179/// Normalizes a monitor check-in payload.
180pub 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    // Missed status cannot be ingested, this is computed on the server.
187    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    // Use the project_id + monitor_slug + monitor env as the routing key hint. This helps ensure
210    // monitor check-ins are processed in order by consistently routing check-ins from the same
211    // monitor + env combo.
212    //
213    // Keep this in sync with `CheckinItem.processing_key` in Sentry
214    // https://github.com/getsentry/sentry/blob/master/src/sentry/monitors/types.py
215    //
216    // Also keep the environment in sync with Sentry's `ensure_environment`
217    // https://github.com/getsentry/sentry/blob/master/src/sentry/monitors/models.py
218    // We translate empty environments to `production`. This needs to be consistent here or we can
219    // end up with checkins for the same monitor/env routed to different partitions.
220    //
221    // Only the routing key is normalized here, the payload is forwarded untouched.
222    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        // The routing_hint should be consistent for the (project_id, monitor_slug, environment)
360        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        // The consumer groups on (project, slug, environment) and only guarantees order within a
382        // group, so environments of one monitor do not need to share a partition.
383        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        // Sentry resolves all three to the same monitor environment, so they have to share a
406        // partition or their check-ins can be processed out of order.
407        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}