Repository: serverless/serverless (JavaScript) — Feature

Issue:
API Gateway Websockets API Logs
Implement API Gateway Logs for Websockets.

Refs https://github.com/serverless/serverless/issues/4461


Discussion:

Resolving patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index 4218e236c38..b95dee02b98 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -7,6 +7,7 @@ module.exports = {
     "func-names": "off",
     "global-require": "off", // Interfers with optional and eventual circular references
     "import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*.test.js",  "**/scripts/**", "**/tests/**"]}],
+    "no-use-before-define": "off",
     "react/require-extension": "off", // Forced by airbnb, not applicable (also deprecated)
     "strict": ["error", "safe"], // airbnb implies we're transpiling with babel, we're not
   },
diff --git a/docs/providers/aws/events/websocket.md b/docs/providers/aws/events/websocket.md
index 8d88f4681e1..7df2f1327a3 100644
--- a/docs/providers/aws/events/websocket.md
+++ b/docs/providers/aws/events/websocket.md
@@ -186,3 +186,17 @@ module.exports.defaultHandler = async (event, context) => {
   };
 }
 ```
+
+## Logs
+
+Use the following configuration to enable Websocket logs:
+
+```yml
+# serverless.yml
+provider:
+  name: aws
+  logs:
+    websocket: true
+```
+
+The log streams will be generated in a dedicated log group which follows the naming schema `/aws/websocket/{service}-{stage}`.
diff --git a/docs/providers/aws/guide/serverless.yml.md b/docs/providers/aws/guide/serverless.yml.md
index 413602b474b..ff129c57f99 100644
--- a/docs/providers/aws/guide/serverless.yml.md
+++ b/docs/providers/aws/guide/serverless.yml.md
@@ -128,6 +128,7 @@ provider:
     lambda: true # Optional, can be true (true equals 'Active'), 'Active' or 'PassThrough'
   logs:
     restApi: true # Optional configuration which specifies if API Gateway logs are used
+    websocket: true # Optional configuration which specifies if Websockets logs are used
 
 package: # Optional deployment packaging configuration
   include: # Specify the directories and files which should be included in the deployment package
diff --git a/lib/plugins/aws/deploy/lib/uploadArtifacts.js b/lib/plugins/aws/deploy/lib/uploadArtifacts.js
index e921fce1869..c941f9b42a7 100644
--- a/lib/plugins/aws/deploy/lib/uploadArtifacts.js
+++ b/lib/plugins/aws/deploy/lib/uploadArtifacts.js
@@ -1,7 +1,5 @@
 'use strict';
 
-/* eslint-disable no-use-before-define */
-
 const _ = require('lodash');
 const fs = require('fs');
 const path = require('path');
diff --git a/lib/plugins/aws/lib/naming.js b/lib/plugins/aws/lib/naming.js
index 7ad93bc53b5..694fd1021df 100644
--- a/lib/plugins/aws/lib/naming.js
+++ b/lib/plugins/aws/lib/naming.js
@@ -204,6 +204,15 @@ module.exports = {
   getWebsocketsAuthorizerLogicalId(functionName) {
     return `${this.getNormalizedAuthorizerName(functionName)}WebsocketsAuthorizer`;
   },
+  getWebsocketsLogGroupLogicalId() {
+    return 'WebsocketsLogGroup';
+  },
+  getWebsocketsLogsRoleLogicalId() {
+    return 'IamRoleWebsocketsLogs';
+  },
+  getWebsocketsAccountLogicalId() {
+    return 'WebsocketsAccount';
+  },
 
   // API Gateway
   getApiGatewayName() {
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/hack/updateStage.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/hack/updateStage.js
index 525a0819815..0aa14e06ba3 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/hack/updateStage.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/hack/updateStage.js
@@ -1,5 +1,3 @@
-/* eslint-disable no-use-before-define */
-
 'use strict';
 
 const _ = require('lodash');
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/stage.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/stage.js
index 3fb97854804..e32df1ad3e7 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/stage.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/stage.js
@@ -1,8 +1,7 @@
-/* eslint-disable no-use-before-define */
-/* eslint-disable max-len */
-
 'use strict';
 
+/* eslint-disable max-len */
+
 // NOTE: --> Keep this file in sync with ./hack/updateStage.js
 
 const _ = require('lodash');
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/stage.js b/lib/plugins/aws/package/compile/events/websockets/lib/stage.js
index eecaa15798c..ae2472565b1 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/stage.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/stage.js
@@ -1,30 +1,139 @@
 'use strict';
 
-const _ = require('lodash');
 const BbPromise = require('bluebird');
 
 module.exports = {
   compileStage() {
-    const websocketsStageLogicalId = this.provider.naming
+    const { service, provider } = this.serverless.service;
+    const stage = this.options.stage;
+    const cfTemplate = provider.compiledCloudFormationTemplate;
+
+    // logs
+    const logsEnabled = provider.logs && provider.logs.websocket;
+
+    const stageLogicalId = this.provider.naming
       .getWebsocketsStageLogicalId();
+    const logGroupLogicalId = this.provider.naming
+      .getWebsocketsLogGroupLogicalId();
+    const logsRoleLogicalId = this.provider.naming
+      .getWebsocketsLogsRoleLogicalId();
+    const accountLogicalid = this.provider.naming
+      .getWebsocketsAccountLogicalId();
 
-    _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, {
-      [websocketsStageLogicalId]: {
-        Type: 'AWS::ApiGatewayV2::Stage',
-        Properties: {
-          ApiId: {
-            Ref: this.websocketsApiLogicalId,
-          },
-          DeploymentId: {
-            Ref: this.websocketsDeploymentLogicalId,
-          },
-          StageName: this.provider.getStage(),
-          Description: this.serverless.service.provider
-            .websocketsDescription || 'Serverless Websockets',
+    const stageResource = {
+      Type: 'AWS::ApiGatewayV2::Stage',
+      Properties: {
+        ApiId: {
+          Ref: this.websocketsApiLogicalId,
+        },
+        DeploymentId: {
+          Ref: this.websocketsDeploymentLogicalId,
         },
+        StageName: this.provider.getStage(),
+        Description: this.serverless.service.provider
+          .websocketsDescription || 'Serverless Websockets',
       },
-    });
+    };
+
+    // create log-specific resources
+    if (logsEnabled) {
+      Object.assign(stageResource.Properties, {
+        AccessLogSettings: {
+          DestinationArn: {
+            'Fn::GetAtt': [
+              logGroupLogicalId,
+              'Arn',
+            ],
+          },
+          Format: [
+            '$context.identity.sourceIp',
+            '$context.identity.caller',
+            '$context.identity.user',
+            '[$context.requestTime]',
+            '"$context.eventType $context.routeKey $context.connectionId"',
+            '$context.status',
+            '$context.requestId',
+          ].join(' '),
+        },
+        DefaultRouteSettings: {
+          DataTraceEnabled: true,
+          LoggingLevel: 'INFO',
+        },
+      });
+
+      Object.assign(cfTemplate.Resources, {
+        [logGroupLogicalId]: getLogGroupResource(service, stage),
+        [logsRoleLogicalId]: getIamRoleResource(service, stage),
+        [accountLogicalid]: getAccountResource(logsRoleLogicalId),
+      });
+    }
+
+    Object.assign(cfTemplate.Resources, { [stageLogicalId]: stageResource });
 
     return BbPromise.resolve();
   },
 };
+
+function getLogGroupResource(service, stage) {
+  return ({
+    Type: 'AWS::Logs::LogGroup',
+    Properties: {
+      LogGroupName: `/aws/websocket/${service}-${stage}`,
+    },
+  });
+}
+
+function getIamRoleResource(service, stage) {
+  return ({
+    Type: 'AWS::IAM::Role',
+    Properties: {
+      AssumeRolePolicyDocument: {
+        Version: '2012-10-17',
+        Statement: [
+          {
+            Effect: 'Allow',
+            Principal: {
+              Service: [
+                'apigateway.amazonaws.com',
+              ],
+            },
+            Action: [
+              'sts:AssumeRole',
+            ],
+          },
+        ],
+      },
+      ManagedPolicyArns: [
+        'arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs',
+      ],
+      Path: '/',
+      RoleName: {
+        'Fn::Join': [
+          '-',
+          [
+            service,
+            stage,
+            {
+              Ref: 'AWS::Region',
+            },
+            'apiGatewayLogsRole',
+          ],
+        ],
+      },
+    },
+  });
+}
+
+function getAccountResource(logsRoleLogicalId) {
+  return ({
+    Type: 'AWS::ApiGateway::Account',
+    Properties: {
+      CloudWatchRoleArn: {
+        'Fn::GetAtt': [
+          logsRoleLogicalId,
+          'Arn',
+        ],
+      },
+    },
+  });
+}
diff --git a/lib/plugins/package/lib/zipService.js b/lib/plugins/package/lib/zipService.js
index b723819421c..9b7137c271d 100644
--- a/lib/plugins/package/lib/zipService.js
+++ b/lib/plugins/package/lib/zipService.js
@@ -1,7 +1,5 @@
 'use strict';
 
-/* eslint-disable no-use-before-define */
-
 const BbPromise = require('bluebird');
 const archiver = require('archiver');
 const os = require('os');
diff --git a/scripts/postinstall.js b/scripts/postinstall.js
index 1a6324cefcf..823bed85492 100644
--- a/scripts/postinstall.js
+++ b/scripts/postinstall.js
@@ -3,7 +3,6 @@
 const path = require('path');
 
 /* eslint-disable no-console */
-/* eslint-disable no-use-before-define */
 
 const Serverless = require('../lib/Serverless');
 const execSync = require('child_process').execSync;


Test patch:
diff --git a/lib/plugins/aws/lib/naming.test.js b/lib/plugins/aws/lib/naming.test.js
index 78f9a6b2377..1b10d0198e8 100644
--- a/lib/plugins/aws/lib/naming.test.js
+++ b/lib/plugins/aws/lib/naming.test.js
@@ -302,6 +302,24 @@ describe('#naming()', () => {
     });
   });
 
+  describe('#getWebsocketsLogGroupLogicalId()', () => {
+    it('should return the Websockets log group logical id', () => {
+      expect(sdk.naming.getWebsocketsLogGroupLogicalId()).to.equal('WebsocketsLogGroup');
+    });
+  });
+
+  describe('#getWebsocketsLogsRoleLogicalId()', () => {
+    it('should return the Websockets logs IAM role logical id', () => {
+      expect(sdk.naming.getWebsocketsLogsRoleLogicalId()).to.equal('IamRoleWebsocketsLogs');
+    });
+  });
+
+  describe('#getWebsocketsAccountLogicalId()', () => {
+    it('should return the Websockets account logical id', () => {
+      expect(sdk.naming.getWebsocketsAccountLogicalId()).to.equal('WebsocketsAccount');
+    });
+  });
+
   describe('#getApiGatewayName()', () => {
     it('should return the composition of stage & service name if custom name not provided', () => {
       serverless.service.service = 'myService';
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/stage.test.js b/lib/plugins/aws/package/compile/events/websockets/lib/stage.test.js
index 01dff2d813e..1dc2bdb0ed2 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/stage.test.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/stage.test.js
@@ -7,14 +7,30 @@ const AwsProvider = require('../../../../../provider/awsProvider');
 
 describe('#compileStage()', () => {
   let awsCompileWebsocketsEvents;
+  let stageLogicalId;
+  let accountLogicalid;
+  let logsRoleLogicalId;
+  let logGroupLogicalId;
 
   beforeEach(() => {
+    const options = {
+      stage: 'dev',
+      region: 'us-east-1',
+    };
     const serverless = new Serverless();
     serverless.setProvider('aws', new AwsProvider(serverless));
+    serverless.service.service = 'my-service';
     serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} };
 
-    awsCompileWebsocketsEvents = new AwsCompileWebsocketsEvents(serverless);
-
+    awsCompileWebsocketsEvents = new AwsCompileWebsocketsEvents(serverless, options);
+    stageLogicalId = awsCompileWebsocketsEvents.provider.naming
+      .getWebsocketsStageLogicalId();
+    accountLogicalid = awsCompileWebsocketsEvents.provider.naming
+      .getWebsocketsAccountLogicalId();
+    logsRoleLogicalId = awsCompileWebsocketsEvents.provider.naming
+      .getWebsocketsLogsRoleLogicalId();
+    logGroupLogicalId = awsCompileWebsocketsEvents.provider.naming
+      .getWebsocketsLogGroupLogicalId();
     awsCompileWebsocketsEvents.websocketsApiLogicalId
       = awsCompileWebsocketsEvents.provider.naming.getWebsocketsApiLogicalId();
     awsCompileWebsocketsEvents.websocketsDeploymentLogicalId
@@ -26,13 +42,142 @@ describe('#compileStage()', () => {
       .compiledCloudFormationTemplate.Resources;
     const resourceKeys = Object.keys(resources);
 
-    expect(resourceKeys[0]).to.equal('WebsocketsDeploymentStage');
+    expect(resourceKeys[0]).to.equal(stageLogicalId);
     expect(resources.WebsocketsDeploymentStage.Type).to.equal('AWS::ApiGatewayV2::Stage');
     expect(resources.WebsocketsDeploymentStage.Properties.ApiId).to.deep.equal({
-      Ref: 'WebsocketsApi',
+      Ref: awsCompileWebsocketsEvents.websocketsApiLogicalId,
+    });
+    expect(resources.WebsocketsDeploymentStage.Properties.DeploymentId).to.deep.equal({
+      Ref: awsCompileWebsocketsEvents.websocketsDeploymentLogicalId,
     });
     expect(resources.WebsocketsDeploymentStage.Properties.StageName).to.equal('dev');
     expect(resources.WebsocketsDeploymentStage.Properties.Description)
       .to.equal('Serverless Websockets');
   }));
+
+  describe('logs', () => {
+    beforeEach(() => {
+      // setting up Websocket logs
+      awsCompileWebsocketsEvents.serverless.service.provider.logs = {
+        websocket: true,
+      };
+    });
+
+    it('should create a dedicated stage resource if logs are configured', () =>
+      awsCompileWebsocketsEvents.compileStage().then(() => {
+        const resources = awsCompileWebsocketsEvents.serverless.service.provider
+          .compiledCloudFormationTemplate.Resources;
+
+        expect(resources[stageLogicalId]).to.deep.equal({
+          Type: 'AWS::ApiGatewayV2::Stage',
+          Properties: {
+            ApiId: {
+              Ref: awsCompileWebsocketsEvents.websocketsApiLogicalId,
+            },
+            DeploymentId: {
+              Ref: awsCompileWebsocketsEvents.websocketsDeploymentLogicalId,
+            },
+            StageName: 'dev',
+            Description: 'Serverless Websockets',
+            AccessLogSettings: {
+              DestinationArn: {
+                'Fn::GetAtt': [
+                  logGroupLogicalId,
+                  'Arn',
+                ],
+              },
+              Format: [
+                '$context.identity.sourceIp',
+                '$context.identity.caller',
+                '$context.identity.user',
+                '[$context.requestTime]',
+                '"$context.eventType $context.routeKey $context.connectionId"',
+                '$context.status',
+                '$context.requestId',
+              ].join(' '),
+            },
+            DefaultRouteSettings: {
+              DataTraceEnabled: true,
+              LoggingLevel: 'INFO',
+            },
+          },
+        });
+      }));
+
+    it('should create a Log Group resource', () =>
+      awsCompileWebsocketsEvents.compileStage().then(() => {
+        const resources = awsCompileWebsocketsEvents.serverless.service.provider
+          .compiledCloudFormationTemplate.Resources;
+
+        expect(resources[logGroupLogicalId]).to.deep.equal({
+          Type: 'AWS::Logs::LogGroup',
+          Properties: {
+            LogGroupName: '/aws/websocket/my-service-dev',
+          },
+        });
+      }));
+
+    it('should create a IAM Role resource', () =>
+      awsCompileWebsocketsEvents.compileStage().then(() => {
+        const resources = awsCompileWebsocketsEvents.serverless.service.provider
+          .compiledCloudFormationTemplate.Resources;
+
+        expect(resources[logsRoleLogicalId]).to.deep.equal({
+          Type: 'AWS::IAM::Role',
+          Properties: {
+            AssumeRolePolicyDocument: {
+              Statement: [
+                {
+                  Action: [
+                    'sts:AssumeRole',
+                  ],
+                  Effect: 'Allow',
+                  Principal: {
+                    Service: [
+                      'apigateway.amazonaws.com',
+                    ],
+                  },
+                },
+              ],
+              Version: '2012-10-17',
+            },
+            ManagedPolicyArns: [
+              'arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs',
+            ],
+            Path: '/',
+            RoleName: {
+              'Fn::Join': [
+                '-',
+                [
+                  'my-service',
+                  'dev',
+                  {
+                    Ref: 'AWS::Region',
+                  },
+                  'apiGatewayLogsRole',
+                ],
+              ],
+            },
+          },
+        });
+      }));
+
+    it('should create an Account resource', () =>
+      awsCompileWebsocketsEvents.compileStage().then(() => {
+        const resources = awsCompileWebsocketsEvents.serverless.service.provider
+          .compiledCloudFormationTemplate.Resources;
+
+        expect(resources[accountLogicalid]).to.deep.equal({
+          Type: 'AWS::ApiGateway::Account',
+          Properties: {
+            CloudWatchRoleArn: {
+              'Fn::GetAtt': [
+                logsRoleLogicalId,
+                'Arn',
+              ],
+            },
+          },
+        });
+      }));
+  });
 });