Skip to content

Commit 656effa

Browse files
daithihearnclaude
andcommitted
feat: turn dev alerting off entirely, not just its delivery
alerts_enabled gated the SNS subscription alone, so dev built every alarm and every AMP rule and simply dropped what they produced. Nobody acts on a dev page, and a hibernated environment would sit permanently red. The recording rules and the delivery path stay in both environments: the dashboards query the first, and keeping the second is what makes turning alerts back on a one-line flip rather than a wait on the webhook secret's recovery window. Costs dev's validation of the alert rule YAML, which AMP does server-side when the rule group applies — prod is now the first apply to parse a new rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bfcdf2c commit 656effa

8 files changed

Lines changed: 44 additions & 24 deletions

File tree

‎terraform/README.md‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,20 @@ metric name, and a wildcard also matches the `ALL` aggregate and the managed gro
6060
which double-counts. `CountedRequests` overlaps `AllowedRequests` for the same reason — a count-mode
6161
match does not terminate evaluation — so the two never belong in one stack.
6262

63-
Alerts evaluate in every environment but `alerts_enabled` in the env YAML controls whether they are
64-
delivered, and dev has it off. To rehearse the path end to end: set the `SLACK_WEBHOOK_URL` secret on
65-
the `dev` GitHub Environment, flip `alerts_enabled` to `true`, scale the service to zero, and confirm
66-
the no-healthy-targets alarm arrives and then recovers.
63+
`alerts_enabled` in the env YAML turns alerting on, and dev has it off — no CloudWatch alarms, no AMP
64+
alert rule group, no Alertmanager definition, and Grafana's Alerting UI is not wired to any of them.
65+
Nobody acts on a dev page, and dev is hibernated often enough that alarms there would read as
66+
permanently red. The recording rules are separate and always built, because the dashboards query them.
67+
68+
So is the delivery path — topic, bridge Lambda and webhook secret — which costs nothing idle and is
69+
what keeps re-enabling a one-line flip rather than a wait on that secret's seven-day recovery window.
70+
To rehearse end to end: set the `SLACK_WEBHOOK_URL` secret on the `dev` GitHub Environment, flip
71+
`alerts_enabled` to `true`, deploy, scale the service to zero, and confirm the no-healthy-targets
72+
alarm arrives and then recovers.
73+
74+
The cost of dev not evaluating the AMP rules is that **prod is now the first apply to parse a new
75+
one** — `aws_prometheus_rule_group_namespace` is what validates the YAML and its PromQL, server-side.
76+
Rehearsing in dev before touching `alert_rules_yaml` is the cheap way round it.
6777

6878
Grafana sign-in is Okta SAML, configured from `grafana_okta_saml_metadata_url` and the two
6979
`grafana_*_okta_groups` lists in the env YAML. Blank the URL, or leave the Admin list empty, and the

‎terraform/environments/dev/dev.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ grafana_okta_saml_metadata_url: https://vechaineu.okta.com/app/exk1020c50lQfO4EF
3737
grafana_admin_okta_groups: [APP_AMG_BLOCK_EXPLORER_Admin]
3838
grafana_editor_okta_groups: [APP_AMG_BLOCK_EXPLORER_Editor]
3939

40-
# Rules and alarms evaluate either way; this only gates Slack delivery.
40+
# Off entirely: no alarms, no AMP alert rules, no Alertmanager.
4141
alerts_enabled: false
4242

4343
# WAF. The rate-based IP rule always blocks; the AWS managed groups stay in

‎terraform/observability-aws/alarms.tf‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ locals {
5454
# Running against desired, so autoscaling never has to be restated here.
5555
# `breaching` is load-bearing: a service reporting nothing is what this catches.
5656
resource "aws_cloudwatch_metric_alarm" "ecs_tasks_below_desired" {
57-
for_each = local.monitored_services
57+
for_each = local.alerts_enabled ? local.monitored_services : {}
5858

5959
alarm_name = "${local.name}-${each.key}-tasks-below-desired"
6060
alarm_description = "${each.value} tasks below desired — ECS is running fewer tasks than the service asks for, or has stopped reporting task counts altogether."
@@ -109,7 +109,7 @@ resource "aws_cloudwatch_metric_alarm" "ecs_tasks_below_desired" {
109109
# Catches RUNNING-but-wedged, which the task-count alarm sits through. Maximum,
110110
# not Minimum: the metric is per-AZ, so Minimum false-fires below one task per AZ.
111111
resource "aws_cloudwatch_metric_alarm" "alb_no_healthy_targets" {
112-
count = local.alb_arn_suffix == null || local.target_group_arn_suffix == null ? 0 : 1
112+
count = local.alerts_enabled && local.alb_arn_suffix != null && local.target_group_arn_suffix != null ? 1 : 0
113113

114114
alarm_name = "${local.name}-no-healthy-targets"
115115
alarm_description = "Explorer has no healthy targets — the load balancer has nothing to route to."
@@ -136,6 +136,8 @@ resource "aws_cloudwatch_metric_alarm" "alb_no_healthy_targets" {
136136
# publishes to the topic whose consumer is broken — so read this in CloudWatch or
137137
# Grafana rather than expecting it in Slack.
138138
resource "aws_cloudwatch_metric_alarm" "alert_delivery_failing" {
139+
count = local.alerts_enabled ? 1 : 0
140+
139141
alarm_name = "${local.name}-alert-delivery-failing"
140142
alarm_description = "Alert delivery is failing — the SNS to Slack bridge is erroring, so rules and alarms are firing into nothing."
141143

@@ -156,7 +158,7 @@ resource "aws_cloudwatch_metric_alarm" "alert_delivery_failing" {
156158
# The load balancer's own 5xx, not the target's: 502/503 with nothing healthy to
157159
# route to, 504 on the idle timeout.
158160
resource "aws_cloudwatch_metric_alarm" "alb_elb_5xx" {
159-
count = local.alb_arn_suffix == null ? 0 : 1
161+
count = local.alerts_enabled && local.alb_arn_suffix != null ? 1 : 0
160162

161163
alarm_name = "${local.name}-alb-5xx"
162164
alarm_description = "ALB returning 5xx — 502/503 with no healthy target to route to, or 504 from the idle timeout."
@@ -178,7 +180,7 @@ resource "aws_cloudwatch_metric_alarm" "alb_elb_5xx" {
178180
# What the app actually returned, counted at the load balancer. The app's own
179181
# 5xx rule in AMP is the inside view of the same failure and stays alongside it.
180182
resource "aws_cloudwatch_metric_alarm" "alb_target_5xx" {
181-
count = local.alb_arn_suffix == null || local.target_group_arn_suffix == null ? 0 : 1
183+
count = local.alerts_enabled && local.alb_arn_suffix != null && local.target_group_arn_suffix != null ? 1 : 0
182184

183185
alarm_name = "${local.name}-target-5xx"
184186
alarm_description = "High target 5xx rate — the explorer has been returning more than 1 server error per second for over 5 minutes."
@@ -204,7 +206,7 @@ resource "aws_cloudwatch_metric_alarm" "alb_target_5xx" {
204206
# Published as a per-minute Sum, so the ceiling is the per-second cap times 60. At
205207
# the cap Valkey throttles rather than queues, which the app reads as a plain miss.
206208
resource "aws_cloudwatch_metric_alarm" "cache_ecpu_high" {
207-
count = local.cache_name == null ? 0 : 1
209+
count = local.alerts_enabled && local.cache_name != null ? 1 : 0
208210

209211
alarm_name = "${local.name}-cache-ecpu-high"
210212
alarm_description = "Valkey ECPU usage is high — the shared cache has been above ${format("%.0f", local.saturation_threshold * 100)}% of its configured ECPU ceiling for five minutes, and commands are throttled once it reaches it."
@@ -228,7 +230,7 @@ resource "aws_cloudwatch_metric_alarm" "cache_ecpu_high" {
228230
# A serverless cache exposes no eviction policy, so entries live their full TTL and
229231
# the working set is "distinct keys per day" rather than a number anyone picked.
230232
resource "aws_cloudwatch_metric_alarm" "cache_storage_high" {
231-
count = local.cache_name == null ? 0 : 1
233+
count = local.alerts_enabled && local.cache_name != null ? 1 : 0
232234

233235
alarm_name = "${local.name}-cache-storage-high"
234236
alarm_description = "Valkey storage is high — the shared cache has held more than ${format("%.0f", local.saturation_threshold * 100)}% of its configured storage ceiling for fifteen minutes. Entries expire but are never evicted, so the TTLs are what have to come down."
@@ -251,7 +253,7 @@ resource "aws_cloudwatch_metric_alarm" "cache_storage_high" {
251253
# Reads either way round: a real flood, or a managed rule group false-positiving
252254
# on legitimate traffic once it is flipped from Count to Block.
253255
resource "aws_cloudwatch_metric_alarm" "waf_blocked_requests" {
254-
count = local.waf_web_acl_name == null ? 0 : 1
256+
count = local.alerts_enabled && local.waf_web_acl_name != null ? 1 : 0
255257

256258
alarm_name = "${local.name}-waf-blocking-heavily"
257259
alarm_description = "WAF is blocking heavily — more than 5 requests per second have been blocked for over 10 minutes. Check the WAF log group for whether the source is hostile or a false positive."

‎terraform/observability-aws/alerts.tf‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,7 @@ resource "aws_lambda_permission" "sns_to_slack_invoke" {
158158
source_arn = aws_sns_topic.alerts.arn
159159
}
160160

161-
# Both pipelines publish to the one topic, so this subscription is the single gate
162-
# on delivery. Rules and alarms evaluate and show their state without it.
161+
# The topic, bridge and secret are built either way; only this is gated.
163162
resource "aws_sns_topic_subscription" "sns_to_slack" {
164163
count = local.alerts_enabled ? 1 : 0
165164

@@ -188,13 +187,18 @@ resource "aws_prometheus_rule_group_namespace" "recording" {
188187
data = local.recording_rules_yaml
189188
}
190189

190+
# Gated, unlike the recording rules above: the dashboards query those.
191191
resource "aws_prometheus_rule_group_namespace" "alerts" {
192+
count = local.alerts_enabled ? 1 : 0
193+
192194
workspace_id = aws_prometheus_workspace.this.id
193195
name = "${local.name}-alerts"
194196
data = local.alert_rules_yaml
195197
}
196198

197199
resource "aws_prometheus_alert_manager_definition" "this" {
200+
count = local.alerts_enabled ? 1 : 0
201+
198202
workspace_id = aws_prometheus_workspace.this.id
199203
definition = local.alertmanager_definition
200204
}

‎terraform/observability-aws/locals.tf‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ locals {
66

77
okta_saml_ready = local.env.grafana_okta_saml_metadata_url != "" && length(local.env.grafana_admin_okta_groups) > 0 && length(local.env.grafana_editor_okta_groups) > 0
88

9-
# Delivery only. Off in dev, where nobody acts on a page.
9+
# Gates evaluation, not just delivery. See the README.
1010
alerts_enabled = local.env.alerts_enabled
1111

1212
saturation_threshold = 0.8

‎terraform/observability-aws/outputs.tf‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ output "alerts_sns_topic_arn" {
4444
}
4545

4646
output "alerts_enabled" {
47-
description = "Whether the bridge Lambda is subscribed to the topic. False in dev: rules and alarms evaluate, nothing is delivered."
47+
description = "Whether alerting is on at all. False in dev: no CloudWatch alarms, no AMP alert rules, no Alertmanager and no subscription. Recording rules and the delivery path are built either way."
4848
value = local.alerts_enabled
4949
}
5050

‎terraform/observability-grafana/locals.tf‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ locals {
2020
amg_sa_token_secret_version_id = try(data.terraform_remote_state.observability_aws.outputs.amg_service_account_token_secret_version_id, null)
2121
amp_prometheus_endpoint = try(data.terraform_remote_state.observability_aws.outputs.amp_prometheus_endpoint, null)
2222

23+
# Off, there is no rule group or Alertmanager definition to point Grafana at.
24+
alerts_enabled = try(data.terraform_remote_state.observability_aws.outputs.alerts_enabled, false)
25+
2326
amg_service_account_token = try(data.aws_secretsmanager_secret_version.amg_sa_token[0].secret_string, null)
2427

2528
alb_arn_suffix = try(data.terraform_remote_state.edge.outputs.alb_arn_suffix, null)

‎terraform/observability-grafana/main.tf‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,19 @@ resource "grafana_data_source" "amp" {
5151
uid = local.datasource_uid_amp
5252
url = local.amp_prometheus_endpoint
5353

54-
json_data_encoded = jsonencode({
55-
httpMethod = "POST"
56-
sigV4Auth = true
57-
sigV4AuthType = "default"
58-
sigV4Region = var.aws_region
59-
manageAlerts = true
54+
json_data_encoded = jsonencode(merge({
55+
httpMethod = "POST"
56+
sigV4Auth = true
57+
sigV4AuthType = "default"
58+
sigV4Region = var.aws_region
59+
manageAlerts = local.alerts_enabled
60+
}, local.alerts_enabled ? {
6061
alertmanagerUid = local.datasource_uid_amp_alertmanager
61-
})
62+
} : {}))
6263
}
6364

6465
resource "grafana_data_source" "amp_alertmanager" {
65-
count = local.observability_ready ? 1 : 0
66+
count = local.observability_ready && local.alerts_enabled ? 1 : 0
6667

6768
type = "alertmanager"
6869
name = "amp-alertmanager-${terraform.workspace}"

0 commit comments

Comments
 (0)