How-To Deploy a CloudWatch Monitoring Dashboard with Serverless

How-To Deploy a CloudWatch Monitoring Dashboard with Serverless

CloudWatch monitoring dashboards come in really handy, when you are operating serverless applications on AWS. After manually setting up a nice new monitoring dashboard in CloudWatch with a colleague we came up with the idea of automating the deployment of this dashboard through Serverless.

This would be really cool, as every stage could then have its own monitoring dashboard automatically attached and our team could perform monitoring on every stage during development with little to no additional costs. We managed to achieve this after overcoming some unexpected problems, that others might also encounter. So feel free to profit from our learnings!

In this article I will explain and demonstrate ...

  • ... how a monitoring dashboard can be deployed through the Serverless framework with no additional plugins installed
  • ... how monitoring configurations can be stored in an external file to keep your serverless.yml short and clean
  • ... how you can dynamically write resource names including (for example) the current stage name in the JSON configuration of your dashboards, so you can have distinct dashboards for every deployment stage

After a quick assessment (Hey Google!) we achieved to deploy a simple CloudWatch dashboard by defining a AWS::CloudWatch::Dashboard resource in the Resources scope of our serverless.yml, which CloudFormation can interpret, when we deploy our serverless application. In order to ensure, that we have a dedicated dashboard for every deployment stage, we included the stage name in the DashboardName. How the dashboard will load the metrics of those resources deployed on the same stage will be addressed later in this article.

resources:
  SomeMonitoringDashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: My-First-Dashboard-${self:provider.stage}
      DashboardBody: `
      {
        "widgets": [
          {
            "type": "text",
            "x": 0,
            "y": 0,
            "width": 6,
            "height": 1,
            "properties": {
              "markdown": "\n# Hello World!\n"
            }
          }
        ]
      }`

While this setup works even for bigger and more elaborate monitoring dashboards (like the one we have created 😎), putting the whole dashboard configuration in your serverless.yml is not a very good idea, as this would blow up the complexity of this file. We always try to keep the Serverless file as clean and short as possible and our monitoring dashboards should not make up the larger part of the main configuration file of our whole application.

Therefore, we tried to keep the big JSON configuration of our beautiful dashboard in an external JSON file and load it into the main serverless.yml through the ${file()} command. This however was much harder to achieve, than we expected, because DashboardBody expects a string value and ${file()} will convert and place the content of a JSON file as YAML. Even wrapping ${file()} in quotation marks or storing the JSON in a plain .txt file does not prevent this from happening.

resources:
  SomeMonitoringDashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: My-First-Dashboard-${self:provider.stage}
      DashboardBody: `${file(./dashboard.json)}`

Serverless Error ---------------------------------------
 
An error occurred: SomeMonitoringDashboard - Property validation failure: [Value of property {/DashboardBody} does not match type {String}].

Luckily ${file()} has some hidden superpowers, as it can make a call to a function exported by some JS file and put the returned value in place without any further modifications. So our workaround is to store the JSON configuration in a JS file instead and export a stringified version of it through module.exports.

const dashboard = {
  widgets: [
    {
      type: 'text',
      x: 0,
      y: 0,
      width: 6,
      height: 1,
      properties: {
        markdown: '\n# Hello World!\n'
      },
    },
  ],
}

module.exports.toString = () => JSON.stringify(dashboard)

This allows us to load the configuration for our CloudWatch monitoring dashboard through ${file()} into serverless.yml, which in return enables Serverless to create the dashboard for us through CloudFormation, while still keeping our serverless.yml small and clean 🎉.

resources:
  SomeMonitoringDashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: My-First-Dashboard-${self:provider.stage}
      DashboardBody: ${file(./dashboard.js):toString}

Lastly, our monitoring dashboard was supposed to show metrics and logs of multiple Lambdas and SQS queues, whose names include the name of the stage they are deployed on. Obviously we wanted the monitoring dashboard for the dev stage to display all of the reources deployed on dev and not those deployed on prod. Unfortunately we did not find a solution for accessing the resource names automatically generated by CloudFormation through Serverless, so we came up with two different solutions instead.

  1. We could write the resource names directly in the configuration of our monitoring dashboard while only setting the stage part of them through ${self:provider.stage}. This is possible as CloudFormation generates those names in a predictable way.
  2. We could introduce new variables storing the names of those resources we will monitor on our dashboard and manually set the names of the resources in our serverless.yml and the configuration JSON for the monitoring dashboard.

In the end we chose the latter of both options, because this would allow us to change the names of those resources later, by just altering the value of one variable, instead of also having to change multiple lines in our monitoring dashboard configuration.

By the end of the day our configuration looked somewhat like this (I can not share the fancy dashboard we have created, but you will get the idea):

# serverless.yml

service: sam-services

provider:
  name: aws
  region: eu-central-1
  stage: ${opt:stage, 'dev'}
  # ...
  
custom:
  someLambdaHandlerFunctionName: ${self:service.name}-${self:provider.stage}-someLambdaHandler
  # ...
  
functions:
  someLambdaHandler:
    name: ${self:custom.someLambdaHandlerFunctionName}
    # ...
    
resources:
  SomeMonitoringDashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: ${self:service.name}-${self:provider.stage}
      DashboardBody: ${file(./dashboard.js):toString}

// dashboard.js

const dashboard = {
  widgets: [
    {
      type: 'metric',
      x: 0,
      y: 1,
      width: 3,
      height: 3,
      properties: {
        metrics: [
          ['AWS/Lambda', 'Errors', 'FunctionName', '${self:custom.someLambdaHandlerFunctionName}', { label: '#' }],
        ],
        view: 'singleValue',
        region: 'eu-central-1',
        stat: 'Sum',
        period: 3600,
        stacked: true,
        title: 'Errors',
        setPeriodToTimeRange: true,
      },
    },
  ],
}

module.exports.toString = () => JSON.stringify(dashboard)

Thanks for reading this article and happy monitoring!


Share this article


Subscribe for more

Check your inbox and click the link to confirm your subscription.
Please enter a valid email address!