Coverage for gco / stacks / monitoring_stack.py: 100.00%

365 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1""" 

2Monitoring stack for GCO (Global Capacity Orchestrator on AWS) - Cross-region monitoring and observability. 

3 

4This stack creates centralized monitoring resources for all GCO deployments: 

5- CloudWatch Dashboard with comprehensive widgets for all regions 

6- SNS topic for alerting 

7- CloudWatch Alarms for critical metrics 

8- Log groups for application logs 

9- Anomaly detection for traffic patterns 

10- Composite alarms for better signal-to-noise 

11 

12Dashboard Sections: 

13- Global Accelerator: Flow counts, processed bytes 

14- API Gateway: Request counts, latency, error rates 

15- Lambda Functions: Invocations, errors, duration, throttles 

16- SQS Queues: Message counts, age, dead letter queue depth 

17- DynamoDB Tables: Capacity, latency, throttles, errors 

18- EKS Clusters: CPU/memory utilization per region 

19- FSx for Lustre (when enabled): Throughput, IOPS, free storage 

20- Valkey Serverless (when enabled): ECPU, hit rate, latency, bytes used 

21- Aurora pgvector (when enabled): ACU utilization, connections, latency, CPU 

22- ALBs: Request counts, response times, healthy hosts 

23- Applications: Custom metrics from health monitor and manifest processor 

24 

25Cross-Region Metrics: 

26 CloudWatch metrics are region-specific. This stack handles cross-region 

27 monitoring by specifying the `region` parameter on metrics: 

28 - Global Accelerator metrics: Always in us-west-2 

29 - DynamoDB metrics: In the global region (where tables are deployed) 

30 - Regional metrics: In each cluster's region 

31 

32Alarms: 

33- High CPU/memory utilization on EKS clusters 

34- Unhealthy hosts in ALB target groups 

35- High response times 

36- Manifest processing failures 

37- Lambda errors and throttles 

38- SQS message age (stuck jobs) 

39- DynamoDB throttling and system errors 

40- API Gateway 5XX errors 

41- Secret rotation failures 

42""" 

43 

44from typing import TYPE_CHECKING, Any 

45 

46from aws_cdk import ( 

47 CfnOutput, 

48 Duration, 

49 RemovalPolicy, 

50 Stack, 

51) 

52from aws_cdk import aws_athena as athena 

53from aws_cdk import aws_cloudwatch as cloudwatch 

54from aws_cdk import aws_cloudwatch_actions as cw_actions 

55from aws_cdk import aws_glue as glue 

56from aws_cdk import aws_iam as iam 

57from aws_cdk import aws_kms as kms 

58from aws_cdk import aws_logs as logs 

59from aws_cdk import aws_s3 as s3 

60from aws_cdk import aws_sns as sns 

61from aws_cdk import aws_ssm as ssm 

62from constructs import Construct 

63 

64from gco.config.config_loader import ConfigLoader 

65from gco.stacks.constants import ( 

66 COST_ATHENA_RESULTS_PREFIX, 

67 COST_GLUE_ALLOCATION_TABLE, 

68 COST_REPORT_SCHEDULED_PREFIX, 

69 cost_athena_workgroup_name, 

70 cost_glue_database_name, 

71 cost_report_ssm_parameter_prefix, 

72) 

73 

74# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

75# Generated at (UTC): 2026-09-12T06:04:03Z 

76# Generated from Git commit: e96e2c39c3626a5088651f43873dfade6a346850 

77# Flowchart(s) generated from this file: 

78# * ``GCOMonitoringStack.__init__`` -> ``diagrams/code_diagrams/gco/stacks/monitoring_stack.GCOMonitoringStack___init__.html`` 

79# (PNG: ``diagrams/code_diagrams/gco/stacks/monitoring_stack.GCOMonitoringStack___init__.png``) 

80# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

81# <pyflowchart-code-diagram> END 

82 

83 

84if TYPE_CHECKING: 

85 from gco.stacks.api_gateway_global_stack import GCOApiGatewayGlobalStack 

86 from gco.stacks.global_stack import GCOGlobalStack 

87 from gco.stacks.regional_stack import GCORegionalStack 

88 

89 

90class GCOMonitoringStack(Stack): 

91 """ 

92 Cross-region monitoring and observability stack. 

93 

94 Creates a centralized CloudWatch dashboard and alarms that aggregate 

95 metrics from all regional deployments. 

96 

97 Attributes: 

98 alert_topic: SNS topic for alarm notifications 

99 dashboard: CloudWatch dashboard with all monitoring widgets 

100 """ 

101 

102 def __init__( 

103 self, 

104 scope: Construct, 

105 construct_id: str, 

106 config: ConfigLoader, 

107 global_stack: GCOGlobalStack, 

108 regional_stacks: list[GCORegionalStack], 

109 api_gateway_stack: GCOApiGatewayGlobalStack | None = None, 

110 **kwargs: Any, 

111 ) -> None: 

112 # Enable CDK's native cross-region references. The monitoring stack 

113 # lives in the monitoring region (by default us-east-2) and needs 

114 # resource identifiers from the regional stacks for dashboard 

115 # dimensions — specifically the auto-generated FSx file system IDs, 

116 # whose values aren't known until deploy time. 

117 # 

118 # CDK implements this by provisioning a small Lambda-backed custom 

119 # resource in each source stack that writes the referenced value to 

120 # an SSM parameter in the target region, plus a reader custom 

121 # resource in the target stack. Cost is negligible (the Lambdas run 

122 # once per deploy) and the pattern is the documented canonical 

123 # answer for ``CrossRegionReferencesNotEnabled`` errors. 

124 kwargs.setdefault("cross_region_references", True) 

125 super().__init__(scope, construct_id, **kwargs) 

126 

127 self.config = config 

128 self.global_stack = global_stack 

129 self.regional_stacks = regional_stacks 

130 self.api_gateway_stack = api_gateway_stack 

131 self.project_name = config.get_project_name() 

132 self.regions = config.get_regions() 

133 

134 # Create SNS topic for alerts 

135 self.alert_topic = self._create_alert_topic() 

136 

137 # Cost monitoring pipeline (on by default): the cost report bucket the 

138 # regional cost-monitor services write Parquet allocation reports to, 

139 # plus the Glue database/table and Athena workgroup that make the 

140 # cross-region data queryable from the CLI. 

141 if self.config.get_cost_monitoring_enabled(): 

142 self._create_cost_report_storage() 

143 self._create_cost_analytics() 

144 

145 # Create CloudWatch dashboard 

146 self.dashboard = self._create_dashboard() 

147 

148 # Create alarms 

149 self._create_alarms() 

150 

151 # Create composite alarms 

152 self._create_composite_alarms() 

153 

154 # Create custom metrics 

155 self._create_custom_metrics() 

156 

157 # Export monitoring resources 

158 self._create_outputs() 

159 

160 # Apply cdk-nag suppressions 

161 self._apply_nag_suppressions() 

162 

163 def _apply_nag_suppressions(self) -> None: 

164 """Apply cdk-nag suppressions for this stack.""" 

165 from gco.stacks.nag_suppressions import apply_all_suppressions 

166 

167 apply_all_suppressions( 

168 self, 

169 stack_type="monitoring", 

170 regions=self.config.get_regions(), 

171 global_region=self.config.get_global_region(), 

172 project_name=self.project_name, 

173 ) 

174 

175 def _create_alert_topic(self) -> sns.Topic: 

176 """Create SNS topic for monitoring alerts""" 

177 topic = sns.Topic( 

178 self, 

179 "GCOAlertTopic", 

180 display_name="GCO (Global Capacity Orchestrator on AWS) Monitoring Alerts", 

181 enforce_ssl=True, 

182 ) 

183 return topic 

184 

185 def _create_cost_report_storage(self) -> None: 

186 """Create the central cost report bucket for the cost monitoring pipeline. 

187 

188 Every regional cost-monitor service writes Hive-partitioned Parquet 

189 allocation reports here: 

190 

191 - ``reports/region=<region>/date=<YYYY-MM-DD>/...`` — scheduled 

192 reports; the Glue table's partition projection reads this layout. 

193 - ``adhoc/region=<region>/date=<YYYY-MM-DD>/...`` — user-requested 

194 reports, kept out of the scheduled table so overlapping windows can 

195 never double-count in Athena aggregations. 

196 - ``athena-results/`` — Athena query results for the cost workgroup. 

197 

198 Three constructs mirror the regional-shared bucket pattern: 

199 

200 1. ``cost_report_kms_key`` — customer-managed KMS key with annual 

201 rotation and a 7-day pending window on destroy. Its key policy 

202 grants every regional cost-monitor role ``GenerateDataKey`` / 

203 ``Decrypt`` / ``DescribeKey`` through S3 in this region. 

204 2. ``cost_report_access_logs_bucket`` — the dedicated S3 access-logs 

205 destination for the primary bucket. 

206 3. ``cost_report_bucket`` — the primary bucket. Its physical name is 

207 CloudFormation-generated: S3 bucket names are a global namespace 

208 and a deleted name is not reliably reusable, so the previous fixed 

209 ``<project>-cost-reports-<account>-<region>`` name made every 

210 destroy-and-redeploy a collision hazard (and did fail one). The 

211 regional stacks deploy *before* this stack, so instead of granting 

212 by reconstructed ARN they are granted here, principal based, via 

213 the bucket policy; the bucket's identity is published as SSM 

214 parameters under ``cost_report_ssm_parameter_prefix`` for the 

215 cost-monitor service, the CLI, and release validation to resolve. 

216 

217 Lifecycle policy comes from ``cdk.json`` (``cost_monitoring.reports``): 

218 report objects transition to STANDARD_IA after 

219 ``transition_to_infrequent_access_days`` and expire after 

220 ``retention_days``; Athena results expire after 

221 ``athena.query_results_retention_days``. 

222 """ 

223 cost_config = self.config.get_cost_monitoring_config() 

224 reports_config = cost_config["reports"] 

225 athena_config = cost_config["athena"] 

226 

227 # KMS key for the cost report bucket. Annual rotation, 7-day pending 

228 # window, destroy-on-teardown — matching the shared-bucket posture. 

229 self.cost_report_kms_key = kms.Key( 

230 self, 

231 "CostReportKmsKey", 

232 description=( 

233 "Customer-managed KMS key for the GCO cost report bucket in the monitoring stack." 

234 ), 

235 enable_key_rotation=True, 

236 pending_window=Duration.days(7), 

237 removal_policy=RemovalPolicy.DESTROY, 

238 ) 

239 

240 kms_actions = [ 

241 "kms:Encrypt", 

242 "kms:Decrypt", 

243 "kms:ReEncrypt*", 

244 "kms:GenerateDataKey*", 

245 "kms:DescribeKey", 

246 ] 

247 self.cost_report_kms_key.add_to_resource_policy( 

248 iam.PolicyStatement( 

249 sid="AllowS3ServiceEncryptDecrypt", 

250 effect=iam.Effect.ALLOW, 

251 principals=[iam.ServicePrincipal("s3.amazonaws.com")], 

252 actions=kms_actions, 

253 resources=["*"], 

254 ) 

255 ) 

256 

257 # Every regional cost-monitor role writes KMS-encrypted objects into 

258 # the bucket below. The roles live in other regions and their stacks 

259 # deploy first, so the grant direction is inverted relative to the 

260 # in-region buckets: this stack resolves each role ARN (a cross-region 

261 # reference CDK routes through the same export mechanism the SQS and 

262 # cluster widgets already use) and admits it here, scoped to use via 

263 # S3 in this region. Nothing in the regional stacks needs the key ARN. 

264 cost_monitor_principals = [ 

265 iam.ArnPrincipal(regional_stack.cost_monitor_role.role_arn) 

266 for regional_stack in self.regional_stacks 

267 ] 

268 if cost_monitor_principals: 

269 self.cost_report_kms_key.add_to_resource_policy( 

270 iam.PolicyStatement( 

271 sid="AllowRegionalCostMonitorsViaS3", 

272 effect=iam.Effect.ALLOW, 

273 principals=cost_monitor_principals, 

274 actions=["kms:GenerateDataKey", "kms:Decrypt", "kms:DescribeKey"], 

275 resources=["*"], 

276 conditions={ 

277 "StringEquals": {"kms:ViaService": f"s3.{self.region}.{self.url_suffix}"} 

278 }, 

279 ) 

280 ) 

281 

282 # Retention for the access-logs bucket honors the same `s3_access_logs` 

283 # context field used by the central buckets (default 90 days). 

284 s3_access_logs_ctx = self.node.try_get_context("s3_access_logs") or {} 

285 access_logs_retention_days = int(s3_access_logs_ctx.get("retention_days", 90)) 

286 

287 self.cost_report_access_logs_bucket = s3.Bucket( 

288 self, 

289 "CostReportAccessLogsBucket", 

290 encryption=s3.BucketEncryption.KMS, 

291 encryption_key=self.cost_report_kms_key, 

292 block_public_access=s3.BlockPublicAccess.BLOCK_ALL, 

293 enforce_ssl=True, 

294 versioned=True, 

295 removal_policy=RemovalPolicy.DESTROY, 

296 auto_delete_objects=True, 

297 lifecycle_rules=[ 

298 s3.LifecycleRule( 

299 id="ExpireAccessLogs", 

300 enabled=True, 

301 expiration=Duration.days(access_logs_retention_days), 

302 ) 

303 ], 

304 ) 

