@fjall/components-infrastructure 12.0.0 → 12.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { type TaskDefinition } from "aws-cdk-lib/aws-ecs";
3
3
  import { type IBucket } from "aws-cdk-lib/aws-s3";
4
4
  import { Construct } from "constructs";
5
5
  import { Secret, type SecretImport } from "../../resources/aws/secrets/secret.js";
6
+ import { type ClickHouseAlarmThresholds } from "../../resources/aws/monitoring/index.js";
6
7
  import { type ClickHouseSchemaAdmin, type ProfileSpec } from "../../resources/aws/database/clickhouseSchemas.js";
7
8
  import { type ISecret } from "aws-cdk-lib/aws-secretsmanager";
8
9
  import { type ITopic } from "aws-cdk-lib/aws-sns";
@@ -73,8 +74,15 @@ export interface ClickHouseDatabaseProps {
73
74
  /**
74
75
  * BACKUP DATABASE TO S3 schedule (D19). Schedule string validated by CDK
75
76
  * `Schedule.expression(...)` at synth time. `false` disables the sidecar
76
- * (and skips the backup-task log group).
77
+ * (and skips the backup-task log group, so no backup alarms materialise).
77
78
  * Default: `BACKUP_SCHEDULE` from `clickhouseConstants.ts`.
79
+ *
80
+ * Coupled to `alarms.backupHeartbeatWindowHours`: the backup heartbeat
81
+ * pages when no success lands within the window (default 26 h, sized for
82
+ * the daily default). A customised schedule without a matching window
83
+ * override draws a synth-time warning — sparser-than-daily schedules must
84
+ * widen the window (max 143 h — the paging-guarantee ceiling), and
85
+ * schedules sparser than ~5.8 days must disable the heartbeat.
78
86
  */
79
87
  backupSchedule?: string | false;
80
88
  /**
@@ -136,22 +144,50 @@ export interface ClickHouseDatabaseProps {
136
144
  */
137
145
  tls?: ClickHouseTlsOptions;
138
146
  /**
139
- * Ops alarm SNS topic for the ClickHouse host-posture alarms (CPU / memory /
140
- * disk warn+critical) and when `backupSchedule` is enabled — the
141
- * backup-failure alarm. Accepts an `ITopic`, an `arn:` string, or the
142
- * `"import:<ExportName>"` form the generator scaffolds onto production apps
143
- * (`"import:SharedAlarmTopicArn"`). Forwarded automatically by
144
- * `DatabaseFactory.build` from `BaseDatabaseProps.alertsTopic`. Omitted (the
145
- * default) no host alarms, matching the dormant pre-dogfood behaviour.
147
+ * Ops alarm SNS topic for the full ClickHouse alarm surface: host-posture
148
+ * alarms (CPU / disk warn+critical), the ECS-layer service alarms (cgroup
149
+ * memory, `RunningTaskCount` liveness, task-stop watchdog), the server-log
150
+ * signal alarms (errors, failed queries, backpressure, cold-tier S3), and —
151
+ * when `backupSchedule` is enabled — the backup-failure alarm plus the
152
+ * backup success heartbeat (absence + arming-gate alarms and the paging
153
+ * composite). Accepts an
154
+ * `ITopic`, an `arn:` string, or the `"import:<ExportName>"` form the
155
+ * generator scaffolds onto production apps (`"import:SharedAlarmTopicArn"`).
156
+ * Forwarded automatically by `DatabaseFactory.build`. Omitted (the default)
157
+ * → no alarms, matching the dormant pre-dogfood behaviour.
146
158
  *
147
- * Resolved internally via `resolveAlertsTopic`; the construct owns the
148
- * `instanceRole` / `asgName` / `backupTaskLogGroup` the alarms need, so no
149
- * caller wiring is required. The stuck-merge alarm is NOT declared here —
150
- * `"Stuck merge detected"` is emitted by the webapp app process, so it lives
151
- * on the app service's declarative `logAlarms` instead.
159
+ * Resolved internally via `resolveAlertsTopic` and threaded into the inner
160
+ * `EcsCompute` so the generic ECS alarm pipeline materialises alongside the
161
+ * curated host set; the container-CPU service alarm is deliberately
162
+ * disabled (host CPU owns that signal a 1-vCPU box pins container CPU on
163
+ * routine merges). The construct owns the `instanceRole` / `asgName` /
164
+ * `backupTaskLogGroup` the alarms need, so no caller wiring is required.
165
+ * The stuck-merge alarm is NOT declared here — `"Stuck merge detected"` is
166
+ * emitted by the webapp app process, so it lives on the app service's
167
+ * declarative `logAlarms` instead.
152
168
  */
153
169
  alertsTopic?: ITopic | string;
170
+ /**
171
+ * Alarm-threshold tuning knob, fanned out across the host alarms, the ECS
172
+ * service memory alarm, and the server-log signal alarms — see
173
+ * `ClickHouseAlarmThresholds` for the per-field defaults. `false` suppresses
174
+ * the whole ClickHouse alarm surface (host, service, log, watchdog) even
175
+ * when `alertsTopic` is set; the persistent-data-volume alarms are
176
+ * infrastructure-level and unaffected. Omitted → defaults.
177
+ */
178
+ alarms?: ClickHouseAlarmThresholds | false;
179
+ /**
180
+ * Application ID for webhook-to-application alarm mapping. Tagged onto every
181
+ * ClickHouse alarm and appended to alarm descriptions — same contract as the
182
+ * RDS branch of `DatabaseFactory.build`.
183
+ */
184
+ applicationId?: string;
154
185
  }
186
+ /**
187
+ * Public-facing re-export of the alarm knob so factory consumers can type
188
+ * `alarms:` from the patterns layer (same convention as `ServiceLogAlarm`).
189
+ */
190
+ export type { ClickHouseAlarmThresholds };
155
191
  /**
156
192
  * ClickHouse analytics database wrapper implementing IClickHouseDatabase.
157
193
  *
@@ -19,9 +19,9 @@ import { createClickHouseSecurityGroup } from "../../resources/aws/database/clic
19
19
  import { buildClickHouseEntrypointWrapper, buildClickHouseUserData, generateUsersConfigXml } from "../../resources/aws/database/clickhouseUserData.js";
20
20
  import { toPascalCase } from "../../utils/capitaliseString.js";
21
21
  import { resolveAlertsTopic } from "../../utils/resolveAlertsTopic.js";
22
- import { createClickHouseAlarms } from "../../resources/aws/monitoring/index.js";
22
+ import { createClickHouseAlarms, validateClickHouseAlarmThresholds, BACKUP_HEARTBEAT_DEFAULT_WINDOW_HOURS, BACKUP_HEARTBEAT_MAX_WINDOW_HOURS, METRIC_NAMESPACE, stackScopedMetricNamespace } from "../../resources/aws/monitoring/index.js";
23
23
  import { ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema, PROFILE_NAME_PATTERN } from "../../resources/aws/database/clickhouseSchemas.js";
24
- import { deriveClickHouseDefaultProfiles } from "../../resources/aws/database/clickhouseTuning.js";
24
+ import { deriveClickHouseDefaultProfiles, deriveClickHouseServerTuning } from "../../resources/aws/database/clickhouseTuning.js";
25
25
  import { inferAmiHardwareType } from "../../resources/aws/compute/ecsConstants.js";
26
26
  import { CLICKHOUSE_DATABASE_NAME, DEFAULT_CLICKHOUSE_INSTANCE_TYPE, CLICKHOUSE_IMAGE, CLICKHOUSE_EBS_VOLUME_SIZE_GB, CLICKHOUSE_EBS_IOPS, CLICKHOUSE_EBS_THROUGHPUT_MBPS, clickHouseTaskMemoryMiB, CLICKHOUSE_HTTP_PORT, CLICKHOUSE_HTTPS_PORT, CLICKHOUSE_NATIVE_PORT, CLICKHOUSE_TCP_SECURE_PORT, CLICKHOUSE_TLS_CERT_MOUNT_PATH, CLICKHOUSE_PROMETHEUS_PORT, CLICKHOUSE_DATA_MOUNT_PATH, CLICKHOUSE_SECRET_OPTIONS, CLICKHOUSE_SERVER_ROLE_TAG, CLICKHOUSE_HOST_METRICS, clickHouseUserSecretName, CLICKHOUSE_HEALTH_CHECK, CLICKHOUSE_STOP_TIMEOUT_SECONDS, CLICKHOUSE_EBS_DEVICE_NAME, CLICKHOUSE_CONFIG_SUBDIR, CLICKHOUSE_USERS_SUBDIR, userPasswordEnvName, OPTIMISE_FINAL_SCHEDULE, REPLACING_MERGE_TREE_TABLES, OPTIMISE_MV_TABLES, CLICKHOUSE_CLOUDMAP_SERVICE_NAME, CLICKHOUSE_SERVER_CONTAINER_NAME, CLICKHOUSE_SERVICE_NAME, OPTIMISE_TASK_MEMORY_MIB, OPTIMISE_TASK_CPU_UNITS, BACKUP_SCHEDULE, BACKUP_TASK_MEMORY_MIB, BACKUP_TASK_CPU_UNITS, BACKUP_RETENTION_DAYS } from "../../resources/aws/database/clickhouseConstants.js";
27
27
  import { TlsCertGenerator } from "../../resources/aws/utilities/tlsCertGenerator.js";
@@ -50,6 +50,75 @@ function resolveClickHouseDesiredCount(contextValue, propValue) {
50
50
  return propValue;
51
51
  return 1;
52
52
  }
53
+ /**
54
+ * Positional (space-delimited) CloudWatch filter patterns for ClickHouse
55
+ * server-log lines, e.g.:
56
+ *
57
+ * `2026.08.12 10:15:42.123456 [ 456 ] {query-id} <Error> executeQuery: …`
58
+ *
59
+ * CloudWatch tokenises on spaces, treating the bracketed thread id `[ 456 ]`
60
+ * as ONE field, so the severity token is always field 5 and the component
61
+ * token field 6. Positional matching is the only reliable shape here:
62
+ * CloudWatch's regex subset rejects `<`/`>` and `()` grouping outright, and a
63
+ * plain substring term (`"<Error>"` unanchored) false-positives on query text
64
+ * echoed into log lines (a SELECT containing the literal). The severity
65
+ * position cannot.
66
+ */
67
+ const CLICKHOUSE_ERROR_LINE_PATTERN = '[w1, w2, w3, w4, w5="<Error>" || w5="<Fatal>", ...]';
68
+ const CLICKHOUSE_FAILED_QUERY_PATTERN = '[w1, w2, w3, w4, w5="<Error>", w6="executeQuery:", ...]';
69
+ /**
70
+ * Server-log signal alarm specs for the ClickHouse service's declarative
71
+ * `logAlarms`. Three always-on signals plus a cold-tier S3 signal when the
72
+ * cold tier is enabled. Thresholds come from the `alarms` knob with defaults
73
+ * documented on `ClickHouseAlarmThresholds`; the failed-queries default
74
+ * derives from the instance's `maxConcurrentQueries` so it tracks host size.
75
+ */
76
+ function buildClickHouseLogAlarmSpecs(thresholds, coldTierEnabled, maxConcurrentQueries) {
77
+ const specs = [
78
+ {
79
+ idStem: "ClickHouseServerErrors",
80
+ metricName: "ClickHouseServerErrorCount",
81
+ description: "ClickHouse server error/fatal log rate exceeds threshold — check system.errors, recent merges, and disk headroom",
82
+ literal: CLICKHOUSE_ERROR_LINE_PATTERN,
83
+ threshold: thresholds.serverErrorsPer5Min ?? 30,
84
+ period: Duration.minutes(5),
85
+ evaluationPeriods: 3,
86
+ datapointsToAlarm: 2
87
+ },
88
+ {
89
+ idStem: "ClickHouseFailedQueries",
90
+ metricName: "ClickHouseFailedQueryCount",
91
+ description: "ClickHouse failed-query rate exceeds threshold — check system.query_log for the failing statement and error code",
92
+ literal: CLICKHOUSE_FAILED_QUERY_PATTERN,
93
+ threshold: thresholds.failedQueriesPer5Min ?? 3 * maxConcurrentQueries,
94
+ period: Duration.minutes(5),
95
+ evaluationPeriods: 3,
96
+ datapointsToAlarm: 2
97
+ },
98
+ {
99
+ idStem: "ClickHouseBackpressure",
100
+ metricName: "ClickHouseBackpressureCount",
101
+ description: "ClickHouse background backpressure — merge scheduling pausing or parts accumulating; check background pool sizing and partition granularity",
102
+ anyTerms: ["Temporarily pause scheduling", "Too many parts"],
103
+ threshold: thresholds.backpressurePer15Min ?? 50,
104
+ period: Duration.minutes(15),
105
+ evaluationPeriods: 2,
106
+ datapointsToAlarm: 2
107
+ }
108
+ ];
109
+ if (coldTierEnabled) {
110
+ specs.push({
111
+ idStem: "ClickHouseColdTierS3Errors",
112
+ metricName: "ClickHouseColdTierS3ErrorCount",
113
+ description: "ClickHouse server-side S3 errors (cold-tier moves or BACKUP TO S3) — check the instance role grants and bucket policies for the cold-tier and backup buckets",
114
+ anyTerms: ["S3_ERROR", "AccessDenied"],
115
+ threshold: 1,
116
+ period: Duration.minutes(15),
117
+ evaluationPeriods: 1
118
+ });
119
+ }
120
+ return specs;
121
+ }
53
122
  /**
54
123
  * Narrow `T | undefined` to `T`. Used at the 3 sites where a `Map.get(...)` /
55
124
  * `Array.find(...)` result is structurally undefined-able but invariants
@@ -367,9 +436,22 @@ export class ClickHouseDatabase extends Construct {
367
436
  userSecret.getImport("password");
368
437
  }
369
438
  const resolvedAlertsTopic = resolveAlertsTopic(this, "AlertsTopic", props.alertsTopic);
439
+ const alarmsDisabled = props.alarms === false;
440
+ const alarmThresholds = typeof props.alarms === "object" ? props.alarms : {};
441
+ validateClickHouseAlarmThresholds(alarmThresholds);
442
+ const serverTuning = deriveClickHouseServerTuning(instanceType);
443
+ const serviceLogAlarms = alarmsDisabled
444
+ ? undefined
445
+ : buildClickHouseLogAlarmSpecs(alarmThresholds, coldTierEnabled, serverTuning.maxConcurrentQueries);
370
446
  const ecsCompute = new EcsCompute(this, "Compute", {
371
447
  type: "ecs",
372
448
  vpc,
449
+ ...(resolvedAlertsTopic !== undefined && {
450
+ alertsTopic: resolvedAlertsTopic
451
+ }),
452
+ ...(props.applicationId !== undefined && {
453
+ applicationId: props.applicationId
454
+ }),
373
455
  cluster: {
374
456
  loadBalancer: false,
375
457
  securityGroup,
@@ -380,6 +462,22 @@ export class ClickHouseDatabase extends Construct {
380
462
  name: CLICKHOUSE_SERVICE_NAME,
381
463
  capacityProvider: "EC2",
382
464
  desiredCount,
465
+ alarms: alarmsDisabled
466
+ ? false
467
+ : {
468
+ // Container CPU is deliberately NOT alarmed: the host CPU
469
+ // alarm (AWS/EC2) owns that signal on this single-purpose
470
+ // box, and merges routinely pin container CPU on 1-vCPU
471
+ // hosts. Memory IS alarmed here rather than via CWAgent —
472
+ // the ECS denominator is `clickHouseTaskMemoryMiB()`, so
473
+ // the % means the same headroom on every instance size.
474
+ cpuThreshold: false,
475
+ memoryThreshold: alarmThresholds.memoryThreshold ?? 90
476
+ },
477
+ ...(serviceLogAlarms !== undefined && {
478
+ logAlarms: serviceLogAlarms
479
+ }),
480
+ logMetricNamespace: stackScopedMetricNamespace(METRIC_NAMESPACE.CLICKHOUSE),
383
481
  // Omitting `scaling` attaches default CPU target tracking. A scaled-out
384
482
  // second task is an EMPTY second database behind the same Cloud Map name
385
483
  // (plain MergeTree does not replicate) — desired-count scaling stays off.
@@ -527,12 +625,23 @@ export class ClickHouseDatabase extends Construct {
527
625
  // it the alarms cannot be built; no topic is the default, so skip silently
528
626
  // rather than throw.
529
627
  const asgName = ecsCompute.getAutoScalingGroupName();
530
- if (resolvedAlertsTopic !== undefined && asgName !== undefined) {
628
+ if (resolvedAlertsTopic !== undefined &&
629
+ asgName !== undefined &&
630
+ !alarmsDisabled) {
631
+ if (backupEnabled &&
632
+ typeof props.backupSchedule === "string" &&
633
+ alarmThresholds.backupHeartbeatWindowHours === undefined) {
634
+ Annotations.of(this).addWarningV2("fjall:clickhouse:backup-heartbeat-window", `backupSchedule is customised but alarms.backupHeartbeatWindowHours is not set — the backup heartbeat pages when no successful backup lands within ${BACKUP_HEARTBEAT_DEFAULT_WINDOW_HOURS}h. For sparser schedules set backupHeartbeatWindowHours (schedule interval + 2h margin, max ${BACKUP_HEARTBEAT_MAX_WINDOW_HOURS}); schedules sparser than ~5.8 days cannot be heartbeat-monitored — set false to disable the heartbeat instead.`);
635
+ }
531
636
  createClickHouseAlarms({
532
637
  scope: this,
533
638
  instanceRole,
534
639
  asgName,
535
640
  alarmTopic: resolvedAlertsTopic,
641
+ config: alarmThresholds,
642
+ ...(props.applicationId !== undefined && {
643
+ applicationId: props.applicationId
644
+ }),
536
645
  ...(backupTaskLogGroup !== undefined && { backupTaskLogGroup })
537
646
  });
538
647
  }
@@ -4,7 +4,7 @@ import type { ITopic } from "aws-cdk-lib/aws-sns";
4
4
  import type { RdsAlarmThresholds } from "../../resources/aws/monitoring/index.js";
5
5
  import type App from "../../app.js";
6
6
  import { type DynamoDBKeySchema, type DynamoDBGlobalSecondaryIndex } from "../../resources/aws/database/dynamodb.js";
7
- import { ClickHouseDatabase, type ClickHouseDatabaseProps } from "./clickhouseDatabase.js";
7
+ import { ClickHouseDatabase, type ClickHouseDatabaseProps, type ClickHouseAlarmThresholds } from "./clickhouseDatabase.js";
8
8
  import { type Connections, type IConnectable, type IVpc } from "aws-cdk-lib/aws-ec2";
9
9
  import { type ITable } from "aws-cdk-lib/aws-dynamodb";
10
10
  import { type Secret, type SecretImport } from "../../resources/aws/secrets/index.js";
@@ -331,7 +331,7 @@ export declare class RelationalDatabase extends Construct implements IRelational
331
331
  */
332
332
  grantIamConnect(grantee: IGrantable, dbUsername: string): Grant;
333
333
  }
334
- export { ClickHouseDatabase, type ClickHouseDatabaseProps };
334
+ export { ClickHouseDatabase, type ClickHouseDatabaseProps, type ClickHouseAlarmThresholds };
335
335
  export { ClickHouseDefaultProfiles, ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema, type ClickHouseSchemaAdmin, type ManagedPasswordName, type ProfileSpec } from "../../resources/aws/database/clickhouseSchemas.js";
336
336
  export { renderUsersXml } from "../../resources/aws/database/clickhouseXmlRenderer.js";
337
337
  export type { RenderUsersXmlOptions } from "../../resources/aws/database/clickhouseXmlRenderer.js";
@@ -119,8 +119,9 @@ export function generateServerConfigXml(options) {
119
119
  max_bytes_before_external_group_by) live in the profile blocks of
120
120
  users.xml — ClickHouse 26.3.10 rejects them at top level with
121
121
  UNKNOWN_ELEMENT_IN_CONFIG. The default localhost user inherits
122
- ClickHouse's built-in defaults; workload users (app_writer,
123
- audit_writer, backup_reader, schema_admin) carry explicit caps. -->
122
+ ClickHouse's built-in defaults; the SQL-managed workload users
123
+ (app_writer, audit_writer, backup_reader) and the XML-defined
124
+ schema_admin all carry explicit caps via their bound profiles. -->
124
125
  <!-- Mark + index-mark caches scale linearly with instance memory
125
126
  (384 MiB + 128 MiB at the 4 GiB baseline). CH counts these against
126
127
  max_server_memory_usage. On a single-tenant dashboard workload they
@@ -360,8 +361,10 @@ chmod 600 "$MOUNT_POINT/server-certs/server.key"
360
361
  chmod 644 "$MOUNT_POINT/server-certs/client.xml"
361
362
  `
362
363
  : "";
363
- // The CWAgent-namespace alarms (mem_used_percent / disk_used_percent) have
364
- // no producer without this — no CloudWatch Agent is installed. The ASG name
364
+ // The CWAgent-namespace metrics have no producer without this — no
365
+ // CloudWatch Agent is installed. disk_used_percent feeds the host disk
366
+ // alarms; mem_used_percent is dashboards-only (the CWAgent memory alarm is
367
+ // retired — memory alarming lives on ECS MemoryUtilization). The ASG name
365
368
  // (the alarms' dimension) is unknowable at synth (LaunchTemplate → ASG is a
366
369
  // CFN cycle), so it resolves at run time: IMDS instance tags first, with a
367
370
  // describe-auto-scaling-instances fallback. AL2023 ECS AMIs ship no cron;
@@ -1,5 +1,5 @@
1
1
  import { Duration } from "aws-cdk-lib";
2
- import type { Alarm } from "aws-cdk-lib/aws-cloudwatch";
2
+ import type { Alarm, AlarmBase } from "aws-cdk-lib/aws-cloudwatch";
3
3
  import type { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
4
4
  /**
5
5
  * Tag key for mapping CloudWatch alarms to Fjall applications.
@@ -36,5 +36,5 @@ export declare const ALARM_DEFAULTS: {
36
36
  export declare function buildAlarmDescription(baseDescription: string, applicationId: string | undefined): string;
37
37
  /** Wire an alarm to the SNS topic (both ALARM and OK transitions) and collect it. */
38
38
  export declare function registerAlarm(alarm: Alarm, snsAction: SnsAction, alarms: Alarm[]): void;
39
- /** Tag all alarms with the application ID for webhook-to-application mapping. */
40
- export declare function tagAlarmsWithApplicationId(alarms: Alarm[], applicationId: string | undefined): void;
39
+ /** Tag all alarms (incl. composite alarms) with the application ID for webhook-to-application mapping. */
40
+ export declare function tagAlarmsWithApplicationId(alarms: readonly AlarmBase[], applicationId: string | undefined): void;
@@ -25,7 +25,7 @@ export function registerAlarm(alarm, snsAction, alarms) {
25
25
  alarm.addOkAction(snsAction);
26
26
  alarms.push(alarm);
27
27
  }
28
- /** Tag all alarms with the application ID for webhook-to-application mapping. */
28
+ /** Tag all alarms (incl. composite alarms) with the application ID for webhook-to-application mapping. */
29
29
  export function tagAlarmsWithApplicationId(alarms, applicationId) {
30
30
  if (!applicationId)
31
31
  return;
@@ -3,16 +3,75 @@ import type { IRole } from "aws-cdk-lib/aws-iam";
3
3
  import type { ITopic } from "aws-cdk-lib/aws-sns";
4
4
  import type { ILogGroup } from "aws-cdk-lib/aws-logs";
5
5
  import type { Construct } from "constructs";
6
+ /**
7
+ * The single alarm-tuning knob on `ClickHouseDatabaseProps.alarms`. The
8
+ * ClickHouseDatabase pattern fans it out across three mechanisms: host-level
9
+ * fields feed `createClickHouseAlarms` (this factory), `memoryThreshold` feeds
10
+ * the generic ECS service memory alarm (cgroup-honest — the container limit
11
+ * IS `clickHouseTaskMemoryMiB()`, so the % means the same headroom on every
12
+ * instance size), and the log-signal fields feed the service's declarative
13
+ * `logAlarms` specs.
14
+ */
6
15
  export interface ClickHouseAlarmThresholds {
7
- /** EC2 host CPU % over 5 min. Default 90. */
16
+ /** EC2 host CPU % over 5 min. Default 90. Informational on a 1-vCPU host —
17
+ * merges routinely pin CPU; liveness is owned by the ECS running-tasks
18
+ * alarm. */
8
19
  cpuThreshold?: number;
9
- /** EC2 host memory % over 5 min (CWAgent namespace). Default 80. */
20
+ /** ECS `MemoryUtilization` % over 5 min (container cgroup vs the task
21
+ * memory limit). Default 90. Consumed by the service-level memory alarm
22
+ * the ClickHouseDatabase pattern wires, NOT by this factory — the former
23
+ * CWAgent host-memory alarm is retired (its threshold meant a different
24
+ * headroom per instance size; the timer metric remains for dashboards). */
10
25
  memoryThreshold?: number;
11
26
  /** ClickHouse data volume disk % used. Default 70 (warn) — paired with critical at 85. */
12
27
  diskWarnThreshold?: number;
13
28
  /** ClickHouse data volume disk % used. Default 85. */
14
29
  diskCriticalThreshold?: number;
30
+ /** `<Error>`/`<Fatal>` server-log lines per 5 min. Default 30. */
31
+ serverErrorsPer5Min?: number;
32
+ /** Failed `executeQuery:` error lines per 5 min. Default derived:
33
+ * 3 × `deriveClickHouseServerTuning(instanceType).maxConcurrentQueries`. */
34
+ failedQueriesPer5Min?: number;
35
+ /** Background-backpressure lines ("Temporarily pause scheduling" /
36
+ * "Too many parts") per 15 min. Default 50. */
37
+ backpressurePer15Min?: number;
38
+ /** Backup heartbeat window in hours (integer, 1–143): the maximum tolerated
39
+ * gap between backup successes before the heartbeat composite pages.
40
+ * Default 26 — the daily schedule interval plus a 2 h completion-drift
41
+ * margin. The 143 ceiling keeps the paging guarantee ahead of the arming
42
+ * gate's 7-day memory (see `BACKUP_HEARTBEAT_MAX_WINDOW_HOURS`); schedules
43
+ * sparser than ~5.8 days cannot be heartbeat-monitored — set `false` to
44
+ * disable the heartbeat trio (the success metric filter remains for
45
+ * dashboards). */
46
+ backupHeartbeatWindowHours?: number | false;
15
47
  }
48
+ /**
49
+ * The subset of the knob this factory actually reads — host CPU + disk + the
50
+ * backup heartbeat window. The remaining fields are consumed by the pattern's
51
+ * service-alarm and logAlarms wiring (see `ClickHouseAlarmThresholds`).
52
+ */
53
+ export type ClickHouseHostAlarmThresholds = Pick<ClickHouseAlarmThresholds, "cpuThreshold" | "diskWarnThreshold" | "diskCriticalThreshold" | "backupHeartbeatWindowHours">;
54
+ export declare const BACKUP_HEARTBEAT_DEFAULT_WINDOW_HOURS = 26;
55
+ /**
56
+ * Ceiling derivation — an ORDERING bound, not CloudWatch expressibility.
57
+ * The 7-day evaluation-span cap would allow 168 hourly buckets, but the
58
+ * composite only pages while the arming gate still reads OK, and the gate's
59
+ * 7 daily buckets are UTC-midnight-aligned: after a success at UTC hour h
60
+ * the gate re-alarms 168 − h hours later (h ≤ 23), while the absence alarm
61
+ * needs windowHours + 1 hours. Paging is therefore guaranteed for every
62
+ * schedule time only when windowHours ≤ 143; above that the gate can
63
+ * re-alarm first and the composite never fires. Net: schedules sparser than
64
+ * ~5.8 days (141 h interval + the 2 h margin) cannot be heartbeat-monitored
65
+ * — disable with `false` instead.
66
+ */
67
+ export declare const BACKUP_HEARTBEAT_MAX_WINDOW_HOURS = 143;
68
+ /**
69
+ * Pure validation for the alarm-thresholds knob (synth-contract corpus
70
+ * surface). Called from both the `ClickHouseDatabase` constructor and
71
+ * `createClickHouseAlarms` — the factory call is defence-in-depth; the
72
+ * constructor call is what a direct instantiation hits.
73
+ */
74
+ export declare function validateClickHouseAlarmThresholds(config: ClickHouseHostAlarmThresholds): void;
16
75
  export interface ClickHouseAlarmsProps {
17
76
  scope: Construct;
18
77
  /**
@@ -36,12 +95,14 @@ export interface ClickHouseAlarmsProps {
36
95
  * Omitted when `backupSchedule: false` — no backup task, no log group.
37
96
  */
38
97
  backupTaskLogGroup?: ILogGroup;
39
- config?: ClickHouseAlarmThresholds;
98
+ config?: ClickHouseHostAlarmThresholds;
99
+ /** Application ID for webhook-to-application alarm mapping. */
100
+ applicationId?: string;
40
101
  }
41
102
  /**
42
103
  * Single-node ClickHouse host-posture alarms. Covers host-level CPU (AWS/EC2)
43
- * plus memory and disk in the `CWAgent` namespace — fed NOT by the CloudWatch
44
- * Agent (never installed on these hosts) but by the lightweight put-metric-data
104
+ * plus disk in the `CWAgent` namespace — fed NOT by the CloudWatch Agent
105
+ * (never installed on these hosts) but by the lightweight put-metric-data
45
106
  * timer the user-data installs (see `buildClickHouseUserData`), which publishes
46
107
  * `mem_used_percent` / `disk_used_percent` under the same names and
47
108
  * `AutoScalingGroupName` dimension the agent would use — plus the
@@ -51,9 +112,20 @@ export interface ClickHouseAlarmsProps {
51
112
  * task's BACKUP DATABASE TO S3 statement. Closes the silent-failure mode
52
113
  * that masked the original IAM-grant misconfiguration (see
53
114
  * `designs/2026-04-27-clickhouse-backup-iam-role.md`).
115
+ * - **Backup success heartbeat** — pages when no `BACKUP_CREATED` success
116
+ * marker lands within the heartbeat window on a stack whose backups
117
+ * previously succeeded. The complete-but-slow complement of the failure
118
+ * alarm: catches hangs, dead schedulers, and tasks that never start (see
119
+ * `createBackupHeartbeat` and
120
+ * `designs/2026-08-15-clickhouse-backup-success-heartbeat.md`).
54
121
  *
55
122
  * The stuck-merge alarm — a `"Stuck merge detected"` line emitted by the webapp
56
123
  * app process, not this construct — lives on the app service's declarative
57
124
  * `logAlarms` instead; it is an app-log alarm, not a database-host concern.
125
+ * Memory, liveness, and server-log signals are likewise NOT here: the
126
+ * ClickHouseDatabase pattern wires those through the generic ECS service
127
+ * alarms (cgroup memory ≥ 90, `RunningTaskCount` < 1) and the service's
128
+ * declarative `logAlarms` — this factory owns only what the ECS layer cannot
129
+ * see (the EC2 host and the backup task).
58
130
  */
59
131
  export declare function createClickHouseAlarms(props: ClickHouseAlarmsProps): Alarm[];
@@ -1,15 +1,58 @@
1
1
  import { Duration } from "aws-cdk-lib";
2
- import { Alarm, ComparisonOperator, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch";
2
+ import { Alarm, AlarmRule, AlarmState, ComparisonOperator, CompositeAlarm, MathExpression, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch";
3
3
  import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
4
4
  import { Metric } from "aws-cdk-lib/aws-cloudwatch";
5
5
  import { FilterPattern, MetricFilter } from "aws-cdk-lib/aws-logs";
6
- import { ALARM_DEFAULTS, registerAlarm, buildAlarmDescription } from "./alarmDefaults.js";
7
- import { METRIC_NAMESPACE } from "./metricNamespaces.js";
6
+ import { ALARM_DEFAULTS, registerAlarm, tagAlarmsWithApplicationId, buildAlarmDescription } from "./alarmDefaults.js";
7
+ import { METRIC_NAMESPACE, stackScopedMetricNamespace } from "./metricNamespaces.js";
8
8
  import { CLICKHOUSE_HOST_METRICS } from "../database/clickhouseConstants.js";
9
+ export const BACKUP_HEARTBEAT_DEFAULT_WINDOW_HOURS = 26;
10
+ /**
11
+ * Ceiling derivation — an ORDERING bound, not CloudWatch expressibility.
12
+ * The 7-day evaluation-span cap would allow 168 hourly buckets, but the
13
+ * composite only pages while the arming gate still reads OK, and the gate's
14
+ * 7 daily buckets are UTC-midnight-aligned: after a success at UTC hour h
15
+ * the gate re-alarms 168 − h hours later (h ≤ 23), while the absence alarm
16
+ * needs windowHours + 1 hours. Paging is therefore guaranteed for every
17
+ * schedule time only when windowHours ≤ 143; above that the gate can
18
+ * re-alarm first and the composite never fires. Net: schedules sparser than
19
+ * ~5.8 days (141 h interval + the 2 h margin) cannot be heartbeat-monitored
20
+ * — disable with `false` instead.
21
+ */
22
+ export const BACKUP_HEARTBEAT_MAX_WINDOW_HOURS = 143;
23
+ /** Arming memory of the heartbeat: 7 daily buckets — the 7-day CloudWatch
24
+ * evaluation-span cap, the longest "a backup has succeeded recently" memory
25
+ * an alarm can carry. */
26
+ const BACKUP_HEARTBEAT_ARMING_DAYS = 7;
27
+ /** Composite actions wait this long for the suppressor (arming gate) to
28
+ * reach ALARM before firing — the real page's only added latency. */
29
+ const BACKUP_HEARTBEAT_SUPPRESSOR_WAIT = Duration.minutes(2);
30
+ /** Composite actions stay suppressed this long after the gate LEAVES ALARM:
31
+ * covers the arming transition, where both children flip ALARM→OK on the
32
+ * same first-success datapoint but on unsynchronised ~1-minute evaluation
33
+ * cycles — a gate-first ordering would otherwise transiently satisfy the
34
+ * rule and page the very stacks the design promises stay quiet. */
35
+ const BACKUP_HEARTBEAT_SUPPRESSOR_EXTENSION = Duration.minutes(5);
36
+ /**
37
+ * Pure validation for the alarm-thresholds knob (synth-contract corpus
38
+ * surface). Called from both the `ClickHouseDatabase` constructor and
39
+ * `createClickHouseAlarms` — the factory call is defence-in-depth; the
40
+ * constructor call is what a direct instantiation hits.
41
+ */
42
+ export function validateClickHouseAlarmThresholds(config) {
43
+ const windowHours = config.backupHeartbeatWindowHours;
44
+ if (windowHours === undefined || windowHours === false)
45
+ return;
46
+ if (!Number.isInteger(windowHours) ||
47
+ windowHours < 1 ||
48
+ windowHours > BACKUP_HEARTBEAT_MAX_WINDOW_HOURS) {
49
+ throw new Error(`ClickHouseDatabase: alarms.backupHeartbeatWindowHours must be an integer between 1 and ${BACKUP_HEARTBEAT_MAX_WINDOW_HOURS} (hours), or false to disable the backup heartbeat; got ${windowHours}.`);
50
+ }
51
+ }
9
52
  /**
10
53
  * Single-node ClickHouse host-posture alarms. Covers host-level CPU (AWS/EC2)
11
- * plus memory and disk in the `CWAgent` namespace — fed NOT by the CloudWatch
12
- * Agent (never installed on these hosts) but by the lightweight put-metric-data
54
+ * plus disk in the `CWAgent` namespace — fed NOT by the CloudWatch Agent
55
+ * (never installed on these hosts) but by the lightweight put-metric-data
13
56
  * timer the user-data installs (see `buildClickHouseUserData`), which publishes
14
57
  * `mem_used_percent` / `disk_used_percent` under the same names and
15
58
  * `AutoScalingGroupName` dimension the agent would use — plus the
@@ -19,17 +62,29 @@ import { CLICKHOUSE_HOST_METRICS } from "../database/clickhouseConstants.js";
19
62
  * task's BACKUP DATABASE TO S3 statement. Closes the silent-failure mode
20
63
  * that masked the original IAM-grant misconfiguration (see
21
64
  * `designs/2026-04-27-clickhouse-backup-iam-role.md`).
65
+ * - **Backup success heartbeat** — pages when no `BACKUP_CREATED` success
66
+ * marker lands within the heartbeat window on a stack whose backups
67
+ * previously succeeded. The complete-but-slow complement of the failure
68
+ * alarm: catches hangs, dead schedulers, and tasks that never start (see
69
+ * `createBackupHeartbeat` and
70
+ * `designs/2026-08-15-clickhouse-backup-success-heartbeat.md`).
22
71
  *
23
72
  * The stuck-merge alarm — a `"Stuck merge detected"` line emitted by the webapp
24
73
  * app process, not this construct — lives on the app service's declarative
25
74
  * `logAlarms` instead; it is an app-log alarm, not a database-host concern.
75
+ * Memory, liveness, and server-log signals are likewise NOT here: the
76
+ * ClickHouseDatabase pattern wires those through the generic ECS service
77
+ * alarms (cgroup memory ≥ 90, `RunningTaskCount` < 1) and the service's
78
+ * declarative `logAlarms` — this factory owns only what the ECS layer cannot
79
+ * see (the EC2 host and the backup task).
26
80
  */
27
81
  export function createClickHouseAlarms(props) {
28
- const { scope, instanceRole, asgName, alarmTopic, backupTaskLogGroup, config = {} } = props;
82
+ const { scope, instanceRole, asgName, alarmTopic, backupTaskLogGroup, config = {}, applicationId } = props;
83
+ validateClickHouseAlarmThresholds(config);
29
84
  const alarms = [];
30
85
  const snsAction = new SnsAction(alarmTopic);
31
86
  const cpuAlarm = new Alarm(scope, "ClickHouseCpuAlarm", {
32
- alarmDescription: buildAlarmDescription("ClickHouse host CPU utilisation exceeds threshold", undefined),
87
+ alarmDescription: buildAlarmDescription("ClickHouse host CPU utilisation exceeds threshold", applicationId),
33
88
  metric: new Metric({
34
89
  namespace: "AWS/EC2",
35
90
  metricName: "CPUUtilization",
@@ -44,24 +99,9 @@ export function createClickHouseAlarms(props) {
44
99
  treatMissingData: TreatMissingData.NOT_BREACHING
45
100
  });
46
101
  registerAlarm(cpuAlarm, snsAction, alarms);
47
- const memoryAlarm = new Alarm(scope, "ClickHouseMemoryAlarm", {
48
- alarmDescription: buildAlarmDescription("ClickHouse host memory utilisation exceeds threshold (CWAgent)", undefined),
49
- metric: new Metric({
50
- namespace: CLICKHOUSE_HOST_METRICS.namespace,
51
- metricName: CLICKHOUSE_HOST_METRICS.memoryMetric,
52
- dimensionsMap: { [CLICKHOUSE_HOST_METRICS.asgDimension]: asgName },
53
- period: ALARM_DEFAULTS.EVALUATION_PERIOD,
54
- statistic: "Average"
55
- }),
56
- threshold: config.memoryThreshold ?? 80,
57
- evaluationPeriods: 3,
58
- datapointsToAlarm: 2,
59
- comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
60
- treatMissingData: TreatMissingData.NOT_BREACHING
61
- });
62
- registerAlarm(memoryAlarm, snsAction, alarms);
102
+ const diskWarnThreshold = config.diskWarnThreshold ?? 70;
63
103
  const diskWarnAlarm = new Alarm(scope, "ClickHouseDiskWarnAlarm", {
64
- alarmDescription: buildAlarmDescription("ClickHouse data volume above 70% used — plan growth response", undefined),
104
+ alarmDescription: buildAlarmDescription(`ClickHouse data volume above ${diskWarnThreshold}% used — plan growth response`, applicationId),
65
105
  metric: new Metric({
66
106
  namespace: CLICKHOUSE_HOST_METRICS.namespace,
67
107
  metricName: CLICKHOUSE_HOST_METRICS.diskMetric,
@@ -69,15 +109,16 @@ export function createClickHouseAlarms(props) {
69
109
  period: Duration.minutes(15),
70
110
  statistic: "Average"
71
111
  }),
72
- threshold: config.diskWarnThreshold ?? 70,
112
+ threshold: diskWarnThreshold,
73
113
  evaluationPeriods: 2,
74
114
  datapointsToAlarm: 2,
75
115
  comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
76
116
  treatMissingData: TreatMissingData.NOT_BREACHING
77
117
  });
78
118
  registerAlarm(diskWarnAlarm, snsAction, alarms);
119
+ const diskCriticalThreshold = config.diskCriticalThreshold ?? 85;
79
120
  const diskCriticalAlarm = new Alarm(scope, "ClickHouseDiskCriticalAlarm", {
80
- alarmDescription: buildAlarmDescription("ClickHouse data volume above 85% used — imminent insert failures", undefined),
121
+ alarmDescription: buildAlarmDescription(`ClickHouse data volume above ${diskCriticalThreshold}% used — imminent insert failures`, applicationId),
81
122
  metric: new Metric({
82
123
  namespace: CLICKHOUSE_HOST_METRICS.namespace,
83
124
  metricName: CLICKHOUSE_HOST_METRICS.diskMetric,
@@ -85,7 +126,7 @@ export function createClickHouseAlarms(props) {
85
126
  period: Duration.minutes(5),
86
127
  statistic: "Average"
87
128
  }),
88
- threshold: config.diskCriticalThreshold ?? 85,
129
+ threshold: diskCriticalThreshold,
89
130
  evaluationPeriods: 2,
90
131
  datapointsToAlarm: 2,
91
132
  comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
@@ -98,18 +139,19 @@ export function createClickHouseAlarms(props) {
98
139
  registerAlarm(diskCriticalAlarm, snsAction, alarms);
99
140
  if (backupTaskLogGroup !== undefined) {
100
141
  const backupFailureMetricName = "ClickHouseBackupFailureCount";
142
+ const backupFailureNamespace = stackScopedMetricNamespace(METRIC_NAMESPACE.CLICKHOUSE);
101
143
  new MetricFilter(scope, "ClickHouseBackupFailureMetricFilter", {
102
144
  logGroup: backupTaskLogGroup,
103
- metricNamespace: METRIC_NAMESPACE.CLICKHOUSE,
145
+ metricNamespace: backupFailureNamespace,
104
146
  metricName: backupFailureMetricName,
105
147
  filterPattern: FilterPattern.anyTerm("AccessDenied", "S3Exception"),
106
148
  metricValue: "1",
107
149
  defaultValue: 0
108
150
  });
109
151
  const backupFailureAlarm = new Alarm(scope, "ClickHouseBackupFailureAlarm", {
110
- alarmDescription: buildAlarmDescription(`ClickHouse BACKUP TO S3 emitted AccessDenied/S3Exception — verify instance role '${instanceRole.roleName}' grant on backup bucket`, undefined),
152
+ alarmDescription: buildAlarmDescription(`ClickHouse BACKUP TO S3 emitted AccessDenied/S3Exception — verify instance role '${instanceRole.roleName}' grant on backup bucket`, applicationId),
111
153
  metric: new Metric({
112
- namespace: METRIC_NAMESPACE.CLICKHOUSE,
154
+ namespace: backupFailureNamespace,
113
155
  metricName: backupFailureMetricName,
114
156
  period: Duration.hours(1),
115
157
  statistic: "Sum"
@@ -121,6 +163,126 @@ export function createClickHouseAlarms(props) {
121
163
  treatMissingData: TreatMissingData.NOT_BREACHING
122
164
  });
123
165
  registerAlarm(backupFailureAlarm, snsAction, alarms);
166
+ createBackupHeartbeat({
167
+ scope,
168
+ backupTaskLogGroup,
169
+ namespace: backupFailureNamespace,
170
+ windowHours: config.backupHeartbeatWindowHours,
171
+ snsAction,
172
+ applicationId,
173
+ alarms
174
+ });
124
175
  }
176
+ tagAlarmsWithApplicationId(alarms, applicationId);
125
177
  return alarms;
126
178
  }
179
+ /**
180
+ * Backup success heartbeat — the complete-but-slow half of the backup alarm
181
+ * pair (the AccessDenied/S3Exception failure alarm above is the fast half).
182
+ * Closes the failure modes the error filter cannot see: a hung BACKUP, a
183
+ * scheduler that never fires, a task that cannot start.
184
+ *
185
+ * The success marker is the backup task's single stdout line per successful
186
+ * synchronous `BACKUP DATABASE … TO S3(…)` — `<uuid>\tBACKUP_CREATED`
187
+ * (ClickHouse's terminal success status; a failed backup prints exception
188
+ * text, which never contains the token, and a run that never happens prints
189
+ * nothing). Verified against production backup-task logs 2026-08-15.
190
+ *
191
+ * Three alarms, one pager:
192
+ *
193
+ * - **Absence** (`FILL(m1, 0) < 1`, 1 h buckets × windowHours, no actions) —
194
+ * ALARM when no success lands for windowHours consecutive hours. FILL
195
+ * materialises zeros for empty buckets, so this is deterministic — but it
196
+ * also zero-fills the buckets before the metric existed, putting every
197
+ * fresh stack (and every existing stack on the deploy that introduces the
198
+ * filter) into ALARM until its first backup.
199
+ * - **Unestablished** (success Sum < 1 per daily bucket, 7 × 7, no actions) —
200
+ * ALARM while no success has been observed in the trailing 7 days: true on
201
+ * stacks younger than one backup cycle, false from the first success
202
+ * onward. This is the arming gate that absorbs the absence alarm's
203
+ * cold-start.
204
+ * - **Heartbeat composite** (the pager): Absence in ALARM AND Unestablished
205
+ * in OK — "backups previously succeeded here and have now stopped". Quiet
206
+ * from stack birth, armed by the first observed success. The gate doubles
207
+ * as the composite's actions SUPPRESSOR: at the arming transition both
208
+ * children flip ALARM→OK on the same datapoint but on unsynchronised
209
+ * evaluation cycles, and a gate-first ordering would transiently satisfy
210
+ * the rule — the suppressor's extension period absorbs that race, at the
211
+ * cost of the real page arriving `BACKUP_HEARTBEAT_SUPPRESSOR_WAIT` late.
212
+ * Bounded caveat: after 7 further days of unresolved absence the arming
213
+ * gate re-enters ALARM and the composite returns to OK — with its actions
214
+ * suppressed by the gate, so no misleading "resolved" notification fires;
215
+ * the page fired on day one and the absence alarm stays visibly in ALARM
216
+ * in the console.
217
+ *
218
+ * Both child alarms treat missing data as breaching so the shape is
219
+ * deterministic whichever way the metric-math engine renders a
220
+ * never-populated metric (empty series vs zero-filled grid) — the composite
221
+ * gate makes the breaching reading safe at birth.
222
+ */
223
+ function createBackupHeartbeat(props) {
224
+ const { scope, backupTaskLogGroup, namespace, windowHours, snsAction, applicationId, alarms } = props;
225
+ const successMetricName = "ClickHouseBackupSuccessCount";
226
+ // The filter always materialises (dashboards + the heartbeat both read it);
227
+ // defaultValue 0 emits real zero datapoints whenever backup-task log events
228
+ // arrive without the success marker — an actively failing backup produces
229
+ // deterministic breaching data instead of relying on missing-data handling.
230
+ new MetricFilter(scope, "ClickHouseBackupSuccessMetricFilter", {
231
+ logGroup: backupTaskLogGroup,
232
+ metricNamespace: namespace,
233
+ metricName: successMetricName,
234
+ filterPattern: FilterPattern.allTerms("BACKUP_CREATED"),
235
+ metricValue: "1",
236
+ defaultValue: 0
237
+ });
238
+ if (windowHours === false)
239
+ return;
240
+ const effectiveWindowHours = windowHours ?? BACKUP_HEARTBEAT_DEFAULT_WINDOW_HOURS;
241
+ const hourlySuccesses = new Metric({
242
+ namespace,
243
+ metricName: successMetricName,
244
+ period: Duration.hours(1),
245
+ statistic: "Sum"
246
+ });
247
+ const absenceAlarm = new Alarm(scope, "ClickHouseBackupAbsenceAlarm", {
248
+ alarmDescription: buildAlarmDescription(`No ClickHouse backup success in the trailing ${effectiveWindowHours}h. No actions here — ClickHouseBackupHeartbeatAlarm pages when this coincides with backups having previously succeeded`, applicationId),
249
+ metric: new MathExpression({
250
+ expression: "FILL(m1, 0)",
251
+ usingMetrics: { m1: hourlySuccesses },
252
+ label: "Backup successes per hour",
253
+ period: Duration.hours(1)
254
+ }),
255
+ threshold: 1,
256
+ evaluationPeriods: effectiveWindowHours,
257
+ datapointsToAlarm: effectiveWindowHours,
258
+ comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
259
+ treatMissingData: TreatMissingData.BREACHING
260
+ });
261
+ alarms.push(absenceAlarm);
262
+ const dailySuccesses = new Metric({
263
+ namespace,
264
+ metricName: successMetricName,
265
+ period: Duration.days(1),
266
+ statistic: "Sum"
267
+ });
268
+ const unestablishedAlarm = new Alarm(scope, "ClickHouseBackupUnestablishedAlarm", {
269
+ alarmDescription: buildAlarmDescription(`No ClickHouse backup success in the trailing ${BACKUP_HEARTBEAT_ARMING_DAYS} days — expected (and harmless) on stacks younger than one backup cycle; arms ClickHouseBackupHeartbeatAlarm once the first success lands. No actions attached`, applicationId),
270
+ metric: dailySuccesses,
271
+ threshold: 1,
272
+ evaluationPeriods: BACKUP_HEARTBEAT_ARMING_DAYS,
273
+ datapointsToAlarm: BACKUP_HEARTBEAT_ARMING_DAYS,
274
+ comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
275
+ treatMissingData: TreatMissingData.BREACHING
276
+ });
277
+ alarms.push(unestablishedAlarm);
278
+ const heartbeat = new CompositeAlarm(scope, "ClickHouseBackupHeartbeatAlarm", {
279
+ alarmDescription: buildAlarmDescription(`ClickHouse backups have stopped succeeding: no success in the trailing ${effectiveWindowHours}h on a stack where backups previously succeeded — check the backup scheduled task's ECS run history, then the backup task log group, then the instance role's S3 grants`, applicationId),
280
+ alarmRule: AlarmRule.allOf(AlarmRule.fromAlarm(absenceAlarm, AlarmState.ALARM), AlarmRule.fromAlarm(unestablishedAlarm, AlarmState.OK)),
281
+ actionsSuppressor: unestablishedAlarm,
282
+ actionsSuppressorWaitPeriod: BACKUP_HEARTBEAT_SUPPRESSOR_WAIT,
283
+ actionsSuppressorExtensionPeriod: BACKUP_HEARTBEAT_SUPPRESSOR_EXTENSION
284
+ });
285
+ heartbeat.addAlarmAction(snsAction);
286
+ heartbeat.addOkAction(snsAction);
287
+ tagAlarmsWithApplicationId([heartbeat], applicationId);
288
+ }
@@ -4,10 +4,18 @@ import { type IApplicationTargetGroup } from "aws-cdk-lib/aws-elasticloadbalanci
4
4
  import type { ITopic } from "aws-cdk-lib/aws-sns";
5
5
  import type { Construct } from "constructs";
6
6
  export interface EcsServiceAlarmThresholds {
7
- cpuThreshold?: number;
8
- memoryThreshold?: number;
7
+ /** CPU utilisation % threshold. `false` disables the CPU alarm entirely —
8
+ * for services whose CPU signal is owned elsewhere (e.g. the ClickHouse
9
+ * pattern alarms host CPU via AWS/EC2 and routinely pins container CPU
10
+ * during merges). */
11
+ cpuThreshold?: number | false;
12
+ /** Memory utilisation % threshold. `false` disables the memory alarm. */
13
+ memoryThreshold?: number | false;
14
+ /** Minimum running tasks; `0` disables the running-tasks alarm. */
9
15
  runningTasksMinimum?: number;
10
- http5xxThreshold?: number;
16
+ /** 5xx error-rate % threshold (ALB services only). `false` disables the
17
+ * 5xx alarm; the p99 response-time alarm is unaffected. */
18
+ http5xxThreshold?: number | false;
11
19
  }
12
20
  export interface EcsServiceAlarmsProps {
13
21
  scope: Construct;
@@ -7,30 +7,34 @@ export function createEcsServiceAlarms(props) {
7
7
  const { scope, serviceName, service, targetGroup, config, alarmTopic, applicationId } = props;
8
8
  const alarms = [];
9
9
  const snsAction = new SnsAction(alarmTopic);
10
- const cpuAlarm = new Alarm(scope, `${serviceName}CpuAlarm`, {
11
- alarmDescription: buildAlarmDescription(`ECS service ${serviceName} CPU utilisation exceeds threshold`, applicationId),
12
- metric: service.metricCpuUtilization({
13
- period: ALARM_DEFAULTS.EVALUATION_PERIOD
14
- }),
15
- threshold: config.cpuThreshold ?? ALARM_DEFAULTS.ECS.CPU,
16
- evaluationPeriods: 3,
17
- datapointsToAlarm: 2,
18
- comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
19
- treatMissingData: TreatMissingData.NOT_BREACHING
20
- });
21
- registerAlarm(cpuAlarm, snsAction, alarms);
22
- const memoryAlarm = new Alarm(scope, `${serviceName}MemoryAlarm`, {
23
- alarmDescription: buildAlarmDescription(`ECS service ${serviceName} memory utilisation exceeds threshold`, applicationId),
24
- metric: service.metricMemoryUtilization({
25
- period: ALARM_DEFAULTS.EVALUATION_PERIOD
26
- }),
27
- threshold: config.memoryThreshold ?? ALARM_DEFAULTS.ECS.MEMORY,
28
- evaluationPeriods: 3,
29
- datapointsToAlarm: 2,
30
- comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
31
- treatMissingData: TreatMissingData.NOT_BREACHING
32
- });
33
- registerAlarm(memoryAlarm, snsAction, alarms);
10
+ if (config.cpuThreshold !== false) {
11
+ const cpuAlarm = new Alarm(scope, `${serviceName}CpuAlarm`, {
12
+ alarmDescription: buildAlarmDescription(`ECS service ${serviceName} CPU utilisation exceeds threshold`, applicationId),
13
+ metric: service.metricCpuUtilization({
14
+ period: ALARM_DEFAULTS.EVALUATION_PERIOD
15
+ }),
16
+ threshold: config.cpuThreshold ?? ALARM_DEFAULTS.ECS.CPU,
17
+ evaluationPeriods: 3,
18
+ datapointsToAlarm: 2,
19
+ comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
20
+ treatMissingData: TreatMissingData.NOT_BREACHING
21
+ });
22
+ registerAlarm(cpuAlarm, snsAction, alarms);
23
+ }
24
+ if (config.memoryThreshold !== false) {
25
+ const memoryAlarm = new Alarm(scope, `${serviceName}MemoryAlarm`, {
26
+ alarmDescription: buildAlarmDescription(`ECS service ${serviceName} memory utilisation exceeds threshold`, applicationId),
27
+ metric: service.metricMemoryUtilization({
28
+ period: ALARM_DEFAULTS.EVALUATION_PERIOD
29
+ }),
30
+ threshold: config.memoryThreshold ?? ALARM_DEFAULTS.ECS.MEMORY,
31
+ evaluationPeriods: 3,
32
+ datapointsToAlarm: 2,
33
+ comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
34
+ treatMissingData: TreatMissingData.NOT_BREACHING
35
+ });
36
+ registerAlarm(memoryAlarm, snsAction, alarms);
37
+ }
34
38
  const runningMin = config.runningTasksMinimum ?? ALARM_DEFAULTS.ECS.RUNNING_TASKS_MIN;
35
39
  if (runningMin > 0) {
36
40
  const runningTasksAlarm = new Alarm(scope, `${serviceName}RunningTasksAlarm`, {
@@ -53,25 +57,27 @@ export function createEcsServiceAlarms(props) {
53
57
  }
54
58
  // ALB-based alarms only when a target group is provided
55
59
  if (targetGroup) {
56
- const http5xxThreshold = config.http5xxThreshold ?? ALARM_DEFAULTS.ALB.HTTP_5XX_PERCENT;
57
- const http5xxAlarm = new Alarm(scope, `${serviceName}Http5xxAlarm`, {
58
- alarmDescription: buildAlarmDescription(`ECS service ${serviceName} 5xx error rate exceeds ${http5xxThreshold}%`, applicationId),
59
- metric: new MathExpression({
60
- expression: "(errors / requests) * 100",
61
- label: "5xx Error Rate %",
62
- period: ALARM_DEFAULTS.EVALUATION_PERIOD,
63
- usingMetrics: {
64
- errors: targetGroup.metrics.httpCodeTarget(HttpCodeTarget.TARGET_5XX_COUNT, { statistic: "Sum" }),
65
- requests: targetGroup.metrics.requestCount({ statistic: "Sum" })
66
- }
67
- }),
68
- threshold: http5xxThreshold,
69
- evaluationPeriods: 2,
70
- datapointsToAlarm: 2,
71
- comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
72
- treatMissingData: TreatMissingData.NOT_BREACHING
73
- });
74
- registerAlarm(http5xxAlarm, snsAction, alarms);
60
+ if (config.http5xxThreshold !== false) {
61
+ const http5xxThreshold = config.http5xxThreshold ?? ALARM_DEFAULTS.ALB.HTTP_5XX_PERCENT;
62
+ const http5xxAlarm = new Alarm(scope, `${serviceName}Http5xxAlarm`, {
63
+ alarmDescription: buildAlarmDescription(`ECS service ${serviceName} 5xx error rate exceeds ${http5xxThreshold}%`, applicationId),
64
+ metric: new MathExpression({
65
+ expression: "(errors / requests) * 100",
66
+ label: "5xx Error Rate %",
67
+ period: ALARM_DEFAULTS.EVALUATION_PERIOD,
68
+ usingMetrics: {
69
+ errors: targetGroup.metrics.httpCodeTarget(HttpCodeTarget.TARGET_5XX_COUNT, { statistic: "Sum" }),
70
+ requests: targetGroup.metrics.requestCount({ statistic: "Sum" })
71
+ }
72
+ }),
73
+ threshold: http5xxThreshold,
74
+ evaluationPeriods: 2,
75
+ datapointsToAlarm: 2,
76
+ comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
77
+ treatMissingData: TreatMissingData.NOT_BREACHING
78
+ });
79
+ registerAlarm(http5xxAlarm, snsAction, alarms);
80
+ }
75
81
  const p99Alarm = new Alarm(scope, `${serviceName}P99ResponseTimeAlarm`, {
76
82
  alarmDescription: buildAlarmDescription(`ECS service ${serviceName} p99 response time exceeds threshold`, applicationId),
77
83
  metric: targetGroup.metrics.targetResponseTime({
@@ -4,8 +4,8 @@ export { createRdsAlarms, type RdsAlarmThresholds, type RdsAlarmsProps } from ".
4
4
  export { createLambdaAlarms, type LambdaAlarmThresholds, type LambdaAlarmsProps } from "./lambdaAlarms.js";
5
5
  export { createScheduleAlarms, type ScheduleAlarmThresholds, type CreateScheduleAlarmsProps } from "./scheduleAlarms.js";
6
6
  export { createSqsDlqAlarms, type SqsAlarmThresholds, type SqsDlqAlarmsProps } from "./sqsAlarms.js";
7
- export { createClickHouseAlarms, type ClickHouseAlarmThresholds, type ClickHouseAlarmsProps } from "./clickhouseAlarms.js";
7
+ export { createClickHouseAlarms, validateClickHouseAlarmThresholds, BACKUP_HEARTBEAT_DEFAULT_WINDOW_HOURS, BACKUP_HEARTBEAT_MAX_WINDOW_HOURS, type ClickHouseAlarmThresholds, type ClickHouseHostAlarmThresholds, type ClickHouseAlarmsProps } from "./clickhouseAlarms.js";
8
8
  export { createBuildkiteAlarms, type BuildkiteAlarmsProps } from "./buildkiteAlarms.js";
9
9
  export { createLogPatternAlarms, type LogPatternAlarmSpec, type LogPatternAlarmsProps } from "./logPatternAlarms.js";
10
10
  export { createEcsTaskStopWatchdog, ABNORMAL_TASK_STOP_CODES, TASK_STOP_METRIC_NAME, type EcsTaskStopWatchdog, type EcsTaskStopWatchdogProps } from "./ecsTaskStopWatchdog.js";
11
- export { METRIC_NAMESPACE, type MetricNamespace } from "./metricNamespaces.js";
11
+ export { METRIC_NAMESPACE, stackScopedMetricNamespace, type MetricNamespace } from "./metricNamespaces.js";
@@ -4,8 +4,8 @@ export { createRdsAlarms } from "./rdsAlarms.js";
4
4
  export { createLambdaAlarms } from "./lambdaAlarms.js";
5
5
  export { createScheduleAlarms } from "./scheduleAlarms.js";
6
6
  export { createSqsDlqAlarms } from "./sqsAlarms.js";
7
- export { createClickHouseAlarms } from "./clickhouseAlarms.js";
7
+ export { createClickHouseAlarms, validateClickHouseAlarmThresholds, BACKUP_HEARTBEAT_DEFAULT_WINDOW_HOURS, BACKUP_HEARTBEAT_MAX_WINDOW_HOURS } from "./clickhouseAlarms.js";
8
8
  export { createBuildkiteAlarms } from "./buildkiteAlarms.js";
9
9
  export { createLogPatternAlarms } from "./logPatternAlarms.js";
10
10
  export { createEcsTaskStopWatchdog, ABNORMAL_TASK_STOP_CODES, TASK_STOP_METRIC_NAME } from "./ecsTaskStopWatchdog.js";
11
- export { METRIC_NAMESPACE } from "./metricNamespaces.js";
11
+ export { METRIC_NAMESPACE, stackScopedMetricNamespace } from "./metricNamespaces.js";
@@ -12,3 +12,16 @@ export declare const METRIC_NAMESPACE: {
12
12
  readonly WEBAPP: "Fjall/WebApp";
13
13
  };
14
14
  export type MetricNamespace = (typeof METRIC_NAMESPACE)[keyof typeof METRIC_NAMESPACE];
15
+ /**
16
+ * Scope a metric namespace to the deploying stack (`<base>/<stack name>`).
17
+ *
18
+ * Log-pattern metric streams carry no dimensions — CloudWatch metric-filter
19
+ * dimensions can only reference fields extracted from the log event, and
20
+ * non-JSON logs (ClickHouse server logs) have no field carrying stack
21
+ * identity — so two stacks in one account+region publishing under a bare
22
+ * namespace+name share ONE metric stream and each stack's alarm fires on the
23
+ * union of both stacks' log lines. The stack name is the per-instance
24
+ * discriminator (two databases in one stack is already a synth error via the
25
+ * EC2 capacity slot).
26
+ */
27
+ export declare function stackScopedMetricNamespace(base: MetricNamespace): string;
@@ -1,3 +1,4 @@
1
+ import { Aws } from "aws-cdk-lib";
1
2
  /**
2
3
  * Single source of truth for Fjall CloudWatch metric namespaces.
3
4
  *
@@ -11,3 +12,18 @@ export const METRIC_NAMESPACE = {
11
12
  ECS: "Fjall/ECS",
12
13
  WEBAPP: "Fjall/WebApp"
13
14
  };
15
+ /**
16
+ * Scope a metric namespace to the deploying stack (`<base>/<stack name>`).
17
+ *
18
+ * Log-pattern metric streams carry no dimensions — CloudWatch metric-filter
19
+ * dimensions can only reference fields extracted from the log event, and
20
+ * non-JSON logs (ClickHouse server logs) have no field carrying stack
21
+ * identity — so two stacks in one account+region publishing under a bare
22
+ * namespace+name share ONE metric stream and each stack's alarm fires on the
23
+ * union of both stacks' log lines. The stack name is the per-instance
24
+ * discriminator (two databases in one stack is already a synth error via the
25
+ * EC2 capacity slot).
26
+ */
27
+ export function stackScopedMetricNamespace(base) {
28
+ return `${base}/${Aws.STACK_NAME}`;
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "12.0.0",
3
+ "version": "12.1.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -80,8 +80,8 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^12.0.0",
84
- "@fjall/util": "^12.0.0",
83
+ "@fjall/generator": "^12.1.1",
84
+ "@fjall/util": "^12.1.1",
85
85
  "constructs": "^10.7.2"
86
86
  },
87
87
  "overrides": {
@@ -89,7 +89,7 @@
89
89
  },
90
90
  "peerDependencies": {
91
91
  "aws-cdk": "^2.1134.0",
92
- "aws-cdk-lib": "^2.263.0",
92
+ "aws-cdk-lib": "^2.265.0",
93
93
  "constructs": "^10.7.2"
94
94
  },
95
95
  "engines": {