From 0d79fc265e0392d68bbd16debc41caac423c43c4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 30 Mar 2026 21:04:21 -0400 Subject: [PATCH] deduplication key --- Cargo.lock | 1 + crates/lib/pager/Cargo.toml | 1 + .../generate_deduplication_key.rs | 215 ++++++++++++++++++ crates/lib/pager/src/notification/mod.rs | 1 + .../src/notification/notification_details.rs | 6 + 5 files changed, 224 insertions(+) create mode 100644 crates/lib/pager/src/notification/generate_deduplication_key.rs diff --git a/Cargo.lock b/Cargo.lock index f391e2915e..28b8c76853 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8038,6 +8038,7 @@ dependencies = [ "errors", "log", "rootly_client", + "sha2 0.11.0-rc.5", "tokio", "workspace-build-acceleration", ] diff --git a/crates/lib/pager/Cargo.toml b/crates/lib/pager/Cargo.toml index b000a6cf41..74e2f302e1 100644 --- a/crates/lib/pager/Cargo.toml +++ b/crates/lib/pager/Cargo.toml @@ -21,6 +21,7 @@ rootly_client.workspace = true # External chrono.workspace = true +sha2.workspace = true log.workspace = true tokio.workspace = true diff --git a/crates/lib/pager/src/notification/generate_deduplication_key.rs b/crates/lib/pager/src/notification/generate_deduplication_key.rs new file mode 100644 index 0000000000..1f2f962202 --- /dev/null +++ b/crates/lib/pager/src/notification/generate_deduplication_key.rs @@ -0,0 +1,215 @@ +use chrono::{DateTime, Utc}; +use sha2::{Sha256, Digest}; + +use crate::notification::notification_details::NotificationDetails; + +/// Returns the number of whole hours since the Unix epoch for the given time. +fn hours_since_epoch(time: DateTime) -> i64 { + time.timestamp() / 3600 +} + +/// Generate a deduplication key for a notification. +/// +/// Notifications with the same key are considered duplicates and can be suppressed. +/// The key is a hex-encoded SHA-256 hash of the notification's identifying fields. +/// +/// When both `http_method` and `endpoint_path` are present, they are included in the +/// hash so that different endpoints with the same summary produce distinct keys. +/// The `hours_since_epoch` of the event time is included so that the same notification +/// firing in different hours produces a different key (allowing re-alerting). +pub(crate) fn generate_deduplication_key(details: &NotificationDetails) -> String { + let mut hasher = Sha256::new(); + + hasher.update(details.summary.as_bytes()); + hasher.update(if details.is_from_error { b"1" } else { b"0" }); + + if let (Some(method), Some(path)) = (&details.http_method, &details.endpoint_path) { + hasher.update(method.as_bytes()); + hasher.update(path.as_bytes()); + } + + hasher.update(hours_since_epoch(details.event_time).to_le_bytes()); + + let result = hasher.finalize(); + result.iter().map(|b| format!("{:02x}", b)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn make_details( + summary: &str, + is_from_error: bool, + http_method: Option<&str>, + endpoint_path: Option<&str>, + event_time: DateTime, + ) -> NotificationDetails { + NotificationDetails { + summary: summary.to_string(), + description: None, + event_time, + http_method: http_method.map(|s| s.to_string()), + endpoint_path: endpoint_path.map(|s| s.to_string()), + is_from_error, + } + } + + // -- hours_since_epoch tests -- + + mod hours_since_epoch_tests { + use super::*; + #[test] + fn hours_since_epoch_at_unix_epoch() { + let epoch = Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(); + assert_eq!(hours_since_epoch(epoch), 0); + } + + #[test] + fn hours_since_epoch_at_one_hour() { + let time = Utc.with_ymd_and_hms(1970, 1, 1, 1, 0, 0).unwrap(); + assert_eq!(hours_since_epoch(time), 1); + } + + #[test] + fn hours_since_epoch_mid_hour_rounds_down() { + let time = Utc.with_ymd_and_hms(1970, 1, 1, 1, 30, 0).unwrap(); + assert_eq!(hours_since_epoch(time), 1); + } + + #[test] + fn hours_since_epoch_just_before_next_hour() { + let time = Utc.with_ymd_and_hms(1970, 1, 1, 1, 59, 59).unwrap(); + assert_eq!(hours_since_epoch(time), 1); + } + + #[test] + fn hours_since_epoch_known_date() { + // 2026-03-30 14:00:00 UTC + let time = Utc.with_ymd_and_hms(2026, 3, 30, 14, 0, 0).unwrap(); + assert_eq!(hours_since_epoch(time), 493022); + } + } + + // -- Consistent hashing tests -- + + mod consistent_hashing_tests { + use super::*; + + #[test] + fn consistent_hash_same_inputs() { + let time = Utc.with_ymd_and_hms(2026, 3, 30, 14, 0, 0).unwrap(); + + let a = make_details("db connection failed", true, Some("POST"), Some("/v1/jobs"), time); + let b = make_details("db connection failed", true, Some("POST"), Some("/v1/jobs"), time); + + assert_eq!(generate_deduplication_key(&a), generate_deduplication_key(&b)); + } + + #[test] + fn consistent_hash_without_http_fields() { + let time = Utc.with_ymd_and_hms(2026, 3, 30, 14, 0, 0).unwrap(); + + let a = make_details("something broke", true, None, None, time); + let b = make_details("something broke", true, None, None, time); + + assert_eq!(generate_deduplication_key(&a), generate_deduplication_key(&b)); + } + + // -- Same hour deduplication tests -- + + #[test] + fn same_key_within_same_hour() { + let t1 = Utc.with_ymd_and_hms(2026, 3, 30, 14, 0, 0).unwrap(); + let t2 = Utc.with_ymd_and_hms(2026, 3, 30, 14, 45, 30).unwrap(); + + let a = make_details("timeout", true, Some("GET"), Some("/v1/users"), t1); + let b = make_details("timeout", true, Some("GET"), Some("/v1/users"), t2); + + assert_eq!(generate_deduplication_key(&a), generate_deduplication_key(&b)); + } + } + + // -- Differentiation tests -- + + mod differentiation_tests { + use super::*; + + #[test] + fn different_key_across_hour_boundary() { + let t1 = Utc.with_ymd_and_hms(2026, 3, 30, 14, 59, 59).unwrap(); + let t2 = Utc.with_ymd_and_hms(2026, 3, 30, 15, 0, 0).unwrap(); + + let a = make_details("timeout", true, Some("GET"), Some("/v1/users"), t1); + let b = make_details("timeout", true, Some("GET"), Some("/v1/users"), t2); + + assert_ne!(generate_deduplication_key(&a), generate_deduplication_key(&b)); + } + + #[test] + fn different_summary_different_key() { + let time = Utc.with_ymd_and_hms(2026, 3, 30, 14, 0, 0).unwrap(); + + let a = make_details("error A", true, None, None, time); + let b = make_details("error B", true, None, None, time); + + assert_ne!(generate_deduplication_key(&a), generate_deduplication_key(&b)); + } + + #[test] + fn different_is_from_error_different_key() { + let time = Utc.with_ymd_and_hms(2026, 3, 30, 14, 0, 0).unwrap(); + + let a = make_details("something happened", true, None, None, time); + let b = make_details("something happened", false, None, None, time); + + assert_ne!(generate_deduplication_key(&a), generate_deduplication_key(&b)); + } + + #[test] + fn different_endpoint_different_key() { + let time = Utc.with_ymd_and_hms(2026, 3, 30, 14, 0, 0).unwrap(); + + let a = make_details("db error", true, Some("POST"), Some("/v1/jobs"), time); + let b = make_details("db error", true, Some("POST"), Some("/v1/users"), time); + + assert_ne!(generate_deduplication_key(&a), generate_deduplication_key(&b)); + } + + #[test] + fn different_method_different_key() { + let time = Utc.with_ymd_and_hms(2026, 3, 30, 14, 0, 0).unwrap(); + + let a = make_details("db error", true, Some("GET"), Some("/v1/jobs"), time); + let b = make_details("db error", true, Some("POST"), Some("/v1/jobs"), time); + + assert_ne!(generate_deduplication_key(&a), generate_deduplication_key(&b)); + } + } + + mod other_behaviors { + use super::*; + #[test] + fn only_method_set_ignores_it() { + // When only http_method is Some but endpoint_path is None, HTTP fields are excluded. + let time = Utc.with_ymd_and_hms(2026, 3, 30, 14, 0, 0).unwrap(); + + let a = make_details("db error", true, Some("POST"), None, time); + let b = make_details("db error", true, None, None, time); + + assert_eq!(generate_deduplication_key(&a), generate_deduplication_key(&b)); + } + + #[test] + fn only_path_set_ignores_it() { + // When only endpoint_path is Some but http_method is None, HTTP fields are excluded. + let time = Utc.with_ymd_and_hms(2026, 3, 30, 14, 0, 0).unwrap(); + + let a = make_details("db error", true, None, Some("/v1/jobs"), time); + let b = make_details("db error", true, None, None, time); + + assert_eq!(generate_deduplication_key(&a), generate_deduplication_key(&b)); + } + } +} diff --git a/crates/lib/pager/src/notification/mod.rs b/crates/lib/pager/src/notification/mod.rs index fe94106902..9a989399a4 100644 --- a/crates/lib/pager/src/notification/mod.rs +++ b/crates/lib/pager/src/notification/mod.rs @@ -1,2 +1,3 @@ +mod generate_deduplication_key; pub mod notification_details; pub mod notification_details_builder; diff --git a/crates/lib/pager/src/notification/notification_details.rs b/crates/lib/pager/src/notification/notification_details.rs index 6914ee8d58..06b8282648 100644 --- a/crates/lib/pager/src/notification/notification_details.rs +++ b/crates/lib/pager/src/notification/notification_details.rs @@ -1,6 +1,8 @@ use chrono::{DateTime, Utc}; use std::fmt::{Debug, Display}; +use crate::notification::generate_deduplication_key::generate_deduplication_key; + /// Details for a pager notification. #[derive(Clone, Debug)] pub struct NotificationDetails { @@ -24,6 +26,10 @@ pub struct NotificationDetails { } impl NotificationDetails { + pub fn to_deduplication_key(&self) -> String { + generate_deduplication_key(self) + } + /// Create a notification with a summary and description. pub fn with_summary_and_description(summary: String, description: String) -> Self { Self {