Designing dashboards

Choose your metrics on a big screen

A CloudWatch dashboard is how you tell CloudPeek which metrics to show. Design it in the AWS console with CloudWatch's own metric picker, math and thresholds, then keep it in CloudFormation or Terraform alongside the services it watches.

Updated 13 October 2026

Why a dashboard?

CloudPeek is about your metrics: latency, error rates, queue depth, spend, the custom metrics your services publish, and the alarms that watch them. It doesn't ask you to pick those metrics on your phone. Finding the right namespace and dimensions, writing metric math and setting thresholds is much easier with a keyboard and a big screen, and CloudWatch already has the tool for it.

So a CloudWatch dashboard is simply your list of what to watch:

  • Each widget is something you can pin. A widget groups one or more metrics: three instances' CPU on one line chart, your five biggest costs in one bar chart, an error rate worked out from two metrics in one number.
  • Each dashboard is a set of widgets that belong together. One per service, one for spend, one for the on-call rotation. You can pin from as many dashboards and accounts as you like.
  • A dashboard is plain JSON, so it can be defined in CloudFormation, Terraform, the AWS CDK or anything else that can write a string, and deployed with the services it watches.

Already have dashboards? Pin straight from them. Or make a small one just for your phone, with only the widgets you want to see at a glance.

Design it in the AWS console

  1. Open the CloudWatch console, choose Dashboards › Create dashboard, and name it.
  2. Choose a widget type: Line, Stacked area, Number, Gauge, Bar, Pie, Table, Text or Alarm status. All of them work in CloudPeek (see the widget reference).
  3. On the Browse tab, pick a namespace, such as ApplicationELB or one of your own, then tick the metrics you want on this widget.
  4. On the Graphed metrics tab, set each metric's statistic, period and label. Choose Add math for an expression such as an error rate, and untick the inputs you don't want drawn.
  5. On the Options tab, add horizontal annotations for thresholds and set the y-axis limits. A gauge needs both a minimum and a maximum.
  6. Choose Create widget, give it a short title, and Save the dashboard.

Open the dashboard in CloudPeek and every widget is drawn as it will look on your Home Screen. Pin the ones you want.

Make it read well on a phone

A widget on your Home Screen is a few centimetres across. A few habits make it much easier to read:

  • One question per widget. “Is the API healthy?” reads better as an error-rate number and a latency gauge than as one graph with eight lines.
  • Few series per graph. A medium widget's legend lists two series and a large one four, then “+N more”. A small widget has no legend at all.
  • Short titles. The Lock Screen's inline widget keeps about 18 characters of the title, and the circular one about 8.
  • Pick the view for the glance. A number with "sparkline": true shows the latest value and its trend; a gauge shows it against a range you set; a bar chart ranks things. See what each type looks like.
  • Set the range you care about. A widget's own start overrides the dashboard's, and with neither CloudPeek shows the last 3 hours. Every widget says which window it covers.
  • Labels over IDs. Give metrics a label, or a dynamic one like ${PROP('Dim.ServiceName')}, so the legend says “checkout” rather than “i-0a1b2c3d”.

Example: an API health dashboard

Three widgets for an Application Load Balancer: the error rate as a number, p99 latency as a gauge, and requests against errors on a line chart. In CloudPeek they look like this:

The error rate is worked out from two hidden metrics; the 5xx line has its own scale on the right.
api-health.jsonDownload
{
  "start": "-PT3H",
  "widgets": [
    {
      "type": "metric",
      "x": 0,
      "y": 0,
      "width": 6,
      "height": 6,
      "properties": {
        "title": "5xx error rate",
        "view": "singleValue",
        "region": "us-east-1",
        "period": 300,
        "sparkline": true,
        "metrics": [
          [ { "expression": "100 * FILL(m2, 0) / m1", "label": "Errors (%)", "id": "e1" } ],
          [ "AWS/ApplicationELB", "RequestCount", "LoadBalancer", "app/checkout-api/50dc6c495c0c9188", { "stat": "Sum", "id": "m1", "visible": false } ],
          [ ".", "HTTPCode_Target_5XX_Count", ".", ".", { "stat": "Sum", "id": "m2", "visible": false } ]
        ]
      }
    },
    {
      "type": "metric",
      "x": 6,
      "y": 0,
      "width": 6,
      "height": 6,
      "properties": {
        "title": "Latency p99",
        "view": "gauge",
        "region": "us-east-1",
        "stat": "p99",
        "period": 300,
        "yAxis": { "left": { "min": 0, "max": 2 } },
        "metrics": [
          [ "AWS/ApplicationELB", "TargetResponseTime", "LoadBalancer", "app/checkout-api/50dc6c495c0c9188", { "label": "p99 (s)" } ]
        ]
      }
    },
    {
      "type": "metric",
      "x": 12,
      "y": 0,
      "width": 12,
      "height": 6,
      "properties": {
        "title": "Requests and errors",
        "view": "timeSeries",
        "region": "us-east-1",
        "stat": "Sum",
        "period": 300,
        "metrics": [
          [ "AWS/ApplicationELB", "RequestCount", "LoadBalancer", "app/checkout-api/50dc6c495c0c9188", { "label": "Requests" } ],
          [ ".", "HTTPCode_Target_5XX_Count", ".", ".", { "label": "5xx", "yAxis": "right", "color": "#d62728" } ]
        ]
      }
    }
  ]
}
  • Error rate: the expression 100 * FILL(m2, 0) / m1 divides 5xx responses by requests. FILL counts a period with no errors as zero rather than leaving a gap. Both inputs have "visible": false, so only the rate is shown.
  • Latency: a gauge uses the y-axis min and max as its range (0 to 2 seconds here). Without them, CloudPeek assumes 0 to 100.
  • Requests and errors: "yAxis": "right" gives the 5xx count its own scale, so a handful of errors isn't flattened against thousands of requests. "." repeats the value in the same place on the row above.
  • Replace app/checkout-api/50dc6c495c0c9188 with your load balancer's name from its ARN, after loadbalancer/, or let CloudFormation or Terraform fill it in, as below.

Manage it as code

Design a dashboard in the console until it looks right, then copy its JSON from Actions › View/edit source into your infrastructure code. From then on it's reviewed, versioned and deployed with the services it watches, and it's the same in every account and region you deploy to.

You don't have to put dashboards in code. A dashboard you edit in the console is picked up by CloudPeek on its next refresh, just the same.

If a dashboard is managed by CloudFormation or Terraform, treat the console as a sketchpad: changes you save there are overwritten on the next deploy. Try an idea in the console, then copy the source back into your code (Actions › View/edit source).

Keep the JSON in version control and deploy it with put-dashboard. To start from a dashboard you designed in the console:

Shell
aws cloudwatch get-dashboard \
  --dashboard-name Checkout-API \
  --query DashboardBody --output text > api-health.json

aws cloudwatch put-dashboard \
  --dashboard-name Checkout-API \
  --dashboard-body file://api-health.json

put-dashboard replaces the whole dashboard, so the file is always the complete truth.

An AWS::CloudWatch::Dashboard takes the JSON as a string. Wrap it in !Sub and you can fill in values from the same stack, such as the load balancer's dimension, instead of pasting IDs:

CloudFormation
  CheckoutApiDashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: Checkout-API
      DashboardBody: !Sub
        - |
          {
            "start": "-PT3H",
            "widgets": [
              {
                "type": "metric", "x": 0, "y": 0, "width": 6, "height": 6,
                "properties": {
                  "title": "5xx error rate",
                  "view": "singleValue",
                  "region": "${AWS::Region}",
                  "period": 300,
                  "sparkline": true,
                  "metrics": [
                    [ { "expression": "100 * FILL(m2, 0) / m1", "label": "Errors (%)", "id": "e1" } ],
                    [ "AWS/ApplicationELB", "RequestCount", "LoadBalancer", "${Alb}", { "stat": "Sum", "id": "m1", "visible": false } ],
                    [ ".", "HTTPCode_Target_5XX_Count", ".", ".", { "stat": "Sum", "id": "m2", "visible": false } ]
                  ]
                }
              },
              {
                "type": "metric", "x": 6, "y": 0, "width": 6, "height": 6,
                "properties": {
                  "title": "Latency p99",
                  "view": "gauge",
                  "region": "${AWS::Region}",
                  "stat": "p99",
                  "period": 300,
                  "yAxis": { "left": { "min": 0, "max": 2 } },
                  "metrics": [
                    [ "AWS/ApplicationELB", "TargetResponseTime", "LoadBalancer", "${Alb}", { "label": "p99 (s)" } ]
                  ]
                }
              },
              {
                "type": "metric", "x": 12, "y": 0, "width": 12, "height": 6,
                "properties": {
                  "title": "Requests and errors",
                  "view": "timeSeries",
                  "region": "${AWS::Region}",
                  "stat": "Sum",
                  "period": 300,
                  "metrics": [
                    [ "AWS/ApplicationELB", "RequestCount", "LoadBalancer", "${Alb}", { "label": "Requests" } ],
                    [ ".", "HTTPCode_Target_5XX_Count", ".", ".", { "label": "5xx", "yAxis": "right", "color": "#d62728" } ]
                  ]
                }
              }
            ]
          }
        - Alb: !GetAtt LoadBalancer.LoadBalancerFullName
  • LoadBalancer is the AWS::ElasticLoadBalancingV2::LoadBalancer in the same template; its LoadBalancerFullName is exactly the value CloudWatch's LoadBalancer dimension wants.
  • Inside !Sub, CloudFormation reads every ${…} as one of its own variables. Write a CloudWatch dynamic label such as ${LAST} as ${!LAST}.
  • Dashboards are global, but each widget names its region. ${AWS::Region} uses the stack's.

