Skip to content

Add native AWS CloudWatch and Azure Monitor alert ingress, plus alert write-back mutations - #2

Open
sarora-eightfold wants to merge 11 commits into
masterfrom
add-aws-support
Open

Add native AWS CloudWatch and Azure Monitor alert ingress, plus alert write-back mutations#2
sarora-eightfold wants to merge 11 commits into
masterfrom
add-aws-support

Conversation

@sarora-eightfold

@sarora-eightfold sarora-eightfold commented Jul 31, 2026

Copy link
Copy Markdown

Adds two native alert-ingress integrations so CloudWatch and Azure Monitor can reach GoAlert directly, with no Lambda, forwarder or middleware. They are deliberate siblings: same handler shape, same payload-mapper structure, same dedup discipline, same smoke-test layout.

Neither can use the existing generic endpoint, for different reasons — SNS is blocked on transport and handshake, Azure on payload shape.

Also adds two GraphQL mutations that let an external system write back onto an existing alert, built to support a Jira Automation rule that opens a ticket per alert, assigns it to the current on-call, and needs to avoid opening a second ticket for the same alert.

ticket: https://eightfoldai.atlassian.net/browse/ENG-206319

AWS CloudWatch (via SNS)

CloudWatch alarms reach us through an SNS topic, but GoAlert could not be subscribed to one directly for three independent reasons: SNS requires the endpoint to fetch a SubscribeURL to confirm the subscription and nothing did that, so zero messages were ever delivered; the SNS envelope has no summary field and carries the alarm as a JSON string inside Message; and SNS always sends text/plain, which genericapi.ServeCreateAlert ignores because it only unmarshals application/json. That last point is upstream target#4463.

Add a cloudwatch integration key type and a handler at POST /api/v2/cloudwatch/incoming that performs the subscription handshake and verifies the SNS message signature, so a topic can be subscribed directly with no Lambda or forwarder in between.

The key type is a full integration rather than a reuse of TypeGeneric because the webhook URL shown in the UI is generated server-side from the key type alone (IntegrationKey.Href). Reusing TypeGeneric would hand the user a /api/v2/generic/incoming URL that silently fails to confirm as an SNS subscription -- reproducing the exact bug this change fixes. Because the UI is driven entirely by IntegrationKeyTypes and Href, no frontend changes are needed.

Notable implementation details:

  • The body is parsed regardless of Content-Type, and bounded with MaxBytesReader rather than io.LimitReader so an oversized body yields a 413 instead of silently truncating into a misleading 400.
  • Signature verification is split into pure functions (canonical string, verify, cert parse) so they are testable with no I/O. Subject is a *string because AWS omits the field from the string-to-sign entirely when absent, which a plain string cannot distinguish from an empty value.
  • Both outbound fetches are host-allowlisted with an anchored pattern, and the client blocks redirects: the allowlist only covers the first hop, so a single 302 from an allowlisted host would otherwise reach link-local addresses.
  • The signing-cert cache is bounded with FIFO eviction because the cert URL path is attacker-supplied and would otherwise grow without limit.
  • A freshness window on the signed Timestamp bounds replay of a captured envelope. Tradeoff: retries arriving over an hour late are rejected.
  • Dedup is hex sha256(AlarmName), matching the CloudWatch alarm Lambdas that post to PagerDuty, so one alarm cannot produce two alerts across ingress paths. It is never nil, since a nil dedup silently falls back to a content hash that changes on every state transition and would break both idempotency and the OK close.
  • NewStateReason is capped before assembling details so a verbose reason cannot push AlarmDescription, which carries the runbook URL, past the length limit.
  • INSUFFICIENT_DATA and a stray OK with no open alert both create nothing and return 2xx; non-2xx is reserved for infrastructure failure so SNS retries only when a retry could help.

Azure Monitor

Azure Monitor delivers alerts by having an action group POST a webhook. Azure sends application/json so it clears the content-type gate that blocks SNS, but ServeCreateAlert expects a flat body while Azure nests everything under data.essentials / data.alertContext with no top-level summary — so every delivery would create an alert with a blank summary rather than an error. Nothing maps Azure's alertId onto the dedup field either.

Adds an azureMonitor key type and a handler at POST /api/v2/azuremonitor/incoming.