305 

306 # No ``bucket_name``: see the S3 bucket naming policy in 

307 # ``gco.stacks.constants`` — a CloudFormation-generated name can never 

308 # collide with a previous deployment's deleted bucket. 

309 self.cost_report_bucket = s3.Bucket( 

310 self, 

311 "CostReportBucket", 

312 encryption=s3.BucketEncryption.KMS, 

313 encryption_key=self.cost_report_kms_key, 

314 bucket_key_enabled=True, 

315 block_public_access=s3.BlockPublicAccess.BLOCK_ALL, 

316 enforce_ssl=True, 

317 versioned=True, 

318 removal_policy=RemovalPolicy.DESTROY, 

319 auto_delete_objects=True, 

320 server_access_logs_bucket=self.cost_report_access_logs_bucket, 

321 server_access_logs_prefix="cost-reports/", 

322 lifecycle_rules=[ 

323 # Scheduled + ad-hoc reports share one policy: IA after the 

324 # configured transition, expiry after the retention window. 

325 s3.LifecycleRule( 

326 id="CostReportRetention", 

327 enabled=True, 

328 prefix=f"{COST_REPORT_SCHEDULED_PREFIX}/", 

329 transitions=[ 

330 s3.Transition( 

331 storage_class=s3.StorageClass.INFREQUENT_ACCESS, 

332 transition_after=Duration.days( 

333 int(reports_config["transition_to_infrequent_access_days"]) 

334 ), 

335 ) 

336 ], 

337 expiration=Duration.days(int(reports_config["retention_days"])), 

338 ), 

339 s3.LifecycleRule( 

340 id="AdhocReportRetention", 

341 enabled=True, 

342 prefix="adhoc/", 

343 transitions=[ 

344 s3.Transition( 

345 storage_class=s3.StorageClass.INFREQUENT_ACCESS, 

346 transition_after=Duration.days( 

347 int(reports_config["transition_to_infrequent_access_days"]) 

348 ), 

349 ) 

350 ], 

351 expiration=Duration.days(int(reports_config["retention_days"])), 

352 ), 

353 s3.LifecycleRule( 

354 id="ExpireAthenaResults", 

355 enabled=True, 

356 prefix=f"{COST_ATHENA_RESULTS_PREFIX}/", 

357 expiration=Duration.days(int(athena_config["query_results_retention_days"])), 

358 ), 

359 ], 

360 ) 

361 

362 # Explicit Deny for insecure transport with a verifiable SID, 

363 # duplicating enforce_ssl=True per the central-bucket pattern. 

364 self.cost_report_bucket.add_to_resource_policy( 

365 iam.PolicyStatement( 

366 sid="DenyInsecureTransport", 

367 effect=iam.Effect.DENY, 

368 principals=[iam.AnyPrincipal()], 

369 actions=["s3:*"], 

370 resources=[ 

371 self.cost_report_bucket.bucket_arn, 

372 f"{self.cost_report_bucket.bucket_arn}/*", 

373 ], 

374 conditions={"Bool": {"aws:SecureTransport": "false"}}, 

375 ) 

376 ) 

377 

378 # Principal-based write grant for the regional cost-monitor roles. 

379 # Same-account bucket-policy allows are sufficient on their own, so 

380 # the regional stacks carry no S3 statement for this bucket at all — 

381 # the service writes scheduled/ad-hoc Parquet reports, lists recent 

382 # report objects for the API surface, and needs GetBucketLocation for 

383 # the cross-region client redirect. Object actions are scoped to this 

384 # bucket's key space; nothing here is ``Principal: "*"``. 

385 if cost_monitor_principals: 

386 self.cost_report_bucket.add_to_resource_policy( 

387 iam.PolicyStatement( 

388 sid="AllowRegionalCostMonitorReports", 

389 effect=iam.Effect.ALLOW, 

390 principals=cost_monitor_principals, 

391 actions=[ 

392 "s3:PutObject", 

393 "s3:GetObject", 

394 "s3:ListBucket", 

395 "s3:GetBucketLocation", 

396 ], 

397 resources=[ 

398 self.cost_report_bucket.bucket_arn, 

399 f"{self.cost_report_bucket.bucket_arn}/*", 

400 ], 

401 ) 

402 ) 

403 

404 # Publish the bucket's identity in this region's parameter store, 

405 # mirroring the model, cluster-shared and regional-shared buckets. The 

406 # regional cost-monitor services resolve ``<prefix>/name`` at runtime 

407 # (their stacks deploy before this one, so the parameter is the 

408 # rendezvous point, not a synth-time value); the CLI and release 

409 # validation read ``/name`` and ``/arn``. 

410 cost_report_prefix = cost_report_ssm_parameter_prefix(self.project_name) 

411 ssm.StringParameter( 

412 self, 

413 "CostReportBucketNameParam", 

414 parameter_name=f"{cost_report_prefix}/name", 

415 string_value=self.cost_report_bucket.bucket_name, 

416 description="Name of the central cost report bucket written by every region.", 

417 ) 

418 ssm.StringParameter( 

419 self, 

420 "CostReportBucketArnParam", 

421 parameter_name=f"{cost_report_prefix}/arn", 

422 string_value=self.cost_report_bucket.bucket_arn, 

423 description="ARN of the central cost report bucket written by every region.", 

424 ) 

425 ssm.StringParameter( 

426 self, 

427 "CostReportBucketRegionParam", 

428 parameter_name=f"{cost_report_prefix}/region", 

429 string_value=self.region, 

430 description="Home region of the central cost report bucket.", 

431 ) 

432 

433 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

434 

435 cost_replication_reason = ( 

436 "Cost reports are derived analytics data regenerated continuously " 

437 "by the per-region cost-monitor services; there is no durability " 

438 "requirement that warrants cross-region replication. Access logs " 

439 "do not require replication for the same reason." 

440 ) 

441 acknowledge_nag_findings( 

442 self.cost_report_bucket, 

443 [ 

444 { 

445 "id": "HIPAA.Security-S3BucketReplicationEnabled", 

446 "reason": cost_replication_reason, 

447 }, 

448 { 

449 "id": "NIST.800.53.R5-S3BucketReplicationEnabled", 

450 "reason": cost_replication_reason, 

451 }, 

452 { 

453 "id": "PCI.DSS.321-S3BucketReplicationEnabled", 

454 "reason": cost_replication_reason, 

455 }, 

456 ], 

457 ) 

458 

459 access_logs_is_self_target_reason = ( 

460 "This is the server access logs destination bucket for the cost report bucket." 

461 ) 

462 acknowledge_nag_findings( 

463 self.cost_report_access_logs_bucket, 

464 [ 

465 { 

466 "id": "AwsSolutions-S1", 

467 "reason": access_logs_is_self_target_reason, 

468 }, 

469 { 

470 "id": "HIPAA.Security-S3BucketLoggingEnabled", 

471 "reason": access_logs_is_self_target_reason, 

472 }, 

473 { 

474 "id": "NIST.800.53.R5-S3BucketLoggingEnabled", 

475 "reason": access_logs_is_self_target_reason, 

476 }, 

477 { 

478 "id": "PCI.DSS.321-S3BucketLoggingEnabled", 

479 "reason": access_logs_is_self_target_reason, 

480 }, 

481 { 

482 "id": "HIPAA.Security-S3BucketReplicationEnabled", 

483 "reason": cost_replication_reason, 

484 }, 

485 { 

486 "id": "NIST.800.53.R5-S3BucketReplicationEnabled", 

487 "reason": cost_replication_reason, 

488 }, 

489 { 

490 "id": "PCI.DSS.321-S3BucketReplicationEnabled", 

491 "reason": cost_replication_reason, 

492 }, 

493 ], 

494 ) 

495 

496 CfnOutput( 

497 self, 

498 "CostReportBucketName", 

499 value=self.cost_report_bucket.bucket_name, 

500 description="Central S3 bucket receiving per-region Parquet cost reports", 

501 ) 

502 

503 def _create_cost_analytics(self) -> None: 

504 """Create the Glue database/table and Athena workgroup for cost queries. 

505 

506 The Glue table reads the scheduled report layout 

507 (``reports/region=<region>/date=<YYYY-MM-DD>/*.parquet``) using 

508 **partition projection**, so there is no crawler, no scheduled 

509 ``MSCK REPAIR``, and no partition-management Lambda — new partitions 

510 are queryable the moment an object lands. The ``region`` partition is 

511 projected from the configured deployment regions; ``date`` is a native 

512 date projection from 2026-01-01 to NOW. 

513 

514 The Athena workgroup pins query results to ``athena-results/`` in the 

515 cost report bucket (KMS-encrypted, lifecycle-expired) and enforces its 

516 configuration so callers cannot redirect results elsewhere. 

517 

518 The table schema is the write-side contract of 

519 ``gco.services.cost_monitor`` — the columns below must stay in 

520 lockstep with the Parquet fields the service emits. 

521 """ 

522 database_name = cost_glue_database_name(self.project_name) 

523 

524 self.cost_glue_database = glue.CfnDatabase( 

525 self, 

526 "CostGlueDatabase", 

527 catalog_id=self.account, 

528 database_input=glue.CfnDatabase.DatabaseInputProperty( 

529 name=database_name, 

530 description=( 

531 "GCO cost analytics: per-region OpenCost allocation " 

532 "reports written by the cost-monitor services." 

533 ), 

534 ), 

535 ) 

536 

537 # Columns mirror gco/services/cost_monitor.py::ALLOCATION_REPORT_FIELDS. 

538 columns = [ 

539 glue.CfnTable.ColumnProperty(name="window_start", type="timestamp"), 

540 glue.CfnTable.ColumnProperty(name="window_end", type="timestamp"), 

541 glue.CfnTable.ColumnProperty(name="cluster", type="string"), 

542 glue.CfnTable.ColumnProperty(name="namespace", type="string"), 

543 glue.CfnTable.ColumnProperty(name="cpu_core_hours", type="double"), 

544 glue.CfnTable.ColumnProperty(name="cpu_cost", type="double"), 

545 glue.CfnTable.ColumnProperty(name="ram_gib_hours", type="double"), 

546 glue.CfnTable.ColumnProperty(name="ram_cost", type="double"), 

547 glue.CfnTable.ColumnProperty(name="gpu_hours", type="double"), 

548 glue.CfnTable.ColumnProperty(name="gpu_cost", type="double"), 

549 glue.CfnTable.ColumnProperty(name="pv_cost", type="double"), 

550 glue.CfnTable.ColumnProperty(name="network_cost", type="double"), 

551 glue.CfnTable.ColumnProperty(name="load_balancer_cost", type="double"), 

552 glue.CfnTable.ColumnProperty(name="shared_cost", type="double"), 

553 glue.CfnTable.ColumnProperty(name="external_cost", type="double"), 

554 glue.CfnTable.ColumnProperty(name="total_cost", type="double"), 

555 glue.CfnTable.ColumnProperty(name="total_efficiency", type="double"), 

556 ] 

557 

558 scheduled_location = ( 

559 f"s3://{self.cost_report_bucket.bucket_name}/{COST_REPORT_SCHEDULED_PREFIX}/" 

560 ) 

561 self.cost_glue_table = glue.CfnTable( 

562 self, 

563 "CostAllocationTable", 

564 catalog_id=self.account, 

565 database_name=database_name, 

566 table_input=glue.CfnTable.TableInputProperty( 

567 name=COST_GLUE_ALLOCATION_TABLE, 

568 description="Scheduled OpenCost allocation reports (Parquet)", 

569 table_type="EXTERNAL_TABLE", 

570 parameters={ 

571 "classification": "parquet", 

572 "EXTERNAL": "TRUE", 

573 # Partition projection: no crawler, no MSCK. The region 

574 # projection is pinned to the deployment's configured 

575 # regions; extend deployment_regions.regional and redeploy 

576 # to pick up new regions. 

577 "projection.enabled": "true", 

578 "projection.region.type": "enum", 

579 "projection.region.values": ",".join(self.regions), 

580 "projection.date.type": "date", 

581 "projection.date.range": "2026-01-01,NOW", 

582 "projection.date.format": "yyyy-MM-dd", 

583 "projection.date.interval": "1", 

584 "projection.date.interval.unit": "DAYS", 

585 "storage.location.template": ( 

586 f"{scheduled_location}region=${{region}}/date=${{date}}" 

587 ), 

588 }, 

589 partition_keys=[ 

590 glue.CfnTable.ColumnProperty(name="region", type="string"), 

591 glue.CfnTable.ColumnProperty(name="date", type="string"), 

592 ], 

593 storage_descriptor=glue.CfnTable.StorageDescriptorProperty( 

594 location=scheduled_location, 

595 input_format=("org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"), 

596 output_format=( 

597 "org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat" 

598 ), 

599 serde_info=glue.CfnTable.SerdeInfoProperty( 

600 serialization_library=( 

601 "org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe" 

602 ), 

603 ), 

604 columns=columns, 

605 ), 

606 ), 

607 ) 

608 self.cost_glue_table.add_resource_dependency(self.cost_glue_database) 

609 

610 self.cost_athena_workgroup = athena.CfnWorkGroup( 

611 self, 

612 "CostAthenaWorkGroup", 

613 name=cost_athena_workgroup_name(self.project_name), 

614 description="GCO cost analytics queries over the cost report bucket", 

615 recursive_delete_option=True, 

616 work_group_configuration=athena.CfnWorkGroup.WorkGroupConfigurationProperty( 

617 enforce_work_group_configuration=True, 

618 publish_cloud_watch_metrics_enabled=True, 

619 result_configuration=athena.CfnWorkGroup.ResultConfigurationProperty( 

620 output_location=( 

621 f"s3://{self.cost_report_bucket.bucket_name}/{COST_ATHENA_RESULTS_PREFIX}/" 

622 ), 

623 encryption_configuration=( 

624 athena.CfnWorkGroup.EncryptionConfigurationProperty( 

625 encryption_option="SSE_KMS", 

626 kms_key=self.cost_report_kms_key.key_arn, 

627 ) 

628 ), 

629 ), 

630 ), 

631 ) 

632 

633 CfnOutput( 

634 self, 

635 "CostAthenaWorkGroupName", 

636 value=cost_athena_workgroup_name(self.project_name), 

637 description="Athena workgroup for GCO cost analytics queries", 

638 ) 

639 CfnOutput( 

640 self, 

641 "CostGlueDatabaseName", 

642 value=cost_glue_database_name(self.project_name), 

643 description="Glue database containing the cost allocation table", 

644 ) 

645 

646 def _create_dashboard(self) -> cloudwatch.Dashboard: 

647 """Create comprehensive CloudWatch dashboard for monitoring""" 

648 dashboard = cloudwatch.Dashboard( 

649 self, 

650 "GCODashboard", 

651 period_override=cloudwatch.PeriodOverride.AUTO, 

652 ) 

653 

654 # Add widgets in logical order 

655 dashboard.add_widgets(*self._create_global_accelerator_widgets()) 

656 dashboard.add_widgets(*self._create_api_gateway_widgets()) 

657 dashboard.add_widgets(*self._create_lambda_widgets()) 

658 dashboard.add_widgets(*self._create_sqs_widgets()) 

659 dashboard.add_widgets(*self._create_dynamodb_widgets()) 

660 dashboard.add_widgets(*self._create_eks_widgets()) 

661 dashboard.add_widgets(*self._create_gpu_widgets()) 

662 dashboard.add_widgets(*self._create_fsx_widgets()) 

663 dashboard.add_widgets(*self._create_valkey_widgets()) 

664 dashboard.add_widgets(*self._create_aurora_pgvector_widgets()) 

665 dashboard.add_widgets(*self._create_alb_widgets()) 

666 dashboard.add_widgets(*self._create_application_widgets()) 

667 

668 return dashboard 

669 

670 def _create_global_accelerator_widgets(self) -> list[cloudwatch.IWidget]: 

671 """Create Global Accelerator monitoring widgets. 

672 

673 Note: Global Accelerator metrics are only available in us-west-2, 

674 regardless of where the accelerator endpoints are located. 

675 CloudWatch uses the Accelerator ID (UUID), not the name. 

676 """ 

677 widgets: list[cloudwatch.IWidget] = [] 

678 if self.global_stack.accelerator_id is None: 

679 return widgets 

680 

681 # Get the accelerator ID from the global stack (CloudWatch uses ID, not name) 

682 accelerator_id = self.global_stack.accelerator_id 

683 

684 # Global Accelerator metrics are always in us-west-2 

685 ga_metrics_region = "us-west-2" 

686 

687 # Section header 

688 widgets.append( 

689 cloudwatch.TextWidget( 

690 markdown="# Global Accelerator\nTraffic distribution and connectivity metrics", 

691 width=24, 

692 height=1, 

693 ) 

694 ) 

695 

696 # Flow count with anomaly detection 

697 flow_count_widget = cloudwatch.GraphWidget( 

698 title="Global Accelerator - New Flows", 

699 left=[ 

700 cloudwatch.Metric( 

701 namespace="AWS/GlobalAccelerator", 

702 metric_name="NewFlowCount", 

703 dimensions_map={"Accelerator": accelerator_id}, 

704 statistic="Sum", 

705 period=Duration.minutes(5), 

706 region=ga_metrics_region, 

707 ) 

708 ], 

709 width=12, 

710 height=6, 

711 region=ga_metrics_region, 

712 ) 

713 widgets.append(flow_count_widget) 

714 

715 # Processed bytes 

716 bytes_widget = cloudwatch.GraphWidget( 

717 title="Global Accelerator - Processed Bytes", 

718 left=[ 

719 cloudwatch.Metric( 

720 namespace="AWS/GlobalAccelerator", 

721 metric_name="ProcessedBytesIn", 

722 dimensions_map={"Accelerator": accelerator_id}, 

723 statistic="Sum", 

724 period=Duration.minutes(5), 

725 region=ga_metrics_region, 

726 ), 

727 cloudwatch.Metric( 

728 namespace="AWS/GlobalAccelerator", 

729 metric_name="ProcessedBytesOut", 

730 dimensions_map={"Accelerator": accelerator_id}, 

731 statistic="Sum", 

732 period=Duration.minutes(5), 

733 region=ga_metrics_region, 

734 ), 

735 ], 

736 width=12, 

737 height=6, 

738 region=ga_metrics_region, 

739 ) 

740 widgets.append(bytes_widget) 

741 

742 return widgets 

743 

744 def _create_api_gateway_widgets(self) -> list[cloudwatch.IWidget]: 

745 """Create API Gateway monitoring widgets""" 

746 widgets: list[cloudwatch.IWidget] = [] 

747 

748 # Get the actual API name from the api_gateway_stack 

749 api_name = ( 

750 self.api_gateway_stack.api.rest_api_name 

751 if self.api_gateway_stack 

752 else f"{self.project_name}-global-api" 

753 ) 

754 

755 # API Gateway metrics are in the region where the API is deployed 

756 api_gw_region = self.config.get_api_gateway_region() 

757 

758 # Section header 

759 widgets.append( 

760 cloudwatch.TextWidget( 

761 markdown="# API Gateway\nRequest metrics, latency, and error rates", 

762 width=24, 

763 height=1, 

764 ) 

765 ) 

766 

767 # Request count and latency 

768 request_widget = cloudwatch.GraphWidget( 

769 title="API Gateway - Requests & Latency", 

770 left=[ 

771 cloudwatch.Metric( 

772 namespace="AWS/ApiGateway", 

773 metric_name="Count", 

774 dimensions_map={"ApiName": api_name}, 

775 statistic="Sum", 

776 period=Duration.minutes(5), 

777 region=api_gw_region, 

778 ) 

779 ], 

780 right=[ 

781 cloudwatch.Metric( 

782 namespace="AWS/ApiGateway", 

783 metric_name="Latency", 

784 dimensions_map={"ApiName": api_name}, 

785 statistic="Average", 

786 period=Duration.minutes(5), 

787 region=api_gw_region, 

788 ), 

789 cloudwatch.Metric( 

790 namespace="AWS/ApiGateway", 

791 metric_name="Latency", 

792 dimensions_map={"ApiName": api_name}, 

793 statistic="p99", 

794 period=Duration.minutes(5), 

795 region=api_gw_region, 

796 ), 

797 ], 

798 width=12, 

799 height=6, 

800 region=api_gw_region, 

801 ) 

802 widgets.append(request_widget) 

803 

804 # Error rates (4XX and 5XX) 

805 error_widget = cloudwatch.GraphWidget( 

806 title="API Gateway - Error Rates", 

807 left=[ 

808 cloudwatch.Metric( 

809 namespace="AWS/ApiGateway", 

810 metric_name="4XXError", 

811 dimensions_map={"ApiName": api_name}, 

812 statistic="Sum", 

813 period=Duration.minutes(5), 

814 color="#ff7f0e", 

815 region=api_gw_region, 

816 ), 

817 cloudwatch.Metric( 

818 namespace="AWS/ApiGateway", 

819 metric_name="5XXError", 

820 dimensions_map={"ApiName": api_name}, 

821 statistic="Sum", 

822 period=Duration.minutes(5), 

823 color="#d62728", 

824 region=api_gw_region, 

825 ), 

826 ], 

827 width=12, 

828 height=6, 

829 region=api_gw_region, 

830 ) 

831 widgets.append(error_widget) 

832 

833 return widgets 

834 

835 def _create_lambda_widgets(self) -> list[cloudwatch.IWidget]: 

836 """Create Lambda function monitoring widgets""" 

837 widgets: list[cloudwatch.IWidget] = [] 

838 

839 # Section header 

840 widgets.append( 

841 cloudwatch.TextWidget( 

842 markdown="# Lambda Functions\nProxy, rotation, and regional Lambda metrics", 

843 width=24, 

844 height=1, 

845 ) 

846 ) 

847 

848 # Get API Gateway region for global Lambda functions 

849 api_gw_region = self.config.get_api_gateway_region() 

850 

851 # Build Lambda function list: (function_name, label, region) 

852 lambda_functions: list[tuple[str, str, str]] = [] 

853 

854 # Add API Gateway Lambda functions if available 

855 if self.api_gateway_stack: 

856 if self.api_gateway_stack.proxy_lambda is not None: 

857 lambda_functions.append( 

858 ( 

859 self.api_gateway_stack.proxy_lambda.function_name, 

860 "API Gateway Proxy", 

861 api_gw_region, 

862 ) 

863 ) 

864 lambda_functions.append( 

865 ( 

866 self.api_gateway_stack.rotation_lambda.function_name, 

867 "Secret Rotation", 

868 api_gw_region, 

869 ) 

870 ) 

871 

872 # Add regional Lambda functions from each regional stack 

873 for regional_stack in self.regional_stacks: 

874 region = regional_stack.deployment_region 

875 lambda_functions.extend( 

876 [ 

877 ( 

878 regional_stack.kubectl_lambda_function_name, 

879 f"Kubectl Applier ({region})", 

880 region, 

881 ), 

882 ( 

883 regional_stack.helm_installer_lambda_function_name, 

884 f"Helm Installer ({region})", 

885 region, 

886 ), 

887 ] 

888 ) 

889 

890 # Invocations widget 

891 invocations_widget = cloudwatch.GraphWidget( 

892 title="Lambda - Invocations", 

893 left=[ 

894 cloudwatch.Metric( 

895 namespace="AWS/Lambda", 

896 metric_name="Invocations", 

897 dimensions_map={"FunctionName": func_name}, 

898 statistic="Sum", 

899 period=Duration.minutes(5), 

900 label=label, 

901 region=region, 

902 ) 

903 for func_name, label, region in lambda_functions[:5] 

904 ], 

905 width=12, 

906 height=6, 

907 ) 

908 widgets.append(invocations_widget) 

909 

910 errors_widget = cloudwatch.GraphWidget( 

911 title="Lambda - Errors", 

912 left=[ 

913 cloudwatch.Metric( 

914 namespace="AWS/Lambda", 

915 metric_name="Errors", 

916 dimensions_map={"FunctionName": func_name}, 

917 statistic="Sum", 

918 period=Duration.minutes(5), 

919 label=label, 

920 color="#d62728", 

921 region=region, 

922 ) 

923 for func_name, label, region in lambda_functions[:5] 

924 ], 

925 width=12, 

926 height=6, 

927 ) 

928 widgets.append(errors_widget) 

929 

930 # Duration widget 

931 duration_widget = cloudwatch.GraphWidget( 

932 title="Lambda - Duration (ms)", 

933 left=[ 

934 cloudwatch.Metric( 

935 namespace="AWS/Lambda", 

936 metric_name="Duration", 

937 dimensions_map={"FunctionName": func_name}, 

938 statistic="Average", 

939 period=Duration.minutes(5), 

940 label=label, 

941 region=region, 

942 ) 

943 for func_name, label, region in lambda_functions[:5] 

944 ], 

945 width=12, 

946 height=6, 

947 ) 

948 widgets.append(duration_widget) 

949 

950 # Throttles widget 

951 throttles_widget = cloudwatch.GraphWidget( 

952 title="Lambda - Throttles & Concurrent Executions", 

953 left=[ 

954 cloudwatch.Metric( 

955 namespace="AWS/Lambda", 

956 metric_name="Throttles", 

957 dimensions_map={"FunctionName": func_name}, 

958 statistic="Sum", 

959 period=Duration.minutes(5), 

960 label=f"{label} Throttles", 

961 region=region, 

962 ) 

963 for func_name, label, region in lambda_functions[:3] 

964 ], 

965 right=[ 

966 cloudwatch.Metric( 

967 namespace="AWS/Lambda", 

968 metric_name="ConcurrentExecutions", 

969 dimensions_map={"FunctionName": func_name}, 

970 statistic="Maximum", 

971 period=Duration.minutes(5), 

972 label=f"{label} Concurrent", 

973 region=region, 

974 ) 

975 for func_name, label, region in lambda_functions[:3] 

976 ], 

977 width=12, 

978 height=6, 

979 ) 

