awslabs.cost-explorer-mcp-server 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,719 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Cost Explorer MCP server implementation.
16
+
17
+ Comparison tools for Cost Explorer MCP Server.
18
+ """
19
+
20
+ import os
21
+ import sys
22
+ from awslabs.cost_explorer_mcp_server.constants import VALID_COST_METRICS
23
+ from awslabs.cost_explorer_mcp_server.helpers import (
24
+ create_detailed_group_key,
25
+ extract_group_key_from_complex_selector,
26
+ extract_usage_context_from_selector,
27
+ get_cost_explorer_client,
28
+ validate_comparison_date_range,
29
+ validate_expression,
30
+ validate_group_by,
31
+ )
32
+ from awslabs.cost_explorer_mcp_server.models import DateRange
33
+ from loguru import logger
34
+ from mcp.server.fastmcp import Context
35
+ from pydantic import Field
36
+ from typing import Any, Dict, Optional, Tuple, Union
37
+
38
+
39
+ # Configure Loguru logging
40
+ logger.remove()
41
+ logger.add(sys.stderr, level=os.getenv('FASTMCP_LOG_LEVEL', 'WARNING'))
42
+
43
+ # Constants
44
+ DEFAULT_GROUP_BY = {'Type': 'DIMENSION', 'Key': 'SERVICE'}
45
+ DEFAULT_METRIC = 'UnblendedCost'
46
+
47
+
48
+ def _validate_comparison_inputs(
49
+ baseline_date_range: DateRange,
50
+ comparison_date_range: DateRange,
51
+ metric_for_comparison: str,
52
+ group_by: Optional[Union[Dict[str, str], str]],
53
+ filter_expression: Optional[Dict[str, Any]],
54
+ ) -> Tuple[bool, Optional[str], Dict[str, Any]]:
55
+ """Validate inputs and prepare comparison request parameters.
56
+
57
+ Args:
58
+ baseline_date_range: Baseline period for comparison
59
+ comparison_date_range: Comparison period
60
+ metric_for_comparison: Cost metric to compare
61
+ group_by: Grouping configuration
62
+ filter_expression: Optional filter criteria
63
+
64
+ Returns:
65
+ Tuple of (is_valid, error_message, validated_params)
66
+ """
67
+ baseline_start = baseline_date_range.start_date
68
+ baseline_end = baseline_date_range.end_date
69
+ comparison_start = comparison_date_range.start_date
70
+ comparison_end = comparison_date_range.end_date
71
+
72
+ # Validate both date ranges meet comparison API requirements
73
+ is_valid_baseline, error_baseline = validate_comparison_date_range(
74
+ baseline_start, baseline_end
75
+ )
76
+ if not is_valid_baseline:
77
+ return False, f'Baseline period error: {error_baseline}', {}
78
+
79
+ is_valid_comparison, error_comparison = validate_comparison_date_range(
80
+ comparison_start, comparison_end
81
+ )
82
+ if not is_valid_comparison:
83
+ return False, f'Comparison period error: {error_comparison}', {}
84
+
85
+ # Validate metric
86
+ if metric_for_comparison not in VALID_COST_METRICS:
87
+ return (
88
+ False,
89
+ f'Invalid metric_for_comparison: {metric_for_comparison}. Valid values are {", ".join(VALID_COST_METRICS)}.',
90
+ {},
91
+ )
92
+
93
+ # Validate filter expression if provided
94
+ if filter_expression:
95
+ validation_result = validate_expression(filter_expression, baseline_start, baseline_end)
96
+ if 'error' in validation_result:
97
+ return False, validation_result['error'], {}
98
+
99
+ # Process and validate group_by
100
+ if group_by is None:
101
+ group_by = DEFAULT_GROUP_BY.copy()
102
+ elif isinstance(group_by, str):
103
+ group_by = {'Type': 'DIMENSION', 'Key': group_by}
104
+
105
+ validation_result = validate_group_by(group_by)
106
+ if 'error' in validation_result:
107
+ return False, validation_result['error'], {}
108
+
109
+ return (
110
+ True,
111
+ None,
112
+ {
113
+ 'baseline_start': baseline_start,
114
+ 'baseline_end': baseline_end,
115
+ 'comparison_start': comparison_start,
116
+ 'comparison_end': comparison_end,
117
+ 'metric': metric_for_comparison,
118
+ 'group_by': group_by,
119
+ 'filter_criteria': filter_expression,
120
+ },
121
+ )
122
+
123
+
124
+ def _build_api_params(
125
+ baseline_start: str,
126
+ baseline_end: str,
127
+ comparison_start: str,
128
+ comparison_end: str,
129
+ metric: str,
130
+ group_by: Dict[str, str],
131
+ filter_criteria: Optional[Dict[str, Any]],
132
+ ) -> Dict[str, Any]:
133
+ """Build AWS API parameters from validated request parameters.
134
+
135
+ Args:
136
+ baseline_start: Baseline period start date
137
+ baseline_end: Baseline period end date
138
+ comparison_start: Comparison period start date
139
+ comparison_end: Comparison period end date
140
+ metric: Cost metric to compare
141
+ group_by: Grouping configuration
142
+ filter_criteria: Optional filter criteria
143
+
144
+ Returns:
145
+ Dictionary with AWS API parameters
146
+ """
147
+ params = {
148
+ 'BaselineTimePeriod': {
149
+ 'Start': baseline_start,
150
+ 'End': baseline_end,
151
+ },
152
+ 'ComparisonTimePeriod': {
153
+ 'Start': comparison_start,
154
+ 'End': comparison_end,
155
+ },
156
+ 'MetricForComparison': metric,
157
+ 'GroupBy': [{'Type': group_by['Type'].upper(), 'Key': group_by['Key']}],
158
+ }
159
+
160
+ if filter_criteria:
161
+ params['Filter'] = filter_criteria
162
+
163
+ return params
164
+
165
+
166
+ async def get_cost_and_usage_comparisons(
167
+ ctx: Context,
168
+ baseline_date_range: DateRange,
169
+ comparison_date_range: DateRange,
170
+ metric_for_comparison: str = Field(
171
+ 'UnblendedCost',
172
+ description=f'The cost and usage metric to compare. Valid values are {", ".join(VALID_COST_METRICS)}.',
173
+ ),
174
+ group_by: Optional[Union[Dict[str, str], str]] = Field(
175
+ 'SERVICE',
176
+ description="Either a dictionary with Type and Key for grouping comparisons, or simply a string key to group by (which will default to DIMENSION type). Example dictionary: {'Type': 'DIMENSION', 'Key': 'SERVICE'}. Example string: 'SERVICE'.",
177
+ ),
178
+ filter_expression: Optional[Dict[str, Any]] = Field(
179
+ None,
180
+ description='Filter criteria as a Python dictionary to narrow down AWS cost comparisons. Supports filtering by Dimensions (SERVICE, REGION, etc.), Tags, or CostCategories. You can use logical operators (And, Or, Not) for complex filters. Same format as get_cost_and_usage filter_expression.',
181
+ ),
182
+ ) -> Dict[str, Any]:
183
+ """Compare AWS costs and usage between two time periods.
184
+
185
+ This tool compares cost and usage data between a baseline period and a comparison period,
186
+ providing percentage changes and absolute differences. Both periods must be exactly one month
187
+ and start/end on the first day of a month. The tool also provides detailed cost drivers
188
+ when available, showing what specific factors contributed to cost changes.
189
+
190
+ Important requirements:
191
+ - Both periods must be exactly one month duration
192
+ - Dates must start and end on the first day of a month (e.g., 2025-01-01 to 2025-02-01)
193
+ - Maximum lookback of 13 months (38 months if multi-year data enabled)
194
+ - Start dates must be equal to or no later than current date
195
+
196
+ Example: Compare January 2025 vs December 2024 EC2 costs
197
+ await get_cost_and_usage_comparisons(
198
+ ctx=context,
199
+ baseline_date_range={
200
+ "start_date": "2024-12-01", # December 2024
201
+ "end_date": "2025-01-01"
202
+ },
203
+ comparison_date_range={
204
+ "start_date": "2025-01-01", # January 2025
205
+ "end_date": "2025-02-01"
206
+ },
207
+ metric_for_comparison="UnblendedCost",
208
+ group_by={"Type": "DIMENSION", "Key": "SERVICE"},
209
+ filter_expression={
210
+ "Dimensions": {
211
+ "Key": "SERVICE",
212
+ "Values": ["Amazon Elastic Compute Cloud - Compute"],
213
+ "MatchOptions": ["EQUALS"]
214
+ }
215
+ }
216
+ )
217
+
218
+ Args:
219
+ ctx: MCP context
220
+ baseline_date_range: The reference period for comparison (exactly one month)
221
+ comparison_date_range: The comparison period (exactly one month)
222
+ metric_for_comparison: Cost metric to compare (UnblendedCost, BlendedCost, etc.)
223
+ group_by: Either a dictionary with Type and Key, or simply a string key to group by
224
+ filter_expression: Filter criteria as a Python dictionary
225
+
226
+ Returns:
227
+ Dictionary containing comparison data with percentage changes, absolute differences,
228
+ and detailed cost drivers when available
229
+ """
230
+ # Initialize variables for error handling
231
+ baseline_start = baseline_date_range.start_date
232
+ baseline_end = baseline_date_range.end_date
233
+ comparison_start = comparison_date_range.start_date
234
+ comparison_end = comparison_date_range.end_date
235
+
236
+ try:
237
+ # Validate inputs using validation function
238
+ is_valid, error_msg, validated_params = _validate_comparison_inputs(
239
+ baseline_date_range,
240
+ comparison_date_range,
241
+ metric_for_comparison,
242
+ group_by,
243
+ filter_expression,
244
+ )
245
+
246
+ if not is_valid:
247
+ return {'error': error_msg}
248
+
249
+ # Extract validated parameters
250
+ validated_baseline_start = validated_params['baseline_start']
251
+ validated_baseline_end = validated_params['baseline_end']
252
+ validated_comparison_start = validated_params['comparison_start']
253
+ validated_comparison_end = validated_params['comparison_end']
254
+ validated_metric = validated_params['metric']
255
+ validated_group_by = validated_params['group_by']
256
+ validated_filter_criteria = validated_params['filter_criteria']
257
+
258
+ # Prepare API call parameters
259
+ api_params = _build_api_params(
260
+ validated_baseline_start,
261
+ validated_baseline_end,
262
+ validated_comparison_start,
263
+ validated_comparison_end,
264
+ validated_metric,
265
+ validated_group_by,
266
+ validated_filter_criteria,
267
+ )
268
+
269
+ # Get comparison data
270
+ grouped_comparisons = {}
271
+ next_token = None
272
+ ce = get_cost_explorer_client()
273
+
274
+ while True:
275
+ if next_token:
276
+ api_params['NextPageToken'] = next_token
277
+
278
+ try:
279
+ response = ce.get_cost_and_usage_comparisons(**api_params)
280
+ except Exception as e:
281
+ logger.error(f'Error calling Cost Explorer comparison API: {e}')
282
+ return {'error': f'AWS Cost Explorer comparison API error: {str(e)}'}
283
+
284
+ # Process comparison results
285
+ for comparison_result in response.get('CostAndUsageComparisons', []):
286
+ # Extract group key from CostAndUsageSelector
287
+ selector = comparison_result.get('CostAndUsageSelector', {})
288
+ group_key = 'Unknown'
289
+
290
+ # Extract the actual dimension value (e.g., service name)
291
+ if 'Dimensions' in selector:
292
+ dimension_info = selector['Dimensions']
293
+ if 'Values' in dimension_info and dimension_info['Values']:
294
+ group_key = dimension_info['Values'][0] # Use the first value as group key
295
+ elif 'Tags' in selector:
296
+ tag_info = selector['Tags']
297
+ if 'Values' in tag_info and tag_info['Values']:
298
+ group_key = f'{tag_info.get("Key", "Tag")}:{tag_info["Values"][0]}'
299
+ elif 'CostCategories' in selector:
300
+ cc_info = selector['CostCategories']
301
+ if 'Values' in cc_info and cc_info['Values']:
302
+ group_key = f'{cc_info.get("Key", "Category")}:{cc_info["Values"][0]}'
303
+
304
+ # Process metrics for this group
305
+ metrics = comparison_result.get('Metrics', {})
306
+
307
+ for metric_name, metric_data in metrics.items():
308
+ if metric_name == metric_for_comparison:
309
+ baseline_amount = float(metric_data.get('BaselineTimePeriodAmount', 0))
310
+ comparison_amount = float(metric_data.get('ComparisonTimePeriodAmount', 0))
311
+ difference = float(metric_data.get('Difference', 0))
312
+ unit = metric_data.get('Unit', 'USD')
313
+
314
+ # Calculate percentage change
315
+ if baseline_amount != 0:
316
+ percentage_change = (difference / baseline_amount) * 100
317
+ else:
318
+ percentage_change = 100.0 if comparison_amount > 0 else 0.0
319
+
320
+ grouped_comparisons[group_key] = {
321
+ 'baseline_value': round(baseline_amount, 2),
322
+ 'comparison_value': round(comparison_amount, 2),
323
+ 'absolute_change': round(difference, 2),
324
+ 'percentage_change': round(percentage_change, 2),
325
+ 'unit': unit,
326
+ }
327
+
328
+ next_token = response.get('NextPageToken')
329
+ if not next_token:
330
+ break
331
+
332
+ # Process total cost and usage
333
+ total_data = {}
334
+ total_cost_and_usage = response.get('TotalCostAndUsage', {})
335
+
336
+ for metric_name, metric_data in total_cost_and_usage.items():
337
+ if metric_name == metric_for_comparison:
338
+ baseline_total = float(metric_data.get('BaselineTimePeriodAmount', 0))
339
+ comparison_total = float(metric_data.get('ComparisonTimePeriodAmount', 0))
340
+ difference_total = float(metric_data.get('Difference', 0))
341
+ unit = metric_data.get('Unit', 'USD')
342
+
343
+ # Calculate total percentage change
344
+ if baseline_total != 0:
345
+ total_percentage_change = (difference_total / baseline_total) * 100
346
+ else:
347
+ total_percentage_change = 100.0 if comparison_total > 0 else 0.0
348
+
349
+ total_data = {
350
+ 'baseline_value': round(baseline_total, 2),
351
+ 'comparison_value': round(comparison_total, 2),
352
+ 'absolute_change': round(difference_total, 2),
353
+ 'percentage_change': round(total_percentage_change, 2),
354
+ 'unit': unit,
355
+ }
356
+ break # We found our metric
357
+
358
+ # If no total data was found, calculate from grouped data
359
+ if not total_data and grouped_comparisons:
360
+ total_baseline = sum(comp['baseline_value'] for comp in grouped_comparisons.values())
361
+ total_comparison = sum(
362
+ comp['comparison_value'] for comp in grouped_comparisons.values()
363
+ )
364
+ total_difference = sum(
365
+ comp['absolute_change'] for comp in grouped_comparisons.values()
366
+ )
367
+
368
+ if total_baseline != 0:
369
+ total_percentage_change = (total_difference / total_baseline) * 100
370
+ else:
371
+ total_percentage_change = 100.0 if total_comparison > 0 else 0.0
372
+
373
+ total_data = {
374
+ 'baseline_value': round(total_baseline, 2),
375
+ 'comparison_value': round(total_comparison, 2),
376
+ 'absolute_change': round(total_difference, 2),
377
+ 'percentage_change': round(total_percentage_change, 2),
378
+ 'unit': list(grouped_comparisons.values())[0].get('unit', 'USD')
379
+ if grouped_comparisons
380
+ else 'USD',
381
+ }
382
+
383
+ # Build response
384
+ result = {
385
+ 'baseline_period': f'{baseline_start} to {baseline_end}',
386
+ 'comparison_period': f'{comparison_start} to {comparison_end}',
387
+ 'metric': metric_for_comparison,
388
+ 'grouped_by': validated_group_by['Key'],
389
+ 'comparisons': grouped_comparisons,
390
+ 'total_comparison': total_data,
391
+ 'metadata': {
392
+ 'grouping_type': validated_group_by['Type'],
393
+ 'total_groups': len(grouped_comparisons),
394
+ },
395
+ }
396
+
397
+ return result
398
+
399
+ except Exception as e:
400
+ logger.error(
401
+ f'Error generating cost comparison between {baseline_start}-{baseline_end} and {comparison_start}-{comparison_end}: {e}'
402
+ )
403
+ return {'error': f'Error generating cost comparison: {str(e)}'}
404
+
405
+
406
+ async def get_cost_comparison_drivers(
407
+ ctx: Context,
408
+ baseline_date_range: DateRange,
409
+ comparison_date_range: DateRange,
410
+ metric_for_comparison: str = Field(
411
+ 'UnblendedCost',
412
+ description=f'The cost and usage metric to analyze drivers for. Valid values are {", ".join(VALID_COST_METRICS)}.',
413
+ ),
414
+ group_by: Optional[Union[Dict[str, str], str]] = Field(
415
+ 'SERVICE',
416
+ description="Either a dictionary with Type and Key for grouping driver analysis, or simply a string key to group by (which will default to DIMENSION type). Example dictionary: {'Type': 'DIMENSION', 'Key': 'SERVICE'}. Example string: 'SERVICE'.",
417
+ ),
418
+ filter_expression: Optional[Dict[str, Any]] = Field(
419
+ None,
420
+ description='Filter criteria as a Python dictionary to narrow down AWS cost driver analysis. Supports filtering by Dimensions (SERVICE, REGION, etc.), Tags, or CostCategories. You can use logical operators (And, Or, Not) for complex filters. Same format as get_cost_and_usage filter_expression.',
421
+ ),
422
+ ) -> Dict[str, Any]:
423
+ """Analyze what drove cost changes between two time periods.
424
+
425
+ This tool provides detailed analysis of the TOP 10 most significant cost drivers
426
+ that caused changes between periods. AWS returns only the most impactful drivers
427
+ to focus on the changes that matter most for cost optimization.
428
+
429
+ The tool provides rich insights including:
430
+ - Top 10 most significant cost drivers across all services (or filtered subset)
431
+ - Specific usage types that drove changes (e.g., "BoxUsage:c5.large", "NatGateway-Hours")
432
+ - Multiple driver types: usage changes, savings plan impacts, enterprise discounts, support fees
433
+ - Both cost and usage quantity changes with units (hours, GB-months, etc.)
434
+ - Context about what infrastructure components changed
435
+ - Detailed breakdown of usage patterns vs pricing changes
436
+
437
+ Can be used with or without filters:
438
+ - Without filters: Shows top 10 cost drivers across ALL services
439
+ - With filters: Shows top 10 cost drivers within the filtered scope
440
+ - Multiple services: Can filter to multiple services and get top 10 within that scope
441
+
442
+ Both periods must be exactly one month and start/end on the first day of a month.
443
+
444
+ Important requirements:
445
+ - Both periods must be exactly one month duration
446
+ - Dates must start and end on the first day of a month (e.g., 2025-01-01 to 2025-02-01)
447
+ - Maximum lookback of 13 months (38 months if multi-year data enabled)
448
+ - Start dates must be equal to or no later than current date
449
+ - Results limited to top 10 most significant drivers (no pagination)
450
+
451
+ Example: Analyze top 10 cost drivers across all services
452
+ await get_cost_comparison_drivers(
453
+ ctx=context,
454
+ baseline_date_range={
455
+ "start_date": "2024-12-01", # December 2024
456
+ "end_date": "2025-01-01"
457
+ },
458
+ comparison_date_range={
459
+ "start_date": "2025-01-01", # January 2025
460
+ "end_date": "2025-02-01"
461
+ },
462
+ metric_for_comparison="UnblendedCost",
463
+ group_by={"Type": "DIMENSION", "Key": "SERVICE"}
464
+ # No filter = top 10 drivers across all services
465
+ )
466
+
467
+ Example: Analyze top 10 cost drivers for specific services
468
+ await get_cost_comparison_drivers(
469
+ ctx=context,
470
+ baseline_date_range={
471
+ "start_date": "2024-12-01",
472
+ "end_date": "2025-01-01"
473
+ },
474
+ comparison_date_range={
475
+ "start_date": "2025-01-01",
476
+ "end_date": "2025-02-01"
477
+ },
478
+ metric_for_comparison="UnblendedCost",
479
+ group_by={"Type": "DIMENSION", "Key": "SERVICE"},
480
+ filter_expression={
481
+ "Dimensions": {
482
+ "Key": "SERVICE",
483
+ "Values": ["Amazon Elastic Compute Cloud - Compute", "Amazon Simple Storage Service"],
484
+ "MatchOptions": ["EQUALS"]
485
+ }
486
+ }
487
+ )
488
+
489
+ Args:
490
+ ctx: MCP context
491
+ baseline_date_range: The reference period for comparison (exactly one month)
492
+ comparison_date_range: The comparison period (exactly one month)
493
+ metric_for_comparison: Cost metric to analyze drivers for (UnblendedCost, BlendedCost, etc.)
494
+ group_by: Either a dictionary with Type and Key, or simply a string key to group by
495
+ filter_expression: Filter criteria as a Python dictionary
496
+
497
+ Returns:
498
+ with specific usage types, usage quantity changes, driver types (savings plans, discounts, usage changes, support fees), and contextual information
499
+ """
500
+ # Initialize variables for error handling
501
+ baseline_start = baseline_date_range.start_date
502
+ baseline_end = baseline_date_range.end_date
503
+ comparison_start = comparison_date_range.start_date
504
+ comparison_end = comparison_date_range.end_date
505
+ try:
506
+ # Validate inputs using validation function
507
+ is_valid, error_msg, validated_params = _validate_comparison_inputs(
508
+ baseline_date_range,
509
+ comparison_date_range,
510
+ metric_for_comparison,
511
+ group_by,
512
+ filter_expression,
513
+ )
514
+
515
+ if not is_valid:
516
+ return {'error': error_msg}
517
+
518
+ # Extract validated parameters
519
+ validated_baseline_start = validated_params['baseline_start']
520
+ validated_baseline_end = validated_params['baseline_end']
521
+ validated_comparison_start = validated_params['comparison_start']
522
+ validated_comparison_end = validated_params['comparison_end']
523
+ validated_metric = validated_params['metric']
524
+ validated_group_by = validated_params['group_by']
525
+ validated_filter_criteria = validated_params['filter_criteria']
526
+
527
+ # Prepare API call parameters
528
+ driver_api_params = _build_api_params(
529
+ validated_baseline_start,
530
+ validated_baseline_end,
531
+ validated_comparison_start,
532
+ validated_comparison_end,
533
+ validated_metric,
534
+ validated_group_by,
535
+ validated_filter_criteria,
536
+ )
537
+
538
+ # Get cost driver data
539
+ grouped_drivers = {}
540
+ next_token = None
541
+ ce = get_cost_explorer_client()
542
+
543
+ while True:
544
+ if next_token:
545
+ driver_api_params['NextPageToken'] = next_token
546
+
547
+ try:
548
+ response = ce.get_cost_comparison_drivers(**driver_api_params)
549
+ except Exception as e:
550
+ logger.error(f'Error calling Cost Explorer comparison drivers API: {e}')
551
+ return {'error': f'AWS Cost Explorer comparison drivers API error: {str(e)}'}
552
+
553
+ # Process cost comparison drivers
554
+ for driver_result in response.get('CostComparisonDrivers', []):
555
+ # Extract group key from CostSelector using improved logic
556
+ selector = driver_result.get('CostSelector', {})
557
+ group_key = extract_group_key_from_complex_selector(selector, validated_group_by)
558
+
559
+ # Extract comprehensive context (service, usage type, region, etc.)
560
+ usage_context = extract_usage_context_from_selector(selector)
561
+
562
+ # Create detailed group key with context
563
+ detailed_key = create_detailed_group_key(
564
+ group_key, usage_context, validated_group_by
565
+ )
566
+
567
+ # Process metrics for this group
568
+ metrics = driver_result.get('Metrics', {})
569
+
570
+ for metric_name, metric_data in metrics.items():
571
+ if metric_name == metric_for_comparison:
572
+ baseline_amount = float(metric_data.get('BaselineTimePeriodAmount', 0))
573
+ comparison_amount = float(metric_data.get('ComparisonTimePeriodAmount', 0))
574
+ difference = float(metric_data.get('Difference', 0))
575
+ unit = metric_data.get('Unit', 'USD')
576
+
577
+ # Calculate percentage change
578
+ if baseline_amount != 0:
579
+ percentage_change = (difference / baseline_amount) * 100
580
+ else:
581
+ percentage_change = 100.0 if comparison_amount > 0 else 0.0
582
+
583
+ driver_data = {
584
+ 'baseline_value': round(baseline_amount, 2),
585
+ 'comparison_value': round(comparison_amount, 2),
586
+ 'absolute_change': round(difference, 2),
587
+ 'percentage_change': round(percentage_change, 2),
588
+ 'unit': unit,
589
+ 'context': usage_context, # Full context information
590
+ 'primary_group_key': group_key, # The actual group key value
591
+ 'cost_drivers': [],
592
+ }
593
+
594
+ # Process detailed cost drivers
595
+ cost_drivers = driver_result.get('CostDrivers', [])
596
+ for driver in cost_drivers:
597
+ driver_metrics = driver.get('Metrics', {})
598
+
599
+ # Process the main comparison metric
600
+ if metric_for_comparison in driver_metrics:
601
+ driver_metric_data = driver_metrics[metric_for_comparison]
602
+ driver_baseline = float(
603
+ driver_metric_data.get('BaselineTimePeriodAmount', 0)
604
+ )
605
+ driver_comparison = float(
606
+ driver_metric_data.get('ComparisonTimePeriodAmount', 0)
607
+ )
608
+ driver_difference = float(driver_metric_data.get('Difference', 0))
609
+
610
+ # Calculate driver percentage change
611
+ if driver_baseline != 0:
612
+ driver_percentage = (driver_difference / driver_baseline) * 100
613
+ else:
614
+ driver_percentage = 100.0 if driver_comparison > 0 else 0.0
615
+
616
+ driver_info = {
617
+ 'type': driver.get('Type', 'Unknown'),
618
+ 'name': driver.get('Name', 'Unknown'),
619
+ 'baseline_value': round(driver_baseline, 2),
620
+ 'comparison_value': round(driver_comparison, 2),
621
+ 'absolute_change': round(driver_difference, 2),
622
+ 'percentage_change': round(driver_percentage, 2),
623
+ 'unit': driver_metric_data.get('Unit', 'USD'),
624
+ 'additional_metrics': {},
625
+ }
626
+
627
+ # Process additional metrics (like UsageQuantity)
628
+ for additional_metric, additional_data in driver_metrics.items():
629
+ if additional_metric != metric_for_comparison:
630
+ add_baseline = float(
631
+ additional_data.get('BaselineTimePeriodAmount', 0)
632
+ )
633
+ add_comparison = float(
634
+ additional_data.get('ComparisonTimePeriodAmount', 0)
635
+ )
636
+ add_difference = float(
637
+ additional_data.get('Difference', 0)
638
+ )
639
+ add_unit = additional_data.get('Unit', '')
640
+
641
+ # Calculate percentage for additional metric
642
+ if add_baseline != 0:
643
+ add_percentage = (add_difference / add_baseline) * 100
644
+ else:
645
+ add_percentage = 100.0 if add_comparison > 0 else 0.0
646
+
647
+ driver_info['additional_metrics'][additional_metric] = {
648
+ 'baseline_value': round(add_baseline, 2),
649
+ 'comparison_value': round(add_comparison, 2),
650
+ 'absolute_change': round(add_difference, 2),
651
+ 'percentage_change': round(add_percentage, 2),
652
+ 'unit': add_unit,
653
+ }
654
+
655
+ driver_data['cost_drivers'].append(driver_info)
656
+
657
+ # Sort cost drivers by absolute impact (descending)
658
+ driver_data['cost_drivers'].sort(
659
+ key=lambda x: abs(x['absolute_change']), reverse=True
660
+ )
661
+
662
+ grouped_drivers[detailed_key] = driver_data
663
+
664
+ next_token = response.get('NextPageToken')
665
+ if not next_token:
666
+ break
667
+
668
+ # Calculate totals from grouped data
669
+ total_baseline = sum(driver['baseline_value'] for driver in grouped_drivers.values())
670
+ total_comparison = sum(driver['comparison_value'] for driver in grouped_drivers.values())
671
+ total_difference = sum(driver['absolute_change'] for driver in grouped_drivers.values())
672
+
673
+ if total_baseline != 0:
674
+ total_percentage_change = (total_difference / total_baseline) * 100
675
+ else:
676
+ total_percentage_change = 100.0 if total_comparison > 0 else 0.0
677
+
678
+ # Build response
679
+ result = {
680
+ 'baseline_period': f'{baseline_start} to {baseline_end}',
681
+ 'comparison_period': f'{comparison_start} to {comparison_end}',
682
+ 'metric': metric_for_comparison,
683
+ 'grouped_by': validated_group_by['Key'],
684
+ 'driver_analysis': grouped_drivers,
685
+ 'total_analysis': {
686
+ 'baseline_value': round(total_baseline, 2),
687
+ 'comparison_value': round(total_comparison, 2),
688
+ 'absolute_change': round(total_difference, 2),
689
+ 'percentage_change': round(total_percentage_change, 2),
690
+ 'unit': list(grouped_drivers.values())[0].get('unit', 'USD')
691
+ if grouped_drivers
692
+ else 'USD',
693
+ },
694
+ 'metadata': {
695
+ 'grouping_type': validated_group_by['Type'],
696
+ 'total_groups': len(grouped_drivers),
697
+ 'total_drivers': sum(
698
+ len(driver.get('cost_drivers', [])) for driver in grouped_drivers.values()
699
+ ),
700
+ 'has_usage_context': any(
701
+ driver.get('context') for driver in grouped_drivers.values()
702
+ ),
703
+ 'has_additional_metrics': any(
704
+ any(
705
+ cost_driver.get('additional_metrics')
706
+ for cost_driver in driver.get('cost_drivers', [])
707
+ )
708
+ for driver in grouped_drivers.values()
709
+ ),
710
+ },
711
+ }
712
+
713
+ return result
714
+
715
+ except Exception as e:
716
+ logger.error(
717
+ f'Error generating cost driver analysis between {baseline_start}-{baseline_end} and {comparison_start}-{comparison_end}: {e}'
718
+ )
719
+ return {'error': f'Error generating cost driver analysis: {str(e)}'}