Provisioning CloudWatch Dashboards for Tile Latency
Most tile platform dashboards are built for the wrong reader. They show aggregate latency, total requests and a CPU graph, which is the view you want when writing a monthly report and useless at 3am when a page has fired and you have four minutes before someone asks what is happening. A dashboard for a tile platform should answer, in order: is it the cache, is it the database, or is it one layer — and it should answer each in a single glance without anyone typing a query. Provisioning it from the same module as the service is what makes it exist the moment the service does. This guide extends Observability for Spatial Infrastructure within CI/CD Automation and Governance.
Designing for the questions, not the metrics
The failure of most dashboards is that they show what is easy to plot. Design instead from the diagnostic sequence a responder actually follows on a tile platform, which is remarkably consistent.
Is the cache still absorbing traffic? The hit ratio is the single most informative number, because a fall from 95 to 80 per cent quadruples origin load with no change in user traffic. This panel goes top-left, where the eye lands first.
Is the origin saturated, and on what? Connection pool utilisation against capacity, not renderer CPU — the pool is the ceiling that binds first, and it fails as a cliff rather than a slope, so it must be visible before it is reached.
Is it one layer or all of them? Latency broken down by layer and zoom band. An aggregate that hides a single pathological layer is the reason a responder ends up querying logs by hand.
Is it the database? Read latency and active connections at the cluster, alongside the top statements by total time. A slow tile query is usually a slow spatial query.
Did something change? A deployment annotation overlay. Half of all incidents correlate with a change, and a dashboard that shows deployments answers that question before anyone has to ask in a channel.
Prerequisites and environment assumptions
Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. Metrics emitted with the bounded dimensions from Observability for Spatial Infrastructure — zoom band, layer, cache result — because every panel below depends on them and none of it is possible against aggregates. A deployment event stream to annotate against.
Decide the time range and refresh before building. A dashboard defaulting to a week is a reporting dashboard; one defaulting to three hours is an incident dashboard. If you need both, build both — a single dashboard tuned for two purposes serves neither, and duplicating a panel is cheap while a misread graph at 3am is not.
Step-by-step implementation
-
Compute derived values in metric expressions, not in the reader’s head. Hit ratio as a percentage, pool utilisation as a percentage of capacity. A panel showing hits and misses as two lines requires arithmetic under pressure, and the arithmetic gets done wrong.
-
Break latency down by the dimensions that matter and cap the series count. Layer and zoom band, with the top few by latency rather than every combination — a panel with forty lines communicates nothing.
-
Put the database beside the tile tier, not on a separate dashboard. The whole point is answering “is it the database” without navigating, and a separate dashboard adds a navigation step at the worst moment.
-
Add annotations for deployments and for alarm state. Alarm annotations mean the responder sees which alarm fired and when, in the same view as the metrics.
-
Generate the dashboard from the same module as the service. A dashboard provisioned alongside the resource exists the moment the resource does, and a deleted panel is drift rather than an unnoticed gap.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.60" }
}
}
variable "service_name" { type = string }
variable "cluster_id" { type = string }
variable "distribution_id" { type = string }
resource "aws_cloudwatch_dashboard" "tiles" {
dashboard_name = "${var.service_name}-tiles"
dashboard_body = jsonencode({
# Three hours: this is an incident dashboard. Reporting views get their own.
start = "-PT3H"
periodOverride = "auto"
widgets = [
{
type = "metric", x = 0, y = 0, width = 12, height = 6
properties = {
title = "Cache hit ratio — the first question"
# Derived here, not in the reader's head. Two raw lines would require
# arithmetic under pressure, and the arithmetic gets done wrong.
metrics = [
[{ expression = "100 * hits / IF(requests > 0, requests, 1)", label = "hit ratio %", id = "ratio" }],
["AWS/CloudFront", "CacheHitRate", "DistributionId", var.distribution_id, { id = "hits", visible = false }],
["AWS/CloudFront", "Requests", "DistributionId", var.distribution_id, { id = "requests", visible = false }],
]
yAxis = { left = { min = 0, max = 100 } }
annotations = {
horizontal = [{ label = "origin load doubles below here", value = 90 }]
}
view = "timeSeries", stat = "Sum", region = data.aws_region.current.name
}
},
{
type = "metric", x = 12, y = 0, width = 12, height = 6
properties = {
title = "Connection pool utilisation — the ceiling that binds first"
metrics = [
[{ expression = "100 * active / IF(capacity > 0, capacity, 1)", label = "pool used %", id = "util" }],
["SpatialPlatform/Tiles", "ActiveConnections", { id = "active", visible = false }],
["SpatialPlatform/Tiles", "PoolCapacity", { id = "capacity", visible = false }],
]
# Saturation is a cliff. The line must be visible before it is reached.
annotations = { horizontal = [{ label = "saturation", value = 90, fill = "above" }] }
yAxis = { left = { min = 0, max = 100 } }
view = "timeSeries", region = data.aws_region.current.name
}
},
{
type = "metric", x = 0, y = 6, width = 24, height = 6
properties = {
title = "p99 origin latency by zoom band — is it one layer, or all of them?"
metrics = [
["SpatialPlatform/Tiles", "OriginLatency", "ZoomBand", "z0-z8", { label = "z0-z8" }],
["...", "ZoomBand", "z9-z12", { label = "z9-z12" }],
["...", "ZoomBand", "z13-z16", { label = "z13-z16 (dense)" }],
["...", "ZoomBand", "z17+", { label = "z17+" }],
]
stat = "p99", view = "timeSeries", region = data.aws_region.current.name
}
},
{
type = "metric", x = 0, y = 12, width = 12, height = 6
properties = {
# Beside the tile tier deliberately: "is it the database" must be
# answerable without navigating away.
title = "Database — read latency and active connections"
metrics = [
["AWS/RDS", "ReadLatency", "DBClusterIdentifier", var.cluster_id],
["AWS/RDS", "DatabaseConnections", "DBClusterIdentifier", var.cluster_id, { yAxis = "right" }],
]
view = "timeSeries", region = data.aws_region.current.name
}
},
{
type = "log", x = 12, y = 12, width = 12, height = 6
properties = {
title = "Slowest tile queries in the window"
query = join(" | ", [
"SOURCE '/aws/rds/cluster/${var.cluster_id}/postgresql'",
"filter @message like /ST_AsMVT/",
"stats avg(duration_ms) as avg_ms, count(*) as n by query_fingerprint",
"sort avg_ms desc",
"limit 10",
])
view = "table", region = data.aws_region.current.name
}
},
]
})
}
data "aws_region" "current" {}
Verification
Verify the dashboard the way it will be used. Open it cold, with no context, and try to answer the three questions in order — cache, saturation, layer. If any of them requires typing a query, changing a time range or navigating to another page, the layout has not done its job and the fix is a panel rather than a runbook paragraph.
Then verify the panels are actually populated. An expression referencing a metric that is never published renders as an empty graph, which reads as “nothing is happening” rather than as “this panel is broken” — and that misreading during an incident is worse than having no panel. Check each panel returns data over a period when the service was busy, and check the log-insights widget’s query against a real log group rather than trusting the field names.
Finally, verify it after a deployment. The annotation overlay should show the deployment, and the panels should show whatever the deployment did. A dashboard that cannot show the effect of a known change will not show the effect of an unknown one.
Preventing recurrence
- Provision dashboards in the same module as the service. A service deployed without its dashboard runs unmonitored until someone notices, and coupling them removes the window entirely.
- Treat a deleted panel as drift. The drift detection in Drift Detection and Remediation should cover dashboards, which are routinely excluded because they are not thought of as infrastructure.
- Add a panel after every incident that needed an ad-hoc query. That query is a missing panel, and adding it is how the dashboard improves rather than merely accumulating.
- Prune panels nobody looks at. Density has a cost at 3am, and a panel that has never informed a decision is taking space from one that would.
Frequently Asked Questions
Why not put everything on one dashboard?
Because the reader differs. An incident reader has four minutes and needs three questions answered in order; a reporting reader has an hour and wants a month of trend. A single view compromises the time range and the density for both, and duplicating a few panels is far cheaper than a graph misread under pressure.
Should the dashboard show renderer CPU?
Low on the page, if at all. CPU is the last ceiling to bind on a tile platform and the first thing people look at, which is precisely the misdirection the layout exists to correct. Pool utilisation and cache hit ratio are the numbers that predict a problem.
How many series should a latency panel show?
Four to six. Zoom bands work well because they are few and meaningful. A panel with one line per layer becomes unreadable at a dozen layers, so show the top few by latency and put the full breakdown in a linked query.
Do annotations really matter?
They are among the highest-value elements on the page. Roughly half of incidents correlate with a change, and a deployment annotation answers “did something change” before anyone has to ask in a channel and wait for a reply.
Related
- Observability for Spatial Infrastructure — the parent topic, including the bounded dimensions these panels rely on
- SLO-Driven Alerting for WMS and WMTS Endpoints — the alarms that send a responder to this dashboard
- Scaling Vector Tile Services for Peak Map Traffic — the decisions these panels inform
- Drift Detection and Remediation — treating a deleted dashboard panel as drift