Unlike SNS there is no subscription handshake and no signature, so this handler makes no outbound requests at all — there is no analogue of cloudwatch's host allowlist or certificate cache. The integration key in the URL is therefore the only credential, which makes the webhook URL credential-grade; the docs say so explicitly for this key type.

Parsing:

  • Only the common alert schema is accepted. A legacy-schema payload is rejected with a message naming the fix (enable the common alert schema on the receiver) rather than degraded to the fallback, which would produce content-free alerts with no indication why. 400 rather than 5xx, since Azure does not retry 4xx and a misconfigured receiver is a permanent condition.
  • Dispatch is on the presence of condition.allOf, not a conditionType allowlist. Every metric and log criteria shape shares that envelope, so one code path covers SingleResourceMultipleMetricCriteria, MultipleResourceMultipleMetricCriteria (used by multi-resource and resource-group-scoped rules), DynamicThresholdCriteria and WebtestLocationAvailabilityCriteria. conditionType still selects the two behaviours that genuinely differ: suppressing the dynamic threshold, which is a sensitivity artifact rather than a limit, and the log query/link lines.
  • Prometheus rule groups get their own branch — that shape carries no conditionType and no condition, only expression/labels/annotations.
  • Service Health and activity-log payloads render from properties, with HTML stripped and string-containing-JSON fields left opaque.
  • Anything unrecognised builds a best-effort alert from essentials, which is present on every payload regardless of type, and logs the signalType/monitorService/conditionType triple so a newly-routed alert type announces itself instead of silently producing thin alerts.

signalType is deliberately not the discriminator. Platform and Prometheus metric alerts both report signalType: "Metric", and Log Alerts V2, Azure Backup and ActivityLog Administrative all report "Log". Branching on it would feed unrelated payloads to the wrong renderer.

Dedup is sha256(essentials.alertId). Azure alerts are stateful, so one alert object carries the whole lifecycle and the Fired and Resolved deliveries share an alertId — which is what lets the Resolved delivery close the alert its Fired delivery opened. originAlertId is deliberately not used: it is per-rule for metric alerts, so a single missed close would hold the dedup key and mute that rule permanently. Status comes from essentials.monitorCondition, never alertContext.status, which can disagree with it because the underlying incident resolved while the alert fired.

A json.UnmarshalTypeError on an individual field is tolerated rather than fatal. Azure documents threshold and dimension values as strings but is not consistent across shapes, and a hard failure means a 400 — which Azure does not retry — so one oddly-typed field would lose the page instead of one value.


Verification

CloudWatch — verified end-to-end against live AWS SNS: the subscription confirms, a real alarm creates one alert with the runbook URL preserved in details, and re-delivery is suppressed as a duplicate.

Azure Monitor — the target tenant's inventory was measured path-agnostically across all resource types, rather than by assuming where the action-group reference lives (it differs per rule type, which is how Prometheus was initially missed): 495 metric rules, 8 log (V2) rules and 3 Prometheus rule groups route to a PagerDuty action group. All three shapes are natively parsed.

Both packages have table-driven unit tests — the first unit tests in any GoAlert ingress package — plus a smoke test each. go build ./..., go vet and gofmt are clean and the full suite passes.

New CW option in integrations:

Screenshot 2026-08-01 at 7 36 21 PM Screenshot 2026-07-31 at 4 31 40 PM Screenshot 2026-08-01 at 7 36 04 PM

Alert write-back mutations (for external automation)

An external system that reacts to a GoAlert alert (e.g. a Jira Automation rule that opens a ticket for it) had no way to write anything back onto that alert. updateAlerts only changes status/noiseReason, and meta/details are otherwise set once, at createAlert time. Two small additions close that gap.

setAlertMetadata(input: SetAlertMetadataInput!): Boolean! — merges the given key/value pairs into an alert's existing metadata rather than replacing it. The underlying store call (SetMetadataTx) does a wholesale INSERT ... ON CONFLICT DO UPDATE SET metadata = $2, so the resolver reads existing metadata first and folds the new keys in, in one transaction — calling the store method directly with just the new key would silently erase everything an ingress integration had already written at creation time.

appendAlertDetails(input: AppendAlertDetailsInput!): Boolean! — appends text to an alert's existing Details, separated by a blank line. This is the one that's actually visible: alert metadata is written correctly by the mutation above but is never surfaced anywhere in the GoAlert web UI, only reachable via the API — appendAlertDetails writes to the same free-text field already shown on the alert page. No merge-by-key is possible here since Details is free text, so the resolver reads the current row (AlertStore.FindOne) and appends, truncating to MaxDetailsLength rather than erroring if the combined text overflows -- losing the append over a length limit is a worse outcome than truncating, since the alert is already real and actionable. Required one new store method (SetDetailsTx) and SQL query, since no existing mutation had ever touched Details post-creation.

Both share the same closed-alert guard as the existing metadata store method (rejected once the alert is closed, no partial effect) and the same permission model (permission.User or permission.Service, service callers scoped to their own alerts) — reachable via a scoped GraphQL API key with no user session, which is how the external automation authenticates.

Two unrelated fixes landed alongside this while building it out:

  • A source link in alert details. CloudWatch alerts now render a Console: line deep-linking to the alarm in the AWS Console, built from its ARN. Azure alerts already rendered essentials.investigationLink when present; added a Portal: fallback (a generic ARM-resource-ID deep link) for when it's absent, which per Microsoft's docs requires limited-preview registration and so is the common case, not the edge case.
  • A real cross-region bug the above surfaced. CloudWatch's Region: field and metadata were derived from the SNS topic's ARN, not the alarm's own ARN -- fine only by coincidence, since the deployed architecture is one topic receiving alarms from every region. An alarm firing in a different region than its topic would silently show the wrong region, while the new Console link (which always parses the alarm's own ARN) showed the correct one -- the same alert, two fields, disagreeing. Fixed to prefer the alarm's own ARN, with a dedicated cross-region test proving the two now agree.

Smoke-tested: merge-not-replace and overwrite-single-key for metadata; append-without-disturbing-existing-content, stacking on a second append, and truncation-not-rejection for details; closed-alert and nonexistent-alert rejection for both, with no partial effect.

Screenshot 2026-08-05 at 2 45 25 PM Screenshot 2026-08-05 at 5 10 18 PM Screenshot 2026-08-05 at 5 34 32 PM

Sarthak Arora and others added 2 commits July 31, 2026 16:54
CloudWatch alarms reach us through an SNS topic, but GoAlert could not be
subscribed to one directly for three independent reasons: SNS requires the
endpoint to fetch a SubscribeURL to confirm the subscription and nothing did
that, so zero messages were ever delivered; the SNS envelope has no `summary`
field and carries the alarm as a JSON string inside `Message`; and SNS always
sends `text/plain`, which genericapi.ServeCreateAlert ignores because it only
unmarshals `application/json`. That last point is upstream target#4463.

Add a `cloudwatch` integration key type and a handler at
POST /api/v2/cloudwatch/incoming that performs the subscription handshake and
verifies the SNS message signature, so a topic can be subscribed directly with
no Lambda or forwarder in between.

The key type is a full integration rather than a reuse of TypeGeneric because
the webhook URL shown in the UI is generated server-side from the key type
alone (IntegrationKey.Href). Reusing TypeGeneric would hand the user a
/api/v2/generic/incoming URL that silently fails to confirm as an SNS
subscription -- reproducing the exact bug this change fixes. Because the UI is
driven entirely by IntegrationKeyTypes and Href, no frontend changes are
needed.

Notable implementation details:

- The body is parsed regardless of Content-Type, and bounded with
  MaxBytesReader rather than io.LimitReader so an oversized body yields a 413
  instead of silently truncating into a misleading 400.
- Signature verification is split into pure functions (canonical string,
  verify, cert parse) so they are testable with no I/O. `Subject` is a *string
  because AWS omits the field from the string-to-sign entirely when absent,
  which a plain string cannot distinguish from an empty value.
- Both outbound fetches are host-allowlisted with an anchored pattern, and the
  client blocks redirects: the allowlist only covers the first hop, so a single
  302 from an allowlisted host would otherwise reach link-local addresses.
- The signing-cert cache is bounded with FIFO eviction because the cert URL
  path is attacker-supplied and would otherwise grow without limit.
- A freshness window on the signed Timestamp bounds replay of a captured
  envelope. Tradeoff: retries arriving over an hour late are rejected.
- Dedup is hex sha256(AlarmName), matching the CloudWatch alarm Lambdas that
  post to PagerDuty, so one alarm cannot produce two alerts across ingress
  paths. It is never nil, since a nil dedup silently falls back to a content
  hash that changes on every state transition and would break both idempotency
  and the OK close.
