Incidents¶
Grouping only sees one poll's worth of alerts. With a 1-minute poll tick, two related alerts ten minutes apart would land in two different batches and get two different messages, which creates the spammy channel the grouping was supposed to fix.
The incident store is the persistence layer that fixes that. It remembers what was posted, so a later tick can merge into an existing message instead of posting a new one, and so two analysts can't open two cases for the same burst.
It lives in src/incidents.js, persists to data/incidents.json, and is shared between the watchers and the button handlers via the application context.
What a record holds¶
{
key, // random UUID. carried on every button
spaceId, hostName, spaceName,
userNames, primaryUser,
channel, messageTs, // where the message is, so it can be updated
alertIds, // everything shown on the message
attachedIds, // the subset that made it onto the case
alertRules, // id > rule name, for every alert
caseId, caseTitle, caseLink, caseOwner,
claim, // { by, at } while someone is mid-click
ruleCounts, // derived from alertRules
representativeRule,
topSeverity,
from, to,
createdAt, lastActivityAt,
}
alertRules is the single source of truth for ruleCounts and representativeRule. Both are recomputed from it on every change rather than maintained separately, so the two can't disagree.
Keys are generated, not derived from anything. They're plain UUIDs, which keeps them safe to put in a Slack button value.
Matching a new burst to an open incident¶
findMatch(group) is deliberately narrow. Same space and host is necessary but not sufficient. Two analysts' sessions on one jump box are two incidents.
A record matches when all of:
- same
spaceIdand samehostName(no host at all → no match, ever) - it has a
messageTs(a failed or in-flight post is inert) - it hasn't expired
- and either the incoming identities overlap the record's, or the whole incoming burst is
machineOnlyand so carries no information that would contradict the match
If somehow there are two candidates, the most recently active wins.
findByAlertId(id) is the other lookup, used by /case and /add_alert so a case made from the command line still updates the block kit.
Claims¶
tryClaim is the thing that makes a duplicate case impossible.
tryClaim(key, slackUserId, { allowExistingCase })
→ { ok: true, rec }
→ { ok: false, reason: 'case_exists' | 'claimed' | 'gone', rec }
It's synchronous and runs before any network call, so the second click loses instantly. The refusal reasons map to different messages:
| reason | meaning |
|---|---|
case_exists |
there's already a case — use it instead of making another |
claimed |
another analyst is mid-click, wait a second |
gone |
the incident was reaped out from under the message |
allowExistingCase is for the add-alerts path, which needs the mutual exclusion (Kibana's attach isn't idempotent, two clicks would double-attach) but for which an existing case is the precondition rather than a refusal.
A claim is honoured for INCIDENT_CLAIM_TTL_MS (60s) and then treated as abandoned. Claims are also cleared wholesale at boot, since nothing can be mid-creation when the process has just started.
Warning
This is a single-process lock. It's correct within one Elastibot instance and
not correct across two pointed at the same workspace. Running more than
one means moving the claim to something with a real compare-and-set - Redis
SETNX, or an ES document updated with if_seq_no.
Pending alerts¶
pending(rec) is alertIds minus attachedIds, the alerts shown on the message that aren't on the case yet. It's what drives state 3 of the
incident message.
The important bit is that recordCase takes the ids that actually attached, not the ids it tried to attach. A partial attach reporting success would under-report pending, and the analyst would never find out an alert is missing from the case.
ruleCountsFor(rec, ids) computes a rule breakdown over any subset, so the "N new alerts" section can show a breakdown for just the pending ones. It reads off the record rather than off whatever batch triggered the render, otherwise a pending alert left over from an earlier tick goes missing from the count the analyst reads.
Reaping¶
sweep() runs at the top of every alert poll, so the store stays bounded without needing its own timer. A record is dropped when either:
now - lastActivityAt >= INCIDENT_IDLE_MS(8h default). Pick something like a shift length rather than something short.now - createdAt >= INCIDENT_MAX_LIFETIME_MS(24h default). To prevent infinitely growing cases.
A reaped incident isn't deleted from Slack. The message stays with whatever buttons it last had, and those buttons report that the incident has closed out.