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.
- awslabs/cost_explorer_mcp_server/comparison_handler.py +719 -0
- awslabs/cost_explorer_mcp_server/constants.py +106 -0
- awslabs/cost_explorer_mcp_server/cost_usage_handler.py +385 -0
- awslabs/cost_explorer_mcp_server/forecasting_handler.py +234 -0
- awslabs/cost_explorer_mcp_server/helpers.py +501 -68
- awslabs/cost_explorer_mcp_server/metadata_handler.py +88 -0
- awslabs/cost_explorer_mcp_server/models.py +70 -0
- awslabs/cost_explorer_mcp_server/server.py +60 -463
- awslabs/cost_explorer_mcp_server/utility_handler.py +50 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/METADATA +44 -14
- awslabs_cost_explorer_mcp_server-0.0.6.dist-info/RECORD +17 -0
- awslabs_cost_explorer_mcp_server-0.0.4.dist-info/RECORD +0 -10
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/WHEEL +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/entry_points.txt +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/licenses/LICENSE +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.4.dist-info → awslabs_cost_explorer_mcp_server-0.0.6.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
"""Constants for the Cost Explorer MCP server."""
|
|
16
|
+
|
|
17
|
+
from typing import List
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# AWS Cost Explorer supported dimensions
|
|
21
|
+
VALID_DIMENSIONS: List[str] = [
|
|
22
|
+
'AZ', # The Availability Zone. An example is us-east-1a.
|
|
23
|
+
'BILLING_ENTITY', # The Amazon Web Services seller that your account is with
|
|
24
|
+
'CACHE_ENGINE', # The Amazon ElastiCache operating system. Examples are Windows or Linux.
|
|
25
|
+
'DEPLOYMENT_OPTION', # The scope of Amazon Relational Database Service deployments. Valid values are SingleAZ and MultiAZ.
|
|
26
|
+
'DATABASE_ENGINE', # The Amazon Relational Database Service database. Examples are Aurora or MySQL.
|
|
27
|
+
'INSTANCE_TYPE', # The type of Amazon EC2 instance. An example is m4.xlarge.
|
|
28
|
+
'INSTANCE_TYPE_FAMILY', # A family of instance types optimized to fit different use cases
|
|
29
|
+
'INVOICING_ENTITY', # The name of the entity that issues the Amazon Web Services invoice
|
|
30
|
+
'LEGAL_ENTITY_NAME', # The name of the organization that sells you Amazon Web Services services
|
|
31
|
+
'LINKED_ACCOUNT', # The description in the attribute map that includes the full name of the member account
|
|
32
|
+
'OPERATING_SYSTEM', # The operating system. Examples are Windows or Linux.
|
|
33
|
+
'OPERATION', # The action performed. Examples include RunInstance and CreateBucket.
|
|
34
|
+
'PLATFORM', # The Amazon EC2 operating system. Examples are Windows or Linux.
|
|
35
|
+
'PURCHASE_TYPE', # The reservation type of the purchase that this usage is related to
|
|
36
|
+
'RESERVATION_ID', # The unique identifier for an Amazon Web Services Reservation Instance
|
|
37
|
+
'SAVINGS_PLAN_ARN', # The unique identifier for your Savings Plans
|
|
38
|
+
'SAVINGS_PLANS_TYPE', # Type of Savings Plans (EC2 Instance or Compute)
|
|
39
|
+
'SERVICE', # The Amazon Web Services service such as Amazon DynamoDB
|
|
40
|
+
'TENANCY', # The tenancy of a resource. Examples are shared or dedicated.
|
|
41
|
+
'USAGE_TYPE', # The type of usage. An example is DataTransfer-In-Bytes
|
|
42
|
+
'USAGE_TYPE_GROUP', # The grouping of common usage types. An example is Amazon EC2: CloudWatch – Alarms
|
|
43
|
+
'REGION', # The Amazon Web Services Region
|
|
44
|
+
'RECORD_TYPE', # The different types of charges such as Reserved Instance (RI) fees, usage costs, tax refunds, and credits
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# Valid cost metrics for AWS Cost Explorer
|
|
48
|
+
VALID_COST_METRICS: List[str] = [
|
|
49
|
+
'AmortizedCost',
|
|
50
|
+
'BlendedCost',
|
|
51
|
+
'NetAmortizedCost',
|
|
52
|
+
'NetUnblendedCost',
|
|
53
|
+
'UnblendedCost',
|
|
54
|
+
'UsageQuantity',
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# Valid granularity options for AWS Cost Explorer
|
|
58
|
+
VALID_GRANULARITIES: List[str] = ['DAILY', 'MONTHLY', 'HOURLY']
|
|
59
|
+
|
|
60
|
+
# Valid forecast granularities (subset of VALID_GRANULARITIES)
|
|
61
|
+
VALID_FORECAST_GRANULARITIES: List[str] = ['DAILY', 'MONTHLY']
|
|
62
|
+
|
|
63
|
+
# Valid match options for different filter types
|
|
64
|
+
VALID_MATCH_OPTIONS = {
|
|
65
|
+
'Dimensions': ['EQUALS', 'CASE_SENSITIVE'],
|
|
66
|
+
'Tags': ['EQUALS', 'ABSENT', 'CASE_SENSITIVE'],
|
|
67
|
+
'CostCategories': ['EQUALS', 'ABSENT', 'CASE_SENSITIVE'],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Valid group by types for AWS Cost Explorer
|
|
71
|
+
VALID_GROUP_BY_TYPES: List[str] = ['DIMENSION', 'TAG', 'COST_CATEGORY']
|
|
72
|
+
|
|
73
|
+
# Valid dimension keys for GROUP BY operations (subset of VALID_DIMENSIONS)
|
|
74
|
+
VALID_GROUP_BY_DIMENSIONS: List[str] = [
|
|
75
|
+
'AZ',
|
|
76
|
+
'INSTANCE_TYPE',
|
|
77
|
+
'LEGAL_ENTITY_NAME',
|
|
78
|
+
'INVOICING_ENTITY',
|
|
79
|
+
'LINKED_ACCOUNT',
|
|
80
|
+
'OPERATION',
|
|
81
|
+
'PLATFORM',
|
|
82
|
+
'PURCHASE_TYPE',
|
|
83
|
+
'SERVICE',
|
|
84
|
+
'TENANCY',
|
|
85
|
+
'RECORD_TYPE',
|
|
86
|
+
'USAGE_TYPE',
|
|
87
|
+
'REGION',
|
|
88
|
+
'DATABASE_ENGINE',
|
|
89
|
+
'INSTANCE_TYPE_FAMILY',
|
|
90
|
+
'OPERATING_SYSTEM',
|
|
91
|
+
'CACHE_ENGINE',
|
|
92
|
+
'DEPLOYMENT_OPTION',
|
|
93
|
+
'BILLING_ENTITY',
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# Valid forecast metrics (UsageQuantity forecasting is not supported by AWS)
|
|
97
|
+
VALID_FORECAST_METRICS: List[str] = [
|
|
98
|
+
'AMORTIZED_COST',
|
|
99
|
+
'BLENDED_COST',
|
|
100
|
+
'NET_AMORTIZED_COST',
|
|
101
|
+
'NET_UNBLENDED_COST',
|
|
102
|
+
'UNBLENDED_COST',
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
# Valid prediction interval levels for forecasts
|
|
106
|
+
VALID_PREDICTION_INTERVALS: List[int] = [80, 95]
|
|
@@ -0,0 +1,385 @@
|
|
|
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
|
+
This server provides tools for analyzing AWS costs and usage data through the AWS Cost Explorer API.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import pandas as pd
|
|
23
|
+
import sys
|
|
24
|
+
from awslabs.cost_explorer_mcp_server.constants import (
|
|
25
|
+
VALID_COST_METRICS,
|
|
26
|
+
VALID_GRANULARITIES,
|
|
27
|
+
VALID_MATCH_OPTIONS,
|
|
28
|
+
)
|
|
29
|
+
from awslabs.cost_explorer_mcp_server.helpers import (
|
|
30
|
+
format_date_for_api,
|
|
31
|
+
get_cost_explorer_client,
|
|
32
|
+
validate_expression,
|
|
33
|
+
validate_group_by,
|
|
34
|
+
)
|
|
35
|
+
from awslabs.cost_explorer_mcp_server.models import DateRange
|
|
36
|
+
from datetime import datetime, timedelta
|
|
37
|
+
from loguru import logger
|
|
38
|
+
from mcp.server.fastmcp import Context
|
|
39
|
+
from pydantic import Field
|
|
40
|
+
from typing import Any, Dict, Optional, Union
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Configure Loguru logging
|
|
44
|
+
logger.remove()
|
|
45
|
+
logger.add(sys.stderr, level=os.getenv('FASTMCP_LOG_LEVEL', 'WARNING'))
|
|
46
|
+
|
|
47
|
+
# Constants
|
|
48
|
+
COST_EXPLORER_END_DATE_OFFSET = 1 # Offset to ensure end date is inclusive
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def get_cost_and_usage(
|
|
52
|
+
ctx: Context,
|
|
53
|
+
date_range: DateRange,
|
|
54
|
+
granularity: str = Field(
|
|
55
|
+
'MONTHLY',
|
|
56
|
+
description=f'The granularity at which cost data is aggregated. Valid values are {", ".join(VALID_GRANULARITIES)}. If not provided, defaults to MONTHLY.',
|
|
57
|
+
),
|
|
58
|
+
group_by: Optional[Union[Dict[str, str], str]] = Field(
|
|
59
|
+
'SERVICE',
|
|
60
|
+
description="Either a dictionary with Type and Key for grouping costs, or simply a string key to group by (which will default to DIMENSION type). Example dictionary: {'Type': 'DIMENSION', 'Key': 'SERVICE'}. Example string: 'SERVICE'.",
|
|
61
|
+
),
|
|
62
|
+
filter_expression: Optional[Dict[str, Any]] = Field(
|
|
63
|
+
None,
|
|
64
|
+
description=f"Filter criteria as a Python dictionary to narrow down AWS costs. Supports filtering by Dimensions (SERVICE, REGION, etc.), Tags, or CostCategories. You can use logical operators (And, Or, Not) for complex filters. MatchOptions validation: For Dimensions, valid values are {VALID_MATCH_OPTIONS['Dimensions']}. For Tags and CostCategories, valid values are {VALID_MATCH_OPTIONS['Tags']} (defaults to EQUALS and CASE_SENSITIVE). Examples: 1) Simple service filter: {{'Dimensions': {{'Key': 'SERVICE', 'Values': ['Amazon Elastic Compute Cloud - Compute', 'Amazon Simple Storage Service'], 'MatchOptions': ['EQUALS']}}}}. 2) Region filter: {{'Dimensions': {{'Key': 'REGION', 'Values': ['us-east-1'], 'MatchOptions': ['EQUALS']}}}}. 3) Combined filter: {{'And': [{{'Dimensions': {{'Key': 'SERVICE', 'Values': ['Amazon Elastic Compute Cloud - Compute'], 'MatchOptions': ['EQUALS']}}}}, {{'Dimensions': {{'Key': 'REGION', 'Values': ['us-east-1'], 'MatchOptions': ['EQUALS']}}}}]}}.",
|
|
65
|
+
),
|
|
66
|
+
metric: str = Field(
|
|
67
|
+
'UnblendedCost',
|
|
68
|
+
description=f'The metric to return in the query. Valid values are {", ".join(VALID_COST_METRICS)}. IMPORTANT: For UsageQuantity, the service aggregates usage numbers without considering units, making results meaningless when mixing different unit types (e.g., compute hours + data transfer GB). To get meaningful UsageQuantity metrics, you MUST filter by USAGE_TYPE or group by USAGE_TYPE/USAGE_TYPE_GROUP to ensure consistent units.',
|
|
69
|
+
),
|
|
70
|
+
) -> Dict[str, Any]:
|
|
71
|
+
"""Retrieve AWS cost and usage data.
|
|
72
|
+
|
|
73
|
+
This tool retrieves AWS cost and usage data for AWS services during a specified billing period,
|
|
74
|
+
with optional filtering and grouping. It dynamically generates cost reports tailored to specific needs
|
|
75
|
+
by specifying parameters such as granularity, billing period dates, and filter criteria.
|
|
76
|
+
|
|
77
|
+
Note: The end_date is treated as inclusive in this tool, meaning if you specify an end_date of
|
|
78
|
+
"2025-01-31", the results will include data for January 31st. This differs from the AWS Cost Explorer
|
|
79
|
+
API which treats end_date as exclusive.
|
|
80
|
+
|
|
81
|
+
IMPORTANT: When using UsageQuantity metric, AWS aggregates usage numbers without considering units.
|
|
82
|
+
This makes results meaningless when different usage types have different units (e.g., EC2 compute hours
|
|
83
|
+
vs data transfer GB). For meaningful UsageQuantity results, you MUST be very specific with filtering, including USAGE_TYPE or USAGE_TYPE_GROUP.
|
|
84
|
+
|
|
85
|
+
Example: Get monthly costs for EC2 and S3 services in us-east-1 for May 2025
|
|
86
|
+
await get_cost_and_usage(
|
|
87
|
+
ctx=context,
|
|
88
|
+
date_range={
|
|
89
|
+
"start_date": "2025-05-01",
|
|
90
|
+
"end_date": "2025-05-31"
|
|
91
|
+
},
|
|
92
|
+
granularity="MONTHLY",
|
|
93
|
+
group_by={"Type": "DIMENSION", "Key": "SERVICE"},
|
|
94
|
+
filter_expression={
|
|
95
|
+
"And": [
|
|
96
|
+
{
|
|
97
|
+
"Dimensions": {
|
|
98
|
+
"Key": "SERVICE",
|
|
99
|
+
"Values": ["Amazon Elastic Compute Cloud - Compute", "Amazon Simple Storage Service"],
|
|
100
|
+
"MatchOptions": ["EQUALS"]
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"Dimensions": {
|
|
105
|
+
"Key": "REGION",
|
|
106
|
+
"Values": ["us-east-1"],
|
|
107
|
+
"MatchOptions": ["EQUALS"]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
metric="UnblendedCost"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
Example: Get meaningful UsageQuantity for specific EC2 instance usage
|
|
116
|
+
await get_cost_and_usage(
|
|
117
|
+
ctx=context,
|
|
118
|
+
{
|
|
119
|
+
"date_range": {
|
|
120
|
+
"start_date": "2025-05-01",
|
|
121
|
+
"end_date": "2025-05-31"
|
|
122
|
+
},
|
|
123
|
+
"filter_expression": {
|
|
124
|
+
"And": [
|
|
125
|
+
{
|
|
126
|
+
"Dimensions": {
|
|
127
|
+
"Values": [
|
|
128
|
+
"Amazon Elastic Compute Cloud - Compute"
|
|
129
|
+
],
|
|
130
|
+
"Key": "SERVICE",
|
|
131
|
+
"MatchOptions": [
|
|
132
|
+
"EQUALS"
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"Dimensions": {
|
|
138
|
+
"Values": [
|
|
139
|
+
"EC2: Running Hours"
|
|
140
|
+
],
|
|
141
|
+
"Key": "USAGE_TYPE_GROUP",
|
|
142
|
+
"MatchOptions": [
|
|
143
|
+
"EQUALS"
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
"metric": "UsageQuantity",
|
|
150
|
+
"group_by": "USAGE_TYPE",
|
|
151
|
+
"granularity": "MONTHLY"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
ctx: MCP context
|
|
156
|
+
date_range: The billing period start and end dates in YYYY-MM-DD format (end date is inclusive)
|
|
157
|
+
granularity: The granularity at which cost data is aggregated (DAILY, MONTHLY, HOURLY)
|
|
158
|
+
group_by: Either a dictionary with Type and Key, or simply a string key to group by
|
|
159
|
+
filter_expression: Filter criteria as a Python dictionary
|
|
160
|
+
metric: Cost metric to use (UnblendedCost, BlendedCost, etc.)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Dictionary containing cost report data grouped according to the specified parameters
|
|
164
|
+
"""
|
|
165
|
+
# Initialize variables at function scope to avoid unbound variable issues
|
|
166
|
+
billing_period_start = date_range.start_date
|
|
167
|
+
billing_period_end = date_range.end_date
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
# Process inputs - simplified granularity validation
|
|
171
|
+
granularity = str(granularity).upper()
|
|
172
|
+
|
|
173
|
+
if granularity not in VALID_GRANULARITIES:
|
|
174
|
+
return {
|
|
175
|
+
'error': f'Invalid granularity: {granularity}. Valid values are {", ".join(VALID_GRANULARITIES)}.'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Validate date range with granularity-specific constraints
|
|
179
|
+
try:
|
|
180
|
+
date_range.validate_with_granularity(granularity)
|
|
181
|
+
except ValueError as e:
|
|
182
|
+
return {'error': str(e)}
|
|
183
|
+
|
|
184
|
+
# Define valid metrics and their expected data structure
|
|
185
|
+
valid_metrics = {
|
|
186
|
+
metric: {'has_unit': True, 'is_cost': metric != 'UsageQuantity'}
|
|
187
|
+
for metric in VALID_COST_METRICS
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if metric not in VALID_COST_METRICS:
|
|
191
|
+
return {
|
|
192
|
+
'error': f'Invalid metric: {metric}. Valid values are {", ".join(VALID_COST_METRICS)}.'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
metric_config = valid_metrics[metric]
|
|
196
|
+
|
|
197
|
+
# Adjust end date for Cost Explorer API (exclusive)
|
|
198
|
+
# Add one day to make the end date inclusive for the user
|
|
199
|
+
billing_period_end_adj = (
|
|
200
|
+
datetime.strptime(billing_period_end, '%Y-%m-%d')
|
|
201
|
+
+ timedelta(days=COST_EXPLORER_END_DATE_OFFSET)
|
|
202
|
+
).strftime('%Y-%m-%d')
|
|
203
|
+
|
|
204
|
+
# Process filter
|
|
205
|
+
filter_criteria = filter_expression
|
|
206
|
+
|
|
207
|
+
# Validate filter expression if provided
|
|
208
|
+
if filter_criteria:
|
|
209
|
+
# This validates both structure and values against AWS Cost Explorer
|
|
210
|
+
validation_result = validate_expression(
|
|
211
|
+
filter_criteria, billing_period_start, billing_period_end_adj
|
|
212
|
+
)
|
|
213
|
+
if 'error' in validation_result:
|
|
214
|
+
return validation_result
|
|
215
|
+
|
|
216
|
+
# Process group_by
|
|
217
|
+
if group_by is None:
|
|
218
|
+
group_by = {'Type': 'DIMENSION', 'Key': 'SERVICE'}
|
|
219
|
+
elif isinstance(group_by, str):
|
|
220
|
+
group_by = {'Type': 'DIMENSION', 'Key': group_by}
|
|
221
|
+
|
|
222
|
+
# Validate group_by using the existing validate_group_by function
|
|
223
|
+
validation_result = validate_group_by(group_by)
|
|
224
|
+
if 'error' in validation_result:
|
|
225
|
+
return validation_result
|
|
226
|
+
|
|
227
|
+
# Prepare API call parameters
|
|
228
|
+
common_params = {
|
|
229
|
+
'TimePeriod': {
|
|
230
|
+
'Start': format_date_for_api(billing_period_start, granularity),
|
|
231
|
+
'End': format_date_for_api(billing_period_end_adj, granularity),
|
|
232
|
+
},
|
|
233
|
+
'Granularity': granularity,
|
|
234
|
+
'GroupBy': [{'Type': group_by['Type'].upper(), 'Key': group_by['Key']}],
|
|
235
|
+
'Metrics': [metric],
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if filter_criteria:
|
|
239
|
+
common_params['Filter'] = filter_criteria
|
|
240
|
+
|
|
241
|
+
# Get cost data
|
|
242
|
+
grouped_costs = {}
|
|
243
|
+
next_token = None
|
|
244
|
+
ce = get_cost_explorer_client()
|
|
245
|
+
|
|
246
|
+
while True:
|
|
247
|
+
if next_token:
|
|
248
|
+
common_params['NextPageToken'] = next_token
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
response = ce.get_cost_and_usage(**common_params)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.error(f'Error calling Cost Explorer API: {e}')
|
|
254
|
+
return {'error': f'AWS Cost Explorer API error: {str(e)}'}
|
|
255
|
+
|
|
256
|
+
for result_by_time in response['ResultsByTime']:
|
|
257
|
+
date = result_by_time['TimePeriod']['Start']
|
|
258
|
+
for group in result_by_time.get('Groups', []):
|
|
259
|
+
if not group.get('Keys') or len(group['Keys']) == 0:
|
|
260
|
+
logger.warning(f'Skipping group with no keys: {group}')
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
group_key = group['Keys'][0]
|
|
264
|
+
|
|
265
|
+
# Validate that the metric exists in the response
|
|
266
|
+
if metric not in group.get('Metrics', {}):
|
|
267
|
+
logger.error(
|
|
268
|
+
f"Metric '{metric}' not found in response for group {group_key}"
|
|
269
|
+
)
|
|
270
|
+
return {
|
|
271
|
+
'error': f"Metric '{metric}' not found in response for group {group_key}"
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
metric_data = group['Metrics'][metric]
|
|
276
|
+
|
|
277
|
+
# Validate metric data structure
|
|
278
|
+
if 'Amount' not in metric_data:
|
|
279
|
+
logger.error(
|
|
280
|
+
f'Amount not found in metric data for {group_key}: {metric_data}'
|
|
281
|
+
)
|
|
282
|
+
return {
|
|
283
|
+
'error': "Invalid response format: 'Amount' not found in metric data"
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
# Process based on metric type
|
|
287
|
+
if metric_config['is_cost']:
|
|
288
|
+
# Handle cost metrics
|
|
289
|
+
cost = float(metric_data['Amount'])
|
|
290
|
+
grouped_costs.setdefault(date, {}).update({group_key: cost})
|
|
291
|
+
else:
|
|
292
|
+
# Handle usage metrics (UsageQuantity, NormalizedUsageAmount)
|
|
293
|
+
if 'Unit' not in metric_data and metric_config['has_unit']:
|
|
294
|
+
logger.warning(
|
|
295
|
+
f"Unit not found in {metric} data for {group_key}, using 'Unknown' as unit"
|
|
296
|
+
)
|
|
297
|
+
unit = 'Unknown'
|
|
298
|
+
else:
|
|
299
|
+
unit = metric_data.get('Unit', 'Count')
|
|
300
|
+
amount = float(metric_data['Amount'])
|
|
301
|
+
grouped_costs.setdefault(date, {}).update({group_key: (amount, unit)})
|
|
302
|
+
except (ValueError, TypeError) as e:
|
|
303
|
+
logger.error(f'Error processing metric data: {e}, group: {group_key}')
|
|
304
|
+
return {'error': f'Error processing metric data: {str(e)}'}
|
|
305
|
+
|
|
306
|
+
next_token = response.get('NextPageToken')
|
|
307
|
+
if not next_token:
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
# Process results
|
|
311
|
+
if not grouped_costs:
|
|
312
|
+
logger.info(
|
|
313
|
+
f'No cost data found for the specified parameters: {billing_period_start} to {billing_period_end}'
|
|
314
|
+
)
|
|
315
|
+
return {
|
|
316
|
+
'message': 'No cost data found for the specified parameters',
|
|
317
|
+
'GroupedCosts': {},
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
if metric_config['is_cost']:
|
|
322
|
+
# Process cost metrics
|
|
323
|
+
df = pd.DataFrame.from_dict(grouped_costs).round(2)
|
|
324
|
+
|
|
325
|
+
# Dynamic labeling based on group dimension
|
|
326
|
+
group_dimension = group_by['Key'].lower().replace('_', ' ')
|
|
327
|
+
df[f'{group_dimension.title()} Total'] = df.sum(axis=1).round(2)
|
|
328
|
+
df.loc[f'Total {metric}'] = df.sum().round(2)
|
|
329
|
+
df = df.sort_values(by=f'{group_dimension.title()} Total', ascending=False)
|
|
330
|
+
|
|
331
|
+
result = {'GroupedCosts': df.to_dict()}
|
|
332
|
+
else:
|
|
333
|
+
# Process usage metrics with cleaner structure
|
|
334
|
+
result_data = {}
|
|
335
|
+
for date, groups in grouped_costs.items():
|
|
336
|
+
result_data[date] = {}
|
|
337
|
+
for group_key, (amount, unit) in groups.items():
|
|
338
|
+
result_data[date][group_key] = {
|
|
339
|
+
'amount': round(float(amount), 2),
|
|
340
|
+
'unit': unit,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
# Add metadata for usage metrics
|
|
344
|
+
result = {
|
|
345
|
+
'metadata': {
|
|
346
|
+
'grouped_by': group_by['Key'],
|
|
347
|
+
'metric': metric,
|
|
348
|
+
'period': f'{billing_period_start} to {billing_period_end}',
|
|
349
|
+
},
|
|
350
|
+
'GroupedUsage': result_data,
|
|
351
|
+
}
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.error(f'Error processing cost data into DataFrame: {e}')
|
|
354
|
+
return {
|
|
355
|
+
'error': f'Error processing cost data: {str(e)}',
|
|
356
|
+
'raw_data': grouped_costs,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Test JSON serialization first, only stringify if needed
|
|
360
|
+
try:
|
|
361
|
+
json.dumps(result)
|
|
362
|
+
return result
|
|
363
|
+
except (TypeError, ValueError):
|
|
364
|
+
# Only stringify if JSON serialization fails
|
|
365
|
+
def stringify_keys(d: Any) -> Any:
|
|
366
|
+
if isinstance(d, dict):
|
|
367
|
+
return {str(k): stringify_keys(v) for k, v in d.items()}
|
|
368
|
+
elif isinstance(d, list):
|
|
369
|
+
return [stringify_keys(i) if i is not None else None for i in d]
|
|
370
|
+
else:
|
|
371
|
+
return d
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
result = stringify_keys(result)
|
|
375
|
+
return result
|
|
376
|
+
except Exception as e:
|
|
377
|
+
logger.error(f'Error serializing result: {e}')
|
|
378
|
+
return {'error': f'Error serializing result: {str(e)}'}
|
|
379
|
+
|
|
380
|
+
except Exception as e:
|
|
381
|
+
logger.error(
|
|
382
|
+
f'Error generating cost report for period {billing_period_start} to {billing_period_end}: {e}'
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return {'error': f'Error generating cost report: {str(e)}'}
|