Skip to content

Commit 57d9845

Browse files
authored
fix(billing-cost-management): repair Savings Plans utilization and surface dropped RI metrics (#4580)
get_savings_plans_utilization read the wrong response member, returning no data; details reported every plan at 0%. Both now pass the API response through. get_reservation_utilization surfaces 9 dropped monetary fields. Removes four derived keys contradicting the service's own aggregate. Fixes #3351
1 parent e9f2439 commit 57d9845

5 files changed

Lines changed: 527 additions & 536 deletions

File tree

src/billing-cost-management-mcp-server/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
### Added
1010
- Added AWS Billing preferences support via a read-only `get-billing-preferences` tool (`GetBillingPreferences`) reporting which member accounts participate in the Reserved Instance / Savings Plans discount pool and in credit sharing, whether newly created accounts join automatically, whether sharing is open, whether billing alerts are enabled, and the per-billing-period history of those settings. This is the only source for an account's discount-sharing state
1111
- Added AWS Billing Enterprise Support data via an `enterprise_support` tool (`GetEnterpriseSupportChargeSummary`, `GetEnterpriseSupportContractDetails`, `ListEnterpriseSupportLinkedAccountCharges`) covering a billing period's Enterprise Support charge and the Support-eligible spend it was calculated from, the contract terms that govern how the charge is allocated, and the per-linked-account charge breakdown
12+
- Added additional reservation cost and savings metrics to `get_reservation_utilization`: `on_demand_cost_of_ri_hours_used`, `net_ri_savings`, `total_potential_ri_savings`, `amortized_upfront_fee`, `amortized_recurring_fee`, `total_amortized_fee`, `ri_cost_for_unused_hours`, `realized_savings` and `unrealized_savings`.
1213
- Added AWS Billing credits support via a `credits` tool (`GetCredits`, `GetCreditAllocationHistory`) covering credit balance, expiration, product applicability, sharing configuration, and the per-service allocation ledger
1314
- Added AWS Compute Optimizer Automation support via a `compute-optimizer-automation` tool (`GetAutomationEvent`, `GetAutomationRule`, `GetEnrollmentConfiguration`, `ListAccounts`, `ListAutomationEvents`, `ListAutomationEventSteps`, `ListAutomationEventSummaries`, `ListAutomationRules`, `ListRecommendedActions`, `ListRecommendedActionSummaries`, `ListAutomationRulePreview`, `ListAutomationRulePreviewSummaries`, `ListTagsForResource`)
1415
- Extending support for Billing and Cost Management Pricing Calculator's Workload estimate (`CreateWorkloadEstimate`, `BatchCreateWorkloadEstimateUsage`).
@@ -26,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2627
- Added an optional `context` parameter to the Cost Explorer `get_dimension_values` operation, so `SAVINGS_PLANS` returns values scoped to the plans an account owns rather than to its usage
2728

2829
### Fixed
30+
- Fixed `get_savings_plans_utilization` returning "No Savings Plans utilization data found" on every call (#3351). It read `SavingsPlansUtilizations` where the API returns `SavingsPlansUtilizationsByTime`, and behind that early return the figures were read from each row's top level as `{Amount, Unit}` mappings, but the API nests them under `Utilization`, `Savings` and `AmortizedCommitment` as decimal strings. Rows and `Total` now pass through as the API sends them, preserving decimal precision and the previously discarded blocks
31+
- Fixed `get_savings_plans_utilization_details` reporting every plan's utilization and savings as zero, from the same two causes. Rows now pass through as the API sends them, including the previously discarded `AmortizedCommitment` block and the whole of `Attributes` -- superseding the `summary` block, which read lowercase keys the API does not send, and exposing the owning `AccountId`
2932
- Corrected AWS Compute Optimizer recommendation response field names so EC2, Auto Scaling group, Lambda, and RDS tools return actual values instead of null. Fixed the shared savings-opportunity parser (`savingsOpportunityPercentage`), projected utilization metrics, EC2/RDS idle flags, nested ASG instance types, and the RDS instance/storage recommendation schema.
3033
- Corrected AWS Compute Optimizer ECS and Lambda recommendation response field names that read non-existent SDK fields and returned null. ECS now reads `currentPerformanceRisk`, `autoScalingConfiguration`, and `projectedUtilizationMetrics` (previously the non-existent `currentPerformance`, `autoScalingGroupArn`, and `projectedPerformance`), and Lambda derives the function name from the ARN (there is no `functionName` field).
3134

src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/ri_performance_tools.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,4 +477,39 @@ def format_utilization_metrics(utilization_data: Dict) -> Dict:
477477
'UtilizationPercentageInUnits'
478478
]
479479

480+
if 'OnDemandCostOfRIHoursUsed' in utilization_data:
481+
formatted_utilization['on_demand_cost_of_ri_hours_used'] = utilization_data[
482+
'OnDemandCostOfRIHoursUsed'
483+
]
484+
485+
if 'NetRISavings' in utilization_data:
486+
formatted_utilization['net_ri_savings'] = utilization_data['NetRISavings']
487+
488+
if 'TotalPotentialRISavings' in utilization_data:
489+
formatted_utilization['total_potential_ri_savings'] = utilization_data[
490+
'TotalPotentialRISavings'
491+
]
492+
493+
if 'AmortizedUpfrontFee' in utilization_data:
494+
formatted_utilization['amortized_upfront_fee'] = utilization_data['AmortizedUpfrontFee']
495+
496+
if 'AmortizedRecurringFee' in utilization_data:
497+
formatted_utilization['amortized_recurring_fee'] = utilization_data[
498+
'AmortizedRecurringFee'
499+
]
500+
501+
if 'TotalAmortizedFee' in utilization_data:
502+
formatted_utilization['total_amortized_fee'] = utilization_data['TotalAmortizedFee']
503+
504+
if 'RICostForUnusedHours' in utilization_data:
505+
formatted_utilization['ri_cost_for_unused_hours'] = utilization_data[
506+
'RICostForUnusedHours'
507+
]
508+
509+
if 'RealizedSavings' in utilization_data:
510+
formatted_utilization['realized_savings'] = utilization_data['RealizedSavings']
511+
512+
if 'UnrealizedSavings' in utilization_data:
513+
formatted_utilization['unrealized_savings'] = utilization_data['UnrealizedSavings']
514+
480515
return formatted_utilization

src/billing-cost-management-mcp-server/awslabs/billing_cost_management_mcp_server/tools/sp_performance_tools.py

Lines changed: 22 additions & 241 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
)
2828
from ..utilities.logging_utils import get_context_logger
2929
from fastmcp import Context, FastMCP
30-
from typing import Any, Dict, Optional, Union
30+
from typing import Any, Dict, Optional
3131

3232

3333
sp_performance_server = FastMCP(
@@ -234,7 +234,7 @@ async def get_savings_plans_utilization(
234234
)
235235

236236
# Prepare the request parameters
237-
request_params = {
237+
request_params: Dict[str, Any] = {
238238
'TimePeriod': {'Start': start, 'End': end},
239239
'Granularity': granularity,
240240
}
@@ -243,147 +243,29 @@ async def get_savings_plans_utilization(
243243
if filter_expr:
244244
request_params['Filter'] = parse_json(filter_expr, 'filter')
245245

246-
# Use the paginate_aws_response utility for consistent pagination
247-
all_utilizations, pagination_metadata = await paginate_aws_response(
248-
ctx=ctx,
249-
operation_name='GetSavingsPlansUtilization',
250-
api_function=ce_client.get_savings_plans_utilization,
251-
request_params=request_params,
252-
result_key='SavingsPlansUtilizations',
253-
token_param='NextToken',
254-
token_key='NextToken',
255-
max_pages=None,
256-
)
257-
258-
# Check if we have any utilization data
259-
if not all_utilizations:
260-
await ctx_logger.warning(
261-
'No Savings Plans utilization data found for the specified period'
262-
)
263-
return format_response(
264-
'success',
265-
{
266-
'savings_plans_utilizations': [],
267-
'pagination': pagination_metadata,
268-
'time_period': {'start': start, 'end': end},
269-
'granularity': granularity,
270-
'message': 'No Savings Plans utilization data found for the specified period. This could be because you do not have any active Savings Plans, or because the specified date range is outside your Savings Plans period.',
271-
},
272-
)
246+
response = ce_client.get_savings_plans_utilization(**request_params)
273247

274-
# Format utilization data for better readability with proper default values
275-
formatted_utilizations = []
276-
for utilization in all_utilizations:
277-
# Helper function to parse monetary values with defaults
278-
def parse_monetary_value(key: str) -> Dict[str, Union[float, str]]:
279-
value = utilization.get(key, {})
280-
if not value or not isinstance(value, dict):
281-
return {'amount': 0.0, 'currency': 'USD', 'formatted': '0.0 USD'}
282-
283-
amount = value.get('Amount', 0.0)
284-
# Handle numeric strings or None values
285-
try:
286-
amount = float(amount) if amount is not None else 0.0
287-
except (ValueError, TypeError):
288-
amount = 0.0
289-
290-
currency = value.get('Unit', 'USD')
291-
return {
292-
'amount': amount,
293-
'currency': currency,
294-
'formatted': f'{amount} {currency}',
295-
}
296-
297-
# Get time period with defaults
298-
time_period = utilization.get('TimePeriod', {})
299-
if not time_period:
300-
time_period = {'Start': start, 'End': end}
301-
302-
# Get utilization percentage with default
303-
utilization_pct = utilization.get('UtilizationPercentage')
304-
if utilization_pct is None:
305-
utilization_pct = 0.0
306-
else:
307-
try:
308-
utilization_pct = float(utilization_pct)
309-
except (ValueError, TypeError):
310-
utilization_pct = 0.0
311-
312-
# Build formatted utilization with proper defaults
313-
formatted_utilization = {
314-
'time_period': time_period,
315-
'total_commitment': parse_monetary_value('TotalCommitment'),
316-
'used_commitment': parse_monetary_value('UsedCommitment'),
317-
'unused_commitment': parse_monetary_value('UnusedCommitment'),
318-
'utilization_percentage': utilization_pct,
319-
'savings_plans_count': utilization.get('SavingsPlansCount', 0),
320-
}
321-
formatted_utilizations.append(formatted_utilization)
248+
utilizations_by_time = response.get('SavingsPlansUtilizationsByTime', [])
249+
total = response.get('Total')
322250

323-
# Format the response data
324-
formatted_response = {
325-
'savings_plans_utilizations': formatted_utilizations,
326-
'pagination': pagination_metadata,
251+
formatted_response: Dict[str, Any] = {
252+
'savings_plans_utilizations': utilizations_by_time,
327253
'time_period': {'start': start, 'end': end},
328254
'granularity': granularity,
329255
}
330256

331-
# Add total utilization if available
332-
try:
333-
# We need to make one call to get the Total
334-
initial_response = ce_client.get_savings_plans_utilization(**request_params)
335-
if 'Total' in initial_response:
336-
total = initial_response['Total']
337-
338-
# Parse total values with defaults
339-
def parse_total_monetary_value(key: str) -> Dict[str, Union[float, str]]:
340-
value = total.get(key, {})
341-
if not value or not isinstance(value, dict):
342-
return {'amount': 0.0, 'currency': 'USD', 'formatted': '0.0 USD'}
343-
344-
amount = value.get('Amount', 0.0)
345-
# Handle numeric strings or None values
346-
try:
347-
amount = float(amount) if amount is not None else 0.0
348-
except (ValueError, TypeError):
349-
amount = 0.0
350-
351-
currency = value.get('Unit', 'USD')
352-
return {
353-
'amount': amount,
354-
'currency': currency,
355-
'formatted': f'{amount} {currency}',
356-
}
357-
358-
# Get utilization percentage with default
359-
total_utilization_pct = total.get('UtilizationPercentage')
360-
if total_utilization_pct is None:
361-
total_utilization_pct = 0.0
362-
else:
363-
try:
364-
total_utilization_pct = float(total_utilization_pct)
365-
except (ValueError, TypeError):
366-
total_utilization_pct = 0.0
367-
368-
formatted_response['total'] = {
369-
'total_commitment': parse_total_monetary_value('TotalCommitment'),
370-
'used_commitment': parse_total_monetary_value('UsedCommitment'),
371-
'unused_commitment': parse_total_monetary_value('UnusedCommitment'),
372-
'utilization_percentage': total_utilization_pct,
373-
}
374-
375-
except Exception as e:
376-
# Log but don't fail if we can't get total
377-
await ctx_logger.warning(f'Could not retrieve total utilization data: {str(e)}')
378-
# Provide default total based on summing values if possible
379-
if formatted_utilizations:
380-
total_util_pct = sum(
381-
item['utilization_percentage'] for item in formatted_utilizations
382-
) / len(formatted_utilizations)
383-
formatted_response['total'] = {
384-
'utilization_percentage': total_util_pct,
385-
'note': 'Estimated from individual utilization data',
386-
}
257+
if total is not None:
258+
formatted_response['total'] = total
259+
260+
if not utilizations_by_time and total is None:
261+
await ctx_logger.warning(
262+
'No Savings Plans utilization data found for the specified period'
263+
)
264+
formatted_response['message'] = (
265+
'No Savings Plans utilization data found for the specified period. This could be '
266+
'because you do not have any active Savings Plans, or because the specified date '
267+
'range is outside your Savings Plans period.'
268+
)
387269

388270
return format_response('success', formatted_response)
389271

@@ -465,114 +347,13 @@ async def get_savings_plans_utilization_details(
465347
},
466348
)
467349

468-
# Format utilization details for better readability
469-
formatted_details = []
470-
for detail in all_details:
471-
# Helper function to parse monetary values with defaults
472-
def parse_monetary_value(key: str) -> Dict[str, Union[float, str]]:
473-
value = detail.get(key, {})
474-
if not value or not isinstance(value, dict):
475-
return {'amount': 0.0, 'currency': 'USD', 'formatted': '0.0 USD'}
476-
477-
amount = value.get('Amount', 0.0)
478-
# Handle numeric strings or None values
479-
try:
480-
amount = float(amount) if amount is not None else 0.0
481-
except (ValueError, TypeError):
482-
amount = 0.0
483-
484-
currency = value.get('Unit', 'USD')
485-
return {
486-
'amount': amount,
487-
'currency': currency,
488-
'formatted': f'{amount} {currency}',
489-
}
490-
491-
# Get utilization percentage with default
492-
utilization_pct = detail.get('UtilizationPercentage')
493-
if utilization_pct is None:
494-
utilization_pct = 0.0
495-
else:
496-
try:
497-
utilization_pct = float(utilization_pct)
498-
except (ValueError, TypeError):
499-
utilization_pct = 0.0
500-
501-
# Build formatted detail with proper defaults
502-
formatted_detail = {
503-
'savings_plan_arn': detail.get('SavingsPlanArn', ''),
504-
'attributes': detail.get('Attributes', {}),
505-
'utilization': {
506-
'total_commitment': parse_monetary_value('TotalCommitment'),
507-
'used_commitment': parse_monetary_value('UsedCommitment'),
508-
'unused_commitment': parse_monetary_value('UnusedCommitment'),
509-
'utilization_percentage': utilization_pct,
510-
},
511-
'savings': {
512-
'net_savings': parse_monetary_value('NetSavings'),
513-
'on_demand_cost_equivalent': parse_monetary_value('OnDemandCostEquivalent'),
514-
'amortized_upfront_fee': parse_monetary_value('AmortizedUpfrontFee'),
515-
'recurring_commitment': parse_monetary_value('RecurringCommitment'),
516-
},
517-
}
518-
519-
# Extract relevant information from attributes if available
520-
if 'Attributes' in detail and detail['Attributes']:
521-
attributes = detail['Attributes']
522-
523-
# Format and extract useful attribute information
524-
region = attributes.get('region')
525-
instance_family = attributes.get('instanceFamily')
526-
savings_plan_type = attributes.get('savingsPlanType')
527-
528-
# Add formatted attribute info
529-
if region or instance_family or savings_plan_type:
530-
formatted_detail['summary'] = {
531-
'region': region,
532-
'instance_family': instance_family,
533-
'savings_plan_type': savings_plan_type,
534-
}
535-
536-
formatted_details.append(formatted_detail)
537-
538-
# Format the response data
539-
formatted_response = {
540-
'savings_plans_utilization_details': formatted_details,
350+
formatted_response: Dict[str, Any] = {
351+
'savings_plans_utilization_details': all_details,
541352
'pagination': pagination_metadata,
542353
'time_period': {'start': start, 'end': end},
543-
'total_count': len(formatted_details),
354+
'total_count': len(all_details),
544355
}
545356

546-
# Add summary stats
547-
if formatted_details:
548-
try:
549-
total_utilization = sum(
550-
detail['utilization']['utilization_percentage'] for detail in formatted_details
551-
) / len(formatted_details)
552-
formatted_response['average_utilization_percentage'] = round(total_utilization, 2)
553-
554-
total_plans = len(formatted_details)
555-
formatted_response['total_savings_plans'] = total_plans
556-
557-
# Calculate fully utilized plans (>95%)
558-
fully_utilized = sum(
559-
1
560-
for detail in formatted_details
561-
if detail['utilization']['utilization_percentage'] >= 95.0
562-
)
563-
formatted_response['fully_utilized_plans'] = fully_utilized
564-
565-
# Calculate underutilized plans (<80%)
566-
under_utilized = sum(
567-
1
568-
for detail in formatted_details
569-
if detail['utilization']['utilization_percentage'] < 80.0
570-
)
571-
formatted_response['under_utilized_plans'] = under_utilized
572-
573-
except Exception as e:
574-
await ctx_logger.warning(f'Could not compute summary statistics: {str(e)}')
575-
576357
return format_response('success', formatted_response)
577358

578359
except Exception as e:

0 commit comments

Comments
 (0)