Skip to content

Commit 22f0f73

Browse files
Add CUR 2.0 cost analytics and billing protection (#103)
## Summary - **CUR 2.0 export** (daily Parquet with resource IDs) via `aws_bcmdataexports_export` — enables resource-level cost attribution ("this specific S3 bucket cost $X") - **Athena workgroup + Glue crawler** for querying CUR data from Lambdas and CLI - **Billing protection**: CloudWatch billing alarm ($200 threshold, ~4-6h delay) + account-level budget with 80%/100% notifications ($500/month) - **Weekly cost report**: new resource-level drilldown section (top 10 resources by cost), enriched LLM narrative with resource context - **Daily spike check**: per-spike resource breakdown showing top 5 resources for each spiking service - **Shared `athena.py`**: reusable Athena query utility with graceful degradation (reports still send if CUR data is unavailable) ## New resources | Resource | Purpose | |----------|---------| | S3 `javabin-cur-553637109631` | CUR 2.0 Parquet data (lifecycle: IA 90d, expire 365d) | | S3 `javabin-athena-results-553637109631` | Athena query results (expire 7d) | | `aws_bcmdataexports_export` | CUR 2.0 daily export with RESOURCES | | Glue database `javabin_cur` | Catalog for CUR tables | | Glue crawler `javabin-cur-crawler` | Daily 06:00 UTC schema discovery | | Athena workgroup `javabin-cost-analytics` | 100MB scan limit | | CloudWatch alarm `javabin-billing-alarm` | EstimatedCharges > $200 (us-east-1) | | Budget `javabin-account-monthly` | $500/month, alerts at 80%/100% | ## Notes - CUR data takes 24-48h for first delivery after apply - Glue crawler must run after first delivery before Athena queries work - All CUR queries gracefully degrade — existing reports work unchanged until data arrives - Billing alarm SNS topic is in us-east-1 (CloudWatch billing constraint). A Lambda forwarder to eu-central-1 Slack can be added later. ## Test plan - [ ] `terraform fmt -recursive && terraform validate` passes - [ ] Apply via CI pipeline (plan → review → apply) - [ ] After 24-48h: verify CUR Parquet files in `s3://javabin-cur-553637109631/cur/` - [ ] Verify Glue crawler populates table in `javabin_cur` database - [ ] Run manual Athena query in `javabin-cost-analytics` workgroup - [ ] Invoke `javabin-cost-report` Lambda, check Slack for resource drilldown section - [ ] Invoke `javabin-daily-cost-check` Lambda, verify resource context on spikes
1 parent 7506122 commit 22f0f73

9 files changed

Lines changed: 833 additions & 7 deletions

File tree

terraform/lambda-src/cost_report/handler.py

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import boto3
99
from botocore.config import Config
10+
from shared.athena import run_query
1011
from shared.constants import USD_TO_NOK, risk_emoji
1112
from shared.slack import get_webhook_url, post_to_slack
1213

@@ -16,6 +17,9 @@
1617
ssm = boto3.client("ssm")
1718

1819
COST_WEBHOOK_PARAM = os.environ["COST_WEBHOOK_PARAM"]
20+
CUR_DATABASE = os.environ.get("CUR_DATABASE", "")
21+
CUR_TABLE = os.environ.get("CUR_TABLE", "")
22+
ATHENA_WORKGROUP = os.environ.get("ATHENA_WORKGROUP", "")
1923

2024
# ---------------------------------------------------------------------------
2125
# Service categorisation — pattern-based, not hardcoded lists
@@ -315,7 +319,7 @@ def _llm_structured(prompt, tool_config):
315319

316320

317321
def generate_narrative(this_week, prev_week, mtd, tw_total, pw_total,
318-
mtd_total, projected, month_name):
322+
mtd_total, projected, month_name, resource_summary=""):
319323
"""Generate executive summary. Returns dict with summary/notable or None."""
320324
top_services = sorted(this_week.items(), key=lambda x: x[1], reverse=True)[:8]
321325
svc_summary = ", ".join(f"{s}: ${c:.2f}" for s, c in top_services)
@@ -341,8 +345,9 @@ def generate_narrative(this_week, prev_week, mtd, tw_total, pw_total,
341345
342346
Top services: {svc_summary}
343347
Biggest movers WoW: {mover_lines or 'no significant changes'}
348+
{resource_summary}
344349
345-
Be specific about which services drove changes. Set notable=true only if there is a meaningful change worth highlighting, false if costs are stable week-over-week."""
350+
Be specific about which services and resources drove changes. Set notable=true only if there is a meaningful change worth highlighting, false if costs are stable week-over-week."""
346351
return _llm_structured(prompt, _NARRATIVE_TOOL)
347352

348353

@@ -375,6 +380,104 @@ def analyze_spike(this_week, prev_week, tw_total, pw_total):
375380
return _llm_structured(prompt, _SPIKE_TOOL)
376381

377382

383+
# ---------------------------------------------------------------------------
384+
# CUR resource-level drilldown via Athena
385+
# ---------------------------------------------------------------------------
386+
def _friendly_resource_id(resource_id):
387+
"""Shorten an ARN to a readable name."""
388+
if not resource_id:
389+
return "(no resource ID)"
390+
# Strip common ARN prefix, keep the useful tail
391+
if "::" in resource_id:
392+
# S3 bucket: arn:aws:s3:::bucket-name → bucket-name
393+
return resource_id.rsplit(":::", 1)[-1]
394+
if "/" in resource_id:
395+
# ECS/Lambda/etc: ...service/cluster/name → name
396+
parts = resource_id.split("/")
397+
return parts[-1] if len(parts) <= 3 else "/".join(parts[-2:])
398+
if ":" in resource_id:
399+
return resource_id.rsplit(":", 1)[-1]
400+
return resource_id
401+
402+
403+
def get_resource_drilldown(week_start, week_end):
404+
"""Query CUR via Athena for top resources this week. Returns dict or None."""
405+
if not (CUR_DATABASE and CUR_TABLE and ATHENA_WORKGROUP):
406+
return None
407+
408+
year = str(week_start.year)
409+
month = f"{week_start.month:02d}"
410+
411+
# Top 10 resources overall
412+
top_query = f"""
413+
SELECT line_item_resource_id,
414+
line_item_product_code,
415+
COALESCE(resource_tags_user_team, '') as team,
416+
SUM(CAST(line_item_unblended_cost AS double)) as total_cost
417+
FROM "{CUR_DATABASE}"."{CUR_TABLE}"
418+
WHERE year = '{year}' AND month = '{month}'
419+
AND line_item_usage_start_date >= TIMESTAMP '{week_start}'
420+
AND line_item_usage_start_date < TIMESTAMP '{week_end + timedelta(days=1)}'
421+
AND line_item_resource_id != ''
422+
AND line_item_line_item_type = 'Usage'
423+
GROUP BY line_item_resource_id, line_item_product_code,
424+
COALESCE(resource_tags_user_team, '')
425+
HAVING SUM(CAST(line_item_unblended_cost AS double)) >= 0.01
426+
ORDER BY total_cost DESC
427+
LIMIT 10
428+
"""
429+
430+
top_resources = run_query(CUR_DATABASE, top_query, ATHENA_WORKGROUP)
431+
if not top_resources:
432+
return None
433+
434+
return {"top_resources": top_resources}
435+
436+
437+
def build_resource_drilldown_blocks(drilldown):
438+
"""Build Block Kit blocks for resource-level cost drilldown."""
439+
blocks = []
440+
top = drilldown.get("top_resources", [])
441+
if not top:
442+
return blocks
443+
444+
blocks.append({
445+
"type": "section",
446+
"text": {"type": "mrkdwn", "text": ":mag: *Resource-Level Drilldown*"}
447+
})
448+
449+
header = [
450+
{"type": "raw_text", "text": "Resource"},
451+
{"type": "raw_text", "text": "Service"},
452+
{"type": "raw_text", "text": "Team"},
453+
{"type": "raw_text", "text": "Cost"},
454+
]
455+
rows = [header]
456+
457+
for item in top:
458+
cost = float(item.get("total_cost", 0))
459+
nok = cost * USD_TO_NOK
460+
rows.append([
461+
{"type": "raw_text", "text": _friendly_resource_id(item.get("line_item_resource_id", ""))},
462+
{"type": "raw_text", "text": item.get("line_item_product_code", "")},
463+
{"type": "raw_text", "text": item.get("team", "(untagged)")},
464+
{"type": "raw_text", "text": f"${cost:.2f} (~{nok:.0f} NOK)"},
465+
])
466+
467+
blocks.append({
468+
"type": "table",
469+
"column_settings": [
470+
{"is_wrapped": True},
471+
{},
472+
{},
473+
{"align": "right"},
474+
],
475+
"rows": rows,
476+
})
477+
478+
return blocks
479+
480+
378481
# ---------------------------------------------------------------------------
379482
# Block Kit builder
380483
# ---------------------------------------------------------------------------
@@ -493,6 +596,14 @@ def build_blocks(this_week, prev_week, mtd, prev_mtd, project_costs, team_costs,
493596

494597
blocks.append({"type": "divider"})
495598

599+
# Resource-level drilldown from CUR (graceful — skipped if unavailable)
600+
drilldown = get_resource_drilldown(tw_start, tw_end)
601+
if drilldown:
602+
blocks.extend(build_resource_drilldown_blocks(drilldown))
603+
blocks.append({"type": "divider"})
604+
else:
605+
logger.info("CUR resource drilldown unavailable — skipping")
606+
496607
# LLM spike root cause (shown as a section — important)
497608
spike_result = analyze_spike(this_week, prev_week, tw_total, pw_total)
498609
if spike_result:
@@ -506,9 +617,19 @@ def build_blocks(this_week, prev_week, mtd, prev_mtd, project_costs, team_costs,
506617
blocks.append({"type": "divider"})
507618

508619
# LLM narrative + footer as context blocks
620+
resource_summary = ""
621+
if drilldown:
622+
top_res = drilldown.get("top_resources", [])[:5]
623+
resource_summary = "\nTop resources by cost: " + ", ".join(
624+
f"{_friendly_resource_id(r.get('line_item_resource_id',''))} "
625+
f"({r.get('line_item_product_code','')}, {r.get('team','?')}): "
626+
f"${float(r.get('total_cost',0)):.2f}"
627+
for r in top_res
628+
)
509629
narrative_result = generate_narrative(
510630
this_week, prev_week, mtd,
511631
tw_total, pw_total, mtd_total, projected, curr_month_name,
632+
resource_summary=resource_summary,
512633
)
513634

514635
ce_url = cost_explorer_url(tw_start, tw_end)

terraform/lambda-src/daily_cost_check/handler.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from datetime import datetime, timedelta, timezone
66

77
import boto3
8+
from shared.athena import run_query
89
from shared.constants import USD_TO_NOK
910
from shared.slack import get_webhook_url, post_to_slack
1011

@@ -17,6 +18,9 @@
1718
SPIKE_THRESHOLD = float(os.environ.get("SPIKE_THRESHOLD", "1.2")) # 20% above average
1819
# Minimum daily spend (USD) to qualify as a spike — filters noise on tiny amounts
1920
MIN_SPIKE_AMOUNT = float(os.environ.get("MIN_SPIKE_AMOUNT", "1.00"))
21+
CUR_DATABASE = os.environ.get("CUR_DATABASE", "")
22+
CUR_TABLE = os.environ.get("CUR_TABLE", "")
23+
ATHENA_WORKGROUP = os.environ.get("ATHENA_WORKGROUP", "")
2024

2125

2226
# ---------------------------------------------------------------------------
@@ -129,6 +133,54 @@ def cost_explorer_url(start, end, service=None):
129133
return f"{base}?{urllib.parse.urlencode(params)}"
130134

131135

136+
# ---------------------------------------------------------------------------
137+
# CUR resource drilldown for spiking services
138+
# ---------------------------------------------------------------------------
139+
def get_spike_resources(day, service):
140+
"""Query CUR for top resources in a spiking service. Returns list or []."""
141+
if not (CUR_DATABASE and CUR_TABLE and ATHENA_WORKGROUP):
142+
return []
143+
144+
year = str(day.year)
145+
month = f"{day.month:02d}"
146+
next_day = day + timedelta(days=1)
147+
148+
query = f"""
149+
SELECT line_item_resource_id,
150+
line_item_usage_type,
151+
COALESCE(resource_tags_user_team, '') as team,
152+
SUM(CAST(line_item_unblended_cost AS double)) as total_cost
153+
FROM "{CUR_DATABASE}"."{CUR_TABLE}"
154+
WHERE year = '{year}' AND month = '{month}'
155+
AND line_item_usage_start_date >= TIMESTAMP '{day}'
156+
AND line_item_usage_start_date < TIMESTAMP '{next_day}'
157+
AND line_item_product_code = '{service}'
158+
AND line_item_resource_id != ''
159+
AND line_item_line_item_type = 'Usage'
160+
GROUP BY line_item_resource_id, line_item_usage_type,
161+
COALESCE(resource_tags_user_team, '')
162+
HAVING SUM(CAST(line_item_unblended_cost AS double)) >= 0.01
163+
ORDER BY total_cost DESC
164+
LIMIT 5
165+
"""
166+
167+
return run_query(CUR_DATABASE, query, ATHENA_WORKGROUP)
168+
169+
170+
def _friendly_resource_id(resource_id):
171+
"""Shorten an ARN to a readable name."""
172+
if not resource_id:
173+
return "(no resource ID)"
174+
if ":::" in resource_id:
175+
return resource_id.rsplit(":::", 1)[-1]
176+
if "/" in resource_id:
177+
parts = resource_id.split("/")
178+
return parts[-1] if len(parts) <= 3 else "/".join(parts[-2:])
179+
if ":" in resource_id:
180+
return resource_id.rsplit(":", 1)[-1]
181+
return resource_id
182+
183+
132184
# ---------------------------------------------------------------------------
133185
# Spike detection
134186
# ---------------------------------------------------------------------------
@@ -237,6 +289,16 @@ def build_alert_blocks(spikes, spike_details, yesterday_date):
237289
)
238290
detail_parts.append(f"*By team:* {team_lines}")
239291

292+
resources = detail.get("resources")
293+
if resources:
294+
res_lines = "\n".join(
295+
f" \u2022 {_friendly_resource_id(r.get('line_item_resource_id', ''))}: "
296+
f"${float(r.get('total_cost', 0)):.2f}"
297+
f"{' (' + r['team'] + ')' if r.get('team') else ''}"
298+
for r in resources
299+
)
300+
detail_parts.append(f"*Top resources:*\n{res_lines}")
301+
240302
ce_url = detail.get("url")
241303
if ce_url:
242304
detail_parts.append(f"<{ce_url}|View in Cost Explorer>")
@@ -295,6 +357,10 @@ def handler(event, context):
295357
detail["team_tags"] = get_tag_breakdown(ce, yesterday, svc, tag_key="team")
296358
except Exception as e:
297359
logger.warning("Team tag query failed for %s: %s", svc, e)
360+
try:
361+
detail["resources"] = get_spike_resources(yesterday, svc)
362+
except Exception as e:
363+
logger.warning("CUR resource query failed for %s: %s", svc, e)
298364
spike_details[svc] = detail
299365

300366
blocks = build_alert_blocks(spikes, spike_details, yesterday)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Shared Athena query utility for CUR cost analytics."""
2+
3+
import logging
4+
import time
5+
6+
import boto3
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def run_query(database, query, workgroup, timeout_seconds=30):
12+
"""Execute an Athena query synchronously and return rows as list of dicts.
13+
14+
On failure (timeout, query error, missing table), logs a warning and returns
15+
an empty list so callers can gracefully degrade.
16+
"""
17+
athena = boto3.client("athena")
18+
19+
try:
20+
start = athena.start_query_execution(
21+
QueryString=query,
22+
QueryExecutionContext={"Database": database},
23+
WorkGroup=workgroup,
24+
)
25+
execution_id = start["QueryExecutionId"]
26+
except Exception as e:
27+
logger.warning("Athena start_query_execution failed: %s", e)
28+
return []
29+
30+
# Poll until done
31+
deadline = time.time() + timeout_seconds
32+
while time.time() < deadline:
33+
try:
34+
status = athena.get_query_execution(QueryExecutionId=execution_id)
35+
state = status["QueryExecution"]["Status"]["State"]
36+
except Exception as e:
37+
logger.warning("Athena get_query_execution failed: %s", e)
38+
return []
39+
40+
if state == "SUCCEEDED":
41+
break
42+
if state in ("FAILED", "CANCELLED"):
43+
reason = status["QueryExecution"]["Status"].get(
44+
"StateChangeReason", "unknown"
45+
)
46+
logger.warning("Athena query %s: %s", state, reason)
47+
return []
48+
49+
time.sleep(1)
50+
else:
51+
logger.warning("Athena query timed out after %ds", timeout_seconds)
52+
try:
53+
athena.stop_query_execution(QueryExecutionId=execution_id)
54+
except Exception:
55+
pass
56+
return []
57+
58+
# Fetch results with pagination
59+
rows = []
60+
columns = None
61+
next_token = None
62+
63+
while True:
64+
kwargs = {"QueryExecutionId": execution_id}
65+
if next_token:
66+
kwargs["NextToken"] = next_token
67+
68+
try:
69+
result = athena.get_query_results(**kwargs)
70+
except Exception as e:
71+
logger.warning("Athena get_query_results failed: %s", e)
72+
return rows
73+
74+
result_set = result["ResultSet"]
75+
76+
if columns is None:
77+
columns = [
78+
col["Name"] for col in result_set["ResultSetMetadata"]["ColumnInfo"]
79+
]
80+
# First page includes the header row — skip it
81+
data_rows = result_set["Rows"][1:]
82+
else:
83+
data_rows = result_set["Rows"]
84+
85+
for row in data_rows:
86+
values = [d.get("VarCharValue", "") for d in row["Data"]]
87+
rows.append(dict(zip(columns, values)))
88+
89+
next_token = result.get("NextToken")
90+
if not next_token:
91+
break
92+
93+
return rows

0 commit comments

Comments
 (0)