Skip to content

Commit 7b5dc6e

Browse files
andre-salvaticlaude
andcommitted
feat: attribute Databricks spend to jobs and pipelines in the cost report
Adds a "Databricks by Job / Pipeline" table to `make project-costs`, placed directly after Combined Totals in the generated report and printed last on stdout. One row per job or SDP pipeline with native quantity, USD at list price, and the count of days it was active. Job names come straight off `usage_metadata.job_name`; only pipelines need a dimension lookup, and `system.lakeflow.pipelines` is pre-collapsed per pipeline_id before the join. Both `system.lakeflow.jobs` and `.pipelines` are slowly-changing, so joining them to usage without a time predicate or a pre-collapse fans each usage row out per definition revision and multiplies SUM(usd) — the docstring records this so it is not reintroduced. Extracts `_connect()` and `_run_sql()`, which `fetch_databricks` previously had inline, so the new fetch reuses them instead of duplicating the connect and poll loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fcb7518 commit 7b5dc6e

2 files changed

Lines changed: 159 additions & 27 deletions

File tree

.claude/commands/project-costs.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ session, then re-run. Do not attempt the browser login yourself.
1818

1919
## What the script emits
2020

21-
Three tables, and only three:
21+
Four tables, and only four:
2222

2323
1. **AWS by Week × Service** (USD, Total column)
2424
2. **Databricks by Week × SKU** (USD at list price, Total column)
2525
3. **Combined Totals by Service** across both clouds — native `Quantity`/`Unit` plus a totalled
2626
`USD` column. This is the only place native DBU/DSU/GB quantities appear.
27+
4. **Databricks by Job / Pipeline** — one row per job or SDP pipeline that incurred spend, with
28+
`Kind`, native `Quantity`/`Unit`, `USD` and `Days` (distinct days with usage). In the generated
29+
report it sits directly after Combined Totals; on stdout it prints last.
2730

2831
Databricks usage is monetized by joining `system.billing.list_prices` inside the script, so DBU/DSU
2932
/GB and AWS dollars are directly comparable.
@@ -54,6 +57,17 @@ to dollars before calling them big or small.
5457
dashboard nobody opens.
5558
- Trend: is DBU consumption flat, growing, or declining?
5659

60+
**By job / pipeline**
61+
- The top spenders, and the prod / staging / dev split — prod runs daily, staging and dev only on
62+
the days someone deployed, so compare *per active day*, never raw totals. The `Days` column is
63+
what makes that comparison possible.
64+
- Compare `job1_*` against its `job1_sdp_*` counterpart: they produce the same medallion tables by
65+
different execution models, so a persistent gap between them is a real finding, not noise.
66+
- Watch the integration-test jobs. They are easy to overlook and can rival the pipeline they test.
67+
- Reconcile before trusting: the attributed total is always *less* than the Databricks total, since
68+
SQL warehouse and interactive compute carry no `job_id`. The note under the table gives the
69+
attributed share — if it moves a lot between runs, interactive usage changed, not the jobs.
70+
5771
**Cross-cloud observation**
5872
- Note whether AWS S3/egress spikes correlate with high Databricks DBU days (they should — heavy
5973
job runs produce more S3 I/O and egress). Call out any that *don't* correlate, in either

scripts/project_costs.py

Lines changed: 144 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
"account discounts and commit contracts, so treat it as an upper bound."
3232
)
3333
_WEEK_NOTE = "Weeks start Monday; the first and last weeks in the window are usually partial."
34+
_ATTRIBUTION_NOTE = (
35+
"Only usage tagged with a job_id or dlt_pipeline_id is attributable ({pct}% of Databricks "
36+
"spend here); SQL warehouse and other interactive compute carry neither, so this table is a "
37+
"breakdown of scheduled work, not of the whole bill."
38+
)
3439

3540

3641
def _money(v: float) -> str:
@@ -142,6 +147,104 @@ def fetch_aws(days: int, profile: str | None = None) -> pd.DataFrame | None:
142147
return aws_df
143148

144149

150+
def _connect(profile: str) -> tuple[WorkspaceClient, str] | None:
151+
"""Return (client, warehouse_id) for the profile, or None if either is unavailable."""
152+
try:
153+
w = WorkspaceClient(profile=profile)
154+
except Exception as e:
155+
# Avoid printing the exception directly — SDK exceptions can include the workspace URL.
156+
print(f"Could not connect to Databricks ({profile} profile): {type(e).__name__}\n")
157+
return None
158+
159+
warehouses = list(w.warehouses.list())
160+
if not warehouses:
161+
print("No SQL warehouse available.\n")
162+
return None
163+
return w, warehouses[0].id
164+
165+
166+
def _run_sql(w: WorkspaceClient, warehouse_id: str, sql: str) -> list[list[str]]:
167+
"""Execute a statement and return its rows. Raises on any non-SUCCEEDED terminal state."""
168+
stmt = w.statement_execution.execute_statement(
169+
warehouse_id=warehouse_id,
170+
statement=sql,
171+
wait_timeout="30s",
172+
)
173+
while stmt.status.state not in _POLL_TERMINAL:
174+
time.sleep(1)
175+
stmt = w.statement_execution.get_statement(stmt.statement_id)
176+
177+
if stmt.status.state != StatementState.SUCCEEDED:
178+
error_msg = stmt.status.error.message if stmt.status.error else stmt.status.state.value
179+
raise RuntimeError(error_msg)
180+
return stmt.result.data_array or []
181+
182+
183+
def fetch_by_entity(profile: str, days: int) -> pd.DataFrame | None:
184+
"""Attribute Databricks spend to the job or pipeline that incurred it.
185+
186+
Two joins, and only one of them is a dimension:
187+
188+
* list_prices is slowly-changing (one row per price revision). The usage_end_time BETWEEN
189+
price_start_time AND price_end_time predicate pins it to a single version, collapsing it
190+
to 1:1.
191+
* Job names come straight off usage_metadata.job_name, which is populated on every job
192+
record — no dimension join at all. Only pipelines need a lookup (usage_metadata carries
193+
dlt_pipeline_id but no pipeline name), and system.lakeflow.pipelines is slowly-changing
194+
too, so it is pre-collapsed to one row per pipeline_id *before* being joined.
195+
196+
Never join system.lakeflow.jobs/.pipelines to usage without one of those two safeguards: the
197+
row fans out once per definition revision and SUM(usd) comes back an integer multiple of the
198+
truth.
199+
200+
Only usage carrying a job_id or dlt_pipeline_id is attributable — SQL warehouse and other
201+
interactive compute has neither, so this never reconciles to the full Databricks total.
202+
"""
203+
conn = _connect(profile)
204+
if conn is None:
205+
return None
206+
w, warehouse_id = conn
207+
208+
sql = f"""
209+
WITH pipe_names AS (
210+
SELECT pipeline_id, MAX_BY(name, change_time) AS name
211+
FROM system.lakeflow.pipelines
212+
GROUP BY pipeline_id
213+
)
214+
SELECT
215+
COALESCE(u.usage_metadata.job_name, n.name, '(unnamed)') AS entity,
216+
CASE WHEN u.usage_metadata.dlt_pipeline_id IS NOT NULL THEN 'pipeline' ELSE 'job' END AS kind,
217+
SUM(u.usage_quantity) AS quantity,
218+
u.usage_unit,
219+
SUM(u.usage_quantity * p.pricing.effective_list.default) AS usd,
220+
COUNT(DISTINCT u.usage_date) AS active_days
221+
FROM system.billing.usage u
222+
LEFT JOIN system.billing.list_prices p
223+
ON u.sku_name = p.sku_name
224+
AND u.usage_end_time >= p.price_start_time
225+
AND (p.price_end_time IS NULL OR u.usage_end_time < p.price_end_time)
226+
LEFT JOIN pipe_names n
227+
ON n.pipeline_id = u.usage_metadata.dlt_pipeline_id
228+
WHERE u.usage_date >= CURRENT_DATE() - INTERVAL {days} DAYS
229+
AND COALESCE(u.usage_metadata.job_id, u.usage_metadata.dlt_pipeline_id) IS NOT NULL
230+
GROUP BY entity, kind, u.usage_unit
231+
ORDER BY usd DESC
232+
"""
233+
234+
try:
235+
rows = _run_sql(w, warehouse_id, sql)
236+
if not rows:
237+
return None
238+
entity_df = pd.DataFrame(rows, columns=["Entity", "Kind", "Quantity", "Unit", "USD", "Days"])
239+
entity_df["Quantity"] = entity_df["Quantity"].astype(float)
240+
entity_df["USD"] = entity_df["USD"].astype(float)
241+
entity_df["Days"] = entity_df["Days"].astype(int)
242+
return entity_df
243+
except Exception as e:
244+
print(f"Could not attribute usage to jobs/pipelines: {e}\n")
245+
return None
246+
247+
145248
def fetch_databricks(profile: str, days: int) -> pd.DataFrame | None:
146249
"""Pull per-day, per-SKU usage and list-price cost. Returns None on any failure.
147250
@@ -158,18 +261,10 @@ def fetch_databricks(profile: str, days: int) -> pd.DataFrame | None:
158261
field Databricks documents for costing. These are LIST prices: account-level discounts and
159262
commit contracts are not exposed here, so the USD figures are an upper bound.
160263
"""
161-
try:
162-
w = WorkspaceClient(profile=profile)
163-
except Exception as e:
164-
# Avoid printing the exception directly — SDK exceptions can include the workspace URL.
165-
print(f"Could not connect to Databricks ({profile} profile): {type(e).__name__}\n")
264+
conn = _connect(profile)
265+
if conn is None:
166266
return None
167-
168-
warehouses = list(w.warehouses.list())
169-
if not warehouses:
170-
print("No SQL warehouse available.\n")
171-
return None
172-
warehouse_id = warehouses[0].id
267+
w, warehouse_id = conn
173268

174269
# No ROUND() here: rounding per day and then summing 30 days destroys small SKUs — internet
175270
# egress bills ~$0.000009/day, which rounds to 0.0000 every single day and totals to nothing.
@@ -193,20 +288,7 @@ def fetch_databricks(profile: str, days: int) -> pd.DataFrame | None:
193288
"""
194289

195290
try:
196-
stmt = w.statement_execution.execute_statement(
197-
warehouse_id=warehouse_id,
198-
statement=sql,
199-
wait_timeout="30s",
200-
)
201-
while stmt.status.state not in _POLL_TERMINAL:
202-
time.sleep(1)
203-
stmt = w.statement_execution.get_statement(stmt.statement_id)
204-
205-
if stmt.status.state != StatementState.SUCCEEDED:
206-
error_msg = stmt.status.error.message if stmt.status.error else stmt.status.state.value
207-
raise RuntimeError(error_msg)
208-
209-
rows = stmt.result.data_array or []
291+
rows = _run_sql(w, warehouse_id, sql)
210292
if not rows:
211293
print("No usage data found.\n")
212294
return None
@@ -357,10 +439,30 @@ def print_combined(combined: pd.DataFrame, days: int) -> None:
357439
print()
358440

359441

442+
def print_by_entity(entity_df: pd.DataFrame, usage_df: pd.DataFrame | None, days: int) -> None:
443+
print(f"Databricks Costs by Job / Pipeline — last {days} days (USD, list price)")
444+
print(f"{'Entity':<48} {'Kind':<9} {'Quantity':>12} {'Unit':<5} {'USD':>10} {'Days':>5}")
445+
print("-" * 94)
446+
for r in entity_df.itertuples(index=False):
447+
print(f"{r.Entity:<48.48} {r.Kind:<9} {r.Quantity:>12.4f} {r.Unit:<5} {r.USD:>10.4f} {r.Days:>5}")
448+
print("-" * 94)
449+
print(f"{'Attributed total':<48} {'':<9} {'':>12} {'':<5} {entity_df['USD'].sum():>10.4f}")
450+
print(_ATTRIBUTION_NOTE.format(pct=_attributed_pct(entity_df, usage_df)))
451+
print()
452+
453+
454+
def _attributed_pct(entity_df: pd.DataFrame, usage_df: pd.DataFrame | None) -> str:
455+
"""Share of total Databricks spend this table accounts for, as a display string."""
456+
if usage_df is None or not usage_df["usd"].sum():
457+
return "?"
458+
return f"{100 * entity_df['USD'].sum() / usage_df['usd'].sum():.0f}"
459+
460+
360461
def write_markdown(
361462
aws_df: pd.DataFrame | None,
362463
usage_df: pd.DataFrame | None,
363464
combined: pd.DataFrame | None,
465+
entity_df: pd.DataFrame | None,
364466
days: int,
365467
) -> Path:
366468
"""Write the data tables to cost_report/YYYY-MM-DD.md, leaving Analysis for the skill."""
@@ -395,6 +497,18 @@ def write_markdown(
395497
out.append("_No data available._")
396498
out.append("")
397499

500+
if entity_df is not None:
501+
out += [
502+
"## Databricks — by Job / Pipeline (USD)",
503+
"",
504+
f"Attributed total: **${entity_df['USD'].sum():.2f}** over {days} days at list price.",
505+
"",
506+
_md_table(entity_df),
507+
"",
508+
f"> {_ATTRIBUTION_NOTE.format(pct=_attributed_pct(entity_df, usage_df))}",
509+
"",
510+
]
511+
398512
if aws_df is not None:
399513
estimated = (
400514
" Weeks marked `*` contain estimated (not-yet-finalized) days." if bool(aws_df["estimated"].any()) else ""
@@ -490,7 +604,11 @@ def main() -> None:
490604
if combined is not None:
491605
print_combined(combined, args.days)
492606

493-
path = write_markdown(aws_df, usage_df, combined, args.days)
607+
entity_df = fetch_by_entity(args.profile, args.days) if usage_df is not None else None
608+
if entity_df is not None:
609+
print_by_entity(entity_df, usage_df, args.days)
610+
611+
path = write_markdown(aws_df, usage_df, combined, entity_df, args.days)
494612
print(f"Report written to {path.relative_to(Path.cwd()) if path.is_relative_to(Path.cwd()) else path}\n")
495613

496614

0 commit comments

Comments
 (0)