fix(graylog): block legacy duplicate triggers by normalized message
This commit is contained in:
parent
43078ebf3d
commit
d300b64603
2 changed files with 155 additions and 1 deletions
|
|
@ -393,6 +393,34 @@ impl GraylogSubject {
|
||||||
let rows = stmt.query_map(params![project_id], subject_from_row)?;
|
let rows = stmt.query_map(params![project_id], subject_from_row)?;
|
||||||
rows.collect()
|
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 {
|
impl GraylogDetection {
|
||||||
|
|
@ -721,4 +749,36 @@ mod tests {
|
||||||
assert!(subject_detections[0].triggered);
|
assert!(subject_detections[0].triggered);
|
||||||
assert_eq!(subject_detections[0].score, 80);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,37 @@ fn should_trigger_subject(score: i32, threshold: i32, has_active_ticket: bool) -
|
||||||
score >= threshold && !has_active_ticket
|
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 {
|
fn synthetic_artifact_id(subject_key: &str) -> i32 {
|
||||||
// Stable FNV-1a hash so the synthetic id remains deterministic across runs.
|
// Stable FNV-1a hash so the synthetic id remains deterministic across runs.
|
||||||
const FNV_OFFSET: u64 = 0xcbf29ce484222325;
|
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)
|
(subject, has_active_ticket)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -355,7 +395,13 @@ pub async fn poll_project_once(
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn test_should_trigger_subject_respects_active_ticket() {
|
fn test_should_trigger_subject_respects_active_ticket() {
|
||||||
|
|
@ -377,4 +423,52 @@ mod tests {
|
||||||
assert!(url.contains("relative=1800"));
|
assert!(url.contains("relative=1800"));
|
||||||
assert!(url.contains("q=message%3A%22timeout%20user%20%3Cnum%3E%22"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue