What CDK Aspects Reach That Props Can't
My credit-card side project synthesizes 31 Lambda functions. All 31 wrote to CloudWatch log groups with a two-year retention default and no removal policy, which costs nothing today and shows up on a bill eighteen months from now.
Setting retention on the 29 API handlers and the Cognito trigger was straightforward. The thirty-first function is what this post is about.
The prop only reaches what you declared
The current, non-deprecated way to control a Lambda's log retention is to build the log group yourself and hand it over.
new lambda.Function(this, 'Handler', {
runtime: lambda.Runtime.NODEJS_24_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('dist'),
logGroup: new logs.LogGroup(this, 'HandlerLogs', {
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: RemovalPolicy.DESTROY,
}),
});
The older logRetention prop still works, but aws-cdk-lib
2.261 marks it @deprecated: "This is a legacy API and we
strongly recommend you move away from it if you can." It provisions a
CloudFormation custom resource per function. That's a lot of moving
parts for a number.
A feature flag covers most of the rest.
@aws-cdk/aws-lambda:useCdkManagedLogGroup makes CDK emit
an explicit AWS::Logs::LogGroup for every L2 function
that doesn't set logGroup itself. I already had it on,
and that's what got me to 30.
All three share a limit. They apply only to functions somebody wrote a
constructor call for. My stack has a bucket with
autoDeleteObjects: true, and that one prop makes CDK
build a Lambda behind your back: the custom-resource handler that
empties the bucket before CloudFormation deletes it. I never declared
it. There was no props object to add a key to.
Its log group didn't exist at all, so this wasn't a case of the wrong retention. Lambda would create one implicitly on first invoke, outside CloudFormation, and keep it forever.
Aspects visit the tree, not your code
An Aspect is a visitor. You hand it to
Aspects.of(scope) and CDK calls visit() once
per construct in that subtree during synthesis, after the tree is
fully built. That timing is the whole point. It sees the finished
tree, including every node CDK added on its own.
export class LogRetentionAspect implements IAspect {
constructor(
private readonly retention: RetentionDays,
private readonly removalPolicy: RemovalPolicy,
) {}
public visit(node: IConstruct): void {
if (!(node instanceof CfnLogGroup)) return;
node.retentionInDays = this.retention;
node.applyRemovalPolicy(this.removalPolicy);
}
}
Twelve lines. Applied once at stack scope, every log group picks up
the same retention, including the one CDK's
BucketDeployment provider makes for itself.
Scope matters more than it looks. My first attempt attached this to
the WebApp construct that owns the bucket, and it missed
the BucketDeployment handler completely, because CDK's
SingletonFunction machinery attaches its real Lambda as a
direct child of Stack.of(scope) rather than of the
construct that asked for it. cdk.out/tree.json shows it
plainly.
CreditCardsStack
├── WebApp
│ └── WebBucket
├── Custom::CDKBucketDeployment8693BB… <- sibling, not nested
│ └── LogGroup
└── Custom::S3AutoDeleteObjectsCustomResourceProvider
└── Handler <- no LogGroup child
Apply the Aspect at stack scope if it needs to catch CDK-internal resources. A construct-scoped Aspect quietly covers less than it appears to.
instanceof is not the same as resource type
That got me to 30 of 31. The auto-delete handler still had no log group to configure, so I wrote a second Aspect to create one and reached for the obvious check.
// wrong: skips the one function this Aspect exists for
if (node instanceof CfnFunction) {
backfillLogGroup(node);
}
It compiles, reads correctly, and skips the exact function I wrote it
for. CustomResourceProvider doesn't use the generated
CfnFunction class at all; it builds a bare
CfnResource and sets the type as a plain string.
this.handler = new CfnResource(this, 'Handler', {
type: 'AWS::Lambda::Function',
properties: {/* ... */},
});
Both land in the template as AWS::Lambda::Function, but
only one is an instance of CfnFunction. Matching on the
resource type instead catches both, because
CfnFunction extends CfnResource and carries
the identical cfnResourceType string. Two lines, and no
more blind spot.
if (
!(node instanceof CfnResource) ||
node.cfnResourceType !== 'AWS::Lambda::Function'
) {
return;
}
Deciding what "already has one" means
This is the part that took longest and the part I'd get wrong again.
An Aspect that creates log groups has to skip functions that already
have one, or it produces duplicates. The natural check looks for a
CfnLogGroup somewhere under the function's parent scope.
const scope = node.node.scope;
const covered = scope.node.findAll().some((d) => d instanceof CfnLogGroup);
if (covered) return; // wrong
For an L2 function that's correct. The parent scope is the
Function construct, whose children are
Resource and LogGroup, so the search finds
its own group and stops.
It breaks when the parent scope is the stack.
findAll() then walks the whole stack, finds the 30 other
log groups, and concludes this function is already covered, so nothing
gets created and nothing is logged about it; synth exits zero. I built
a throwaway stack to watch it happen, with one L2 function and one raw
CfnResource Lambda at stack scope, and got 3 functions
and 2 log groups with no error anywhere.
Comparing resolved names instead of presence fixes it.
const logGroupName = `/aws/lambda/${node.ref}`;
const stack = Stack.of(node);
const wanted = JSON.stringify(stack.resolve(logGroupName));
const alreadyCovered = scope.node
.findAll()
.some(
(d) =>
d instanceof CfnLogGroup &&
JSON.stringify(stack.resolve(d.logGroupName)) === wanted,
);
if (alreadyCovered) return;
new CfnLogGroup(node, 'LogGroup', { logGroupName }).applyRemovalPolicy(
RemovalPolicy.DESTROY,
);
stack.resolve() turns the CDK token into the
CloudFormation intrinsic it will become, so the comparison works on
unresolved references. The name renders as
{"Fn::Join": ["", ["/aws/lambda/", {"Ref":
"<LogicalId>"}]]}, byte-identical to what the feature flag produces for the other 30.
Two properties come free. The check is self-idempotent, because the
group it creates lives inside the scope it searches and matches the
name it looks for. It also catches a failure the presence check can't
see at all: two log groups sharing one LogGroupName,
which synths cleanly and fails at deploy.
Priority is load-bearing
Two Aspects now. One creates missing log groups, one sets retention on log groups, and the creating one has to run first.
Aspects.of(this).add(new LambdaLogGroupCoverageAspect(RemovalPolicy.DESTROY), {
priority: AspectPriority.MUTATING, // 200
});
Aspects.of(this).add(
new LogRetentionAspect(RetentionDays.ONE_MONTH, RemovalPolicy.DESTROY),
); // DEFAULT, 500
Aspects run in ascending priority order, so MUTATING goes
first. Reverse them and you get a log group carrying no retention at
all, once again with nothing in the output to say so. It cost me a
synth to notice.
I assumed the @aws-cdk/core:aspectStabilization feature
flag made the ordering unimportant, since it exists to re-walk the
tree for Aspects that create nodes. It doesn't cover this.
bumpAspectTreeRevision fires when an Aspect is
added, never when a construct is created. The real mechanism
is duller: the new CfnLogGroup gets appended to the
function's children while the walk is in progress, and the recursion
iterates node.children right after, so the same pass
picks it up. I ran the whole thing with stabilization turned off to be
sure, and retention still applied.
The distinction changes what failure looks like. If the stabilization loop were doing the work, wrong priority would cost a second pass and still converge. It isn't, so wrong priority ships the bad template.
Verifying without trusting the tests
All three mistakes produce a clean synth and a wrong template, so unit tests are the control that matters. One earns its keep more than the rest.
it('gives a stack-child raw Lambda its own group even when other groups exist', () => {
const template = synth((stack) => {
l2Lambda(stack, 'Covered');
rawLambda(stack, 'RawAtStackScope');
});
expect(counts(template)).toEqual({ functions: 2, logGroups: 2 });
});
I checked that these tests earn their place by swapping the subtree check back in. Four of eight failed. A test that passes against both implementations isn't testing the thing.
The other half is asserting against the synthesized template rather than the snapshots. Mine didn't move at all when I added the second Aspect, because the stack-level snapshot test builds the stack without the web app, so it has no auto-delete bucket and no uncovered function, which means a green snapshot run said nothing whatsoever about the change I had just made.
cdk synth
python3 -c "
import json,glob
t=json.load(open(glob.glob('cdk.out/assembly-*/*credit-cards*.template.json')[0]))
lg=[r for r in t['Resources'].values() if r['Type']=='AWS::Logs::LogGroup']
print(len(lg), all(r['Properties'].get('RetentionInDays')==30 for r in lg))
"
31 groups, all at 30 days. That count is the assertion. It's the first thing I'd re-run after a CDK upgrade, because a change in how CDK names or nests its internal handlers would show up here before it showed up anywhere else.
What I'd still watch
The backfilled log group and the auto-delete custom resource are
unordered siblings that both depend on the handler. At stack deletion
CloudFormation may remove the log group before the handler runs, and
Lambda will re-create an unretained orphan. It's cosmetic, and already
true of BucketDeployment's group, but an explicit
DependsOn would close it.
The bigger one involves state outside the template. If
/aws/lambda/<name> already exists in CloudWatch
outside CloudFormation, declaring it fails the deploy with
already-exists, because CloudFormation won't adopt a log group it
didn't create. This handler only runs at stack teardown, so it has
probably never been invoked and has no implicit group. Probably isn't
a deploy strategy.
aws logs describe-log-groups --log-group-name-prefix
settles it in a second.
When to reach for one
Aspects make a bad default. Setting a prop on a construct you own is local, typed, and visible to whoever reads that construct next. The case for an Aspect is narrower than it first looks: the resource needing the change is one nobody declared, or the same policy has to hold across a stack no matter who adds a resource later. Log retention hit both. Tagging does too, as does forcing encryption or removal policies across resource types.
Every mistake in this post was silent. No error, no warning, no failed synth, just a template quietly missing something. I now diff the synthesized template before and after adding an Aspect, and count the resources I expected to change.