This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
exec-aide/lib/constructs/socket-mode.ts
Adam Moussa e5f68f1cd3 feat(exec-aide): CloudWatch alarm coverage for Lambdas, DynamoDB, ECS
Add ALARM-only CloudWatch alarms routed to the shared site-alerts SNS
topic (imported once via Topic.fromTopicArn and injected into both
constructs via props). All alarms use treatMissingData NOT_BREACHING and
have no OK / InsufficientData actions, mirroring the proposal-system
alarm construct.

Lambda (fetch-classify, daily-digest, conversation):
- Errors  (Sum >= 1, eval 1)
- Throttles (Sum >= 1, eval 1)
- Duration (p99, eval 3 / datapoints 2, ~80% of timeout:
  96000ms for the 120s fns, 144000ms for conversation's 180s)
  -- thresholds pending Adam sign-off.

DynamoDB exec-aide table:
- ThrottledRequests and SystemErrors. These metrics are NOT published at
  the bare TableName dimension (CDK's metricThrottledRequests /
  metricSystemErrors are deprecated as invalid); they are keyed by the
  Operation dimension. Used the *ForOperations math helpers scoped to the
  6 operations this single-table app issues (GetItem/PutItem/Query/Scan/
  UpdateItem/DeleteItem) to stay within the 10-metric math-expr cap.

ECS exec-aide-listener Fargate service (AWS/ECS, no Container Insights):
- CPU and Memory utilization (Average > 80%, eval 3 / datapoints 2).
- Service assigned to a const (logical id 'Service' unchanged) so metrics
  can reference it.

The RunningTaskCount alarm (requires Container Insights) is intentionally
deferred to a separate sign-off-gated commit.
2026-06-17 13:56:25 -04:00

184 lines
7 KiB
TypeScript

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ecrAssets from 'aws-cdk-lib/aws-ecr-assets';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as path from 'path';
export interface SocketModeProps {
table: dynamodb.ITable;
conversationFnArn: string;
/**
* Shared site-alerts SNS topic for CloudWatch ALARM actions. Imported once at
* the stack level (sns.Topic.fromTopicArn) and injected here, mirroring how
* the table is wired into this construct.
*/
alarmTopic: sns.ITopic;
}
export class SocketModeConstruct extends Construct {
constructor(scope: Construct, id: string, props: SocketModeProps) {
super(scope, id);
const account = cdk.Stack.of(this).account;
const region = cdk.Stack.of(this).region;
// ── VPC ────────────────────────────────────────────────────
const vpc = new ec2.Vpc(this, 'Vpc', {
vpcName: 'exec-aide',
ipAddresses: ec2.IpAddresses.cidr('10.30.0.0/16'),
maxAzs: 1,
natGateways: 0,
subnetConfiguration: [
{
cidrMask: 24,
name: 'exec-aide-listener',
subnetType: ec2.SubnetType.PUBLIC,
},
],
});
const sg = new ec2.SecurityGroup(this, 'ListenerSG', {
vpc,
description: 'exec-aide listener - outbound only',
allowAllOutbound: true,
});
// ── ECR ────────────────────────────────────────────────────
new ecr.Repository(this, 'ListenerRepo', {
repositoryName: 'exec-aide-listener',
imageScanOnPush: true,
lifecycleRules: [
{
maxImageCount: 5,
description: 'Keep last 5 images',
},
],
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// ── ECS Cluster + Task Definition ──────────────────────────
const cluster = new ecs.Cluster(this, 'Cluster', {
clusterName: 'exec-aide',
vpc,
});
const taskDef = new ecs.FargateTaskDefinition(this, 'TaskDef', {
family: 'exec-aide-listener',
cpu: 256,
memoryLimitMiB: 512,
runtimePlatform: {
cpuArchitecture: ecs.CpuArchitecture.ARM64,
operatingSystemFamily: ecs.OperatingSystemFamily.LINUX,
},
});
taskDef.addContainer('listener', {
image: ecs.ContainerImage.fromAsset(
path.join(__dirname, '../../listener'),
// Explicit platform: the task is ARM64, and without this an amd64 CI
// runner builds an amd64 image even with QEMU enabled — tasks then
// crash-loop with "exec format error" (50 failed starts on 2026-06-03).
{ platform: ecrAssets.Platform.LINUX_ARM64 },
),
essential: true,
environment: {
TABLE_NAME: props.table.tableName,
SECRET_SLACK: 'exec-aide/slack-credentials',
SSM_PREFIX: '/exec-aide',
CONVERSATION_FN_ARN: props.conversationFnArn,
},
logging: ecs.LogDrivers.awsLogs({
streamPrefix: 'listener',
logGroup: new logs.LogGroup(this, 'ListenerLogGroup', {
logGroupName: '/ecs/exec-aide-listener',
retention: logs.RetentionDays.TWO_MONTHS,
}),
}),
});
// ── Task role IAM ──────────────────────────────────────────
props.table.grantReadWriteData(taskDef.taskRole);
taskDef.taskRole.addToPrincipalPolicy(new iam.PolicyStatement({
actions: ['secretsmanager:GetSecretValue'],
resources: [
`arn:aws:secretsmanager:${region}:${account}:secret:exec-aide/slack-credentials-*`,
],
}));
taskDef.taskRole.addToPrincipalPolicy(new iam.PolicyStatement({
actions: ['ssm:GetParametersByPath', 'ssm:GetParameter'],
resources: [
`arn:aws:ssm:${region}:${account}:parameter/exec-aide`,
`arn:aws:ssm:${region}:${account}:parameter/exec-aide/*`,
],
}));
taskDef.taskRole.addToPrincipalPolicy(new iam.PolicyStatement({
actions: ['lambda:InvokeFunction'],
resources: [props.conversationFnArn],
}));
// ── Fargate Service ────────────────────────────────────────
// Assigned to a const (no logical-id change vs. the prior anonymous
// construct — id 'Service' is unchanged) so the service-level CloudWatch
// metrics below can reference it.
const service = new ecs.FargateService(this, 'Service', {
serviceName: 'exec-aide-listener',
cluster,
taskDefinition: taskDef,
desiredCount: 1,
assignPublicIp: true,
securityGroups: [sg],
vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
});
// ── CloudWatch Alarms ──────────────────────────────────────
// ALARM-only (no OK / InsufficientData action); treatMissingData
// NOT_BREACHING. Shared site-alerts action injected via props.
// CPU and Memory come from AWS/ECS service metrics (no Container Insights
// required). The RunningTaskCount alarm — which DOES require Container
// Insights — is added in a separate, sign-off-gated commit.
const alarmAction = new cloudwatchActions.SnsAction(props.alarmTopic);
new cloudwatch.Alarm(this, 'ListenerCpuHigh', {
alarmName: 'exec-aide-listener-cpu-high',
alarmDescription: 'exec-aide-listener Fargate service CPU utilization is high',
metric: service.metricCpuUtilization({
period: cdk.Duration.minutes(5),
statistic: 'Average',
}),
threshold: 80,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
evaluationPeriods: 3,
datapointsToAlarm: 2,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
}).addAlarmAction(alarmAction);
new cloudwatch.Alarm(this, 'ListenerMemoryHigh', {
alarmName: 'exec-aide-listener-memory-high',
alarmDescription: 'exec-aide-listener Fargate service memory utilization is high',
metric: service.metricMemoryUtilization({
period: cdk.Duration.minutes(5),
statistic: 'Average',
}),
threshold: 80,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
evaluationPeriods: 3,
datapointsToAlarm: 2,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
}).addAlarmAction(alarmAction);
}
}