980 widgets.append(throttles_widget) 

981 

982 return widgets 

983 

984 def _create_sqs_widgets(self) -> list[cloudwatch.IWidget]: 

985 """Create SQS queue monitoring widgets""" 

986 widgets: list[cloudwatch.IWidget] = [] 

987 

988 # Section header 

989 widgets.append( 

990 cloudwatch.TextWidget( 

991 markdown="# SQS Queues\nJob submission queue metrics and dead letter queue", 

992 width=24, 

993 height=1, 

994 ) 

995 ) 

996 

997 # Build queue info from regional stacks: (queue_name, dlq_name, region) 

998 queue_info = [ 

999 ( 

1000 regional_stack.job_queue.queue_name, 

1001 regional_stack.job_dlq.queue_name, 

1002 regional_stack.deployment_region, 

1003 ) 

1004 for regional_stack in self.regional_stacks 

1005 ] 

1006 

1007 # Messages visible and in-flight per region 

1008 messages_widget = cloudwatch.GraphWidget( 

1009 title="SQS - Messages (Visible & In-Flight)", 

1010 left=[ 

1011 cloudwatch.Metric( 

1012 namespace="AWS/SQS", 

1013 metric_name="ApproximateNumberOfMessagesVisible", 

1014 dimensions_map={"QueueName": queue_name}, 

1015 statistic="Average", 

1016 period=Duration.minutes(1), 

1017 label=f"{region} Visible", 

1018 region=region, 

1019 ) 

1020 for queue_name, _, region in queue_info 

1021 ], 

1022 right=[ 

1023 cloudwatch.Metric( 

1024 namespace="AWS/SQS", 

1025 metric_name="ApproximateNumberOfMessagesNotVisible", 

1026 dimensions_map={"QueueName": queue_name}, 

1027 statistic="Average", 

1028 period=Duration.minutes(1), 

1029 label=f"{region} In-Flight", 

1030 region=region, 

1031 ) 

1032 for queue_name, _, region in queue_info 

1033 ], 

1034 width=12, 

1035 height=6, 

1036 ) 

1037 widgets.append(messages_widget) 

1038 

1039 # Age of oldest message (critical for detecting stuck jobs) 

1040 age_widget = cloudwatch.GraphWidget( 

1041 title="SQS - Age of Oldest Message (seconds)", 

1042 left=[ 

1043 cloudwatch.Metric( 

1044 namespace="AWS/SQS", 

1045 metric_name="ApproximateAgeOfOldestMessage", 

1046 dimensions_map={"QueueName": queue_name}, 

1047 statistic="Maximum", 

1048 period=Duration.minutes(1), 

1049 label=region, 

1050 region=region, 

1051 ) 

1052 for queue_name, _, region in queue_info 

1053 ], 

1054 width=12, 

1055 height=6, 

1056 ) 

1057 widgets.append(age_widget) 

1058 

1059 # Dead letter queue depth 

1060 dlq_widget = cloudwatch.GraphWidget( 

1061 title="SQS - Dead Letter Queue Depth", 

1062 left=[ 

1063 cloudwatch.Metric( 

1064 namespace="AWS/SQS", 

1065 metric_name="ApproximateNumberOfMessagesVisible", 

1066 dimensions_map={"QueueName": dlq_name}, 

1067 statistic="Average", 

1068 period=Duration.minutes(1), 

1069 label=f"{region} DLQ", 

1070 color="#d62728", 

1071 region=region, 

1072 ) 

1073 for _, dlq_name, region in queue_info 

1074 ], 

1075 width=12, 

1076 height=6, 

1077 ) 

1078 widgets.append(dlq_widget) 

1079 

1080 # Messages sent/received/deleted 

1081 throughput_widget = cloudwatch.GraphWidget( 

1082 title="SQS - Throughput", 

1083 left=[ 

1084 cloudwatch.Metric( 

1085 namespace="AWS/SQS", 

1086 metric_name="NumberOfMessagesSent", 

1087 dimensions_map={"QueueName": queue_name}, 

1088 statistic="Sum", 

1089 period=Duration.minutes(5), 

1090 label=f"{region} Sent", 

1091 region=region, 

1092 ) 

1093 for queue_name, _, region in queue_info 

1094 ], 

1095 right=[ 

1096 cloudwatch.Metric( 

1097 namespace="AWS/SQS", 

1098 metric_name="NumberOfMessagesDeleted", 

1099 dimensions_map={"QueueName": queue_name}, 

1100 statistic="Sum", 

1101 period=Duration.minutes(5), 

1102 label=f"{region} Processed", 

1103 region=region, 

1104 ) 

1105 for queue_name, _, region in queue_info 

1106 ], 

1107 width=12, 

1108 height=6, 

1109 ) 

1110 widgets.append(throughput_widget) 

1111 

1112 return widgets 

1113 

1114 def _create_dynamodb_widgets(self) -> list[cloudwatch.IWidget]: 

1115 """Create DynamoDB monitoring widgets for job queue, templates, and webhooks tables.""" 

1116 widgets: list[cloudwatch.IWidget] = [] 

1117 

1118 # Get table names from global stack 

1119 templates_table = self.global_stack.templates_table.table_name 

1120 webhooks_table = self.global_stack.webhooks_table.table_name 

1121 jobs_table = self.global_stack.jobs_table.table_name 

1122 

1123 # DynamoDB tables are in the global region 

1124 global_region = self.config.get_global_region() 

1125 

1126 # Section header 

1127 widgets.append( 

1128 cloudwatch.TextWidget( 

1129 markdown="# DynamoDB Tables\nJob queue, templates, and webhooks storage metrics", 

1130 width=24, 

1131 height=1, 

1132 ) 

1133 ) 

1134 

1135 # Read/Write capacity consumed 

1136 capacity_widget = cloudwatch.GraphWidget( 

1137 title="DynamoDB - Consumed Capacity", 

1138 left=[ 

1139 cloudwatch.Metric( 

1140 namespace="AWS/DynamoDB", 

1141 metric_name="ConsumedReadCapacityUnits", 

1142 dimensions_map={"TableName": jobs_table}, 

1143 statistic="Sum", 

1144 period=Duration.minutes(5), 

1145 label="Jobs Read", 

1146 region=global_region, 

1147 ), 

1148 cloudwatch.Metric( 

1149 namespace="AWS/DynamoDB", 

1150 metric_name="ConsumedReadCapacityUnits", 

1151 dimensions_map={"TableName": templates_table}, 

1152 statistic="Sum", 

1153 period=Duration.minutes(5), 

1154 label="Templates Read", 

1155 region=global_region, 

1156 ), 

1157 cloudwatch.Metric( 

1158 namespace="AWS/DynamoDB", 

1159 metric_name="ConsumedReadCapacityUnits", 

1160 dimensions_map={"TableName": webhooks_table}, 

1161 statistic="Sum", 

1162 period=Duration.minutes(5), 

1163 label="Webhooks Read", 

1164 region=global_region, 

1165 ), 

1166 ], 

1167 right=[ 

1168 cloudwatch.Metric( 

1169 namespace="AWS/DynamoDB", 

1170 metric_name="ConsumedWriteCapacityUnits", 

1171 dimensions_map={"TableName": jobs_table}, 

1172 statistic="Sum", 

1173 period=Duration.minutes(5), 

1174 label="Jobs Write", 

1175 region=global_region, 

1176 ), 

1177 cloudwatch.Metric( 

1178 namespace="AWS/DynamoDB", 

1179 metric_name="ConsumedWriteCapacityUnits", 

1180 dimensions_map={"TableName": templates_table}, 

1181 statistic="Sum", 

1182 period=Duration.minutes(5), 

1183 label="Templates Write", 

1184 region=global_region, 

1185 ), 

1186 ], 

1187 width=12, 

1188 height=6, 

1189 region=global_region, 

1190 ) 

1191 widgets.append(capacity_widget) 

1192 

1193 # Latency metrics 

1194 latency_widget = cloudwatch.GraphWidget( 

1195 title="DynamoDB - Latency (ms)", 

1196 left=[ 

1197 cloudwatch.Metric( 

1198 namespace="AWS/DynamoDB", 

1199 metric_name="SuccessfulRequestLatency", 

1200 dimensions_map={"TableName": jobs_table, "Operation": "GetItem"}, 

1201 statistic="Average", 

1202 period=Duration.minutes(5), 

1203 label="Jobs GetItem", 

1204 region=global_region, 

1205 ), 

1206 cloudwatch.Metric( 

1207 namespace="AWS/DynamoDB", 

1208 metric_name="SuccessfulRequestLatency", 

1209 dimensions_map={"TableName": jobs_table, "Operation": "PutItem"}, 

1210 statistic="Average", 

1211 period=Duration.minutes(5), 

1212 label="Jobs PutItem", 

1213 region=global_region, 

1214 ), 

1215 cloudwatch.Metric( 

1216 namespace="AWS/DynamoDB", 

1217 metric_name="SuccessfulRequestLatency", 

1218 dimensions_map={"TableName": jobs_table, "Operation": "Query"}, 

1219 statistic="Average", 

1220 period=Duration.minutes(5), 

1221 label="Jobs Query", 

1222 region=global_region, 

1223 ), 

1224 ], 

1225 width=12, 

1226 height=6, 

1227 region=global_region, 

1228 ) 

1229 widgets.append(latency_widget) 

1230 

1231 # Throttled requests 

1232 throttle_widget = cloudwatch.GraphWidget( 

1233 title="DynamoDB - Throttled Requests", 

1234 left=[ 

1235 cloudwatch.Metric( 

1236 namespace="AWS/DynamoDB", 

1237 metric_name="ThrottledRequests", 

1238 dimensions_map={"TableName": jobs_table}, 

1239 statistic="Sum", 

1240 period=Duration.minutes(5), 

1241 label="Jobs", 

1242 color="#d62728", 

1243 region=global_region, 

1244 ), 

1245 cloudwatch.Metric( 

1246 namespace="AWS/DynamoDB", 

1247 metric_name="ThrottledRequests", 

1248 dimensions_map={"TableName": templates_table}, 

1249 statistic="Sum", 

1250 period=Duration.minutes(5), 

1251 label="Templates", 

1252 color="#ff7f0e", 

1253 region=global_region, 

1254 ), 

1255 cloudwatch.Metric( 

1256 namespace="AWS/DynamoDB", 

1257 metric_name="ThrottledRequests", 

1258 dimensions_map={"TableName": webhooks_table}, 

1259 statistic="Sum", 

1260 period=Duration.minutes(5), 

1261 label="Webhooks", 

1262 color="#9467bd", 

1263 region=global_region, 

1264 ), 

1265 ], 

1266 width=12, 

1267 height=6, 

1268 region=global_region, 

1269 ) 

1270 widgets.append(throttle_widget) 

1271 

1272 # System errors 

1273 errors_widget = cloudwatch.GraphWidget( 

1274 title="DynamoDB - System Errors", 

1275 left=[ 

1276 cloudwatch.Metric( 

1277 namespace="AWS/DynamoDB", 

1278 metric_name="SystemErrors", 

1279 dimensions_map={"TableName": jobs_table}, 

1280 statistic="Sum", 

1281 period=Duration.minutes(5), 

1282 label="Jobs", 

1283 color="#d62728", 

1284 region=global_region, 

1285 ), 

1286 cloudwatch.Metric( 

1287 namespace="AWS/DynamoDB", 

1288 metric_name="SystemErrors", 

1289 dimensions_map={"TableName": templates_table}, 

1290 statistic="Sum", 

1291 period=Duration.minutes(5), 

1292 label="Templates", 

1293 color="#ff7f0e", 

1294 region=global_region, 

1295 ), 

1296 ], 

1297 width=12, 

1298 height=6, 

1299 region=global_region, 

1300 ) 

1301 widgets.append(errors_widget) 

1302 

1303 return widgets 

1304 

1305 def _create_eks_widgets(self) -> list[cloudwatch.IWidget]: 

1306 """Create EKS cluster monitoring widgets""" 

1307 widgets: list[cloudwatch.IWidget] = [] 

1308 

1309 # Section header 

1310 widgets.append( 

1311 cloudwatch.TextWidget( 

1312 markdown="# EKS Clusters\nCluster resource utilization and node metrics", 

1313 width=24, 

1314 height=1, 

1315 ) 

1316 ) 

1317 

1318 # Build cluster info from regional stacks: (cluster_name, region) 

1319 cluster_info = [ 

1320 (regional_stack.cluster.cluster_name, regional_stack.deployment_region) 

1321 for regional_stack in self.regional_stacks 

1322 ] 

1323 

1324 # EKS cluster status 

1325 cluster_status_widget = cloudwatch.SingleValueWidget( 

1326 title="EKS Clusters - Failed Requests", 

1327 metrics=[ 

1328 cloudwatch.Metric( 

1329 namespace="AWS/EKS", 

1330 metric_name="cluster_failed_request_count", 

1331 dimensions_map={"cluster_name": cluster_name}, 

1332 statistic="Sum", 

1333 period=Duration.minutes(5), 

1334 region=region, 

1335 ) 

1336 for cluster_name, region in cluster_info 

1337 ], 

1338 width=12, 

1339 height=6, 

1340 ) 

1341 widgets.append(cluster_status_widget) 

1342 

1343 # Container Insights - Node CPU utilization (aggregated across all nodes) 

1344 # Note: region parameter enables cross-region metrics in dashboard 

1345 cpu_widget = cloudwatch.GraphWidget( 

1346 title="EKS Clusters - Node CPU Utilization (%)", 

1347 left=[ 

1348 cloudwatch.Metric( 

1349 namespace="ContainerInsights", 

1350 metric_name="node_cpu_utilization", 

1351 dimensions_map={"ClusterName": cluster_name}, 

1352 statistic="Average", 

1353 period=Duration.minutes(5), 

1354 label=region, 

1355 region=region, 

1356 ) 

1357 for cluster_name, region in cluster_info 

1358 ], 

1359 width=12, 

1360 height=6, 

1361 ) 

1362 widgets.append(cpu_widget) 

1363 

1364 # Container Insights - Node Memory utilization (aggregated across all nodes) 

1365 memory_widget = cloudwatch.GraphWidget( 

1366 title="EKS Clusters - Node Memory Utilization (%)", 

1367 left=[ 

1368 cloudwatch.Metric( 

1369 namespace="ContainerInsights", 

1370 metric_name="node_memory_utilization", 

1371 dimensions_map={"ClusterName": cluster_name}, 

1372 statistic="Average", 

1373 period=Duration.minutes(5), 

1374 label=region, 

1375 region=region, 

1376 ) 

1377 for cluster_name, region in cluster_info 

1378 ], 

1379 width=12, 

1380 height=6, 

1381 ) 

1382 widgets.append(memory_widget) 

1383 

1384 # Node status - running pods capacity 

1385 node_widget = cloudwatch.GraphWidget( 

1386 title="EKS Clusters - Node Pod Capacity", 

1387 left=[ 

1388 cloudwatch.Metric( 

1389 namespace="ContainerInsights", 

1390 metric_name="node_status_capacity_pods", 

1391 dimensions_map={"ClusterName": cluster_name}, 

1392 statistic="Sum", 

1393 period=Duration.minutes(5), 

1394 label=f"{region} Capacity", 

1395 region=region, 

1396 ) 

1397 for cluster_name, region in cluster_info 

1398 ], 

1399 right=[ 

1400 cloudwatch.Metric( 

1401 namespace="ContainerInsights", 

1402 metric_name="node_number_of_running_pods", 

1403 dimensions_map={"ClusterName": cluster_name}, 

1404 statistic="Sum", 

1405 period=Duration.minutes(5), 

1406 label=f"{region} Running", 

1407 region=region, 

1408 ) 

1409 for cluster_name, region in cluster_info 

1410 ], 

1411 width=12, 

1412 height=6, 

1413 ) 

1414 widgets.append(node_widget) 

1415 

1416 return widgets 

1417 

1418 def _create_gpu_widgets(self) -> list[cloudwatch.IWidget]: 

1419 """Create GPU monitoring widgets using DCGM Exporter metrics via ContainerInsights.""" 

1420 widgets: list[cloudwatch.IWidget] = [] 

1421 

1422 widgets.append( 

1423 cloudwatch.TextWidget( 

1424 markdown="# GPU Metrics\nGPU utilization, memory, and temperature from DCGM Exporter", 

1425 width=24, 

1426 height=1, 

1427 ) 

1428 ) 

1429 

1430 cluster_info = [ 

1431 (regional_stack.cluster.cluster_name, regional_stack.deployment_region) 

1432 for regional_stack in self.regional_stacks 

1433 ] 

1434 

1435 # GPU utilization percentage 

1436 gpu_util_widget = cloudwatch.GraphWidget( 

1437 title="GPU Utilization (%)", 

1438 left=[ 

1439 cloudwatch.Metric( 

1440 namespace="ContainerInsights", 

1441 metric_name="node_gpu_utilization", 

1442 dimensions_map={"ClusterName": cluster_name}, 

1443 statistic="Average", 

1444 period=Duration.minutes(5), 

1445 label=region, 

1446 region=region, 

1447 ) 

1448 for cluster_name, region in cluster_info 

1449 ], 

1450 width=12, 

1451 height=6, 

1452 ) 

1453 widgets.append(gpu_util_widget) 

1454 

1455 # GPU memory utilization 

1456 gpu_mem_widget = cloudwatch.GraphWidget( 

1457 title="GPU Memory Utilization (%)", 

1458 left=[ 

1459 cloudwatch.Metric( 

1460 namespace="ContainerInsights", 

1461 metric_name="node_gpu_memory_utilization", 

1462 dimensions_map={"ClusterName": cluster_name}, 

1463 statistic="Average", 

1464 period=Duration.minutes(5), 

1465 label=region, 

1466 region=region, 

1467 ) 

1468 for cluster_name, region in cluster_info 

1469 ], 

1470 width=12, 

1471 height=6, 

1472 ) 

1473 widgets.append(gpu_mem_widget) 

1474 

1475 # GPU temperature 

1476 gpu_temp_widget = cloudwatch.GraphWidget( 

1477 title="GPU Temperature (°C)", 

1478 left=[ 

1479 cloudwatch.Metric( 

1480 namespace="ContainerInsights", 

1481 metric_name="node_gpu_temperature", 

1482 dimensions_map={"ClusterName": cluster_name}, 

1483 statistic="Maximum", 

1484 period=Duration.minutes(5), 

1485 label=region, 

1486 region=region, 

1487 ) 

1488 for cluster_name, region in cluster_info 

1489 ], 

1490 width=12, 

1491 height=6, 

1492 ) 

1493 widgets.append(gpu_temp_widget) 

1494 

1495 # GPU count (active GPUs) 

1496 gpu_count_widget = cloudwatch.GraphWidget( 

1497 title="Active GPU Count", 

1498 left=[ 

1499 cloudwatch.Metric( 

1500 namespace="ContainerInsights", 

1501 metric_name="node_gpu_limit", 

1502 dimensions_map={"ClusterName": cluster_name}, 

1503 statistic="Sum", 

1504 period=Duration.minutes(5), 

1505 label=region, 

1506 region=region, 

1507 ) 

1508 for cluster_name, region in cluster_info 

1509 ], 

1510 width=12, 

1511 height=6, 

1512 ) 

1513 widgets.append(gpu_count_widget) 

1514 

1515 return widgets 

1516 

1517 def _create_fsx_widgets(self) -> list[cloudwatch.IWidget]: 

1518 """Create FSx for Lustre monitoring widgets. 

1519 

1520 Only emits widgets for regions where the FSx file system is actually 

1521 provisioned (``regional_stack.fsx_file_system`` is non-None). The 

1522 dimension ``FileSystemId`` is the CDK-generated CloudFormation ref 

1523 from each regional stack — CDK's ``cross_region_references=True`` 

1524 (enabled on this stack's constructor) plumbs the value across 

1525 regions via SSM + custom resources. 

1526 

1527 Returns an empty list if no region has FSx enabled — the dashboard 

1528 skips the section entirely. 

1529 """ 

1530 # Collect (file_system_id, region) tuples for regions that have FSx on. 

1531 # fsx_file_system is either a CfnFileSystem or None; the local 

1532 # assignment + is-not-None check lets mypy narrow the type so 

1533 # ``.ref`` access typechecks cleanly (a list comprehension with 

1534 # the guard in the ``if`` clause does not narrow the value clause). 

1535 fsx_info: list[tuple[str, str]] = [] 

1536 for regional_stack in self.regional_stacks: 

1537 fsx = getattr(regional_stack, "fsx_file_system", None) 

1538 if fsx is None: 

1539 continue 

1540 fsx_info.append((fsx.ref, regional_stack.deployment_region)) 

1541 if not fsx_info: 

1542 return [] 

1543 

1544 widgets: list[cloudwatch.IWidget] = [] 

1545 

1546 # Section header 

1547 widgets.append( 

1548 cloudwatch.TextWidget( 

1549 markdown=( 

1550 "# FSx for Lustre\n" 

1551 "Parallel file system throughput, IOPS, and free storage " 

1552 "capacity. Each line below is scoped to the exact GCO " 

1553 "file system in its region — so unrelated FSx file " 

1554 "systems in the same account do not appear on the " 

1555 "dashboard." 

1556 ), 

1557 width=24, 

1558 height=1, 

1559 ) 

1560 ) 

1561 

1562 # Throughput: bytes read vs written 

1563 throughput_widget = cloudwatch.GraphWidget( 

1564 title="FSx - Throughput (Bytes/sec)", 

1565 left=[ 

1566 cloudwatch.Metric( 

1567 namespace="AWS/FSx", 

1568 metric_name="DataReadBytes", 

1569 dimensions_map={"FileSystemId": fs_id}, 

1570 statistic="Sum", 

1571 period=Duration.minutes(1), 

1572 label=f"{region} Read", 

1573 region=region, 

1574 ) 

1575 for fs_id, region in fsx_info 

1576 ], 

1577 right=[ 

1578 cloudwatch.Metric( 

1579 namespace="AWS/FSx", 

1580 metric_name="DataWriteBytes", 

1581 dimensions_map={"FileSystemId": fs_id}, 

1582 statistic="Sum", 

1583 period=Duration.minutes(1), 

1584 label=f"{region} Write", 

1585 region=region, 

1586 ) 

1587 for fs_id, region in fsx_info 

1588 ], 

1589 width=12, 

1590 height=6, 

1591 ) 

1592 widgets.append(throughput_widget) 

1593 

1594 # IOPS: read vs write operations 

1595 iops_widget = cloudwatch.GraphWidget( 

1596 title="FSx - IOPS", 

1597 left=[ 

1598 cloudwatch.Metric( 

1599 namespace="AWS/FSx", 

1600 metric_name="DataReadOperations", 

1601 dimensions_map={"FileSystemId": fs_id}, 

1602 statistic="Sum", 

1603 period=Duration.minutes(1), 

1604 label=f"{region} Read", 

1605 region=region, 

1606 ) 

1607 for fs_id, region in fsx_info 

1608 ], 

1609 right=[ 

1610 cloudwatch.Metric( 

1611 namespace="AWS/FSx", 

1612 metric_name="DataWriteOperations", 

1613 dimensions_map={"FileSystemId": fs_id}, 

1614 statistic="Sum", 

1615 period=Duration.minutes(1), 

1616 label=f"{region} Write", 

1617 region=region, 

1618 ) 

1619 for fs_id, region in fsx_info 

1620 ], 

1621 width=12, 

1622 height=6, 

1623 ) 

1624 widgets.append(iops_widget) 

1625 

1626 # Free storage capacity — the classic "running out of space" signal. 

1627 # FreeDataStorageCapacity is emitted in bytes. 

1628 free_storage_widget = cloudwatch.GraphWidget( 

1629 title="FSx - Free Storage Capacity (Bytes)", 

1630 left=[ 

1631 cloudwatch.Metric( 

1632 namespace="AWS/FSx", 

1633 metric_name="FreeDataStorageCapacity", 

1634 dimensions_map={"FileSystemId": fs_id}, 

1635 statistic="Minimum", 

1636 period=Duration.minutes(5), 

1637 label=region, 

1638 region=region, 

1639 ) 

1640 for fs_id, region in fsx_info 

1641 ], 

1642 width=24, 

1643 height=6, 

1644 ) 

1645 widgets.append(free_storage_widget) 

1646 

1647 return widgets 

1648 

1649 def _create_valkey_widgets(self) -> list[cloudwatch.IWidget]: 

1650 """Create Valkey (ElastiCache Serverless) monitoring widgets. 

1651 

1652 Uses explicit ``clusterId`` dimension values (camelCase — the 

1653 ElastiCache Serverless variant; distinct from the node-based 

1654 ``CacheClusterId``). The regional stack names its cache 

1655 deterministically as ``gco-{deployment_region}``, so we reproduce 

1656 that name here and pin each widget to the exact cache in its 

1657 region. No SEARCH expression, so the dashboard ignores every 

1658 unrelated ElastiCache cluster in the account. 

1659 """ 

1660 valkey_enabled = self.config.get_valkey_config().get("enabled", False) 

1661 if not valkey_enabled or not self.regions: 

1662 return [] 

1663 

1664 widgets: list[cloudwatch.IWidget] = [] 

1665 

1666 widgets.append( 

1667 cloudwatch.TextWidget( 

1668 markdown=( 

1669 "# Valkey Serverless Cache\n" 

1670 "ECPU consumption, storage, hit rate, and request " 

1671 "latency — scoped to each region's ``gco-{region}`` " 

1672 "cache exactly (no SEARCH)." 

1673 ), 

1674 width=24, 

1675 height=1, 

1676 ) 

1677 ) 

1678 

1679 # Build (cache_name, region) pairs. cache_name is the literal 

1680 # ``serverless_cache_name`` the regional stack passes to the 

1681 # CfnServerlessCache. 

1682 cache_info = [(f"{self.project_name}-{region}", region) for region in self.regions] 

1683 

1684 # ECPU consumption and cache size per region 

1685 for cache_name, region in cache_info: 

1686 widgets.append( 

1687 cloudwatch.GraphWidget( 

1688 title=f"Valkey - ECPU & Cache Size ({region})", 

1689 left=[ 

1690 cloudwatch.Metric( 

1691 namespace="AWS/ElastiCache", 

1692 metric_name="ElastiCacheProcessingUnits", 

1693 dimensions_map={"clusterId": cache_name}, 

1694 statistic="Sum", 

1695 period=Duration.minutes(1), 

1696 label="ECPUs", 

1697 region=region, 

1698 ), 

1699 ], 

1700 right=[ 

1701 cloudwatch.Metric( 

1702 namespace="AWS/ElastiCache", 

1703 metric_name="BytesUsedForCache", 

1704 dimensions_map={"clusterId": cache_name}, 

1705 statistic="Average", 

1706 period=Duration.minutes(5), 

1707 label="Bytes", 

1708 region=region, 

1709 ), 

1710 ], 

1711 width=12, 

1712 height=6, 

1713 region=region, 

1714 ) 

1715 ) 

1716 

1717 # Hit rate and p99 read/write latency per region 

1718 for cache_name, region in cache_info: 

1719 widgets.append( 

1720 cloudwatch.GraphWidget( 

1721 title=f"Valkey - Hit Rate & Latency ({region})", 

1722 left=[ 

1723 cloudwatch.Metric( 

1724 namespace="AWS/ElastiCache", 

1725 metric_name="CacheHitRate", 

1726 dimensions_map={"clusterId": cache_name}, 

1727 statistic="Average", 

1728 period=Duration.minutes(5), 

1729 label="Hit Rate %", 

1730 region=region, 

1731 ), 

1732 ], 

1733 right=[ 

1734 cloudwatch.Metric( 

1735 namespace="AWS/ElastiCache", 

1736 metric_name="SuccessfulReadRequestLatency", 

1737 dimensions_map={"clusterId": cache_name}, 

1738 statistic="p99", 

1739 period=Duration.minutes(1), 

1740 label="Read p99 µs", 

1741 region=region, 

1742 ), 

1743 cloudwatch.Metric( 

1744 namespace="AWS/ElastiCache", 

1745 metric_name="SuccessfulWriteRequestLatency", 

1746 dimensions_map={"clusterId": cache_name}, 

1747 statistic="p99", 

1748 period=Duration.minutes(1), 

1749 label="Write p99 µs", 

1750 region=region, 

1751 ), 

1752 ], 

1753 width=12, 

1754 height=6, 

1755 region=region, 

1756 ) 

1757 ) 

1758 

1759 return widgets 

1760 

1761 def _create_aurora_pgvector_widgets(self) -> list[cloudwatch.IWidget]: 

1762 """Create Aurora Serverless v2 (pgvector) monitoring widgets. 

1763 

1764 Pins each widget to the exact Aurora cluster provisioned by the 

1765 regional stack via ``regional_stack.aurora_cluster.cluster_identifier``. 

1766 CDK-generated cluster IDs are CloudFormation tokens; the 

1767 ``cross_region_references=True`` flag on this stack handles 

1768 plumbing them from each regional stack into the monitoring stack 

1769 (us-east-2 by default) through SSM + custom resources. 

1770 

1771 Returns an empty list when every region has Aurora pgvector 

1772 disabled so the dashboard skips the section entirely. 

1773 """ 

1774 # (cluster_identifier, region) pairs for regions with Aurora on. 

1775 # Use a guarded loop (not a comprehension) so mypy can narrow the 

1776 # Optional[DatabaseCluster] to a real cluster before dereferencing. 

1777 aurora_info: list[tuple[str, str]] = [] 

1778 for regional_stack in self.regional_stacks: 

1779 aurora = getattr(regional_stack, "aurora_cluster", None) 

1780 if aurora is None: 

1781 continue 

1782 aurora_info.append((aurora.cluster_identifier, regional_stack.deployment_region)) 

1783 if not aurora_info: 

1784 return [] 

1785 

1786 widgets: list[cloudwatch.IWidget] = [] 

1787 

1788 widgets.append( 

1789 cloudwatch.TextWidget( 

1790 markdown=( 

1791 "# Aurora pgvector (Serverless v2)\n" 

1792 "ACU utilization, database connections, query latency, " 

1793 "and CPU utilization — pinned to each regional GCO " 

1794 "Aurora cluster by ID. ACU utilization is the primary " 

1795 "scale/cost signal for Serverless v2." 

1796 ), 

1797 width=24, 

1798 height=1, 

1799 ) 

1800 ) 

1801 

1802 # ACU utilization and capacity 

1803 for cluster_id, region in aurora_info: 

1804 widgets.append( 

1805 cloudwatch.GraphWidget( 

1806 title=f"Aurora - ACU Utilization & Capacity ({region})", 

1807 left=[ 

1808 cloudwatch.Metric( 

1809 namespace="AWS/RDS", 

1810 metric_name="ACUUtilization", 

1811 dimensions_map={"DBClusterIdentifier": cluster_id}, 

1812 statistic="Average", 

1813 period=Duration.minutes(1), 

1814 label="ACU %", 

1815 region=region, 

1816 ), 

1817 ], 

1818 right=[ 

1819 cloudwatch.Metric( 

1820 namespace="AWS/RDS", 

1821 metric_name="ServerlessDatabaseCapacity", 

1822 dimensions_map={"DBClusterIdentifier": cluster_id}, 

1823 statistic="Average", 

1824 period=Duration.minutes(1), 

1825 label="ACUs", 

1826 region=region, 

1827 ), 

1828 ], 

1829 width=12, 

1830 height=6, 

1831 region=region, 

1832 ) 

1833 ) 

1834 

1835 # Database connections and CPU utilization 

1836 for cluster_id, region in aurora_info: 

1837 widgets.append( 

1838 cloudwatch.GraphWidget( 

1839 title=f"Aurora - Connections & CPU ({region})", 

1840 left=[ 

1841 cloudwatch.Metric( 

1842 namespace="AWS/RDS", 

1843 metric_name="DatabaseConnections", 

1844 dimensions_map={"DBClusterIdentifier": cluster_id}, 

1845 statistic="Average", 

1846 period=Duration.minutes(1), 

1847 label="Connections", 

1848 region=region, 

1849 ), 

1850 ], 

1851 right=[ 

1852 cloudwatch.Metric( 

1853 namespace="AWS/RDS", 

1854 metric_name="CPUUtilization", 

1855 dimensions_map={"DBClusterIdentifier": cluster_id}, 

1856 statistic="Average", 

1857 period=Duration.minutes(1), 

1858 label="CPU %", 

1859 region=region, 

1860 ), 

1861 ], 

1862 width=12, 

1863 height=6, 

1864 region=region, 

1865 ) 

1866 ) 

1867 

1868 # Read and write latency p99 

1869 for cluster_id, region in aurora_info: 

1870 widgets.append( 

1871 cloudwatch.GraphWidget( 

1872 title=f"Aurora - Query Latency p99 ({region})", 

1873 left=[ 

1874 cloudwatch.Metric( 

1875 namespace="AWS/RDS", 

1876 metric_name="ReadLatency", 

1877 dimensions_map={"DBClusterIdentifier": cluster_id}, 

1878 statistic="p99", 

1879 period=Duration.minutes(1), 

1880 label="Read p99", 

1881 region=region, 

1882 ), 

1883 ], 

1884 right=[ 

1885 cloudwatch.Metric( 

1886 namespace="AWS/RDS", 

1887 metric_name="WriteLatency", 

1888 dimensions_map={"DBClusterIdentifier": cluster_id}, 

1889 statistic="p99", 

1890 period=Duration.minutes(1), 

1891 label="Write p99", 

1892 region=region, 

1893 ), 

1894 ], 

1895 width=24, 

1896 height=6, 

1897 region=region, 

1898 ) 

1899 ) 

1900 

1901 return widgets 

1902 

1903 def _create_alb_widgets(self) -> list[cloudwatch.IWidget]: 

1904 """Create ALB monitoring widgets scoped to the GCO platform ALB. 

1905 

1906 ALBs are created by the AWS Load Balancer Controller at runtime 

1907 from an Ingress resource (not by CDK), so the exact ALB name 

1908 isn't known at synth time. We originally tried reading the ARN 

1909 off the regional stack's ``GaRegistration`` custom resource via 

1910 ``cross_region_references=True``, but that path races the 

1911 custom-resource response pipeline: CDK's cross-region 

1912 ``ExportsWriter`` executes ``Fn::GetAtt: [GaRegistration, AlbArn]`` 

1913 before CloudFormation has the updated response data stored, and 

1914 errors with "Vendor response doesn't contain AlbArn attribute". 

1915 

1916 Instead we use a SEARCH expression with a composite-token 

1917 filter. The ALB Controller names the platform ALB 

1918 ``k8s-gco-<hash>`` (the namespace is shortened because the 

1919 controller enforces a 32-char total name limit); CloudWatch's 

1920 ``LoadBalancer`` dimension is the ARN suffix ``app/<name>/<hash>``, 

1921 so an unquoted filter ``LoadBalancer=app/k8s-gco-`` performs a 

1922 composite-token match (the sequence ``app``, ``k``, ``8``, ``s``, 

1923 ``gco`` must appear consecutively in the dimension value). 

1924 Double-quoted filters would be exact matches and return nothing 

1925 because no ALB's dimension value is literally ``app/k8s-gco-``. 

1926 """ 

1927 widgets: list[cloudwatch.IWidget] = [] 

1928 

1929 # Section header 

1930 widgets.append( 

1931 cloudwatch.TextWidget( 

1932 markdown=( 

1933 "# Application Load Balancers\n" 

1934 "Request metrics, response time, HTTP errors, and " 

1935 "connection counts — scoped via SEARCH composite-token " 

1936 "match to ALBs named ``app/k8s-gco-*`` so only the GCO " 

1937 "platform ALB in each region appears. Inference ALBs " 

1938 "(named per endpoint) and unrelated ALBs in the " 

1939 "account are excluded." 

1940 ), 

1941 width=24, 

1942 height=1, 

1943 ) 

1944 ) 

1945 

1946 # Per-region request count 

1947 for region in self.regions: 

1948 widgets.append( 

1949 cloudwatch.GraphWidget( 

1950 title=f"ALB - Request Count ({region})", 

1951 left=[ 

1952 cloudwatch.MathExpression( 

1953 expression=( 

1954 "SEARCH('{AWS/ApplicationELB,LoadBalancer} " 

1955 'MetricName="RequestCount" ' 

1956 'LoadBalancer=app/k8s-gco-\', "Sum", 300)' 

1957 ), 

1958 label="Request Count", 

1959 period=Duration.minutes(5), 

1960 ), 

1961 ], 

1962 width=12, 

1963 height=6, 

1964 region=region, 

1965 ) 

1966 ) 

1967 

1968 # Per-region response time (average and p99) 

1969 for region in self.regions: 

1970 widgets.append( 

1971 cloudwatch.GraphWidget( 

1972 title=f"ALB - Response Time ({region})", 

1973 left=[ 

1974 cloudwatch.MathExpression( 

1975 expression=( 

1976 "SEARCH('{AWS/ApplicationELB,LoadBalancer} " 

1977 'MetricName="TargetResponseTime" ' 

1978 'LoadBalancer=app/k8s-gco-\', "Average", 300)' 

1979 ), 

1980 label="Avg Response Time", 

1981 period=Duration.minutes(5), 

1982 ), 

1983 cloudwatch.MathExpression( 

1984 expression=( 

1985 "SEARCH('{AWS/ApplicationELB,LoadBalancer} " 

1986 'MetricName="TargetResponseTime" ' 

1987 'LoadBalancer=app/k8s-gco-\', "p99", 300)' 

1988 ), 

1989 label="p99 Response Time", 

1990 period=Duration.minutes(5), 

1991 ), 

1992 ], 

1993 width=12, 

1994 height=6, 

1995 region=region, 

1996 ) 

1997 ) 

1998 

1999 # Per-region HTTP errors (4XX + 5XX from targets) 

2000 for region in self.regions: 

2001 widgets.append( 

2002 cloudwatch.GraphWidget( 

2003 title=f"ALB - HTTP Errors ({region})", 

2004 left=[ 

2005 cloudwatch.MathExpression( 

2006 expression=( 

2007 "SEARCH('{AWS/ApplicationELB,LoadBalancer} " 

2008 'MetricName="HTTPCode_Target_4XX_Count" ' 

2009 'LoadBalancer=app/k8s-gco-\', "Sum", 300)' 

2010 ), 

2011 label="4XX Errors", 

2012 period=Duration.minutes(5), 

2013 ), 

2014 ], 

2015 right=[ 

2016 cloudwatch.MathExpression( 

2017 expression=( 

2018 "SEARCH('{AWS/ApplicationELB,LoadBalancer} " 

2019 'MetricName="HTTPCode_Target_5XX_Count" ' 

2020 'LoadBalancer=app/k8s-gco-\', "Sum", 300)' 

2021 ), 

2022 label="5XX Errors", 

2023 period=Duration.minutes(5), 

2024 ), 

2025 ], 

2026 width=12, 

2027 height=6, 

2028 region=region, 

2029 ) 

2030 ) 

2031 

2032 # Per-region active connections 

2033 for region in self.regions: 

2034 widgets.append( 

2035 cloudwatch.GraphWidget( 

2036 title=f"ALB - Active Connections ({region})", 

2037 left=[ 

2038 cloudwatch.MathExpression( 

2039 expression=( 

2040 "SEARCH('{AWS/ApplicationELB,LoadBalancer} " 

2041 'MetricName="ActiveConnectionCount" ' 

2042 'LoadBalancer=app/k8s-gco-\', "Sum", 300)' 

2043 ), 

2044 label="Active Connections", 

2045 period=Duration.minutes(5), 

2046 ), 

2047 ], 

2048 width=12, 

2049 height=6, 

2050 region=region, 

2051 ) 

2052 ) 

2053 

2054 return widgets 

2055 

2056 def _create_application_widgets(self) -> list[cloudwatch.IWidget]: 

2057 """Create custom application monitoring widgets""" 

2058 widgets: list[cloudwatch.IWidget] = [] 

2059 

2060 # Section header 

2061 widgets.append( 

2062 cloudwatch.TextWidget( 

2063 markdown="# Application Metrics\n" 

2064 "Health monitor and manifest processor metrics. " 

2065 "Application logs are available in Container Insights at " 

2066 "`/aws/containerinsights/<cluster>/application`.", 

2067 width=24, 

2068 height=1, 

2069 ) 

2070 ) 

2071 

2072 # Build cluster info from regional stacks: (cluster_name, region) 

2073 cluster_info = [ 

2074 (regional_stack.cluster.cluster_name, regional_stack.deployment_region) 

2075 for regional_stack in self.regional_stacks 

2076 ] 

2077 

2078 # Health monitor metrics 

2079 health_monitor_widget = cloudwatch.GraphWidget( 

2080 title="Health Monitor - Resource Utilization", 

2081 left=[ 

2082 cloudwatch.Metric( 

2083 namespace="GCO/HealthMonitor", 

2084 metric_name="ClusterCpuUtilization", 

2085 dimensions_map={ 

2086 "ClusterName": cluster_name, 

2087 "Region": region, 

2088 }, 

2089 statistic="Average", 

2090 period=Duration.minutes(5), 

2091 label=f"{region} CPU", 

2092 region=region, 

2093 ) 

2094 for cluster_name, region in cluster_info 

2095 ], 

2096 right=[ 

2097 cloudwatch.Metric( 

2098 namespace="GCO/HealthMonitor", 

2099 metric_name="ClusterMemoryUtilization", 

2100 dimensions_map={ 

2101 "ClusterName": cluster_name, 

2102 "Region": region, 

2103 }, 

2104 statistic="Average", 

2105 period=Duration.minutes(5), 

2106 label=f"{region} Memory", 

2107 region=region, 

2108 ) 

2109 for cluster_name, region in cluster_info 

2110 ], 

2111 width=12, 

2112 height=6, 

2113 ) 

2114 widgets.append(health_monitor_widget) 

2115 

2116 # Manifest processor metrics 

2117 manifest_processor_widget = cloudwatch.GraphWidget( 

2118 title="Manifest Processor - Submissions", 

2119 left=[ 

2120 cloudwatch.Metric( 

2121 namespace="GCO/ManifestProcessor", 

2122 metric_name="ManifestSubmissions", 

2123 dimensions_map={ 

2124 "ClusterName": cluster_name, 

2125 "Region": region, 

2126 }, 

2127 statistic="Sum", 

2128 period=Duration.minutes(5), 

2129 label=f"{region} Submissions", 

2130 region=region, 

2131 ) 

2132 for cluster_name, region in cluster_info 

2133 ], 

2134 right=[ 

2135 cloudwatch.Metric( 

2136 namespace="GCO/ManifestProcessor", 

2137 metric_name="ManifestFailures", 

2138 dimensions_map={ 

2139 "ClusterName": cluster_name, 

2140 "Region": region, 

2141 }, 

2142 statistic="Sum", 

2143 period=Duration.minutes(5), 

2144 label=f"{region} Failures", 

2145 color="#d62728", 

2146 region=region, 

2147 ) 

2148 for cluster_name, region in cluster_info 

2149 ], 

2150 width=12, 

2151 height=6, 

2152 ) 

2153 widgets.append(manifest_processor_widget) 

2154 

2155 # Container Insights - Pod restarts (indicates application issues) 

2156 pod_restarts_widget = cloudwatch.GraphWidget( 

2157 title="Container Insights - Pod Restarts", 

2158 left=[ 

2159 cloudwatch.Metric( 

2160 namespace="ContainerInsights", 

2161 metric_name="pod_number_of_container_restarts", 

2162 dimensions_map={"ClusterName": cluster_name}, 

2163 statistic="Sum", 

2164 period=Duration.minutes(5), 

2165 label=f"{region}", 

2166 region=region, 

2167 ) 

2168 for cluster_name, region in cluster_info 

2169 ], 

2170 width=12, 

2171 height=6, 

2172 ) 

2173 widgets.append(pod_restarts_widget) 

2174 

2175 # Secret rotation Lambda metrics (Secrets Manager doesn't publish rotation metrics, 

2176 # so we monitor the rotation Lambda function instead) 

2177 if self.api_gateway_stack: 

2178 rotation_function_name = self.api_gateway_stack.rotation_lambda.function_name 

2179 api_gw_region = self.config.get_api_gateway_region() 

2180 

2181 rotation_widget = cloudwatch.GraphWidget( 

2182 title="Secret Rotation Lambda - Invocations & Errors", 

2183 left=[ 

2184 cloudwatch.Metric( 

2185 namespace="AWS/Lambda", 

2186 metric_name="Invocations", 

2187 dimensions_map={"FunctionName": rotation_function_name}, 

2188 statistic="Sum", 

2189 period=Duration.hours(1), 

2190 label="Invocations", 

2191 color="#2ca02c", 

2192 region=api_gw_region, 

2193 ), 

2194 ], 

2195 right=[ 

2196 cloudwatch.Metric( 

2197 namespace="AWS/Lambda", 

2198 metric_name="Errors", 

2199 dimensions_map={"FunctionName": rotation_function_name}, 

2200 statistic="Sum", 

2201 period=Duration.hours(1), 

2202 label="Errors", 

2203 color="#d62728", 

2204 region=api_gw_region, 

2205 ), 

2206 ], 

2207 width=12, 

2208 height=6, 

2209 ) 

2210 widgets.append(rotation_widget) 

2211 else: 

2212 # Fallback text widget if api_gateway_stack not available 

2213 fallback_widget = cloudwatch.TextWidget( 

2214 markdown="**Secret Rotation:** API Gateway stack not configured. " 

2215 "Rotation Lambda metrics unavailable.", 

2216 width=12, 

2217 height=6, 

2218 ) 

2219 widgets.append(fallback_widget) 

2220 

2221 return widgets 

2222 

2223 def _create_alarms(self) -> None: 

2224 """Create CloudWatch alarms""" 

2225 self._create_global_accelerator_alarms() 

2226 self._create_api_gateway_alarms() 

2227 self._create_lambda_alarms() 

2228 self._create_sqs_alarms() 

2229 self._create_dynamodb_alarms() 

2230 self._create_eks_alarms() 

2231 self._create_alb_alarms() 

2232 self._create_application_alarms() 

2233 

2234 def _create_global_accelerator_alarms(self) -> None: 

2235 """Create Global Accelerator alarms. 

2236 

2237 Note: Global Accelerator metrics are only available in us-west-2. 

2238 CloudWatch Alarms must be in the same region as the metrics they monitor. 

2239 Since this monitoring stack may be deployed in a different region, 

2240 we skip GA alarms here. To monitor GA, either: 

2241 1. Create alarms manually in us-west-2 

2242 2. Use CloudWatch cross-region dashboard widgets (which we do) 

2243 3. Deploy a separate alarm stack in us-west-2 

2244 """ 

2245 # GA alarms skipped - metrics only available in us-west-2 

2246 # Dashboard widgets use region parameter to display GA metrics correctly 

2247 pass 

2248 

2249 def _create_api_gateway_alarms(self) -> None: 

2250 """Create API Gateway alarms""" 

2251 # Get the actual API name from the api_gateway_stack 

2252 api_name = ( 

2253 self.api_gateway_stack.api.rest_api_name 

2254 if self.api_gateway_stack 

2255 else f"{self.project_name}-global-api" 

2256 ) 

2257 

2258 # High 5XX error rate 

2259 api_5xx_alarm = cloudwatch.Alarm( 

2260 self, 

2261 "ApiGateway5xxAlarm", 

2262 alarm_description="API Gateway has high 5XX error rate", 

2263 metric=cloudwatch.Metric( 

2264 namespace="AWS/ApiGateway", 

2265 metric_name="5XXError", 

2266 dimensions_map={"ApiName": api_name}, 

2267 statistic="Sum", 

2268 period=Duration.minutes(5), 

2269 ), 

2270 threshold=10, 

2271 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2272 evaluation_periods=2, 

2273 datapoints_to_alarm=2, 

2274 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2275 ) 

2276 api_5xx_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2277 

2278 # High latency 

2279 api_latency_alarm = cloudwatch.Alarm( 

2280 self, 

2281 "ApiGatewayHighLatencyAlarm", 

2282 alarm_description="API Gateway has high latency", 

2283 metric=cloudwatch.Metric( 

2284 namespace="AWS/ApiGateway", 

2285 metric_name="Latency", 

2286 dimensions_map={"ApiName": api_name}, 

2287 statistic="p99", 

2288 period=Duration.minutes(5), 

2289 ), 

2290 threshold=10000, # 10 seconds 

2291 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2292 evaluation_periods=3, 

2293 datapoints_to_alarm=2, 

2294 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2295 ) 

2296 api_latency_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2297 

2298 def _create_lambda_alarms(self) -> None: 

2299 """Create Lambda function alarms.""" 

2300 if self.api_gateway_stack is None: 

2301 return 

2302 

2303 proxy_lambda = self.api_gateway_stack.proxy_lambda 

2304 if proxy_lambda is not None: 

2305 proxy_function_name = proxy_lambda.function_name 

2306 proxy_errors_alarm = cloudwatch.Alarm( 

2307 self, 

2308 "ProxyLambdaErrorsAlarm", 

2309 alarm_description="API Gateway proxy Lambda has errors", 

2310 metric=cloudwatch.Metric( 

2311 namespace="AWS/Lambda", 

2312 metric_name="Errors", 

2313 dimensions_map={"FunctionName": proxy_function_name}, 

2314 statistic="Sum", 

2315 period=Duration.minutes(5), 

2316 ), 

2317 threshold=5, 

2318 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2319 evaluation_periods=2, 

2320 datapoints_to_alarm=2, 

2321 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2322 ) 

2323 proxy_errors_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2324 

2325 proxy_throttles_alarm = cloudwatch.Alarm( 

2326 self, 

2327 "ProxyLambdaThrottlesAlarm", 

2328 alarm_description="API Gateway proxy Lambda is being throttled", 

2329 metric=cloudwatch.Metric( 

2330 namespace="AWS/Lambda", 

2331 metric_name="Throttles", 

2332 dimensions_map={"FunctionName": proxy_function_name}, 

2333 statistic="Sum", 

2334 period=Duration.minutes(5), 

2335 ), 

2336 threshold=1, 

2337 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, 

2338 evaluation_periods=2, 

2339 datapoints_to_alarm=2, 

2340 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2341 ) 

2342 proxy_throttles_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2343 

2344 rotation_errors_alarm = cloudwatch.Alarm( 

2345 self, 

2346 "RotationLambdaErrorsAlarm", 

2347 alarm_description="Secret rotation Lambda has errors", 

2348 metric=cloudwatch.Metric( 

2349 namespace="AWS/Lambda", 

2350 metric_name="Errors", 

2351 dimensions_map={ 

2352 "FunctionName": self.api_gateway_stack.rotation_lambda.function_name 

2353 }, 

2354 statistic="Sum", 

2355 period=Duration.hours(1), 

2356 ), 

2357 threshold=1, 

2358 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, 

2359 evaluation_periods=1, 

2360 datapoints_to_alarm=1, 

2361 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2362 ) 

2363 rotation_errors_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2364 

2365 def _create_sqs_alarms(self) -> None: 

2366 """Create SQS queue alarms""" 

2367 for regional_stack in self.regional_stacks: 

2368 region = regional_stack.deployment_region 

2369 queue_name = regional_stack.job_queue.queue_name 

2370 dlq_name = regional_stack.job_dlq.queue_name 

2371 region_id = region.replace("-", "").title() 

2372 

2373 # Old message alarm (stuck jobs) 

2374 old_message_alarm = cloudwatch.Alarm( 

2375 self, 

2376 f"SqsOldMessageAlarm{region_id}", 

2377 alarm_description=f"SQS queue in {region} has old messages (potential stuck jobs)", 

2378 metric=cloudwatch.Metric( 

2379 namespace="AWS/SQS", 

2380 metric_name="ApproximateAgeOfOldestMessage", 

2381 dimensions_map={"QueueName": queue_name}, 

2382 statistic="Maximum", 

2383 period=Duration.minutes(5), 

2384 ), 

2385 threshold=3600, # 1 hour 

2386 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2387 evaluation_periods=2, 

2388 datapoints_to_alarm=2, 

2389 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2390 ) 

2391 old_message_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2392 

2393 # Dead letter queue alarm 

2394 dlq_alarm = cloudwatch.Alarm( 

2395 self, 

2396 f"SqsDlqAlarm{region_id}", 

2397 alarm_description=f"SQS dead letter queue in {region} has messages", 

2398 metric=cloudwatch.Metric( 

2399 namespace="AWS/SQS", 

2400 metric_name="ApproximateNumberOfMessagesVisible", 

2401 dimensions_map={"QueueName": dlq_name}, 

2402 statistic="Sum", 

2403 period=Duration.minutes(5), 

2404 ), 

2405 threshold=1, 

2406 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, 

2407 evaluation_periods=1, 

2408 datapoints_to_alarm=1, 

2409 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2410 ) 

2411 dlq_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2412 

2413 def _create_dynamodb_alarms(self) -> None: 

2414 """Create DynamoDB alarms for job queue, templates, and webhooks tables.""" 

2415 # Get table names from global stack 

2416 jobs_table = self.global_stack.jobs_table.table_name 

2417 

2418 # DynamoDB tables are in the global region 

2419 global_region = self.config.get_global_region() 

2420 

2421 # Jobs table throttling alarm 

2422 jobs_throttle_alarm = cloudwatch.Alarm( 

2423 self, 

2424 "DynamoDBJobsThrottleAlarm", 

2425 alarm_description="DynamoDB jobs table is being throttled", 

2426 metric=cloudwatch.Metric( 

2427 namespace="AWS/DynamoDB", 

2428 metric_name="ThrottledRequests", 

2429 dimensions_map={"TableName": jobs_table}, 

2430 statistic="Sum", 

2431 period=Duration.minutes(5), 

2432 region=global_region, 

2433 ), 

2434 threshold=1, 

2435 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, 

2436 evaluation_periods=2, 

2437 datapoints_to_alarm=2, 

2438 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2439 ) 

2440 jobs_throttle_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2441 

2442 # Jobs table system errors alarm 

2443 jobs_errors_alarm = cloudwatch.Alarm( 

2444 self, 

2445 "DynamoDBJobsErrorsAlarm", 

2446 alarm_description="DynamoDB jobs table has system errors", 

2447 metric=cloudwatch.Metric( 

2448 namespace="AWS/DynamoDB", 

2449 metric_name="SystemErrors", 

2450 dimensions_map={"TableName": jobs_table}, 

2451 statistic="Sum", 

2452 period=Duration.minutes(5), 

2453 region=global_region, 

2454 ), 

2455 threshold=1, 

2456 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, 

2457 evaluation_periods=1, 

2458 datapoints_to_alarm=1, 

2459 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2460 ) 

2461 jobs_errors_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2462 

2463 def _create_eks_alarms(self) -> None: 

2464 """Create EKS cluster alarms""" 

2465 for regional_stack in self.regional_stacks: 

2466 region = regional_stack.deployment_region 

2467 cluster_name = regional_stack.cluster.cluster_name 

2468 region_id = region.replace("-", "").title() 

2469 

2470 # High CPU utilization alarm (node-level metric) 

2471 high_cpu_alarm = cloudwatch.Alarm( 

2472 self, 

2473 f"EksHighCpuAlarm{region_id}", 

2474 alarm_description=f"EKS cluster {cluster_name} has high CPU utilization", 

2475 metric=cloudwatch.Metric( 

2476 namespace="ContainerInsights", 

2477 metric_name="node_cpu_utilization", 

2478 dimensions_map={"ClusterName": cluster_name}, 

2479 statistic="Average", 

2480 period=Duration.minutes(5), 

2481 ), 

2482 threshold=80, 

2483 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2484 evaluation_periods=3, 

2485 datapoints_to_alarm=2, 

2486 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2487 ) 

2488 high_cpu_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2489 

2490 # High memory utilization alarm (node-level metric) 

2491 high_memory_alarm = cloudwatch.Alarm( 

2492 self, 

2493 f"EksHighMemoryAlarm{region_id}", 

2494 alarm_description=f"EKS cluster {cluster_name} has high memory utilization", 

2495 metric=cloudwatch.Metric( 

2496 namespace="ContainerInsights", 

2497 metric_name="node_memory_utilization", 

2498 dimensions_map={"ClusterName": cluster_name}, 

2499 statistic="Average", 

2500 period=Duration.minutes(5), 

2501 ), 

2502 threshold=85, 

2503 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2504 evaluation_periods=3, 

2505 datapoints_to_alarm=2, 

2506 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2507 ) 

2508 high_memory_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2509 

2510 def _create_alb_alarms(self) -> None: 

2511 """Create ALB alarms. 

2512 

2513 Status: no alarms created yet, even though we now have the ALB 

2514 ARN at deploy time via the GA registration custom resource (which 

2515 also feeds the dashboard widgets). Adding per-ALB alarms here is 

2516 a straightforward enhancement — derive the ``LoadBalancer`` 

2517 dimension the same way ``_create_alb_widgets`` does 

2518 (``Fn.split(":loadbalancer/", alb_arn)[1]``) and wire it into 

2519 ``cloudwatch.Alarm`` constructs. 

2520 

2521 For now we rely on: 

2522 1. Dashboard widgets pinned to each platform ALB (see 

2523 ``_create_alb_widgets``) 

2524 2. EKS Container Insights alarms for pod/node health 

2525 3. API Gateway alarms for request-level monitoring 

2526 """ 

2527 # TODO: Add UnHealthyHostCount / 5XXCount alarms using the ARN 

2528 # returned by regional_stack.ga_registration.get_att_string("AlbArn"). 

2529 # The test suite explicitly documents that the ALB alarm count is 

2530 # currently zero (test_alb_unhealthy_hosts_alarm_skipped); update 

2531 # that test when adding real alarms. 

2532 pass 

2533 

2534 def _create_application_alarms(self) -> None: 

2535 """Create application-specific alarms""" 

2536 for regional_stack in self.regional_stacks: 

2537 region = regional_stack.deployment_region 

2538 cluster_name = regional_stack.cluster.cluster_name 

2539 region_id = region.replace("-", "").title() 

2540 

2541 # High manifest failure rate alarm 

2542 high_failure_rate_alarm = cloudwatch.Alarm( 

2543 self, 

2544 f"ManifestHighFailureRateAlarm{region_id}", 

2545 alarm_description=f"Manifest processor in {region} has high failure rate", 

2546 metric=cloudwatch.Metric( 

2547 namespace="GCO/ManifestProcessor", 

2548 metric_name="ManifestFailures", 

2549 dimensions_map={"ClusterName": cluster_name, "Region": region}, 

2550 statistic="Sum", 

2551 period=Duration.minutes(5), 

2552 ), 

2553 threshold=10, 

2554 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2555 evaluation_periods=2, 

2556 datapoints_to_alarm=2, 

2557 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2558 ) 

2559 high_failure_rate_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2560 

2561 def _create_composite_alarms(self) -> None: 

2562 """Create composite alarms for better signal-to-noise ratio""" 

2563 

2564 # Store individual alarms for composite alarm references 

2565 regional_alarms: dict[str, list[cloudwatch.Alarm]] = {} 

2566 

2567 for regional_stack in self.regional_stacks: 

2568 region = regional_stack.deployment_region 

2569 cluster_name = regional_stack.cluster.cluster_name 

2570 region_id = region.replace("-", "").title() 

2571 regional_alarms[region] = [] 

2572 

2573 # Create regional health composite alarm 

2574 # Triggers when multiple issues occur in the same region 

2575 eks_cpu_alarm = cloudwatch.Alarm( 

2576 self, 

2577 f"CompositeEksCpu{region_id}", 

2578 metric=cloudwatch.Metric( 

2579 namespace="ContainerInsights", 

2580 metric_name="node_cpu_utilization", 

2581 dimensions_map={"ClusterName": cluster_name}, 

2582 statistic="Average", 

2583 period=Duration.minutes(5), 

2584 ), 

2585 threshold=90, 

2586 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2587 evaluation_periods=2, 

2588 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2589 ) 

2590 regional_alarms[region].append(eks_cpu_alarm) 

2591 

2592 eks_memory_alarm = cloudwatch.Alarm( 

2593 self, 

2594 f"CompositeEksMemory{region_id}", 

2595 metric=cloudwatch.Metric( 

2596 namespace="ContainerInsights", 

2597 metric_name="node_memory_utilization", 

2598 dimensions_map={"ClusterName": cluster_name}, 

2599 statistic="Average", 

2600 period=Duration.minutes(5), 

2601 ), 

2602 threshold=90, 

2603 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2604 evaluation_periods=2, 

2605 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2606 ) 

2607 regional_alarms[region].append(eks_memory_alarm) 

2608 

2609 # Create composite alarm for critical regional issues. Every region 

2610 # above appends exactly the CPU and memory alarms (two), so this 

2611 # always fires; no `len(alarms) >= 2` guard is needed here. 

2612 for region, alarms in regional_alarms.items(): 

2613 region_id = region.replace("-", "").title() 

2614 composite_alarm = cloudwatch.CompositeAlarm( 

2615 self, 

2616 f"RegionalCriticalAlarm{region_id}", 

2617 alarm_description=f"Critical: Multiple issues detected in {region}", 

2618 alarm_rule=cloudwatch.AlarmRule.all_of(*alarms), 

2619 ) 

2620 composite_alarm.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2621 

2622 # API Gateway + Lambda composite alarm (only if api_gateway_stack is available) 

2623 if self.api_gateway_stack and self.api_gateway_stack.proxy_lambda is not None: 

2624 api_name = self.api_gateway_stack.api.rest_api_name 

2625 proxy_function_name = self.api_gateway_stack.proxy_lambda.function_name 

2626 

2627 api_error_alarm = cloudwatch.Alarm( 

2628 self, 

2629 "CompositeApiErrors", 

2630 metric=cloudwatch.Metric( 

2631 namespace="AWS/ApiGateway", 

2632 metric_name="5XXError", 

2633 dimensions_map={"ApiName": api_name}, 

2634 statistic="Sum", 

2635 period=Duration.minutes(5), 

2636 ), 

2637 threshold=5, 

2638 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2639 evaluation_periods=2, 

2640 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2641 ) 

2642 

2643 lambda_error_alarm = cloudwatch.Alarm( 

2644 self, 

2645 "CompositeLambdaErrors", 

2646 metric=cloudwatch.Metric( 

2647 namespace="AWS/Lambda", 

2648 metric_name="Errors", 

2649 dimensions_map={"FunctionName": proxy_function_name}, 

2650 statistic="Sum", 

2651 period=Duration.minutes(5), 

2652 ), 

2653 threshold=3, 

2654 comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, 

2655 evaluation_periods=2, 

2656 treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING, 

2657 ) 

2658 

2659 api_lambda_composite = cloudwatch.CompositeAlarm( 

2660 self, 

2661 "ApiLambdaCompositeAlarm", 

2662 alarm_description="Critical: Both API Gateway and Lambda proxy have errors", 

2663 alarm_rule=cloudwatch.AlarmRule.all_of(api_error_alarm, lambda_error_alarm), 

2664 ) 

2665 api_lambda_composite.add_alarm_action(cw_actions.SnsAction(self.alert_topic)) 

2666 

2667 def _create_custom_metrics(self) -> None: 

2668 """Create custom metric filters and log groups""" 

2669 for regional_stack in self.regional_stacks: 

2670 region = regional_stack.deployment_region 

2671 region_id = region.replace("-", "").title() 

2672 

2673 # Health monitor log group 

2674 # log_group_name intentionally omitted - let CDK generate unique name 

2675 logs.LogGroup( 

2676 self, 

2677 f"HealthMonitorLogGroup{region_id}", 

2678 retention=logs.RetentionDays.ONE_MONTH, 

2679 removal_policy=RemovalPolicy.DESTROY, 

2680 ) 

2681 

2682 # Manifest processor log group 

2683 # log_group_name intentionally omitted - let CDK generate unique name 

2684 logs.LogGroup( 

2685 self, 

2686 f"ManifestProcessorLogGroup{region_id}", 

2687 retention=logs.RetentionDays.ONE_MONTH, 

2688 removal_policy=RemovalPolicy.DESTROY, 

2689 ) 

2690 

2691 def _create_outputs(self) -> None: 

2692 """Create CloudFormation outputs""" 

2693 CfnOutput( 

2694 self, 

2695 "DashboardUrl", 

2696 value=f"https://console.aws.amazon.com/cloudwatch/home?region={self.region}#dashboards:name={self.dashboard.dashboard_name}", 

2697 description="CloudWatch Dashboard URL", 

2698 ) 

2699 

2700 CfnOutput( 

2701 self, 

2702 "AlertTopicArn", 

2703 value=self.alert_topic.topic_arn, 

2704 description="SNS Topic ARN for monitoring alerts", 

2705 ) 

2706 

2707 CfnOutput( 

2708 self, 

2709 "AlarmCount", 

2710 value="See CloudWatch Alarms console for full list", 

2711 description="Monitoring alarms created", 

2712 )