An aws_cloudwatch_dashboard takes the JSON as a string. Write it in HCL and wrap it in jsonencode, and you can use references, variables and for expressions instead of pasting IDs:

Terraform
resource "aws_lb" "checkout" {
  name    = "checkout-api"
  subnets = ["subnet-11111111", "subnet-22222222"]
}

locals {
  alb    = aws_lb.checkout.arn_suffix # the LoadBalancer dimension: app/checkout-api/…
  region = "us-east-1"                # where the load balancer is
}

resource "aws_cloudwatch_dashboard" "checkout_api" {
  dashboard_name = "Checkout-API"
  dashboard_body = jsonencode({
    start = "-PT3H"
    widgets = [
      {
        type = "metric", x = 0, y = 0, width = 6, height = 6
        properties = {
          title     = "5xx error rate"
          view      = "singleValue"
          region    = local.region
          period    = 300
          sparkline = true
          metrics = [
            [{ expression = "100 * FILL(m2, 0) / m1", label = "Errors (%)", id = "e1" }],
            ["AWS/ApplicationELB", "RequestCount", "LoadBalancer", local.alb, { stat = "Sum", id = "m1", visible = false }],
            [".", "HTTPCode_Target_5XX_Count", ".", ".", { stat = "Sum", id = "m2", visible = false }],
          ]
        }
      },
      {
        type = "metric", x = 6, y = 0, width = 6, height = 6
        properties = {
          title  = "Latency p99"
          view   = "gauge"
          region = local.region
          stat   = "p99"
          period = 300
          yAxis  = { left = { min = 0, max = 2 } }
          metrics = [
            ["AWS/ApplicationELB", "TargetResponseTime", "LoadBalancer", local.alb, { label = "p99 (s)" }],
          ]
        }
      },
      {
        type = "metric", x = 12, y = 0, width = 12, height = 6
        properties = {
          title  = "Requests and errors"
          view   = "timeSeries"
          region = local.region
          stat   = "Sum"
          period = 300
          metrics = [
            ["AWS/ApplicationELB", "RequestCount", "LoadBalancer", local.alb, { label = "Requests" }],
            [".", "HTTPCode_Target_5XX_Count", ".", ".", { label = "5xx", yAxis = "right", color = "#d62728" }],
          ]
        }
      },
    ]
  })
}
  • arn_suffix on an aws_lb is exactly the value CloudWatch's LoadBalancer dimension wants.
  • Terraform reads ${…} in a string as an interpolation. Write a CloudWatch dynamic label such as ${LAST} as $${LAST}.
  • Prefer to keep the JSON as a file? dashboard_body = file("api-health.json") works too, or templatefile() to fill in values.
  • To bring a dashboard you made in the console under Terraform, add an import block with its name as the id, or run terraform import aws_cloudwatch_dashboard.checkout_api Checkout-API.

The AWS CDK, Pulumi and other tools work the same way: anything that can set a dashboard's body can define what CloudPeek shows.

How changes reach your phone

  • Each time a pinned widget refreshes from AWS, CloudPeek reads the dashboard again, so a change you save in the console or deploy from code shows on the next refresh. To see it at once, tap the widget's refresh button.
  • Pinned widgets follow their widget when you move it, resize it, retitle it or change its metrics. If CloudPeek can't tell which widget it was, it keeps showing the version you pinned.
  • If you delete the dashboard, the widget keeps its last data and says Dashboard deleted.
  • Pinning the same widget again reconnects any Home Screen widgets that showed it.