Skip to content

feat(ai-sre-relay): auto-close the relay's Jira ticket after an alert stays resolved [JDWLABS-476] - #213

Merged
jdwillmsen merged 4 commits into
mainfrom
feat/JDWLABS-476-relay-ticket-auto-close
Sep 5, 2026
Merged

feat(ai-sre-relay): auto-close the relay's Jira ticket after an alert stays resolved [JDWLABS-476]#213
jdwillmsen merged 4 commits into
mainfrom
feat/JDWLABS-476-relay-ticket-auto-close

Conversation

@jdwillmsen

@jdwillmsen jdwillmsen commented Sep 5, 2026

Copy link
Copy Markdown
Member

Why

The relay opens one Jira ticket per alert fingerprint and, while that ticket is
open, treats every later firing as a repeat and skips the investigation. It
never closed a ticket itself, so once a condition was fixed the ticket stayed
open until a human noticed — and every later firing of that alertname was
absorbed into it as a "still firing" comment. A fixed porkbun-ddns
KubeJobFailed ticket collected 24 repeat comments while a different failing
job of the same alertname never got its own investigation; six tickets for one
fixed runAsNonRoot failure sat open for four days.

The missing input was the resolve notification. The relay had no idea a
condition had ever cleared: webhook.go enqueued only status: firing and
dropped resolved alerts on the floor.

Design

Resolve → grace → close, with no timers.

Event Behaviour
resolved for a fingerprint the relay owns Comments the resolve time on the ticket, starts the grace window
resolved repeated Grace clock keeps its original start; no second comment
Grace elapsed, ticket still open Closing comment naming the resolve time, transition to Done, counter +1
Grace elapsed, ticket already Done Pending close dropped; the relay does not comment on a finished ticket
firing inside the grace window Pending close cancelled before the investigation starts
firing after an automatic close Fresh investigation; the existing upsert path reopens the same ticket
  • Config: RESOLVED_CLOSE_GRACE (default 6h) and RESOLVED_SWEEP_INTERVAL
    (default 10m), read through the same env mechanism as the rest of the
    service. An unparseable or non-positive value logs a warning and falls back —
    a typo must not silently turn auto-close into never-close.
  • State stays where it already was. Pending closes live in the existing
    in-process fingerprint → firing map as a resolvedAt field. There are no
    per-ticket timers: SweepResolved evaluates "resolved since" on each incoming
    resolve notification and on a periodic sweep. A restart therefore drops pending
    closes and the ticket simply stays open as it did before this change, rather
    than a persisted timer firing against state that no longer holds.
  • Cancellation happens up front. An investigation runs for minutes; a sweep
    landing mid-investigation would otherwise close the ticket that investigation
    is about to write to. TestPipelineRefireCancelsPendingCloseBeforeInvestigating
    pins this by sweeping from inside the investigation.
  • Dedup keys on fingerprint plus ticket status. The repeat path already
    re-checked IsOpen per refire, so a Done ticket never suppresses a new
    investigation — that now covers relay-closed tickets too, and is pinned by a
    test rather than left implied.
  • Transitions are resolved by target status category, not a hardcoded id,
    matching the convention the reopen path already established. reopen and the
    new Close now share one transition(ctx, key, toDone bool).
  • Dispatcher coalescing keys on fingerprint and status. Keyed on the
    fingerprint alone, a resolve arriving while the investigation it belongs to is
    still running would be coalesced away as a repeat of it, and nothing else would
    ever report the condition cleared.
  • Metric: ai_sre_relay_tickets_auto_closed_total.
  • Restart recovery (review round 2): a resolve for an untracked fingerprint
    recovers its ticket through the same amfp-<fingerprint> label search the
    firing path uses, so neither direction is stranded by a restart. What a
    restart still loses is an already-pending close, because Alertmanager sends a
    resolved notification once rather than repeating it — that ticket waits for a
    human, which is the pre-existing behaviour.
  • Close/re-fire race (review round 2): the pending close is re-read under
    the lock immediately before the transition, and a re-fire that lands while
    the transition is in flight is repaired by reopening the ticket rather than
    leaving a Done ticket for a firing alert.
  • Worker safety (review round 2): a resolve evaluates only its own
    fingerprint and uses TryLock, so a slow Jira cannot park the dispatcher
    workers behind a full-table sweep; the ticker keeps the full scan. That work
    runs on a context detached from the per-alert deadline (WithoutCancel +
    timeout), like the Holmes failure notice.
  • Close ordering (review round 2): transition first, comment second. The
    old order meant a workflow that could not reach Done collected one "Closed
    automatically" comment per sweep interval forever. A ticket that reached Done
    without its comment reports ErrClosedWithoutNote so the caller counts it
    closed instead of retrying a Done ticket.
  • CI actually runs the race detector (review round 2): the Nx test target
    now sets race: true. CI runs nx affected -t lint test, so without it the
    detector never ran against the goroutine this PR adds.
  • Docs: new apps/backend/ai-sre-relay/README.md — lifecycle table, config,
    metrics, and a "a ticket is not closing" runbook.

