Created
June 26, 2026 22:36
-
-
Save tpschmidt/2abe7b10ec58ebe6ba8c6ec3ea18b1d2 to your computer and use it in GitHub Desktop.
Weekly AWS cost report to Slack (Lambda + Cost Explorer + EventBridge)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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