- NewStateReason is capped before assembling details so a verbose reason cannot
  push AlarmDescription, which carries the runbook URL, past the length limit.
- INSUFFICIENT_DATA and a stray OK with no open alert both create nothing and
  return 2xx; non-2xx is reserved for infrastructure failure so SNS retries
  only when a retry could help.

Tests: table-driven unit tests for the canonical string, allowlist (including
the unanchored-suffix bypass), signature and freshness, and the alarm mapping;
plus a smoke test that generates an RSA key, serves a self-signed cert from an
httptest server, and drives the real crypto and allowlist paths end to end.
These are the first unit tests in any GoAlert ingress package.

Verified against live AWS SNS: subscription confirms, an alarm creates one
alert with the runbook URL preserved, and re-delivery is suppressed as a
duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Azure Monitor delivers alerts by having an action group POST a webhook. Those
webhooks point at PagerDuty's Events API today; this adds a native GoAlert
destination, as a sibling to the existing cloudwatch integration.

The generic endpoint cannot serve this: Azure sends application/json so it
clears the content-type gate, but ServeCreateAlert expects a flat
{summary, details, action, dedup, meta} body while Azure nests everything under
data.essentials / data.alertContext with no top-level summary -- so every
delivery would create an alert with a blank summary rather than an error. There
is also nothing mapping Azure's alertId onto the dedup field.

Unlike SNS there is no subscription handshake and no signature, so the handler
makes no outbound requests at all -- there is no analogue of cloudwatch's host
allowlist or certificate cache. The integration key in the URL is the only
credential, which makes the webhook URL credential-grade; the docs say so
explicitly for this key type.

Parsing:

- Only the common alert schema is accepted. A legacy-schema payload is rejected
  with a message naming the fix (enable the common alert schema on the receiver)
  rather than being degraded to the fallback, which would produce content-free
  alerts with no indication why. 400 rather than 5xx, since Azure does not retry
  4xx and a misconfigured receiver is a permanent condition.
- Dispatch is on the presence of condition.allOf, not on a conditionType
  allowlist. Every metric and log criteria shape shares that envelope, so this
  covers SingleResource, MultipleResource (used by multi-resource and
  resource-group-scoped rules), DynamicThreshold and WebtestLocationAvailability
  with one code path. conditionType still selects the two behaviours that
  genuinely differ: suppressing the dynamic threshold, which is a sensitivity
  artifact rather than a limit, and the log query/link lines.
- Prometheus rule groups get their own branch: the shape carries no
  conditionType and no condition, only expression/labels/annotations.
- Service Health and activity-log payloads render from properties, with HTML
  stripped and string-containing-JSON fields left opaque.
- Anything unrecognised builds a best-effort alert from essentials, which is
  present on every payload regardless of type, and logs the
  signalType/monitorService/conditionType triple so a newly-routed alert type
  announces itself instead of silently producing thin alerts.

signalType is deliberately not the discriminator: Platform and Prometheus metric
alerts share signalType "Metric", and Log Alerts V2, Azure Backup and
ActivityLog Administrative all share "Log". Branching on it would feed unrelated
payloads to the wrong renderer.

Dedup is sha256(essentials.alertId). Azure alerts are stateful, so one alert
object carries the whole lifecycle and the Fired and Resolved deliveries share an
alertId -- which is what lets the Resolved delivery close the alert its Fired
delivery opened. originAlertId is deliberately not used: it is per-rule for
metric alerts, so a single missed close would hold the dedup key and mute that
rule permanently. Status comes from essentials.monitorCondition, never
alertContext.status, which can disagree with it because the underlying incident
resolved while the alert fired.

A json.UnmarshalTypeError on an individual field is tolerated rather than fatal.
Azure documents threshold and dimension values as strings but is not consistent
across shapes, and a hard failure means a 400, which Azure does not retry -- so
one oddly-typed field would lose the page instead of one value.

Verified against the target tenant's inventory, measured path-agnostically
across all resource types rather than by assuming where the action-group
reference lives: 495 metric rules, 8 log (V2) rules and 3 Prometheus rule groups
route to a PagerDuty action group. All three shapes are natively parsed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sarora-eightfold sarora-eightfold changed the title Add native AWS CloudWatch (via SNS) alert ingress Add native AWS CloudWatch and Azure Monitor alert ingress Aug 1, 2026
Comment thread cloudwatch/cloudwatch.go Dismissed
Comment thread cloudwatch/message.go Outdated
sarora-eightfold and others added 4 commits August 5, 2026 13:40
…tems

Nothing in the GraphQL API can modify an existing alert's content after
creation -- updateAlerts only changes status/noiseReason, and meta is
otherwise set once at createAlert time. External systems (e.g. a Jira
Automation rule that opens a ticket for an alert) have no way to write
anything back onto the alert, such as the resulting ticket key.

Add setAlertMetadata(input: SetAlertMetadataInput!): Boolean!, taking an
alertID and a list of key/value pairs.

The resolver merges rather than replaces: the underlying store call
(alert.Store.SetMetadataTx) does a wholesale
`INSERT ... ON CONFLICT (alert_id) DO UPDATE SET metadata = $2`, so calling
it directly with just the new key would silently wipe out everything an
ingress integration (CloudWatch, Azure Monitor, etc.) had already written at
creation time. The resolver reads existing metadata first and folds the new
keys in before writing, within one transaction, so unrelated keys are left
untouched.

Auth and alert-closed handling are both inherited from the existing store
method, unchanged: permission.User or permission.Service (service callers
are scoped to their own alerts via the existing ServiceID check), and the
call is rejected once the alert is closed (`AND a.status != 'closed'` in the
underlying query) rather than silently reopening or partially applying.

This is callable via a GraphQL API key (existing gqlAPIKeys mechanism) bound
to a fixed mutation, which is how an external automation rule reaches it
without a user session.

Smoke test covers: merging a new key without disturbing existing ones,
overwriting a single existing key, rejection (with no partial effect) once
the alert is closed, and rejection for a nonexistent alert ID.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both ingress handlers already surface the alarm ARN / alert ID as plain text
in details, but neither gave a human a click-through back to the source. Add
one for each.

CloudWatch: a "Console:" line deep-linking to the alarm in the AWS Console,
built from AlarmArn. Degrades to nothing rather than a broken link on any ARN
that doesn't parse cleanly. The alarm name is escaped with url.PathEscape, not
url.QueryEscape -- the console's SPA decodes the URL fragment with
decodeURIComponent, which does not treat "+" as a space, so QueryEscape would
silently mangle any alarm name containing one (real names commonly do, e.g.
"[us-west-2] Too Many Write Errors").

