Rendering a 30-day AWS cost chart with no charting library
The CostWatch dashboard shows a daily spend chart for each connected AWS account. I didn't want to pull in Chart.js or any other library — the whole app is a single Lambda + a hand-rolled HTML file, and I wanted to keep it that way.
Here's how it works.
The backend: Cost Explorer with DAILY granularity
Cost Explorer's GetCostAndUsage API supports two granularities: MONTHLY and DAILY. Monthly is cheaper per call (you get a month of data in one request), but for a sparkline you need day-level detail.
resp = ce.get_cost_and_usage(
TimePeriod={"Start": start, "End": end}, # ISO dates, up to 90 days
Granularity="DAILY",
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
)
The response comes back as ResultsByTime: one entry per day, each with a Groups array of service → cost pairs. I flatten it into two parallel lists: dates (ISO strings) and totals (daily spend floats), plus a by_service dict of the top-5 services by total spend over the window.
The endpoint is GET /account/{account_id}/cost-chart?days=N, capped at 90 days. Default is 30. The Lambda assumes the customer's cross-account role for the query, so the customer's own Cost Explorer data is used, not mine.
**The frontend: drawing on <canvas>**
No library. Just a <canvas> element and the 2D context API.
function drawSparkline(canvasId, dates, totals, bySvc) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext("2d");
const W = canvas.width, H = canvas.height;
const PAD = { top: 20, right: 10, bottom: 24, left: 56 };
ctx.clearRect(0, 0, W, H);
const maxVal = Math.max(...totals, 0.01);
const xStep = (W - PAD.left - PAD.right) / Math.max(totals.length - 1, 1);
function xFor(i) { return PAD.left + i * xStep; }
function yFor(v) { return PAD.top + (1 - v / maxVal) * (H - PAD.top - PAD.bottom); }
// Area fill
ctx.beginPath();
ctx.moveTo(xFor(0), yFor(totals[0]));
totals.forEach((v, i) => ctx.lineTo(xFor(i), yFor(v)));
ctx.lineTo(xFor(totals.length - 1), H - PAD.bottom);
ctx.lineTo(xFor(0), H - PAD.bottom);
ctx.closePath();
ctx.fillStyle = "rgba(99, 179, 237, 0.18)";
ctx.fill();
// Line
ctx.beginPath();
ctx.strokeStyle = "#63b3ed";
ctx.lineWidth = 2;
totals.forEach((v, i) => {
if (i === 0) ctx.moveTo(xFor(0), yFor(v));
else ctx.lineTo(xFor(i), yFor(v));
});
ctx.stroke();
The area fill is the key visual element. Draw the line path, then extend it down to the baseline and close the path, then fill with a translucent rgba. That gives you the gradient-without-gradient look you see in most sparklines — just a flat translucent fill under the line.
For Y-axis labels I render max spend at the top and "$0" at the baseline. For X-axis I render only the first and last date tick — any more than two ticks on a 200px-wide chart looks cluttered.
Per-service breakdown as a stacked legend
The by_service payload from the API gives me the top 5 services. I render them as colored dots below the chart with the service name and total spend for the window.
const CHART_COLORS = ["#63b3ed","#68d391","#f6ad55","#fc8181","#b794f4"];
Object.entries(bySvc).forEach(([svc, vals], idx) => {
const total = vals.reduce((s, v) => s + v, 0);
if (total < 0.01) return;
// dot + label
ctx.fillStyle = CHART_COLORS[idx % CHART_COLORS.length];
ctx.fillRect(PAD.left, legendY, 10, 10);
ctx.fillStyle = "#e2e8f0";
ctx.fillText(`${shortSvc(svc)}: $${total.toFixed(2)}`, PAD.left + 14, legendY + 9);
legendY += 16;
});
shortSvc() strips the "Amazon " and "AWS " prefixes that Cost Explorer adds to every service name. "Amazon EC2-Instance" becomes "EC2-Instance". Much more readable at the 10-character widths the legend has.
The toggle pattern
The chart is collapsed by default — a "show 30-day chart" link expands it. On first expand it fires the API call and draws the canvas. On subsequent toggles it just shows/hides the already-rendered canvas (no repeat API calls).
async function toggleSparkline(accountId, canvasId) {
const wrap = document.getElementById(`spark-wrap-${accountId}`);
if (wrap.dataset.loaded === "1") {
wrap.classList.toggle("open");
return;
}
wrap.classList.add("open");
const resp = await fetch(`/account/${accountId}/cost-chart?days=30`);
const data = await resp.json();
if (data.totals) {
drawSparkline(canvasId, data.dates, data.totals, data.by_service);
wrap.dataset.loaded = "1";
}
}
Why not Chart.js?
Mostly weight. Chart.js minified is ~200KB. For a feature that shows a 30-day line with two axis labels, that's a lot of bytes for what you get. The canvas 2D API is available everywhere, the code is ~60 lines, and I control every pixel.
The tradeoff is that I can't easily add tooltips. If you hover a data point there's no tooltip — just the line. Tooltip support with raw canvas means implementing hit detection on a mousemove listener, which adds another 40-50 lines. I'll add it when someone asks for it.
CostWatch is at costwatch.dev — $5/mo Solo, $15/mo Team. Connect your AWS account in two clicks (SAR CloudFormation template, no manual IAM fiddling).