def __init__(
self,
scope: Construct,
construct_id: str,
*,
idempotency_table: dynamodb.ITableV2,
is_production_env: bool = True,
appconfig_monitor: bool = False,
) -> None:
"""Build the application construct.
Args:
scope: The CDK construct scope.
construct_id: The scoped construct ID.
idempotency_table: The Powertools idempotency table, created in the
separate :class:`DataStack` and passed in cross-stack.
This construct wires it into the Lambda (the
``IDEMPOTENCY_TABLE_NAME`` env var and a scoped read/write
grant) but does not own its lifecycle — see that stack for the
stateful-resource separation rationale.
is_production_env: When True (the default, and what the default
``prod`` deployment environment passes), alarm notifications
are routed to a CMK-encrypted SNS topic. Non-production
environments — ephemeral per-developer/per-branch stacks in
particular — still get the full dashboard and alarm set, but
without the SNS topic so short-lived stacks never page anyone.
appconfig_monitor: Opt-in production switch for AppConfig feature-flag
rollouts. When True, the flag deployment uses a gradual strategy
(LINEAR 25%/step over 10 min, 5-min bake) and the environment
carries a CloudWatch alarm monitor that auto-rolls-back a bad flag
config (``_attach_appconfig_rollback_monitor``). Defaults to False
— all-at-once, no monitor — because a monitored CFN-managed
deployment **cannot create a cold stack** (the monitor alarm starts
``INSUFFICIENT_DATA``, which AppConfig treats as a rollback signal).
Enable it only AFTER a first all-at-once deploy has produced metric
data; see that method and README "Deployment safety".
"""
super().__init__(scope, construct_id)
stack = Stack.of(self)
# Stateful data layer lives in its own stack (DataStack); the
# table is passed in cross-stack. This construct's own CMK below covers
# the compute-side encryption (Lambda env vars, log groups, AppConfig,
# SNS) — the table is encrypted by the data stack's separate key.
self.idempotency_table = idempotency_table
# Compute-side KMS key, shared across this stack's CloudWatch log groups,
# Lambda env vars, AppConfig hosted configuration content, and the SNS
# alarm topic. The DynamoDB table has its own key in the data stack
# (see DataStack) — keys are not shared across the stack
# boundary, so each carries a tighter, least-privilege key policy.
# CloudWatch Logs requires the Logs service principal to be granted access
# so it can encrypt data on behalf of the service.
# Note: SSM StringParameter cannot use CMK — CloudFormation does not support
# creating SecureString parameters. AppConfig support arrived later (via
# the kms_key_identifier property on CfnConfigurationProfile), wired below.
self.encryption_key = kms.Key(
self,
"EncryptionKey",
description=f"KMS key for {stack.stack_name} log groups, Lambda env, AppConfig, and SNS",
enable_key_rotation=True,
# 90 days is a common compliance-aligned cadence (PCI/HIPAA forks
# default to 90). Rotation is fully managed by AWS — key ID/ARN
# and policies stay constant, prior versions are retained for
# transparent decryption, no dependent redeploys required.
rotation_period=Duration.days(90),
removal_policy=RemovalPolicy.DESTROY,
)
# Confused-deputy guard: scope the Logs service principal grant to
# log-group ARNs in this account+region. See ``grant_logs_service_to_key``
# in ``nag_utils.py`` — three CMKs in this project share the statement.
grant_logs_service_to_key(
self.encryption_key,
region=stack.region,
account=stack.account,
partition=stack.partition,
)
# Deliberately NO grant for GuardDuty (or DevOps Guru / Application
# Insights / Resource Explorer): those services' service-linked roles
# probe kms:Decrypt against this CMK when introspecting the Lambda's
# CMK-encrypted env vars, and are denied — by design. A previous
# service-principal grant (Principal: guardduty.amazonaws.com +
# SourceAccount/SourceArn conditions) was removed after live CloudTrail
# evidence (2026-07-02, GuardDuty enabled in-account) showed it can
# never match: the call is made by the assumed
# AWSServiceRoleForAmazonGuardDuty role, and a role-session principal
# is not matched by a service-principal key statement — the grant was
# dead policy. The SLR's AWS-managed policy carries no kms:Decrypt
# either, and GuardDuty's documented Lambda coverage (function
# metadata, tags, network activity) does not require reading env-var
# plaintext, so nothing is lost. A fork that wants these services to
# read CMK-encrypted config must add a key-policy statement naming the
# specific service-linked role ARN — possible only in accounts where
# that SLR already exists (KMS validates key-policy principals), which
# is why this template doesn't ship it.
# SSM parameter for Powertools Parameters.
# parameter_name omitted so CDK auto-generates. Lambda reads the value
# through the GREETING_PARAM_NAME env var, so the name doesn't need to
# be human-memorable.
self.greeting_param = ssm.StringParameter(
self,
"GreetingParameter",
string_value="hello world",
)
# AppConfig for Powertools Feature Flags
self.app_config_app = appconfig.CfnApplication(
self,
"FeatureFlagsApp",
name=f"{stack.stack_name}-features",
)
# deletion_protection_check=BYPASS — same teardown rationale as the
# configuration profile below: the account-level deletion-protection
# window would otherwise fail `cdk destroy` for any environment a
# Lambda polled recently.
app_config_env = appconfig.CfnEnvironment(
self,
"FeatureFlagsEnv",
application_id=self.app_config_app.ref,
name=f"{stack.stack_name}-env",
deletion_protection_check="BYPASS",
)
# kms_key_identifier CMK-encrypts the hosted configuration content at
# rest in AppConfig. This compute-side CMK already covers the Lambda's
# log groups and env vars; pinning AppConfig to the same key keeps the
# compute-side auditable encryption surface inside one ARN. (The Lambda
# also gets an explicit kms:Decrypt grant on this key below for the
# GetLatestConfiguration read path — see the grant near the role policy.)
#
# Type is FREEFORM on purpose, not AWS.AppConfig.FeatureFlags: the
# native flags type stores the authoring format but its data plane
# serves the flattened {"<flag>":{"enabled":bool}} form, which
# Powertools FeatureFlags rejects with SchemaValidationError ("feature
# 'default' boolean key must be present"). Powertools consumes its OWN
# schema ({"<flag>":{"default":bool,"rules":{...}}}) from a freeform
# profile — see the Powertools feature-flags docs. Found on a live
# deployment; the handler's fallback path masks it at synth/test time.
# Name is "-flags" (not the application's "-features") deliberately:
# changing a profile's Type forces CFN replacement, and replacement is
# create-before-delete — a replacement that keeps the same pinned name
# collides with the not-yet-deleted old profile ("Resource already
# exists outside the stack"). AppConfig L1s require a name (no CDK
# auto-generation), so any future property change that replaces this
# profile must change the name in the same commit.
#
# deletion_protection_check=BYPASS: AppConfig deletion protection (an
# account-level setting) refuses to delete environments and hosted
# configurations that a client polled within the protection window.
# The Lambda polls this profile continuously, so with the account
# default enabled every `cdk destroy` of a recently used stack would
# fail mid-teardown — the same class of dangling-teardown problem
# `make destroy-clean` exists to solve. BYPASS keeps teardown
# deterministic; production forks that want the guardrail can flip
# these to ACCOUNT_DEFAULT and accept manual deletes on destroy.
app_config_profile = appconfig.CfnConfigurationProfile(
self,
"FeatureFlagsProfile",
application_id=self.app_config_app.ref,
name=f"{stack.stack_name}-flags",
location_uri="hosted",
type="AWS.Freeform",
kms_key_identifier=self.encryption_key.key_arn,
deletion_protection_check="BYPASS",
)
# Initial feature flags configuration, in the Powertools feature-flags
# schema. "rules" (conditional enablement, e.g. by source_ip /
# user_agent from the evaluation context) can be authored here later.
#
# The flag content lives in feature_flags.json next to this module —
# one file read by both this construct (at synth) and the unit test
# that validates it against the Powertools feature-flags schema
# (tests/unit/test_feature_flags_schema.py, which runs in the venv
# where Powertools is importable). json.loads is the synth-side guard:
# it can't check the flag schema (Powertools isn't installable next to
# CDK — see the attrs conflict in pyproject.toml) but it does fail the
# build on malformed JSON instead of shipping it to AppConfig.
flags_content = (Path(__file__).parent / "feature_flags.json").read_text()
json.loads(flags_content)
flags_version = appconfig.CfnHostedConfigurationVersion(
self,
"FeatureFlagsVersion",
application_id=self.app_config_app.ref,
configuration_profile_id=app_config_profile.ref,
content_type="application/json",
content=flags_content,
)
# A hosted configuration version is inert until DEPLOYED: the AppConfig
# data plane (GetLatestConfiguration, which Powertools FeatureFlags
# calls) only serves configuration that has been deployed to the
# environment. Without this Deployment the enhanced_greeting flag could
# never evaluate true and every fetch would take the handler's
# error-fallback path. CFN re-runs the deployment whenever the hosted
# version changes (ConfigurationVersion references flags_version.ref).
#
# Deployment strategy: all-at-once by default; gradual when the opt-in
# appconfig_monitor switch is set (it pairs with the environment monitor
# wired below — the bake window is what gives the monitor time to act).
#
# Why all-at-once is the default: a *gradual* strategy with a CloudWatch
# alarm **monitor** for automatic rollback is the production-grade pattern
# for protecting ongoing flag changes — but it cannot be wired into this
# CFN-managed deployment when it runs during *initial* stack creation.
# AppConfig rolls back when a monitored alarm is in ALARM **or
# INSUFFICIENT_DATA**, and on a cold stack the rollback metric
# (FeatureFlagEvaluationFailure, emitted only by the running Lambda) has
# no data, so the fresh alarm sits in INSUFFICIENT_DATA and AppConfig
# aborts the initial deploy (verified on a live deploy). The metric is
# always emitted for observability; gradual + alarm rollback is opt-in via
# appconfig_monitor and must be turned on only AFTER a first all-at-once
# deploy — see _attach_appconfig_rollback_monitor and README
# "Deployment safety" / TODO "AppConfig".
if appconfig_monitor:
flags_deployment_strategy = appconfig.CfnDeploymentStrategy(
self,
"FeatureFlagsDeployStrategy",
name=f"{stack.stack_name}-gradual",
deployment_duration_in_minutes=10,
growth_factor=25,
growth_type="LINEAR",
final_bake_time_in_minutes=5,
replicate_to="NONE",
)
else:
flags_deployment_strategy = appconfig.CfnDeploymentStrategy(
self,
"FeatureFlagsDeployStrategy",
name=f"{stack.stack_name}-all-at-once",
deployment_duration_in_minutes=0,
growth_factor=100,
growth_type="LINEAR",
final_bake_time_in_minutes=0,
replicate_to="NONE",
)
appconfig.CfnDeployment(
self,
"FeatureFlagsDeployment",
application_id=self.app_config_app.ref,
environment_id=app_config_env.ref,
configuration_profile_id=app_config_profile.ref,
configuration_version=flags_version.ref,
deployment_strategy_id=flags_deployment_strategy.ref,
# Same CMK as the hosted content — keeps the deployment data inside
# the one auditable key, matching the profile's kms_key_identifier.
kms_key_identifier=self.encryption_key.key_arn,
)
# Explicit Lambda log group with 1-week retention (implicit group has no retention).
# log_group_name omitted — CDK auto-generates a unique name and wires it into the
# Lambda function via the log_group property below.
lambda_log_group = logs.LogGroup(
self,
"FunctionLogGroup",
encryption_key=self.encryption_key,
# 90 days for operational app logs — enough debugging history and
# satisfies a "3 months immediately available" clause without paying
# CloudWatch storage for a long tail. Audit-relevant logs (CloudTrail,
# access logs) go to S3 for cheaper long-term retention instead — see
# README "Audit stack and log retention".
retention=logs.RetentionDays.THREE_MONTHS,
removal_policy=RemovalPolicy.DESTROY,
)
# Lambda function with automatic dependency bundling.
# environment_encryption pins the env-var encryption to our CMK so the
# security boundary stays inside one key — without it Lambda falls back
# to an AWS-managed key.
self.function = PythonFunction(
self,
"ApiFunction",
runtime=_lambda.Runtime.PYTHON_3_14,
entry="lambda",
index="app.py",
handler="lambda_handler",
architecture=_lambda.Architecture.ARM_64,
memory_size=256,
timeout=Duration.seconds(10),
# Async-retry posture made explicit: this function is only invoked
# synchronously (API Gateway), so Lambda's default of two automatic
# async retries is dead config — pinning it to 0 documents that no
# async path exists, and any future async event source must revisit
# this together with an on_failure destination (see the LambdaDLQ
# suppressions below).
retry_attempts=0,
# Reserved concurrency caps how much of the account's concurrency pool
# this one function can consume, so a runaway loop or a traffic spike
# on /greeting can't starve every other Lambda in the account. 100 is
# a deliberately modest ceiling for a reference workload — size it to
# real peak traffic in a fork (and note that a reserved value also
# guarantees that headroom is always available to this function).
# First-deploy precondition: Lambda rejects any reservation that
# would leave the account's UNRESERVED pool below 100, so this line
# needs an applied account concurrency quota of at least 200. The
# documented default is 1000, but fresh accounts often start with a
# much lower applied quota — check `aws lambda get-account-settings`
# and request an increase (or lower this value) before the first
# deploy in a new account, or the stack fails on this resource.
# Retires the NIST.800.53.R5-LambdaConcurrency /
# HIPAA.Security-LambdaConcurrency suppressions below.
reserved_concurrent_executions=100,
tracing=_lambda.Tracing.ACTIVE,
log_group=lambda_log_group,
logging_format=_lambda.LoggingFormat.JSON,
# Platform/system log records (START, REPORT, platform.report, …)
# are filtered at INFO. That is the service default today, but the
# posture is pinned in code — same "visible in code, not implicit
# in the runtime default" rationale as recursive_loop below. Note
# the SlowInvocations saved query reads platform.report records,
# so this must never be raised above INFO without updating it.
system_log_level_v2=_lambda.SystemLogLevel.INFO,
environment_encryption=self.encryption_key,
environment={
"POWERTOOLS_SERVICE_NAME": "serverless-app",
"POWERTOOLS_METRICS_NAMESPACE": "ServerlessApp",
"POWERTOOLS_LOG_LEVEL": "INFO",
"IDEMPOTENCY_TABLE_NAME": self.idempotency_table.table_name,
"GREETING_PARAM_NAME": self.greeting_param.parameter_name,
# Sourcing AppConfig identifiers from the CFN constructs (instead
# of re-formatting f"{stack.stack_name}-...") keeps the Lambda's
# reads in lockstep with the IAM grant below: any future rename
# of the AppConfig resources flows through .name automatically.
"APPCONFIG_APP_NAME": self.app_config_app.name,
"APPCONFIG_ENV_NAME": app_config_env.name,
"APPCONFIG_PROFILE_NAME": app_config_profile.name,
# In-memory TTL for fetched feature flags (seconds). The handler
# defaults to 300 anyway; set explicitly so the caching posture
# is visible and tunable here rather than buried in code.
"APPCONFIG_MAX_AGE_SECONDS": "300",
},
)
# Recursive-loop detection. Default is Terminate, but the L2 PythonFunction
# construct doesn't surface this property — set it explicitly on the
# underlying CfnFunction so the posture is visible in code rather than
# implicit in the runtime default. A runtime isinstance check (instead of a
# bare cast) makes a future CDK change to the L2->L1 default_child
# relationship fail loudly at synth rather than silently dropping the
# Terminate posture — mirroring the provider-lookup guard in the frontend stack.
cfn_function = self.function.node.default_child
if not isinstance(cfn_function, _lambda.CfnFunction):
raise TypeError(f"Expected ApiFunction default_child to be CfnFunction, got {type(cfn_function)}")
cfn_function.recursive_loop = "Terminate"
# Lambda alias for CodeDeploy traffic-shifting deployments. The API
# integration targets this alias rather than $LATEST, so a code change
# rolls out through CodeDeploy (canary in prod, all-at-once in dev) with
# automatic rollback on the alias error alarm — see
# _attach_canary_deployment. current_version publishes a new Lambda
# version whenever the function's code or config changes; CodeDeploy
# shifts the alias onto it. (Reserved concurrency stays on the function;
# it is shared across versions, so the alias needs none of its own.)
self.alias = _lambda.Alias(
self,
"LiveAlias",
alias_name="live",
version=self.function.current_version,
)
# Grant permissions
self.idempotency_table.grant_read_write_data(self.function)
self.greeting_param.grant_read(self.function)
# AppConfig least-privilege: both calls authorize against the
# application/environment/configuration ARN. The session token in the
# GetLatestConfiguration request body is opaque request data, not the
# IAM resource — IAM still evaluates the call against this profile ARN.
appconfig_profile_arn = (
f"arn:{stack.partition}:appconfig:{stack.region}:{stack.account}:"
f"application/{self.app_config_app.ref}/"
f"environment/{app_config_env.ref}/"
f"configuration/{app_config_profile.ref}"
)
self.function.add_to_role_policy(
statement=iam.PolicyStatement(
actions=["appconfig:StartConfigurationSession", "appconfig:GetLatestConfiguration"],
resources=[appconfig_profile_arn],
)
)
# AppConfig CMK-decrypt grant. The hosted configuration is encrypted at
# rest with this stack's CMK (kms_key_identifier on the profile +
# deployment above), and AppConfig evaluates kms:Decrypt against the
# *caller's* role on GetLatestConfiguration — so the Lambda needs decrypt
# on this key. This permission used to ride the DynamoDB grant back when
# the table shared this CMK; now that the table has its own key in
# DataStack, the AppConfig decrypt path needs an explicit,
# scoped grant here (read-only path, so kms:Decrypt only).
self.encryption_key.grant_decrypt(self.function)
# Explicit API Gateway access log group with 1-week retention.
# log_group_name omitted — CDK auto-generates and passes it into the
# RestApi via LogGroupLogDestination below.
api_log_group = logs.LogGroup(
self,
"ApiAccessLogs",
encryption_key=self.encryption_key,
# 90 days — operational retention (see FunctionLogGroup).
retention=logs.RetentionDays.THREE_MONTHS,
removal_policy=RemovalPolicy.DESTROY,
)
# API Gateway REST API
# cloud_watch_role=True (default) creates an implicit IAM role scoped to
# allow API Gateway to write execution logs to CloudWatch — this is a
# region-level account setting managed by CDK automatically.
self.api = apigw.RestApi(
self,
"RestApi",
cloud_watch_role=True,
cloud_watch_role_removal_policy=RemovalPolicy.DESTROY,
deploy_options=apigw.StageOptions(
stage_name="Prod",
tracing_enabled=True,
# Stage-level throttling caps steady-state and burst request rates
# across the whole stage, bounding both abuse and runaway cost.
# These are method-level defaults applied to every route; per-method
# overrides or a usage plan can layer tighter limits later. Values
# are deliberately modest for a reference workload — raise them in a
# fork sized to real traffic. This retires the
# Serverless-APIGWDefaultThrottling suppression in BackendStack.
throttling_rate_limit=100,
throttling_burst_limit=200,
access_log_destination=apigw.LogGroupLogDestination(api_log_group),
access_log_format=apigw.AccessLogFormat.custom(
# Built from typed AccessLogField references — json_with_standard_fields
# only supports 10 fixed fields; custom() is the CDK API for extended formats.
#
# errorMessage stays the quoted RAW $context.error.message on
# purpose. $context.error.messageString looks like the
# JSON-safe variant but is unusable here: absent access-log
# variables render as a bare dash, so the unquoted form
# corrupts every success line ("errorMessage":-) and the
# quoted form double-quotes every error line. The residual
# exposure of the raw form — a message containing a double
# quote breaks JSON parsing for that one line — is accepted.
"{"
+ ",".join(
[
f'"requestId":"{apigw.AccessLogField.context_request_id()}"',
f'"accountId":"{apigw.AccessLogField.context_owner_account_id()}"',
f'"apiId":"{apigw.AccessLogField.context_api_id()}"',
f'"stage":"{apigw.AccessLogField.context_stage()}"',
f'"resourcePath":"{apigw.AccessLogField.context_resource_path()}"',
f'"httpMethod":"{apigw.AccessLogField.context_http_method()}"',
f'"protocol":"{apigw.AccessLogField.context_protocol()}"',
f'"status":"{apigw.AccessLogField.context_status()}"',
f'"responseType":"{apigw.AccessLogField.context_error_response_type()}"',
f'"errorMessage":"{apigw.AccessLogField.context_error_message()}"',
f'"requestTime":"{apigw.AccessLogField.context_request_time()}"',
# Feeds the SlowestRequests saved query in
# _create_insights_queries — total request latency in ms.
f'"responseLatency":"{apigw.AccessLogField.context_response_latency()}"',
f'"ip":"{apigw.AccessLogField.context_identity_source_ip()}"',
f'"caller":"{apigw.AccessLogField.context_identity_caller()}"',
f'"user":"{apigw.AccessLogField.context_identity_user()}"',
f'"responseLength":"{apigw.AccessLogField.context_response_length()}"',
f'"xrayTraceId":"{apigw.AccessLogField.context_xray_trace_id()}"',
]
)
+ "}"
),
logging_level=apigw.MethodLoggingLevel.INFO,
data_trace_enabled=False,
),
)
greeting_resource = self.api.root.add_resource("greeting")
# Integrate with the alias (not $LATEST) so CodeDeploy traffic shifting
# is what actually moves production traffic onto a new version.
greeting_resource.add_method("GET", apigw.LambdaIntegration(self.alias))
greeting_resource.add_cors_preflight(
allow_origins=apigw.Cors.ALL_ORIGINS,
allow_methods=["GET", "OPTIONS"],
# X-Amzn-Trace-Id is required for CloudWatch RUM to propagate the
# client-side X-Ray trace header into the API Gateway → Lambda
# segments so the browser and backend appear on the same trace.
# Idempotency-Key must be allowed by the preflight or browsers will
# block the actual request — the Lambda requires it (returns 400
# without it) so the preflight has to permit it explicitly.
allow_headers=[*apigw.Cors.DEFAULT_HEADERS, "X-Amzn-Trace-Id", "Idempotency-Key"],
)
# Explicit execution log group — API Gateway creates this outside CloudFormation
# when logging_level is enabled. Pre-creating it here transfers ownership to CFN
# so it is deleted on cdk destroy. Name format is fixed by the API Gateway service.
execution_log_group = logs.LogGroup(
self,
"ApiExecutionLogs",
log_group_name=f"API-Gateway-Execution-Logs_{self.api.rest_api_id}/Prod",
encryption_key=self.encryption_key,
# 90 days — operational retention (see FunctionLogGroup).
retention=logs.RetentionDays.THREE_MONTHS,
removal_policy=RemovalPolicy.DESTROY,
)
# Order the stage after the group: if the stage goes live first and a
# request arrives mid-deploy, API Gateway auto-creates the group
# (unencrypted, no retention) and this LogGroup CREATE then fails with
# "already exists". No cycle: the group depends only on the RestApi
# (via rest_api_id in its name), not on the stage.
self.api.deployment_stage.node.add_dependency(execution_log_group)
# Regional WAF on API Gateway — closes the CloudFront-bypass window on the
# public execute-api URL. See _attach_regional_waf for the full rationale.
self._attach_regional_waf()
# CodeDeploy traffic-shifting deployment for the Lambda alias, with
# automatic rollback if the alias error alarm fires during the shift.
self._attach_canary_deployment(is_production_env)
# AppConfig deployment monitor (opt-in): a CloudWatch alarm AppConfig
# watches during a flag rollout, auto-rolling-back the config if it fires.
# Off by default — it cannot create a cold stack (see the method docstring).
if appconfig_monitor:
self._attach_appconfig_rollback_monitor(app_config_env)
self._create_insights_queries(lambda_log_group, api_log_group)
# Application Insights
resource_group = rg.CfnGroup(
self,
"ApplicationResourceGroup",
name=f"ApplicationInsights-{stack.stack_name}",
resource_query=rg.CfnGroup.ResourceQueryProperty(
type="CLOUDFORMATION_STACK_1_0",
),
)
app_insights = appinsights.CfnApplication(
self,
"ApplicationInsightsMonitoring",
resource_group_name=resource_group.name,
auto_configuration_enabled=True,
)
app_insights.add_dependency(resource_group)
# CMK-encrypted log group for the AwsCustomResource provider Lambda.
# Passing log_group= here (instead of log_retention=) avoids the legacy
# LogRetention singleton path and lets us own every log group with our
# CMK — no dangling AWS-managed-key log group left after cdk destroy.
custom_resource_log_group = logs.LogGroup(
self,
"AwsCustomResourceLogGroup",
encryption_key=self.encryption_key,
retention=logs.RetentionDays.ONE_WEEK,
removal_policy=RemovalPolicy.DESTROY,
)
# Custom resource to delete the Application Insights auto-created CloudWatch
# dashboard on stack destroy. Application Insights creates a dashboard named
# "ApplicationInsights-{resource-group-name}" outside of CloudFormation, so
# CDK cannot own it directly. Because the resource group's own name already
# starts with "ApplicationInsights-", the real dashboard name carries the
# DOUBLED prefix — deleting resource_group.name verbatim silently deletes
# nothing (verified against a live teardown). This Lambda-backed custom
# resource calls DeleteDashboards at destroy time so no dashboard is left
# behind after cdk destroy. Policy is scoped to the exact dashboard ARN —
# CloudWatch dashboards have a known global ARN format.
app_insights_dashboard_name = f"ApplicationInsights-{resource_group.name}"
app_insights_dashboard_arn = (
f"arn:{stack.partition}:cloudwatch::{stack.account}:dashboard/{app_insights_dashboard_name}"
)
app_insights_dashboard_cleanup = cr.AwsCustomResource(
self,
"AppInsightsDashboardCleanup",
on_delete=cr.AwsSdkCall(
service="CloudWatch",
action="deleteDashboards",
parameters={"DashboardNames": [app_insights_dashboard_name]},
physical_resource_id=cr.PhysicalResourceId.of(app_insights_dashboard_name),
),
policy=cr.AwsCustomResourcePolicy.from_sdk_calls(
resources=[app_insights_dashboard_arn],
),
install_latest_aws_sdk=False,
log_group=custom_resource_log_group,
)
# Must run after Application Insights has had a chance to create the dashboard
app_insights_dashboard_cleanup.node.add_dependency(app_insights)
# Monitoring dashboard, alarms, and (in production) SNS alarm routing.
self.alarm_topic = self._build_monitoring(lambda_log_group, is_production_env)
# Expose API URL for consumption by the enclosing stack and cross-stack refs
self.api_url = self.api.url
self._add_resource_suppressions(app_insights_dashboard_cleanup)