|
| 1 | +# RFC: Log Alarm L2 Construct |
| 2 | + |
| 3 | +* **Original Author(s):** @rtuna |
| 4 | +* **Tracking Issue:** https://github.com/aws/aws-cdk-rfcs/issues/960 |
| 5 | +* **API Bar Raiser:** TBD |
| 6 | + |
| 7 | +Add a new `LogAlarm` L2 construct to `aws-cdk-lib/aws-cloudwatch` so customers can define CloudWatch log alarms — alarms |
| 8 | +evaluated against the results of a scheduled CloudWatch Logs query — in CDK, without hand-writing the |
| 9 | +`AWS::CloudWatch::LogAlarm` L1 resource. |
| 10 | + |
| 11 | +## Summary |
| 12 | + |
| 13 | +`LogAlarm` lets customers alarm directly on logs. Instead of a metric + threshold + evaluation periods, a log alarm runs |
| 14 | +a scheduled Logs query (a query string + aggregation over one or more log groups) and compares the aggregated result |
| 15 | +against a threshold using an M-out-of-N evaluation. It also can surface matching log lines in the alarm notification. |
| 16 | +The construct wraps the `AWS::CloudWatch::LogAlarm` CloudFormation resource, reuses the existing |
| 17 | +`ComparisonOperator`/`TreatMissingData` enums, and — like `Alarm` and `PromQLAlarm` — extends `AlarmBase` so it is |
| 18 | +interchangeable anywhere an `IAlarm` is accepted. |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## Working Backwards |
| 23 | + |
| 24 | +`CHANGELOG: feat(cloudwatch): add Log Alarm L2 construct` |
| 25 | + |
| 26 | +### README |
| 27 | + |
| 28 | +#### Log Alarm |
| 29 | + |
| 30 | +Create a CloudWatch alarm that evaluates a scheduled CloudWatch Logs query against one or more log groups and alarms on the aggregated results. |
| 31 | + |
| 32 | +```ts |
| 33 | +import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; |
| 34 | +import * as iam from 'aws-cdk-lib/aws-iam'; |
| 35 | +import * as logs from 'aws-cdk-lib/aws-logs'; |
| 36 | +import { Duration } from 'aws-cdk-lib'; |
| 37 | + |
| 38 | +declare const logGroup: logs.LogGroup; |
| 39 | +declare const queryRole: iam.IRole; |
| 40 | + |
| 41 | +new cloudwatch.LogAlarm(this, 'ErrorRateAlarm', { |
| 42 | + threshold: 5, |
| 43 | + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, |
| 44 | + queryResultsToEvaluate: 3, |
| 45 | + queryResultsToAlarm: 2, |
| 46 | + scheduledQueryConfiguration: { |
| 47 | + queryString: 'fields @message | filter @message like /ERROR/', |
| 48 | + aggregationExpression: 'count(*)', |
| 49 | + logGroupIdentifiers: [logGroup.logGroupName], |
| 50 | + scheduledQueryRole: queryRole, |
| 51 | + schedule: { |
| 52 | + frequency: Duration.minutes(5), |
| 53 | + startTimeOffset: Duration.minutes(5), |
| 54 | + }, |
| 55 | + }, |
| 56 | +}); |
| 57 | +``` |
| 58 | + |
| 59 | +##### Including Log Lines in Notifications |
| 60 | + |
| 61 | +Set `actionLogLineCount` to include matching log lines in alarm notifications. When it is greater than 0, an |
| 62 | +`actionLogLineRole` is required so CloudWatch can read the log lines. |
| 63 | + |
| 64 | +```ts |
| 65 | +new cloudwatch.LogAlarm(this, 'ErrorRateAlarm', { |
| 66 | + threshold: 5, |
| 67 | + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, |
| 68 | + queryResultsToEvaluate: 3, |
| 69 | + queryResultsToAlarm: 2, |
| 70 | + actionLogLineCount: 10, |
| 71 | + actionLogLineRole: logLineRole, |
| 72 | + scheduledQueryConfiguration: { /* ... */ }, |
| 73 | +}); |
| 74 | +``` |
| 75 | + |
| 76 | +##### Adding Actions |
| 77 | + |
| 78 | +`LogAlarm` extends `AlarmBase`, so you add alarm, OK, and insufficient-data actions the same way as existing alarms: |
| 79 | + |
| 80 | +```ts |
| 81 | +import * as cloudwatch_actions from 'aws-cdk-lib/aws-cloudwatch-actions'; |
| 82 | + |
| 83 | +declare const logAlarm: cloudwatch.LogAlarm; |
| 84 | +declare const topic: sns.Topic; |
| 85 | + |
| 86 | +logAlarm.addAlarmAction(new cloudwatch_actions.SnsAction(topic)); |
| 87 | +``` |
| 88 | + |
| 89 | +##### Importing an Existing Log Alarm |
| 90 | + |
| 91 | +```ts |
| 92 | +const imported = cloudwatch.LogAlarm.fromLogAlarmArn( |
| 93 | + this, 'ImportedAlarm', |
| 94 | + 'arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyLogAlarm', |
| 95 | +); |
| 96 | + |
| 97 | +const importedByName = cloudwatch.LogAlarm.fromLogAlarmName(this, 'ImportedByName', 'MyLogAlarm'); |
| 98 | +``` |
| 99 | + |
| 100 | +--- |
| 101 | + |
| 102 | +Ticking the box below indicates that the public API of this RFC has been |
| 103 | +signed-off by the API bar raiser (the `status/api-approved` label was applied to |
| 104 | +the RFC pull request): |
| 105 | + |
| 106 | +``` |
| 107 | +[ ] Signed-off by API Bar Raiser @xxxxx |
| 108 | +``` |
| 109 | + |
| 110 | +## Public FAQ |
| 111 | + |
| 112 | +### What are we launching today? |
| 113 | + |
| 114 | +A new `LogAlarm` L2 construct in the existing `aws-cdk-lib/aws-cloudwatch` module, wrapping the `AWS::CloudWatch::LogAlarm` CloudFormation resource. |
| 115 | + |
| 116 | +### What is a log alarm? |
| 117 | + |
| 118 | +A log alarm evaluates a scheduled CloudWatch Logs query against one or more log groups on a recurring schedule and |
| 119 | +alarms on an aggregation of the results (for example `count(*)`). It removes the old two-step pattern (metric filter → |
| 120 | +metric alarm) and can attach the matching log lines directly to the alarm notification. |
| 121 | + |
| 122 | +### Why should I use this feature? |
| 123 | + |
| 124 | +Customers monitoring logs previously had to create a metric filter and then a metric alarm, and then manually go back to |
| 125 | +the log group to find the offending log lines. `LogAlarm` collapses that into a single construct and can surface the |
| 126 | +triggering log lines in the notification, reducing MTTR. |
| 127 | + |
| 128 | +### Why do I need a new construct instead of the existing `Alarm`? |
| 129 | + |
| 130 | +The `Alarm` construct is built around a metric, comparison operator, threshold, and evaluation periods. A log alarm has |
| 131 | +a different model: instead of a metric it runs a scheduled query (`scheduledQueryConfiguration`), and instead of |
| 132 | +`evaluationPeriods`/`datapointsToAlarm` it uses `queryResultsToEvaluate` (N) and `queryResultsToAlarm` (M). A dedicated |
| 133 | +construct gives a clean, type-safe API without overloading `Alarm` with mutually exclusive property groups. |
| 134 | + |
| 135 | +--- |
| 136 | + |
| 137 | +## Internal FAQ |
| 138 | + |
| 139 | +### Why are we doing this? |
| 140 | + |
| 141 | +Customers who want to alarm on logs currently must create a metric filter and then a metric alarm, and then manually |
| 142 | +return to the log group to find the log lines that triggered the alarm. CloudWatch launched `AWS::CloudWatch::LogAlarm` |
| 143 | +to collapse this into a single resource that runs a scheduled Logs query and can surface the triggering log lines in the |
| 144 | +notification. Without an L2, CDK users must hand-write the L1 `CfnLogAlarm`, manually render the `rate(...)` schedule |
| 145 | +expression, wire the IAM role ARN, and discover the service's validation rules by trial and error. An L2 provides a |
| 146 | +type-safe, validated, idiomatic API consistent with the existing `Alarm` and `PromQLAlarm` constructs. |
| 147 | + |
| 148 | +### Why should we _not_ do this? |
| 149 | + |
| 150 | +The only reason not to is that the `ScheduledQueryConfiguration` field constraints are still being finalised upstream |
| 151 | +(see open issues) — the schema could shift. This is mitigated by tracking the published `@aws-cdk/aws-service-spec` and |
| 152 | +adjusting the L2 as the schema settles; the core shape (query + aggregation + schedule + M-of-N) is stable. |
| 153 | + |
| 154 | +### What is the technical solution (design) of this feature? |
| 155 | + |
| 156 | +`LogAlarm` is a new class extending `AlarmBase` (mirroring `Alarm`, `CompositeAlarm`, and `PromQLAlarm`), so it is |
| 157 | +interchangeable anywhere an `IAlarm` is accepted: |
| 158 | + |
| 159 | +``` |
| 160 | +IAlarm (extends IResource) |
| 161 | + └── AlarmBase (abstract) |
| 162 | + ├── Alarm |
| 163 | + ├── CompositeAlarm |
| 164 | + ├── PromQLAlarm |
| 165 | + └── LogAlarm ← NEW |
| 166 | +``` |
| 167 | + |
| 168 | +It maps to the `AWS::CloudWatch::LogAlarm` resource (a distinct resource type, unlike `PromQLAlarm` which reuses |
| 169 | +`AWS::CloudWatch::Alarm`). The full API is in the Proposed API Design section below. Key design decisions: |
| 170 | + |
| 171 | +- **`Duration` for the schedule** (not raw `number` seconds as `PromQLAlarm` used) — per the CDK Design Guidelines' |
| 172 | + preference for strong types. `frequency` is rendered to a `rate(...)` schedule expression and must be a whole number of |
| 173 | + minutes ≥ 1 (validated at synth); `startTimeOffset`/`endTimeOffset` convert to the integer seconds the L1 expects. |
| 174 | +- **`startTimeOffset` is required** — the scheduled-query service rejects a null start-time offset, so requiring it fails |
| 175 | + fast at synth instead of at deploy (per the guideline on synth-time validation of always-fail-at-deploy input). |
| 176 | +- **`logGroupIdentifiers: string[]` (not `ILogGroup[]`)** — `aws-logs` already depends on `aws-cloudwatch`, so importing |
| 177 | + `ILogGroup` into `aws-cloudwatch` would create a circular module dependency and break the jsii build (which is why no |
| 178 | + `aws-cloudwatch` construct references `aws-logs`). `string[]` also matches how CWLI `SOURCE`-prefix queries identify log |
| 179 | + groups. |
| 180 | + |
| 181 | +A proof-of-concept construct with unit tests and an integration test has been implemented and deployed against a real |
| 182 | +account, confirming it creates a valid `AWS::CloudWatch::LogAlarm`. |
| 183 | + |
| 184 | +### Is this a breaking change? |
| 185 | + |
| 186 | +No. This is a new construct; it does not alter any existing API. |
| 187 | + |
| 188 | +### What alternative solutions did you consider? |
| 189 | + |
| 190 | +1. **Extend `Alarm` with optional log-alarm props** — rejected. Would require making most `AlarmProps` optional and adding |
| 191 | + mutual-exclusivity validation, degrading the type system for both standard and log alarms. |
| 192 | +2. **A static factory on `Alarm`** (e.g. `Alarm.fromLogQuery(...)`) — rejected. Hides the different configuration model and |
| 193 | + still needs to return a distinct type. |
| 194 | +3. **New `LogAlarm extends AlarmBase`** — chosen. Clean dedicated surface, interchangeable via `IAlarm`, consistent with |
| 195 | + the accepted `PromQLAlarm` precedent. |
| 196 | + |
| 197 | +### What are the drawbacks of this solution? |
| 198 | + |
| 199 | +The `ScheduledQueryConfiguration` field constraints are still being finalised upstream (notably whether |
| 200 | +`LogGroupIdentifiers` is required and the exact `StartTimeOffset` range). The construct follows the published |
| 201 | +`@aws-cdk/aws-service-spec` and will be adjusted as the schema settles. |
| 202 | + |
| 203 | +### What is the high-level project plan? |
| 204 | + |
| 205 | +- [x] Implement the `LogAlarm` construct, unit tests, integration test, and README |
| 206 | +- [x] Validate against `aws-cdk-lib` built from `@aws-cdk/aws-service-spec` 0.1.190 |
| 207 | +- [x] Deploy the integration test to a real account and confirm resource creation |
| 208 | +- [ ] RFC review and API bar-raiser sign-off |
| 209 | +- [ ] Open the public `aws/aws-cdk` PR |
| 210 | +- [ ] Address review feedback, merge, and release |
| 211 | + |
| 212 | +### Are there any open issues that need to be addressed later? |
| 213 | + |
| 214 | +- Alignment of `ScheduledQueryConfiguration` field constraints across the Coral model, CFN schema, and Hermes validation |
| 215 | + (tracked in EVENTS-4979): `LogGroupIdentifiers` required→optional, `StartTimeOffset` range `[1, 2592000]`, |
| 216 | + `AggregationExpression` maxLength 256→2048. The L2 will follow the published spec. |
| 217 | + |
| 218 | +--- |
| 219 | + |
| 220 | +## Proposed API Design |
| 221 | + |
| 222 | +### `LogAlarmProps` |
| 223 | + |
| 224 | +```ts |
| 225 | +export interface LogAlarmProps { |
| 226 | + readonly threshold: number; |
| 227 | + readonly comparisonOperator: ComparisonOperator; // static-threshold operators only |
| 228 | + readonly queryResultsToEvaluate: number; // N (positive integer) |
| 229 | + readonly queryResultsToAlarm: number; // M (positive integer, ≤ N) |
| 230 | + readonly scheduledQueryConfiguration: ScheduledQueryConfiguration; |
| 231 | + |
| 232 | + readonly alarmName?: string; // @default - generated |
| 233 | + readonly alarmDescription?: string; // @default - none |
| 234 | + readonly actionsEnabled?: boolean; // @default true |
| 235 | + readonly treatMissingData?: TreatMissingData; // @default - service default |
| 236 | + readonly actionLogLineCount?: number; // 0–50; requires role when > 0 |
| 237 | + readonly actionLogLineRole?: IRole; |
| 238 | + readonly alarmActions?: IAlarmAction[]; |
| 239 | + readonly okActions?: IAlarmAction[]; |
| 240 | + readonly insufficientDataActions?: IAlarmAction[]; |
| 241 | +} |
| 242 | + |
| 243 | +export interface ScheduledQueryConfiguration { |
| 244 | + readonly queryString: string; |
| 245 | + readonly aggregationExpression: string; // e.g. count(*) |
| 246 | + readonly logGroupIdentifiers: string[]; // names, ARNs, or CWLI SOURCE patterns |
| 247 | + readonly scheduledQueryRole: IRole; |
| 248 | + readonly schedule: ScheduledQuerySchedule; |
| 249 | +} |
| 250 | + |
| 251 | +export interface ScheduledQuerySchedule { |
| 252 | + readonly frequency: Duration; // rendered to rate(...); whole minutes |
| 253 | + readonly startTimeOffset: Duration; // required by the service |
| 254 | + readonly endTimeOffset?: Duration; // @default - none |
| 255 | +} |
| 256 | +``` |
| 257 | + |
| 258 | +### `LogAlarm` |
| 259 | + |
| 260 | +```ts |
| 261 | +@propertyInjectable |
| 262 | +export class LogAlarm extends AlarmBase { |
| 263 | + public static readonly PROPERTY_INJECTION_ID: string; |
| 264 | + public static fromLogAlarmArn(scope: Construct, id: string, alarmArn: string): IAlarm; |
| 265 | + public static fromLogAlarmName(scope: Construct, id: string, alarmName: string): IAlarm; |
| 266 | + public get alarmArn(): string; |
| 267 | + public get alarmName(): string; |
| 268 | + constructor(scope: Construct, id: string, props: LogAlarmProps); |
| 269 | +} |
| 270 | +``` |
| 271 | + |
| 272 | +### Validation performed at synth |
| 273 | + |
| 274 | +- `comparisonOperator` must be a static-threshold operator (anomaly-detection operators rejected). |
| 275 | +- `queryResultsToEvaluate` / `queryResultsToAlarm` positive integers; `queryResultsToAlarm ≤ queryResultsToEvaluate`. |
| 276 | +- `actionLogLineCount` integer in `[0, 50]`; requires `actionLogLineRole` when `> 0`. |
| 277 | +- `logGroupIdentifiers` length in `[1, 50]`. |
| 278 | +- `schedule.frequency` a whole number of minutes ≥ 1. |
| 279 | + |
| 280 | +--- |
| 281 | + |
| 282 | +## CloudFormation Mapping |
| 283 | + |
| 284 | +```ts |
| 285 | +new cloudwatch.LogAlarm(this, 'ErrorRateAlarm', { |
| 286 | + threshold: 5, |
| 287 | + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, |
| 288 | + queryResultsToEvaluate: 3, |
| 289 | + queryResultsToAlarm: 2, |
| 290 | + scheduledQueryConfiguration: { |
| 291 | + queryString: 'fields @message | filter @message like /ERROR/', |
| 292 | + aggregationExpression: 'count(*)', |
| 293 | + logGroupIdentifiers: [logGroup.logGroupName], |
| 294 | + scheduledQueryRole: queryRole, |
| 295 | + schedule: { frequency: Duration.minutes(5), startTimeOffset: Duration.minutes(5) }, |
| 296 | + }, |
| 297 | +}); |
| 298 | +``` |
| 299 | + |
| 300 | +Synthesizes to: |
| 301 | + |
| 302 | +```yaml |
| 303 | +Type: AWS::CloudWatch::LogAlarm |
| 304 | +Properties: |
| 305 | + ComparisonOperator: GreaterThanThreshold |
| 306 | + Threshold: 5 |
| 307 | + QueryResultsToEvaluate: 3 |
| 308 | + QueryResultsToAlarm: 2 |
| 309 | + ScheduledQueryConfiguration: |
| 310 | + QueryString: "fields @message | filter @message like /ERROR/" |
| 311 | + AggregationExpression: "count(*)" |
| 312 | + LogGroupIdentifiers: [ { "Ref": "LogGroup..." } ] |
| 313 | + ScheduledQueryRoleARN: { "Fn::GetAtt": ["ScheduledQueryRole...", "Arn"] } |
| 314 | + ScheduleConfiguration: |
| 315 | + ScheduleExpression: "rate(5 minutes)" |
| 316 | + StartTimeOffset: 300 |
| 317 | +``` |
| 318 | +
|
| 319 | +--- |
| 320 | +
|
| 321 | +## Appendix |
| 322 | +
|
| 323 | +### References |
| 324 | +
|
| 325 | +- [AWS::CloudWatch::LogAlarm — CloudFormation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-logalarm.html) |
| 326 | +- [CDK Design Guidelines](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) |
| 327 | +- [Existing Alarm L2 source](https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/aws-cloudwatch/lib/alarm.ts) |
| 328 | +- [AlarmBase source](https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/aws-cloudwatch/lib/alarm-base.ts) |
0 commit comments