fix(graylog): block legacy duplicate triggers by normalized message

This commit is contained in:
thibaud-lclr 2026-04-21 14:38:09 +02:00
parent 43078ebf3d
commit d300b64603
2 changed files with 155 additions and 1 deletions

View file

@ -393,6 +393,34 @@ impl GraylogSubject {
let rows = stmt.query_map(params![project_id], subject_from_row)?;
rows.collect()
}
pub fn list_by_project_and_message(
conn: &Connection,
project_id: &str,
normalized_message: &str,
) -> Result<Vec<GraylogSubject>> {
let mut stmt = conn.prepare(
"SELECT
id,
project_id,
subject_key,
source,
normalized_message,
first_seen_at,
last_seen_at,
last_score,
active_ticket_id,
status,
created_at,
updated_at
FROM graylog_subjects
WHERE project_id = ?1
AND normalized_message = ?2
ORDER BY updated_at DESC, id DESC",
)?;
let rows = stmt.query_map(params![project_id, normalized_message], subject_from_row)?;
rows.collect()
}
}
impl GraylogDetection {
@ -721,4 +749,36 @@ mod tests {
assert!(subject_detections[0].triggered);
assert_eq!(subject_detections[0].score, 80);
}
#[test]
fn test_list_by_project_and_message_returns_all_matching_subjects() {
let (conn, project, _, _) = setup();
let message = "timeout while calling payment gateway";
GraylogSubject::upsert_subject(
&conn,
&project.id,
"api|timeout",
"api",
message,
"2026-04-17T08:00:00Z",
42,
)
.expect("first subject upsert should succeed");
GraylogSubject::upsert_subject(
&conn,
&project.id,
"worker|timeout",
"worker",
message,
"2026-04-17T08:05:00Z",
38,
)
.expect("second subject upsert should succeed");
let subjects = GraylogSubject::list_by_project_and_message(&conn, &project.id, message)
.expect("list by message should succeed");
assert_eq!(subjects.len(), 2);
}
}

View file

@ -19,6 +19,37 @@ fn should_trigger_subject(score: i32, threshold: i32, has_active_ticket: bool) -
score >= threshold && !has_active_ticket
}
fn has_active_ticket_for_message(
conn: &Connection,
project_id: &str,
normalized_message: &str,
current_subject_id: &str,
) -> Result<bool, String> {
let related_subjects =
GraylogSubject::list_by_project_and_message(conn, project_id, normalized_message)
.map_err(|e| format!("list related subjects failed: {}", e))?;
for related in related_subjects {
if related.id == current_subject_id {
continue;
}
if let Some(active_ticket_id) = related.active_ticket_id.clone() {
match ProcessedTicket::get_by_id(conn, &active_ticket_id) {
Ok(ticket) if is_ticket_active(&ticket.status) => {
return Ok(true);
}
_ => {
GraylogSubject::set_active_ticket(conn, &related.id, None, "idle")
.map_err(|e| format!("clear stale related ticket failed: {}", e))?;
}
}
}
}
Ok(false)
}
fn synthetic_artifact_id(subject_key: &str) -> i32 {
// Stable FNV-1a hash so the synthetic id remains deterministic across runs.
const FNV_OFFSET: u64 = 0xcbf29ce484222325;
@ -276,6 +307,15 @@ pub async fn poll_project_once(
}
}
if !has_active_ticket {
has_active_ticket = has_active_ticket_for_message(
&conn,
project_id,
&aggregate.normalized_message,
&subject.id,
)?;
}
(subject, has_active_ticket)
};
@ -355,7 +395,13 @@ pub async fn poll_project_once(
#[cfg(test)]
mod tests {
use super::{build_graylog_subject_permalink, should_trigger_subject};
use super::{
build_graylog_subject_permalink, has_active_ticket_for_message, should_trigger_subject,
};
use crate::db;
use crate::models::graylog::GraylogSubject;
use crate::models::project::Project;
use crate::models::ticket::ProcessedTicket;
#[test]
fn test_should_trigger_subject_respects_active_ticket() {
@ -377,4 +423,52 @@ mod tests {
assert!(url.contains("relative=1800"));
assert!(url.contains("q=message%3A%22timeout%20user%20%3Cnum%3E%22"));
}
#[test]
fn test_has_active_ticket_for_message_checks_related_subjects() {
let conn = db::init_in_memory().expect("db init should succeed");
let project = Project::insert(&conn, "Graylog", "/tmp/graylog", None, "main")
.expect("project insert should succeed");
let first = GraylogSubject::upsert_subject(
&conn,
&project.id,
"api|timeout user <num>",
"api",
"timeout user <num>",
"2026-04-17T10:00:00Z",
80,
)
.expect("first subject should upsert");
let second = GraylogSubject::upsert_subject(
&conn,
&project.id,
"worker|timeout user <num>",
"worker",
"timeout user <num>",
"2026-04-17T10:01:00Z",
70,
)
.expect("second subject should upsert");
let ticket = ProcessedTicket::insert_external(
&conn,
&project.id,
"graylog",
Some(&first.id),
-123,
"[Graylog] timeout user <num>",
"{}",
)
.expect("ticket should insert");
ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing")
.expect("status update should succeed");
GraylogSubject::set_active_ticket(&conn, &first.id, Some(&ticket.id), "queued")
.expect("active ticket should set");
let has_related =
has_active_ticket_for_message(&conn, &project.id, "timeout user <num>", &second.id)
.expect("related active ticket lookup should succeed");
assert!(has_related);
}
}