Azure Monitor: essentials.investigationLink (Microsoft's own field for this)
was already rendered as "Investigate:". Add a fallback "Portal:" line for when
it's absent, which per Microsoft's docs requires "limited preview
registration" -- meaning absent is the common case, not the edge case. The
fallback uses the generic "view any resource by ARM ID" portal pattern
(essentials.alertId is a full resource ID), not a dedicated alert-details
blade route: that specific deep-link format could not be confirmed against
current Microsoft documentation, and shipping an unverified blade route with
false confidence seemed worse than a generic-but-stable one. Investigate and
Portal are mutually exclusive, never both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Region was derived from the SNS envelope's TopicArn. That's only the alarm's
true region by coincidence: the deployed architecture is one topic receiving
CloudWatch alarms from every region, so a topic's region and a given alarm's
region routinely differ. The Console link added last commit already parses
region independently from the alarm's own AlarmArn, so the two fields could
silently disagree on the same alert -- "Region: us-west-2" next to a Console
link pointing at us-east-1.

Extract parseAlarmARN as a shared helper (previously duplicated inline in
alarmConsoleURL) and have buildAlarm prefer the alarm's own ARN for region,
falling back to the topic-derived value only when the alarm ARN doesn't parse.
The raw/non-CloudWatch branch is unaffected -- it has no per-item ARN to fall
back to, so the topic's region remains the only signal there.

Two existing tests asserted the old (buggy) fallback behavior for an
empty/malformed topic ARN; updated to reflect that region now correctly
recovers from the alarm's own ARN in both cases. Added a dedicated
cross-region test (topic in us-west-2, alarm in us-east-1) asserting Region
and Console now agree, which is the actual bug this closes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tails

setAlertMetadata (previous commit) lets an external system record structured
data against an alert, but it only writes to metadata -- which the GoAlert web
UI never surfaces anywhere. An external automation that creates a ticket for
an alert and wants that reflected somewhere a human actually looks needs the
visible Details text, not meta.

Add appendAlertDetails(input: AppendAlertDetailsInput!): Boolean!, taking an
alertID and a text string appended to the alert's existing Details, separated
by a blank line.

Unlike setAlertMetadata there's no existing store method to build on -- no
mutation has ever been able to modify Details after creation, and unlike meta
there's no key/value structure to merge on. New pieces:

- alert/queries.sql: Alert_SetDetails, a plain UPDATE with the same
  closed-alert and service-scoping guard as Alert_SetAlertMetadata.
- alert/details.go: SetDetailsTx, replacing Details wholesale (the resolver is
  responsible for reading the current value and appending before calling it).
- The resolver reads the current alert via AlertStore.FindOne (not
  transaction-scoped, so there's a small read-before-write window -- the same
  best-effort consistency already accepted for setAlertMetadata) and truncates
  the combined text to MaxDetailsLength rather than erroring if it overflows:
  losing the append over a length limit is a worse outcome than truncating,
  since the alert is already real and actionable.

FindOne's bare sql.ErrNoRows for a missing alertID is translated to
validation.NewFieldError rather than left to bubble up: unclassified errors
are logged at error level by GoAlert's own GraphQL error handling, and the
smoke harness (correctly) treats any error-level log line as a test failure.
Same pattern already used at alert.go:607 for a different resolver.

Smoke test covers: appending without disturbing existing content, a second
append stacking rather than overwriting, truncation (not rejection) when the
combined text exceeds the length limit, rejection once the alert is closed
with no partial effect, and rejection for a nonexistent alert ID.

Verified end-to-end against a real Jira Automation rule over the CloudWatch
ingress path: on ticket creation, the rule calls this mutation to append the
ticket key and URL into the alert's Details, visible immediately on the alert
page with no GraphQL query required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@sarora-eightfold sarora-eightfold changed the title Add native AWS CloudWatch and Azure Monitor alert ingress Add native AWS CloudWatch and Azure Monitor alert ingress, plus alert write-back mutations Aug 5, 2026
Comment thread azuremonitor/payload.go Outdated
Comment thread alert/queries.sql
Comment thread graphql2/schema.graphql Outdated
Comment thread graphql2/graphqlapp/alert.go Outdated
Comment thread azuremonitor/payload.go
Comment thread cloudwatch/payload.go Outdated
Comment thread cloudwatch/signature.go Outdated
Comment thread cloudwatch/certcache.go Outdated
Comment thread cloudwatch/cloudwatch.go
sarora-eightfold and others added 2 commits August 5, 2026 17:42
signedField was unreachable in practice (signedFields is a package-level
literal and every entry is covered by the switch), but a reviewer flagged
panic() as risky in a webhook handler. Return an error instead so a future
mismatch degrades to a rejected message rather than relying on the HTTP
layer's recover() to contain it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Status codes. Both providers' retry logic keys off the response code, and
each had a gap in the opposite place: SNS retries all 5xx and 429 but treats
408 as permanent, while Azure Monitor retries 408, 429, 503 and 504 but not
500. So an exhausted database write returned 500 -- dropped by Azure with
zero retries, exactly the Aurora-failover case most worth retrying -- and
concurrency back-pressure returned 408, dropped by SNS.

- ctxlock.ErrTimeout now answers 429 instead of 408, application-wide. 408
  means the client failed to send a complete request in time (RFC 9110
  15.5.9); this is server-side back-pressure, the same condition as
  ErrQueueFull, which already answers 429. Upstream target#4403 split the two
  apart when both had shared a 429 branch; the split is right, the code
  it picked is not.
- Adds errutil.HTTPErrorRetry, which answers 503 rather than 500 for an
  unexpected error and leaves every client-error branch alone. Both ingress
  handlers use it for the body read and the alert write. Existing HTTPError
  callers are untouched.

Review comments:

- azuremonitor: dedup no longer hashes "" when essentials.alertId is absent.
  Every such payload shared one key, so unrelated alerts collapsed onto a
  single alert that any one Resolved delivery could close. Falls back to the
  remaining stable identifiers, excluding resolvedDateTime so a Resolved
  delivery still matches its own Fired.
- appendAlertDetails: the read moves inside the write's transaction and takes
  a row lock (Alert_LockOneAlertDetails). Two concurrent appends both read
  the same original and one was silently discarded while still returning
  true. The doc comment had claimed parity with setAlertMetadata, which reads
  inside its transaction; that was wrong.
- setAlertMetadata: dropped the schema's claim that an empty value deletes a
  key -- nothing implemented it, so the key persisted with an empty value and
  could not be removed. Documents the real behaviour and pins it with a test.
- cloudwatch: NewHandler now enforces CheckRedirect rather than trusting the
  caller. Not following redirects is the load-bearing half of the SSRF
  control, and the cert fetch precedes signature verification.
- cloudwatch: certcache briefly negative-caches URLs that cannot yield a key,
  so a stream of distinct random .pem paths no longer costs an outbound fetch
  each. Transport errors are deliberately not cached, so a blip cannot poison
  a good URL. Adds the package's first certcache tests, covering eviction,
  the bound, truncation, and that failure traffic cannot evict a live entry.
- cloudwatch: freshness window is configurable (Config.MaxMessageAge) for
  subscriptions with a custom delivery policy, and a stale message now logs
  distinctly from a forged one -- only one is actionable. The response stays
  403 for both.
- alert/queries.sql: moved a trailing comment inline; sqlc was attaching it
  to the next query as its doc comment.
- Fixed two stranded doc comments left by an earlier move.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread graphql2/graphqlapp/alert.go
Comment thread util/errutil/httperror.go Outdated
Comment thread cloudwatch/certcache.go
Comment thread cloudwatch/payload.go Outdated
Comment thread azuremonitor/payload.go Outdated
Comment thread graphql2/graphqlapp/alert.go
Six follow-up findings from the previous fix's review pass, each with a
regression test that fails on the prior code and passes now.

- setAlertMetadata: the metadata read was unlocked (AlertStore.Metadata is a
  plain SELECT), unlike appendAlertDetails's FOR UPDATE. Two concurrent calls
  setting different keys on the same alert -- the realistic case, e.g. two
  automations each writing their own key -- both read the same starting
  document and the later commit silently discarded the other's key while still
  returning true. Adds Alert_LockOneAlertMetadata, locking `alerts` rather than
  `alert_data`: alert_data has no row before an alert's first metadata write, so
  locking it cannot serialize the first-writer case. Also makes the
  not-found path consistent with appendAlertDetails (validation.NewFieldError
  instead of SetMetadataTx's access-denied fallback, which conflated "no such
  alert" with "wrong service"). New smoke subtest fires 8 concurrent calls and
  reliably reproduced the lost update pre-fix.

- 408: kept the app-wide 429 change as directed, but gave ErrQueueFull and
  ErrTimeout distinct response bodies so a caller can still tell "queue full"
  from "timed out waiting" apart now that they share a status code. Adds
  util/errutil's first test file.

- cloudwatch/certcache.go: the negative-cache comment overclaimed -- it only
  suppresses a repeated fetch of the same failing URL, not a flood of distinct
  ones, since each is a first-time miss on both caches. Adds a semaphore
  bounding concurrent fetches across all URLs, and singleflight to collapse
  concurrent callers requesting the same URL (the legitimate case right after
  AWS rotates a cert) into one fetch. New tests: 100 distinct URLs stay under
  the concurrency bound, and 20 concurrent callers for one URL produce a single
  fetch.

- cloudwatch buildAlarm / azuremonitor alertSummary: the blank-name fallback
  tested TrimSpace, but SanitizeText strips non-printable characters TrimSpace
  leaves alone, so a control-character-only name passed the blank check, then
  sanitized to "" a few lines later. validate.Text accepts an empty body
  regardless of minimum length, so this doesn't 400 -- it silently creates a
  real, blank-summary alert. Both now test the sanitized value.

- azuremonitor buildMeta / cloudwatch alarmMeta: maxMetaValueLen bounds each
  value in runes, but alert.ValidateMetadata sums bytes; enough multi-byte
  values near that cap can exceed the 32KiB total (reachable on azuremonitor's
  dozen keys, and on cloudwatch's seven only by arithmetic coincidence).
  cleanMeta now computes a byte budget per value from however many keys are
  actually populated, so the total fits regardless of key count -- including if
  one is added later.

- appendAlertDetails doc comment: dropped the claim that it races an ingress
  CreateOrUpdate rewriting details -- verified CreateOrUpdateWithMeta only
  writes details at creation, never on an existing dedup match, so there's no
  such race to guard against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants