Skip to content

Commit

Permalink
feat(lambda): avail function log group in the CDK
Browse files Browse the repository at this point in the history
The lambda function's log group is now available for use in the CDK app
so it can be used further to create subscription filters, metric
filters, etc. or in any other way that a regular log group may be used.

The implementation uses an underlying CustomResource to guarantee the
existence of this log group as part of the CloudFormation stack.
A new property called `exposeLogGroup` to enable this, so that existing
stacks that have a Lambda function don't change significantly by
automatically getting this CustomResource, as well as, users who are
not interested in this log group can opt out.

closes #3838
  • Loading branch information
Niranjan Jayakar committed Jan 20, 2020
1 parent d51350b commit af8ea22
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 18 deletions.
30 changes: 27 additions & 3 deletions packages/@aws-cdk/aws-lambda/lib/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,17 @@ export interface FunctionProps extends EventInvokeConfigOptions {
* @default - A new role is created.
*/
readonly logRetentionRole?: iam.IRole;

/**
* Expose the log group of the lambda function via `logGroup` getter. When
* this property is set, the getter will return the Lambda function's log
* group.
* When this property and the `logRetention` property are both unset, the
* getter will throw an exception.
*
* @default false
*/
readonly exposeLogGroup?: boolean;
}

/**
Expand Down Expand Up @@ -408,6 +419,8 @@ export class Function extends FunctionBase {

private readonly layers: ILayerVersion[] = [];

private _logGroup?: logs.ILogGroup;

/**
* Environment variables for this function
*/
Expand Down Expand Up @@ -486,10 +499,11 @@ export class Function extends FunctionBase {
}

// Log retention
if (props.logRetention) {
new LogRetention(this, 'LogRetention', {
if (props.logRetention || props.exposeLogGroup) {
const retention = props.logRetention || logs.RetentionDays.INFINITE;
this._logGroup = new LogRetention(this, 'LogRetention', {
logGroupName: `/aws/lambda/${this.functionName}`,
retention: props.logRetention,
retention,
role: props.logRetentionRole
});
}
Expand Down Expand Up @@ -571,6 +585,16 @@ export class Function extends FunctionBase {
});
}

/**
* The LogGroup where the Lambda function's logs are made available.
*/
public get logGroup(): logs.ILogGroup {
if (!this._logGroup) {
throw new Error('LogGroup is not available when "exposeLogGroup" property is unset.');
}
return this._logGroup;
}

private renderEnvironment() {
if (!this.environment || Object.keys(this.environment).length === 0) {
return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent
StackId: event.StackId,
RequestId: event.RequestId,
LogicalResourceId: event.LogicalResourceId,
Data: {}
Data: {
LogGroupName: event.ResourceProperties.LogGroupName,
},
});

console.log('Responding', responseBody);
Expand Down
67 changes: 65 additions & 2 deletions packages/@aws-cdk/aws-lambda/lib/log-retention.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as metrics from '@aws-cdk/aws-cloudwatch';
import * as iam from '@aws-cdk/aws-iam';
import * as logs from '@aws-cdk/aws-logs';
import * as cdk from '@aws-cdk/core';
Expand Down Expand Up @@ -33,9 +34,15 @@ export interface LogRetentionProps {
* log group. The log group is created if it doesn't already exist. The policy
* is removed when `retentionDays` is `undefined` or equal to `Infinity`.
*/
export class LogRetention extends cdk.Construct {
export class LogRetention extends cdk.Construct implements logs.ILogGroup {

public readonly logGroupArn: string;
public readonly logGroupName: string;
public readonly stack: cdk.Stack;

constructor(scope: cdk.Construct, id: string, props: LogRetentionProps) {
super(scope, id);
this.stack = cdk.Stack.of(this);

// Custom resource provider
const provider = new SingletonFunction(this, 'Provider', {
Expand All @@ -58,13 +65,69 @@ export class LogRetention extends cdk.Construct {

// Need to use a CfnResource here to prevent lerna dependency cycles
// @aws-cdk/aws-cloudformation -> @aws-cdk/aws-lambda -> @aws-cdk/aws-cloudformation
new cdk.CfnResource(this, 'Resource', {
const resource = new cdk.CfnResource(this, 'Resource', {
type: 'Custom::LogRetention',
properties: {
ServiceToken: provider.functionArn,
LogGroupName: props.logGroupName,
RetentionInDays: props.retention === Infinity ? undefined : props.retention
}
});

this.logGroupName = resource.getAtt('LogGroupName').toString();
// Append ':*' at the end of the ARN to match with how CloudFormation does this for LogGroup ARNs
// See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-loggroup.html#aws-resource-logs-loggroup-return-values
this.logGroupArn = cdk.Stack.of(this).formatArn({
service: 'logs',
resource: 'log-group',
resourceName: this.logGroupName,
sep: ':'
}) + ':*';
}

public addStream(id: string, props: logs.StreamOptions = {}): logs.LogStream {
return new logs.LogStream(this, id, {
logGroup: this,
...props
});
}

public addSubscriptionFilter(id: string, props: logs.SubscriptionFilterOptions): logs.SubscriptionFilter {
return new logs.SubscriptionFilter(this, id, {
logGroup: this,
...props
});
}

public addMetricFilter(id: string, props: logs.MetricFilterOptions): logs.MetricFilter {
return new logs.MetricFilter(this, id, {
logGroup: this,
...props
});
}

public extractMetric(jsonField: string, metricNamespace: string, metricName: string) {
new logs.MetricFilter(this, `${metricNamespace}_${metricName}`, {
logGroup: this,
metricNamespace,
metricName,
filterPattern: logs.FilterPattern.exists(jsonField),
metricValue: jsonField
});

return new metrics.Metric({ metricName, namespace: metricNamespace }).attachTo(this);
}

public grantWrite(grantee: iam.IGrantable) {
return this.grant(grantee, 'logs:CreateLogStream', 'logs:PutLogEvents');
}

public grant(grantee: iam.IGrantable, ...actions: string[]) {
return iam.Grant.addToPrincipal({
grantee,
actions,
resourceArns: [this.logGroupArn],
scope: this,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
"Properties": {
"Code": {
"S3Bucket": {
"Ref": "AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbS3Bucket26B8E770"
"Ref": "AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fS3BucketAD409190"
},
"S3Key": {
"Fn::Join": [
Expand All @@ -146,7 +146,7 @@
"Fn::Split": [
"||",
{
"Ref": "AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbS3VersionKey8B860BBE"
"Ref": "AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fS3VersionKeyAC775F44"
}
]
}
Expand All @@ -159,7 +159,7 @@
"Fn::Split": [
"||",
{
"Ref": "AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbS3VersionKey8B860BBE"
"Ref": "AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fS3VersionKeyAC775F44"
}
]
}
Expand Down Expand Up @@ -331,17 +331,17 @@
}
},
"Parameters": {
"AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbS3Bucket26B8E770": {
"AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fS3BucketAD409190": {
"Type": "String",
"Description": "S3 bucket for asset \"66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfb\""
"Description": "S3 bucket for asset \"f30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2f\""
},
"AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbS3VersionKey8B860BBE": {
"AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fS3VersionKeyAC775F44": {
"Type": "String",
"Description": "S3 key for asset version \"66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfb\""
"Description": "S3 key for asset version \"f30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2f\""
},
"AssetParameters66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfbArtifactHash1C8D5106": {
"AssetParametersf30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2fArtifactHashE9A34B77": {
"Type": "String",
"Description": "Artifact hash for asset \"66b36b09a60a9569c7e68ece07c423f900fda09bfebf230559a4dc2b24562cfb\""
"Description": "Artifact hash for asset \"f30033381ea90291adcf6f9295d04ba5fbab826d5c95119ffdb5674cdbd70b2f\""
}
}
}
27 changes: 26 additions & 1 deletion packages/@aws-cdk/aws-lambda/test/test.function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,30 @@ export = testCase({
code: lambda.Code.fromInline('')
}), /Lambda inline code cannot be empty/);
test.done();
}
},

'logGroup getter throws an exception when both logRetention and exposeLogGroup are unset'(test: Test) {
const stack = new cdk.Stack();
const fn = new lambda.Function(stack, 'fn', {
handler: 'foo',
runtime: lambda.Runtime.NODEJS_10_X,
code: lambda.Code.fromInline('foo'),
});
test.throws(() => fn.logGroup, /LogGroup is not available/);
test.done();
},

'logGroup is available when exposeLogGroup is set'(test: Test) {
const stack = new cdk.Stack();
const fn = new lambda.Function(stack, 'fn', {
handler: 'foo',
runtime: lambda.Runtime.NODEJS_10_X,
code: lambda.Code.fromInline('foo'),
exposeLogGroup: true,
});
const logGroup = fn.logGroup;
test.ok(logGroup.logGroupName);
test.ok(logGroup.logGroupArn);
test.done();
},
});
34 changes: 33 additions & 1 deletion packages/@aws-cdk/aws-lambda/test/test.log-retention-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,5 +263,37 @@ export = {
test.equal(request.isDone(), true);

test.done();
}
},

async 'response data contains the log group name'(test: Test) {
AWS.mock('CloudWatchLogs', 'createLogGroup', sinon.fake.resolves({}));
AWS.mock('CloudWatchLogs', 'putRetentionPolicy', sinon.fake.resolves({}));
AWS.mock('CloudWatchLogs', 'deleteRetentionPolicy', sinon.fake.resolves({}));

const event = {
...eventCommon,
ResourceProperties: {
ServiceToken: 'token',
RetentionInDays: '30',
LogGroupName: 'group'
}
};

async function withOperation(operation: string) {
const request = nock('https://localhost')
.put('/', (body: AWSLambda.CloudFormationCustomResourceResponse) => body.Data?.LogGroupName === 'group')
.reply(200);

const opEvent = { ...event, RequestType: operation };
await provider.handler(opEvent as AWSLambda.CloudFormationCustomResourceCreateEvent, context);

test.equal(request.isDone(), true);
}

await withOperation('Create');
await withOperation('Update');
await withOperation('Delete');

test.done();
},
};
20 changes: 19 additions & 1 deletion packages/@aws-cdk/aws-lambda/test/test.log-retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,23 @@ export = {

test.done();

}
},

'use as a LogGroup'(test: Test) {
const stack = new cdk.Stack();
const group = new LogRetention(stack, 'MyLambda', {
logGroupName: 'group',
retention: logs.RetentionDays.ONE_MONTH,
});

group.addStream('stream');

expect(stack).to(haveResource('AWS::Logs::LogStream', {
LogGroupName: {
'Fn::GetAtt': [ 'MyLambdaCCE802FB', 'LogGroupName' ]
}
}));

test.done();
},
};

0 comments on commit af8ea22

Please sign in to comment.