Skip to content

Instantly share code, notes, and snippets.

@tpschmidt
Created June 26, 2026 22:36
Show Gist options
  • Select an option

  • Save tpschmidt/2abe7b10ec58ebe6ba8c6ec3ea18b1d2 to your computer and use it in GitHub Desktop.

Select an option

Save tpschmidt/2abe7b10ec58ebe6ba8c6ec3ea18b1d2 to your computer and use it in GitHub Desktop.
Weekly AWS cost report to Slack (Lambda + Cost Explorer + EventBridge)
// Weekly AWS cost report -> Slack
//
// Lambda (nodejs18.x) triggered by EventBridge every Friday 08:00 UTC:
// cron(0 8 ? * FRI *)
//
// Pulls month-to-date usage, credits (MTD + YTD) and an end-of-month
// forecast from Cost Explorer, then posts a Block Kit summary to Slack.
// Slack webhook URL is read from SSM Parameter Store (SecureString).
//
// Env:
// SLACK_WEBHOOK_SSM_PATH SSM param holding the Slack webhook URL
// AWS_REGION provided by the Lambda runtime
//
// IAM: ce:GetCostAndUsage, ce:GetCostForecast, ssm:GetParameter (+ logs).
// Note: Cost Explorer is global, so the CE client is pinned to us-east-1.
const {
CostExplorerClient,
GetCostAndUsageCommand,
GetCostForecastCommand,
} = require("@aws-sdk/client-cost-explorer");
const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm");
const ceClient = new CostExplorerClient({ region: "us-east-1" });
const ssmClient = new SSMClient({ region: process.env.AWS_REGION });
let slackWebhookUrl = null;
async function getSlackWebhookUrl() {
if (slackWebhookUrl) return slackWebhookUrl;
const resp = await ssmClient.send(
new GetParameterCommand({ Name: process.env.SLACK_WEBHOOK_SSM_PATH, WithDecryption: true })
);
slackWebhookUrl = resp.Parameter.Value;
return slackWebhookUrl;
}
function fmt(d) {
return d.toISOString().split("T")[0];
}
function currency(n) {
return `$${Math.abs(parseFloat(n)).toFixed(2)}`;
}
exports.handler = async () => {
const now = new Date();
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const firstOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const ytdStart = new Date(now.getFullYear(), 0, 1);
const startStr = fmt(firstOfMonth);
const todayStr = fmt(now);
const tomorrowStr = fmt(tomorrow);
const endStr = fmt(firstOfNextMonth);
const ytdStartStr = fmt(ytdStart);
const monthLabel = now.toLocaleString("en-US", { month: "long", year: "numeric" });
const [usageResp, creditMonthResp, creditYTDResp] = await Promise.all([
startStr < todayStr
? ceClient.send(new GetCostAndUsageCommand({
TimePeriod: { Start: startStr, End: todayStr },
Granularity: "MONTHLY",
Metrics: ["UnblendedCost"],
Filter: { Dimensions: { Key: "RECORD_TYPE", Values: ["Usage"] } },
GroupBy: [{ Type: "DIMENSION", Key: "SERVICE" }],
}))
: null,
startStr < todayStr
? ceClient.send(new GetCostAndUsageCommand({
TimePeriod: { Start: startStr, End: todayStr },
Granularity: "MONTHLY",
Metrics: ["UnblendedCost"],
Filter: { Dimensions: { Key: "RECORD_TYPE", Values: ["Credit"] } },
}))
: null,
ytdStartStr < todayStr
? ceClient.send(new GetCostAndUsageCommand({
TimePeriod: { Start: ytdStartStr, End: todayStr },
Granularity: "MONTHLY",
Metrics: ["UnblendedCost"],
Filter: { Dimensions: { Key: "RECORD_TYPE", Values: ["Credit"] } },
}))
: null,
]);
const usageGroups = usageResp?.ResultsByTime?.[0]?.Groups ?? [];
const grossMTD = usageGroups.reduce((s, g) => s + parseFloat(g.Metrics.UnblendedCost.Amount), 0);
const creditsMonth = Math.abs(parseFloat(creditMonthResp?.ResultsByTime?.[0]?.Total?.UnblendedCost?.Amount ?? "0"));
const creditsYTD = Math.abs((creditYTDResp?.ResultsByTime ?? [])
.reduce((s, r) => s + parseFloat(r.Total?.UnblendedCost?.Amount ?? "0"), 0));
const netMTD = grossMTD - creditsMonth;
const top5 = [...usageGroups]
.sort((a, b) => parseFloat(b.Metrics.UnblendedCost.Amount) - parseFloat(a.Metrics.UnblendedCost.Amount))
.slice(0, 10);
let forecastedGross = null;
if (tomorrowStr < endStr) {
try {
const forecast = await ceClient.send(new GetCostForecastCommand({
TimePeriod: { Start: tomorrowStr, End: endStr },
Granularity: "MONTHLY",
Metric: "UNBLENDED_COST",
Filter: { Dimensions: { Key: "RECORD_TYPE", Values: ["Usage"] } },
}));
forecastedGross = grossMTD + parseFloat(forecast.Total?.Amount ?? "0");
} catch (err) {
console.warn("Forecast unavailable:", err.message);
}
}
const webhookUrl = await getSlackWebhookUrl();
await sendSlack(webhookUrl, monthLabel, grossMTD, creditsMonth, netMTD, creditsYTD, forecastedGross, top5, now);
};
async function sendSlack(webhookUrl, monthLabel, gross, creditsMonth, net, creditsYTD, forecastedGross, top5, now) {
const serviceLines = top5.length > 0
? top5.map((g) => {
const name = g.Keys[0].replace("Amazon ", "").replace("AWS ", "").substring(0, 26);
const cost = parseFloat(g.Metrics.UnblendedCost.Amount);
const pct = gross > 0 ? Math.round((cost / gross) * 100) : 0;
return `${name.padEnd(26)} ${currency(cost).padStart(8)} ${String(pct).padStart(3)}%`;
}).join("\n")
: "No usage data yet.";
const forecastLine = forecastedGross != null ? currency(forecastedGross) : "N/A";
const blocks = [
{
type: "header",
text: { type: "plain_text", text: `💸 AWS Cost Report ${monthLabel}`, emoji: true },
},
{
type: "section",
text: {
type: "mrkdwn",
text: [
`• *Credits applied (MTD):* ${currency(creditsMonth)}`,
`• *Forecasted gross:* ${forecastLine}`,
`• *Credits used YTD:* ${currency(creditsYTD)}`,
].join("\n"),
},
},
{
type: "section",
text: { type: "mrkdwn", text: `*Top 10 Services (Gross MTD)*\n\`\`\`${serviceLines}\`\`\`` },
},
{
type: "context",
elements: [{ type: "mrkdwn", text: `AWS Organization · ${now.toDateString()}` }],
},
];
const resp = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocks }),
});
if (!resp.ok) throw new Error(`Slack webhook returned ${resp.status}: ${await resp.text()}`);
console.log("Cost report sent to Slack");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment