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.