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
3641def _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+
145248def 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+
360461def 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