mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 01:18:34 +00:00
deduplication key
This commit is contained in:
1 parent
4d2d49a146
commit
0d79fc265e
5 files changed
+224
No files matched your search
Generated
+1
@@ -8038,6 +8038,7 @@ dependencies = [
|
||||
"errors",
|
||||
"log",
|
||||
"rootly_client",
|
||||
"sha2 0.11.0-rc.5",
|
||||
"tokio",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
@@ -21,6 +21,7 @@ rootly_client.workspace = true
|
||||
|
||||
# External
|
||||
chrono.workspace = true
|
||||
sha2.workspace = true
|
||||
log.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
|
||||
@@ -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<Utc>) -> 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<Utc>,
|
||||
) -> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
mod generate_deduplication_key;
|
||||
pub mod notification_details;
|
||||
pub mod notification_details_builder;
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user