Platform dependency: send_resolved (companion PR open)

The ai-sre receiver in
tenants/platform/services/kube-prometheus-stack/postInstall/alertmanager-config-externalsecret.yaml
still has send_resolved: false on platform main. The companion change is
jdwlabs/platform#420 — open, all 19 checks green, awaiting a human codeowner
approval
:

     - url: 'http://ai-sre-relay.ai-sre.svc.cluster.local:8080/webhook'
-      send_resolved: false
+      send_resolved: true

(An earlier attempt, platform#419, was auto-closed when its branch had to be
reset and re-committed through the GitHub API — the signature check rejects a
locally signed commit under the bot author. #420 is the live one.)

This PR is inert until #420 merges: without resolved notifications the
relay never learns a condition cleared and the auto-close path never runs.
Neither PR is merged, so the two can land in either order — this one is safe to
merge first, it simply does nothing until the flag flips. No platform change
was made from this repo. No deployment change is needed for the new config:
both new variables have working defaults.

Worth noting the relay was already half-prepared for resolves — discord.go
renders a resolved alert green, a branch that had never executed. It is now
reached: a resolve posts a short "resolved, ticket X closes after the grace
period" notice.

Review round 2

One real bug and nine risks, all fixed on this branch (58965201).

The bug: remember rebuilt the tracking entry from scratch when an
investigation finished, wiping resolvedAt. A resolve arriving mid-investigation
for the same fingerprint — which inflightKey deliberately permits — was
commented on the ticket and announced in Discord, then silently discarded: no
log, no metric, ticket never closes. Reproduced two ways (prior-episode mapping
and the adoption path) as failing tests first, then fixed by merging into the
existing entry. Repeat numbering still resets when the firing episode changes,
because that count belongs to an episode, not to a fingerprint.

# Fix
2 Reopen re-reads the status and returns nil when the ticket is not Done, instead of moving an open ticket to an arbitrary status with a spurious comment
3 The closing transition prefers the target status by name (JIRA_DONE_STATUS, default Done) with the Done category as fallback, so an incident is not closed as "Won't Do" or "Duplicate"
4 Both fingerprint paths share one query — see the disagreement below
5 SweepResolved checks ctx.Err() per iteration, so shutdown and deadlines actually stop it
6 The ticker sweep runs each tick under context.WithTimeout(sweepCtx, sweepEvery)
7 A raced close is counted as ai_sre_relay_ticket_reopens_total{result=...}, not as an auto-close; a failed reopen (terminal) has its own result label
8 A drop at maxTrackedFirings warns and increments ai_sre_relay_fingerprints_untracked_total
9 README lifecycle row corrected to transition-then-comment, with the "closed without its note" outcome added
10 The cross-repo PR reference is out of the checked-in README; it keeps the sentence that the feature is inert until the receiver sets send_resolved
11 The TryLock test joins its goroutine before failing

Also documented in the README restart section, per the reviewer's note: a
resolve's Jira work has a 60s budget while shutdown drains for 25s, so a
rolling update can cut a close between its transition and its comment.

One disagreement, on finding 4

The finding asked for statusCategory != Done on both fingerprint
searches. Applying it to Upsert would break the reopen path this ticket
requires: Upsert has to see a Done ticket in order to reopen it rather than
file a duplicate (TestJiraUpsertReopensDoneDuplicate). Filtering there would
silently start creating a second ticket per fixed-then-refired alert — the
exact duplication the dedup design exists to prevent.

The underlying concern — the two paths diverging when a fingerprint has a newer
Done ticket and an older open one — is real, so it is fixed in the other
direction: both paths now call one fingerprintHit helper with the identical
unfiltered query, and FindOpenByFingerprint decides on the result
(hit.done() → nothing to close). Same issue selected by both, reopen intact.
The ambiguity detection was implemented as asked: maxResults=2 with a warning
naming both keys when a label matches more than one ticket.

Tests

go build ./..., go vet ./..., go test ./... -race all pass, as does
nx run-many -t lint test build --projects=ai-sre-relay (the targets CI runs
via nx affected). Each new behaviour was written test-first and the tests were
mutation-checked — dropping the up-front cancel, ignoring the grace window, and
keying in-flight on the fingerprint alone each make the corresponding test fail.

Added in the first round:

  • TestPipelineResolvedAlertIsNotedWithoutClosing
  • TestPipelineRepeatedResolveNotesOnce
  • TestPipelineClosesTicketAfterGrace
  • TestPipelineRefireCancelsPendingClose
  • TestPipelineRefireCancelsPendingCloseBeforeInvestigating
  • TestPipelineAutoClosedTicketDoesNotSuppressNextFiring
  • TestPipelineDoneTicketNeverSuppressesRepeat
  • TestPipelineSkipsCloseWhenTicketAlreadyDone
  • TestPipelineRetriesCloseAfterFailure
  • TestPipelineResolvedAlertWithoutTicketIsIgnored
  • TestPipelineResolvedAlertWithoutEndsAtUsesReceiptTime
  • TestCountersExposeTicketsAutoClosed
  • TestJiraNoteResolvedRecordsTheResolveTime
  • TestJiraCloseCommentsThenTransitionsToDone
  • TestJiraCloseFailsWithoutDoneTransition
  • TestDispatcherDoesNotCoalesceResolvedWithFiring

Added for the review findings:

  • TestPipelineResolveAdoptsTicketAfterRestart
  • TestPipelineResolveSkipsSearchWhenAlreadyTracked
  • TestPipelineResolveWithNoRecoverableTicketIsIgnored
  • TestPipelineSweepAbandonsCloseCancelledAfterSnapshot
  • TestPipelineReopensTicketClosedByARacedRefire
  • TestPipelineResolveDoesNotBlockOnARunningSweep
  • TestPipelineResolveSurvivesDeadContext
  • TestPipelineFailedCloseDoesNotRepeatTheClosingComment
  • TestPipelineCloseWithoutNoteIsNotRetried
  • TestPipelineRepeatDoesNotResurrectAForgottenFingerprint
  • TestPipelineResolveNotifiesDiscord
  • TestJiraFindOpenByFingerprintSearchesTheFingerprintLabel
  • TestJiraFindOpenByFingerprintReturnsEmptyOnNoMatch
  • TestJiraCloseTransitionsBeforeCommenting
  • TestJiraCloseReportsATransitionedTicketWithNoNote
  • TestJiraReopenMovesTicketOutOfDone
  • TestDispatcherLogsResolveSeparatelyFromInvestigation

Strengthened: TestPipelineRepeatedResolveNotesOnce now clears endsAt and
resolves twice five hours apart, so it fails if a later resolve restarts the
grace window (it could not before). TestPipelineDoneTicketNeverSuppressesRepeat
now keeps the mapping intact and asserts the status check was consulted, so it
passes for the right reason. TestJiraCloseCommentsThenTransitionsToDone became
TestJiraCloseUsesTheDoneCategoryTransition with the ordering assertion
inverted to match the new, correct order.

Every fix above was mutation-checked: reverting each one individually (drop the
restart fallback, drop the pre-close re-read, drop the reopen, swap TryLock
for Lock, re-attach the context, let countRepeat write back, drop the
Discord notice, let a repeated resolve restart the clock, restore the old close
order) makes the corresponding test fail.

Added for round 2:

  • TestPipelineResolveDuringInvestigationSurvivesItsCompletion (the bug)
  • TestPipelineAdoptedResolveSurvivesAConcurrentInvestigation (the bug, adoption path)
  • TestPipelineNewEpisodeRestartsRepeatNumbering
  • TestPipelineRacedCloseIsCountedAsAReopenNotAClose
  • TestPipelineFailedReopenIsCounted
  • TestPipelineSweepStopsOnCancelledContext
  • TestPipelineFullTrackingTableIsVisible
  • TestCountersExposeReopenAndTrackingOutcomes
  • TestJiraReopenLeavesAnOpenTicketAlone
  • TestJiraClosePrefersTheNamedDoneStatus
  • TestJiraCloseFallsBackToTheDoneCategory
  • TestJiraCloseHonoursAConfiguredDoneStatus
  • TestJiraFindOpenByFingerprintIgnoresADoneMatch

Updated for the corrected semantics:
TestJiraFindOpenByFingerprintSearchesTheFingerprintLabel now asserts the
query matches the upsert's (unfiltered) rather than excluding Done;
TestJiraReopenMovesTicketOutOfDone serves the status re-check.

All eight round-2 behaviour changes were mutation-checked individually — most
importantly, restoring the old overwriting remember fails both bug tests.

Changed: TestWebhookEnqueuesOnlyFiringAlerts
TestWebhookEnqueuesFiringAndResolvedAlerts (it encoded the behaviour this PR
removes; it now also asserts that other statuses such as suppressed are still
ignored).

Out of scope

Open-ticket dedup is unchanged and still on; no alert-rule thresholds were
touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY

jdwillmsen and others added 4 commits September 5, 2026 03:51
The relay opened one ticket per fingerprint and never closed one. Once a
condition was fixed the ticket stayed open until a human noticed, and every
later firing of that alertname was absorbed into it as a "still firing" repeat
comment. One fixed job's ticket collected two dozen of those while a different
failing job of the same alertname never got its own investigation, and six
tickets for a single fixed condition sat open for four days.

The missing input was the resolve notification: the Alertmanager receiver has
send_resolved off, and the webhook handler dropped resolved alerts anyway, so
nothing in the relay ever learned that a condition had cleared.

Resolved notifications now reach the pipeline. A resolve is commented on the
ticket immediately and starts a grace window (RESOLVED_CLOSE_GRACE, 6h); once
the alert has stayed resolved for the whole window the relay comments the
resolve time and transitions the ticket to Done. A firing alert cancels a
pending close before the investigation starts, because an investigation runs
for minutes and a sweep landing inside one would otherwise close the ticket it
is about to write to.

There are no per-ticket timers. Pending closes are re-evaluated on each resolve
notification and on a periodic sweep, so a restart drops them — the ticket
simply stays open as it did before — rather than leaving a timer to fire
against state that no longer holds.

Repeat suppression already re-checked ticket status per refire; a Done ticket
therefore never absorbs a firing alert, however it was closed, and a re-fire
after an automatic close reopens the same ticket through the existing reopen
path. The closing transition is resolved by target status category like the
reopen path, not by a hardcoded id.

The dispatcher's in-flight coalescing now keys on fingerprint and status: keyed
on the fingerprint alone, a resolve arriving during the investigation it
belongs to would be coalesced away as a repeat of it and the condition would
never be reported as cleared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
Eight risks and five smaller problems came out of review of the auto-close
work. The ones that changed behaviour:

A resolve consulted only the in-process fingerprint map, while the firing path
recovers its ticket from Jira by label. A resolve arriving after a restart with
no firing in between was therefore a permanent no-op, and the ticket it
belonged to could never be closed by the relay. The resolve path now does the
same fingerprint-label lookup and adopts what it finds, so neither direction is
stranded by a restart.

The sweep decided to close from a snapshot and then made two Jira calls before
acting on it. A re-fire landing in that window could not stop the close, and
the ticket for a currently-firing alert went to Done. The pending close is now
re-read immediately before the transition, and a re-fire that still beats it —
landing while the transition is in flight — is repaired by reopening the ticket
rather than left for a human to notice.

The sweep lock was held across every Jira round-trip in the whole tracking
table, and a resolve notification took that lock on a dispatcher worker. A slow
Jira would have parked all four workers and turned an outage into refused
alerts. A resolve now evaluates only its own fingerprint and gives up rather
than waiting; the periodic sweep keeps the full scan. That work also runs on a
context detached from the per-alert deadline, because a resolve dropped on an
expired context is invisible — the ticket simply never closes — and it was
logging healthy tickets as unreadable when the deadline expired mid-sweep.

The closing comment was posted before the transition, so a workflow that could
not reach Done collected a fresh "Closed automatically" comment on every sweep,
forever. The transition goes first now, and a ticket that reached Done without
its comment reports that distinctly so the caller counts it closed instead of
retrying a Done ticket.

Repeat counting wrote a zero-value entry back when the sweep had already
forgotten the fingerprint, inserting an unusable entry that held a slot in the
bounded tracking table. It is a no-op now, and the repeat it was counting is
treated as new work.

Also: the resolve now posts the Discord notice the green resolved embed was
written for and never reached; a resolve is no longer logged as a completed
investigation; and the Nx test target enables the race detector, which is what
CI runs, so the goroutine added for the sweep is actually covered there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
…ion PR

The README named the first attempt at the Alertmanager change, which was
auto-closed when its branch was reset and re-committed through the API — the
signature check rejects a locally signed commit under the bot author. Name the
live one instead, and say plainly that it has not landed: the auto-close path
does nothing until the flag flips, and a reader who takes the reference as
merged will look for a working feature that cannot run yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
Round-two review found one real bug. A resolve is allowed to arrive while an
investigation for the same fingerprint is still running — the dispatcher tracks
firing and resolved as separate in-flight keys precisely so it can — but the
investigation finishing rebuilt the tracking entry from scratch and wiped the
resolve time with it. The ticket had already been commented and announced in
Discord as resolving; the close was then dropped with no log, no metric and
nothing to distinguish it from an alert that never resolved. Recording the
ticket now merges into the existing entry instead of replacing it. Repeat
numbering still restarts when the firing episode changes, since that count
belongs to an episode rather than to the fingerprint.

The rest are risks that had not bitten yet:

Reopening a ticket did not check whether it was Done first, so a close that
never landed, or one a human had already reversed, moved the ticket to whatever
open status the workflow listed first and commented about a reopen that did not
happen. It now re-reads the status and does nothing when the ticket is open.

The closing transition took the first transition in the Done category. Real
workflows put "Won't Do" and "Duplicate" there too, and closing an incident as
a duplicate says something the relay does not mean. It now prefers the target
status by name, configurable, with the category as the fallback.

The fingerprint search had drifted apart between the two paths: the upsert saw
Done tickets, since it has to reopen them, and the resolve lookup filtered them
out in the query. With both ordering by creation date and taking one row, a
fingerprint carrying a newer Done ticket and an older open one would have sent
the two paths to different issues. They share one query now and the resolve
path decides on the result instead. A label matching more than one ticket is
also worth knowing about, so the search fetches two rows to notice and warns.

The sweep never checked its context between tickets, so a shutdown or an
expired deadline still walked the whole table one Jira round-trip at a time,
and the periodic sweep had no deadline at all. A close that raced a re-fire was
counted as an auto-close although the ticket ends up open again, and a failed
reopen — which is terminal, leaving a Done ticket for a firing alert — was
invisible. Both now have counters, as does an alert dropped at the tracking
ceiling, which used to lose repeat suppression and auto-close in silence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
@jdwillmsen
jdwillmsen force-pushed the feat/JDWLABS-476-relay-ticket-auto-close branch from 5896520 to 5be3ea1 Compare September 5, 2026 03:51
@jdwillmsen
jdwillmsen merged commit bcbe2a9 into main Sep 5, 2026
22 checks passed
@jdwillmsen
jdwillmsen deleted the feat/JDWLABS-476-relay-ticket-auto-close branch September 5, 2026 03:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant