awslabs.cloudwatch-appsignals-mcp-server 0.1.7__py3-none-any.whl → 0.1.8__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/cloudwatch_appsignals_mcp_server/__init__.py +1 -1
- awslabs/cloudwatch_appsignals_mcp_server/audit_presentation_utils.py +231 -0
- awslabs/cloudwatch_appsignals_mcp_server/audit_utils.py +699 -0
- awslabs/cloudwatch_appsignals_mcp_server/aws_clients.py +88 -0
- awslabs/cloudwatch_appsignals_mcp_server/server.py +675 -1220
- awslabs/cloudwatch_appsignals_mcp_server/service_audit_utils.py +231 -0
- awslabs/cloudwatch_appsignals_mcp_server/service_tools.py +659 -0
- awslabs/cloudwatch_appsignals_mcp_server/sli_report_client.py +5 -12
- awslabs/cloudwatch_appsignals_mcp_server/slo_tools.py +386 -0
- awslabs/cloudwatch_appsignals_mcp_server/trace_tools.py +658 -0
- awslabs/cloudwatch_appsignals_mcp_server/utils.py +172 -0
- awslabs_cloudwatch_appsignals_mcp_server-0.1.8.dist-info/METADATA +636 -0
- awslabs_cloudwatch_appsignals_mcp_server-0.1.8.dist-info/RECORD +18 -0
- awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info/METADATA +0 -350
- awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info/RECORD +0 -10
- {awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info → awslabs_cloudwatch_appsignals_mcp_server-0.1.8.dist-info}/WHEEL +0 -0
- {awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info → awslabs_cloudwatch_appsignals_mcp_server-0.1.8.dist-info}/entry_points.txt +0 -0
- {awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info → awslabs_cloudwatch_appsignals_mcp_server-0.1.8.dist-info}/licenses/LICENSE +0 -0
- {awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info → awslabs_cloudwatch_appsignals_mcp_server-0.1.8.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,699 @@
|
|
|
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
|
+
"""Shared utilities for audit tools."""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from loguru import logger
|
|
22
|
+
from typing import Any, Dict, List, Optional, Union
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Constants
|
|
26
|
+
DEFAULT_BATCH_SIZE = 5
|
|
27
|
+
FUZZY_MATCH_THRESHOLD = 30 # Minimum similarity score for fuzzy matching
|
|
28
|
+
HIGH_CONFIDENCE_MATCH_THRESHOLD = 85 # High confidence threshold for exact fuzzy matches
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def execute_audit_api(input_obj: Dict[str, Any], region: str, banner: str) -> str:
|
|
32
|
+
"""Execute the Application Signals audit API call with the given input object."""
|
|
33
|
+
from .aws_clients import appsignals_client
|
|
34
|
+
|
|
35
|
+
# File log path
|
|
36
|
+
desired_log_path = os.environ.get('AUDITOR_LOG_PATH', tempfile.gettempdir())
|
|
37
|
+
try:
|
|
38
|
+
if desired_log_path.endswith(os.sep) or os.path.isdir(desired_log_path):
|
|
39
|
+
os.makedirs(desired_log_path, exist_ok=True)
|
|
40
|
+
log_path = os.path.join(desired_log_path, 'aws_api.log')
|
|
41
|
+
else:
|
|
42
|
+
os.makedirs(os.path.dirname(desired_log_path) or '.', exist_ok=True)
|
|
43
|
+
log_path = desired_log_path
|
|
44
|
+
except Exception:
|
|
45
|
+
temp_dir = tempfile.gettempdir()
|
|
46
|
+
os.makedirs(temp_dir, exist_ok=True)
|
|
47
|
+
log_path = os.path.join(temp_dir, 'aws_api.log')
|
|
48
|
+
|
|
49
|
+
# Process targets in batches if needed
|
|
50
|
+
targets = input_obj.get('AuditTargets', [])
|
|
51
|
+
batch_size = DEFAULT_BATCH_SIZE
|
|
52
|
+
target_batches = []
|
|
53
|
+
|
|
54
|
+
if len(targets) > batch_size:
|
|
55
|
+
logger.info(f'Processing {len(targets)} targets in batches of {batch_size}')
|
|
56
|
+
for i in range(0, len(targets), batch_size):
|
|
57
|
+
batch = targets[i : i + batch_size]
|
|
58
|
+
target_batches.append(batch)
|
|
59
|
+
else:
|
|
60
|
+
target_batches.append(targets)
|
|
61
|
+
|
|
62
|
+
all_batch_results = []
|
|
63
|
+
|
|
64
|
+
for batch_idx, batch_targets in enumerate(target_batches, 1):
|
|
65
|
+
logger.info(
|
|
66
|
+
f'Processing batch {batch_idx}/{len(target_batches)} with {len(batch_targets)} targets'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Build API input for this batch
|
|
70
|
+
batch_input_obj = {
|
|
71
|
+
'StartTime': datetime.fromtimestamp(input_obj['StartTime'], tz=timezone.utc),
|
|
72
|
+
'EndTime': datetime.fromtimestamp(input_obj['EndTime'], tz=timezone.utc),
|
|
73
|
+
'AuditTargets': batch_targets,
|
|
74
|
+
}
|
|
75
|
+
if 'Auditors' in input_obj:
|
|
76
|
+
batch_input_obj['Auditors'] = input_obj['Auditors']
|
|
77
|
+
|
|
78
|
+
# Log API invocation details
|
|
79
|
+
api_pretty_input = json.dumps(
|
|
80
|
+
{
|
|
81
|
+
'StartTime': input_obj['StartTime'],
|
|
82
|
+
'EndTime': input_obj['EndTime'],
|
|
83
|
+
'AuditTargets': batch_targets,
|
|
84
|
+
'Auditors': input_obj.get('Auditors', []),
|
|
85
|
+
},
|
|
86
|
+
indent=2,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Also log the actual batch_input_obj that will be sent to AWS API
|
|
90
|
+
batch_input_for_logging = {
|
|
91
|
+
'StartTime': batch_input_obj['StartTime'].isoformat(),
|
|
92
|
+
'EndTime': batch_input_obj['EndTime'].isoformat(),
|
|
93
|
+
'AuditTargets': batch_input_obj['AuditTargets'],
|
|
94
|
+
}
|
|
95
|
+
if 'Auditors' in batch_input_obj:
|
|
96
|
+
batch_input_for_logging['Auditors'] = batch_input_obj['Auditors']
|
|
97
|
+
|
|
98
|
+
batch_payload_json = json.dumps(batch_input_for_logging, indent=2)
|
|
99
|
+
|
|
100
|
+
logger.info('═' * 80)
|
|
101
|
+
logger.info(
|
|
102
|
+
f'BATCH {batch_idx}/{len(target_batches)} - {datetime.now(timezone.utc).isoformat()}'
|
|
103
|
+
)
|
|
104
|
+
logger.info(banner.strip())
|
|
105
|
+
logger.info('---- API INVOCATION ----')
|
|
106
|
+
logger.info('appsignals_client.list_audit_findings()')
|
|
107
|
+
logger.info('---- API PARAMETERS (JSON) ----')
|
|
108
|
+
logger.info(api_pretty_input)
|
|
109
|
+
logger.info('---- ACTUAL AWS API PAYLOAD ----')
|
|
110
|
+
logger.info(batch_payload_json)
|
|
111
|
+
logger.info('---- END PARAMETERS ----')
|
|
112
|
+
|
|
113
|
+
# Write detailed payload to log file
|
|
114
|
+
try:
|
|
115
|
+
with open(log_path, 'a') as f:
|
|
116
|
+
f.write('═' * 80 + '\n')
|
|
117
|
+
f.write(
|
|
118
|
+
f'BATCH {batch_idx}/{len(target_batches)} - {datetime.now(timezone.utc).isoformat()}\n'
|
|
119
|
+
)
|
|
120
|
+
f.write(banner.strip() + '\n')
|
|
121
|
+
f.write('---- API INVOCATION ----\n')
|
|
122
|
+
f.write('appsignals_client.list_audit_findings()\n')
|
|
123
|
+
f.write('---- API PARAMETERS (JSON) ----\n')
|
|
124
|
+
f.write(api_pretty_input + '\n')
|
|
125
|
+
f.write('---- ACTUAL AWS API PAYLOAD ----\n')
|
|
126
|
+
f.write(batch_payload_json + '\n')
|
|
127
|
+
f.write('---- END PARAMETERS ----\n\n')
|
|
128
|
+
except Exception as log_error:
|
|
129
|
+
logger.warning(f'Failed to write audit log to {log_path}: {log_error}')
|
|
130
|
+
|
|
131
|
+
# Call the Application Signals API for this batch
|
|
132
|
+
try:
|
|
133
|
+
response = appsignals_client.list_audit_findings(**batch_input_obj) # type: ignore[attr-defined]
|
|
134
|
+
|
|
135
|
+
# Format and log output for this batch
|
|
136
|
+
observation_text = json.dumps(response, indent=2, default=str)
|
|
137
|
+
all_batch_results.append(response)
|
|
138
|
+
|
|
139
|
+
if not response.get('AuditFindings'):
|
|
140
|
+
try:
|
|
141
|
+
with open(log_path, 'a') as f:
|
|
142
|
+
f.write(f'📭 Batch {batch_idx}: No findings returned.\n')
|
|
143
|
+
f.write('---- END RESPONSE ----\n\n')
|
|
144
|
+
except Exception as log_error:
|
|
145
|
+
logger.warning(f'Failed to write audit log to {log_path}: {log_error}')
|
|
146
|
+
logger.info(f'📭 Batch {batch_idx}: No findings returned.\n---- END RESPONSE ----')
|
|
147
|
+
else:
|
|
148
|
+
try:
|
|
149
|
+
with open(log_path, 'a') as f:
|
|
150
|
+
f.write(f'---- BATCH {batch_idx} API RESPONSE (JSON) ----\n')
|
|
151
|
+
f.write(observation_text + '\n')
|
|
152
|
+
f.write('---- END RESPONSE ----\n\n')
|
|
153
|
+
except Exception as log_error:
|
|
154
|
+
logger.warning(f'Failed to write audit log to {log_path}: {log_error}')
|
|
155
|
+
logger.info(
|
|
156
|
+
f'---- BATCH {batch_idx} API RESPONSE (JSON) ----\n'
|
|
157
|
+
+ observation_text
|
|
158
|
+
+ '\n---- END RESPONSE ----'
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
error_msg = str(e)
|
|
163
|
+
try:
|
|
164
|
+
with open(log_path, 'a') as f:
|
|
165
|
+
f.write(f'---- BATCH {batch_idx} API ERROR ----\n')
|
|
166
|
+
f.write(error_msg + '\n')
|
|
167
|
+
f.write('---- END ERROR ----\n\n')
|
|
168
|
+
except Exception as log_error:
|
|
169
|
+
logger.warning(f'Failed to write audit log to {log_path}: {log_error}')
|
|
170
|
+
logger.error(
|
|
171
|
+
f'---- BATCH {batch_idx} API ERROR ----\n' + error_msg + '\n---- END ERROR ----'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
batch_error_result = {
|
|
175
|
+
'batch_index': batch_idx,
|
|
176
|
+
'error': f'API call failed: {error_msg}',
|
|
177
|
+
'targets_count': len(batch_targets),
|
|
178
|
+
}
|
|
179
|
+
all_batch_results.append(batch_error_result)
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# Aggregate results from all batches
|
|
183
|
+
if not all_batch_results:
|
|
184
|
+
return banner + 'Result: No findings from any batch.'
|
|
185
|
+
|
|
186
|
+
# Aggregate the findings from all successful batches
|
|
187
|
+
aggregated_findings = []
|
|
188
|
+
total_targets_processed = 0
|
|
189
|
+
failed_batches = 0
|
|
190
|
+
|
|
191
|
+
for batch_result in all_batch_results:
|
|
192
|
+
if isinstance(batch_result, dict):
|
|
193
|
+
if 'error' in batch_result:
|
|
194
|
+
failed_batches += 1
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
batch_findings = batch_result.get('AuditFindings', [])
|
|
198
|
+
aggregated_findings.extend(batch_findings)
|
|
199
|
+
|
|
200
|
+
# Count targets processed (this batch)
|
|
201
|
+
# Get the batch size from the original targets list
|
|
202
|
+
current_batch_size = min(
|
|
203
|
+
DEFAULT_BATCH_SIZE,
|
|
204
|
+
len(targets)
|
|
205
|
+
- (len(aggregated_findings) // DEFAULT_BATCH_SIZE) * DEFAULT_BATCH_SIZE,
|
|
206
|
+
)
|
|
207
|
+
total_targets_processed += current_batch_size
|
|
208
|
+
|
|
209
|
+
# Create final aggregated response
|
|
210
|
+
final_result = {
|
|
211
|
+
'AuditFindings': aggregated_findings,
|
|
212
|
+
'BatchSummary': {
|
|
213
|
+
'TotalBatches': len(target_batches),
|
|
214
|
+
'SuccessfulBatches': len(target_batches) - failed_batches,
|
|
215
|
+
'FailedBatches': failed_batches,
|
|
216
|
+
'TotalTargetsProcessed': total_targets_processed,
|
|
217
|
+
'TotalFindingsCount': len(aggregated_findings),
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Add any error information if there were failed batches
|
|
222
|
+
if failed_batches > 0:
|
|
223
|
+
error_details = []
|
|
224
|
+
for batch_result in all_batch_results:
|
|
225
|
+
if isinstance(batch_result, dict) and 'error' in batch_result:
|
|
226
|
+
error_details.append(
|
|
227
|
+
{
|
|
228
|
+
'batch': batch_result['batch_index'],
|
|
229
|
+
'error': batch_result['error'],
|
|
230
|
+
'targets_count': batch_result['targets_count'],
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
final_result['BatchErrors'] = error_details
|
|
234
|
+
|
|
235
|
+
final_observation_text = json.dumps(final_result, indent=2, default=str)
|
|
236
|
+
return banner + final_observation_text
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _create_service_target(
|
|
240
|
+
service_name: str, environment: str, aws_account_id: Optional[str] = None
|
|
241
|
+
) -> Dict[str, Any]:
|
|
242
|
+
"""Create a standardized service target configuration."""
|
|
243
|
+
service_config = {
|
|
244
|
+
'Type': 'Service',
|
|
245
|
+
'Name': service_name,
|
|
246
|
+
'Environment': environment,
|
|
247
|
+
}
|
|
248
|
+
if aws_account_id:
|
|
249
|
+
service_config['AwsAccountId'] = aws_account_id
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
'Type': 'service',
|
|
253
|
+
'Data': {'Service': service_config},
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def parse_auditors(
|
|
258
|
+
auditors_value: Union[str, None, Any], default_auditors: List[str]
|
|
259
|
+
) -> List[str]:
|
|
260
|
+
"""Parse and validate auditors parameter."""
|
|
261
|
+
# Handle Pydantic Field objects that may be passed instead of actual values
|
|
262
|
+
if hasattr(auditors_value, 'default') and hasattr(auditors_value, 'description'):
|
|
263
|
+
# This is a Pydantic Field object, use its default value
|
|
264
|
+
auditors_value = getattr(auditors_value, 'default', None)
|
|
265
|
+
|
|
266
|
+
if auditors_value is None:
|
|
267
|
+
user_prompt_text = os.environ.get('MCP_USER_PROMPT', '') or ''
|
|
268
|
+
wants_root_cause = 'root cause' in user_prompt_text.lower()
|
|
269
|
+
raw_a = default_auditors if not wants_root_cause else []
|
|
270
|
+
elif str(auditors_value).lower() == 'all':
|
|
271
|
+
raw_a = [] # Empty list means use all auditors
|
|
272
|
+
else:
|
|
273
|
+
raw_a = [a.strip() for a in str(auditors_value).split(',') if a.strip()]
|
|
274
|
+
|
|
275
|
+
# Validate auditors
|
|
276
|
+
if len(raw_a) == 0:
|
|
277
|
+
return [] # Empty list means use all auditors
|
|
278
|
+
else:
|
|
279
|
+
allowed = {
|
|
280
|
+
'slo',
|
|
281
|
+
'operation_metric',
|
|
282
|
+
'trace',
|
|
283
|
+
'log',
|
|
284
|
+
'dependency_metric',
|
|
285
|
+
'top_contributor',
|
|
286
|
+
'service_quota',
|
|
287
|
+
}
|
|
288
|
+
invalid = [a for a in raw_a if a not in allowed]
|
|
289
|
+
if invalid:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f'Invalid auditor(s): {", ".join(invalid)}. Allowed: {", ".join(sorted(allowed))}'
|
|
292
|
+
)
|
|
293
|
+
return raw_a
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def expand_service_wildcard_patterns(
|
|
297
|
+
targets: List[dict], unix_start: int, unix_end: int, appsignals_client=None
|
|
298
|
+
) -> List[dict]:
|
|
299
|
+
"""Expand wildcard patterns for service targets only."""
|
|
300
|
+
from .utils import calculate_name_similarity
|
|
301
|
+
|
|
302
|
+
if appsignals_client is None:
|
|
303
|
+
from .aws_clients import appsignals_client
|
|
304
|
+
|
|
305
|
+
expanded_targets = []
|
|
306
|
+
service_patterns = []
|
|
307
|
+
service_fuzzy_matches = []
|
|
308
|
+
|
|
309
|
+
logger.debug(f'expand_service_wildcard_patterns: Processing {len(targets)} targets')
|
|
310
|
+
|
|
311
|
+
# First pass: identify patterns and collect non-wildcard targets
|
|
312
|
+
for i, target in enumerate(targets):
|
|
313
|
+
logger.debug(f'Target {i}: {target}')
|
|
314
|
+
|
|
315
|
+
if not isinstance(target, dict):
|
|
316
|
+
expanded_targets.append(target)
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
target_type = target.get('Type', '').lower()
|
|
320
|
+
logger.debug(f'Target {i} type: {target_type}')
|
|
321
|
+
|
|
322
|
+
if target_type == 'service':
|
|
323
|
+
# Check multiple possible locations for service name
|
|
324
|
+
service_name = None
|
|
325
|
+
|
|
326
|
+
# Check Data.Service.Name (full format)
|
|
327
|
+
service_data = target.get('Data', {})
|
|
328
|
+
if isinstance(service_data, dict):
|
|
329
|
+
service_info = service_data.get('Service', {})
|
|
330
|
+
if isinstance(service_info, dict):
|
|
331
|
+
service_name = service_info.get('Name', '')
|
|
332
|
+
|
|
333
|
+
# Check shorthand Service field
|
|
334
|
+
if not service_name:
|
|
335
|
+
service_name = target.get('Service', '')
|
|
336
|
+
|
|
337
|
+
logger.debug(f"Target {i} service name: '{service_name}'")
|
|
338
|
+
|
|
339
|
+
if isinstance(service_name, str) and service_name:
|
|
340
|
+
if '*' in service_name:
|
|
341
|
+
logger.debug(f"Target {i} identified as wildcard pattern: '{service_name}'")
|
|
342
|
+
service_patterns.append((target, service_name))
|
|
343
|
+
else:
|
|
344
|
+
# Check if this might be a fuzzy match candidate
|
|
345
|
+
service_fuzzy_matches.append((target, service_name))
|
|
346
|
+
else:
|
|
347
|
+
logger.debug(f'Target {i} has no valid service name, passing through')
|
|
348
|
+
expanded_targets.append(target)
|
|
349
|
+
else:
|
|
350
|
+
# Non-service targets pass through unchanged
|
|
351
|
+
logger.debug(f'Target {i} is not a service target, passing through')
|
|
352
|
+
expanded_targets.append(target)
|
|
353
|
+
|
|
354
|
+
# Expand service patterns and fuzzy matches
|
|
355
|
+
if service_patterns or service_fuzzy_matches:
|
|
356
|
+
logger.debug(
|
|
357
|
+
f'Expanding {len(service_patterns)} service wildcard patterns and {len(service_fuzzy_matches)} fuzzy matches'
|
|
358
|
+
)
|
|
359
|
+
try:
|
|
360
|
+
services_response = appsignals_client.list_services(
|
|
361
|
+
StartTime=datetime.fromtimestamp(unix_start, tz=timezone.utc),
|
|
362
|
+
EndTime=datetime.fromtimestamp(unix_end, tz=timezone.utc),
|
|
363
|
+
MaxResults=100,
|
|
364
|
+
)
|
|
365
|
+
all_services = services_response.get('ServiceSummaries', [])
|
|
366
|
+
|
|
367
|
+
# Handle wildcard patterns
|
|
368
|
+
for original_target, pattern in service_patterns:
|
|
369
|
+
search_term = pattern.strip('*').lower() if pattern != '*' else ''
|
|
370
|
+
matches_found = 0
|
|
371
|
+
|
|
372
|
+
for service in all_services:
|
|
373
|
+
service_attrs = service.get('KeyAttributes', {})
|
|
374
|
+
service_name = service_attrs.get('Name', '')
|
|
375
|
+
service_type = service_attrs.get('Type', '')
|
|
376
|
+
environment = service_attrs.get('Environment', '')
|
|
377
|
+
|
|
378
|
+
# Filter out services without proper names or that are not actual services
|
|
379
|
+
if not service_name or service_name == 'Unknown' or service_type != 'Service':
|
|
380
|
+
logger.debug(
|
|
381
|
+
f"Skipping service: Name='{service_name}', Type='{service_type}', Environment='{environment}'"
|
|
382
|
+
)
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
# Apply search filter
|
|
386
|
+
if search_term == '' or search_term in service_name.lower():
|
|
387
|
+
expanded_targets.append(_create_service_target(service_name, environment))
|
|
388
|
+
matches_found += 1
|
|
389
|
+
logger.debug(
|
|
390
|
+
f"Added service: Name='{service_name}', Environment='{environment}'"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
logger.debug(f"Service pattern '{pattern}' expanded to {matches_found} targets")
|
|
394
|
+
|
|
395
|
+
# Handle fuzzy matches for inexact service names
|
|
396
|
+
for original_target, inexact_name in service_fuzzy_matches:
|
|
397
|
+
best_matches = []
|
|
398
|
+
|
|
399
|
+
# Calculate similarity scores for all services
|
|
400
|
+
for service in all_services:
|
|
401
|
+
service_attrs = service.get('KeyAttributes', {})
|
|
402
|
+
service_name = service_attrs.get('Name', '')
|
|
403
|
+
if not service_name:
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
score = calculate_name_similarity(inexact_name, service_name, 'service')
|
|
407
|
+
|
|
408
|
+
if score >= FUZZY_MATCH_THRESHOLD: # Minimum threshold for consideration
|
|
409
|
+
best_matches.append(
|
|
410
|
+
(service_name, service_attrs.get('Environment'), score)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Sort by score and take the best matches
|
|
414
|
+
best_matches.sort(key=lambda x: x[2], reverse=True)
|
|
415
|
+
|
|
416
|
+
if best_matches:
|
|
417
|
+
# If we have a very high score match, use only that
|
|
418
|
+
if best_matches[0][2] >= HIGH_CONFIDENCE_MATCH_THRESHOLD:
|
|
419
|
+
matched_services = [best_matches[0]]
|
|
420
|
+
else:
|
|
421
|
+
# Otherwise, take top 3 matches above threshold
|
|
422
|
+
matched_services = best_matches[:3]
|
|
423
|
+
|
|
424
|
+
logger.info(
|
|
425
|
+
f"Fuzzy matching service '{inexact_name}' found {len(matched_services)} candidates:"
|
|
426
|
+
)
|
|
427
|
+
for service_name, environment, score in matched_services:
|
|
428
|
+
logger.info(f" - '{service_name}' in '{environment}' (score: {score})")
|
|
429
|
+
expanded_targets.append(_create_service_target(service_name, environment))
|
|
430
|
+
else:
|
|
431
|
+
logger.warning(
|
|
432
|
+
f"No fuzzy matches found for service name '{inexact_name}' (no candidates above threshold)"
|
|
433
|
+
)
|
|
434
|
+
# Keep the original target - let the API handle the error
|
|
435
|
+
expanded_targets.append(original_target)
|
|
436
|
+
|
|
437
|
+
except Exception as e:
|
|
438
|
+
logger.warning(f'Failed to expand service patterns and fuzzy matches: {e}')
|
|
439
|
+
# When expansion fails, we need to return an error rather than passing wildcards to validation
|
|
440
|
+
# This prevents the validation phase from seeing wildcard patterns
|
|
441
|
+
if service_patterns or service_fuzzy_matches:
|
|
442
|
+
pattern_names = [pattern for _, pattern in service_patterns] + [
|
|
443
|
+
name for _, name in service_fuzzy_matches
|
|
444
|
+
]
|
|
445
|
+
raise ValueError(
|
|
446
|
+
f'Failed to expand service wildcard patterns {pattern_names}. '
|
|
447
|
+
f'This may be due to AWS API access issues or missing services. '
|
|
448
|
+
f'Error: {str(e)}'
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return expanded_targets
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def expand_slo_wildcard_patterns(targets: List[dict], appsignals_client=None) -> List[dict]:
|
|
455
|
+
"""Expand wildcard patterns for SLO targets only."""
|
|
456
|
+
if appsignals_client is None:
|
|
457
|
+
from .aws_clients import appsignals_client
|
|
458
|
+
|
|
459
|
+
expanded_targets = []
|
|
460
|
+
wildcard_patterns = []
|
|
461
|
+
|
|
462
|
+
for target in targets:
|
|
463
|
+
if isinstance(target, dict):
|
|
464
|
+
ttype = target.get('Type', '').lower()
|
|
465
|
+
if ttype == 'slo':
|
|
466
|
+
# Check for wildcard patterns in SLO names
|
|
467
|
+
slo_data = target.get('Data', {}).get('Slo', {})
|
|
468
|
+
|
|
469
|
+
# BUG FIX: Handle case where Slo is a string instead of dict
|
|
470
|
+
if isinstance(slo_data, str):
|
|
471
|
+
# Malformed input - Slo should be a dict with SloName key
|
|
472
|
+
raise ValueError(
|
|
473
|
+
f"Invalid SLO target format. Expected {{'Type':'slo','Data':{{'Slo':{{'SloName':'name'}}}}}} "
|
|
474
|
+
f"but got {{'Slo':'{slo_data}'}}. The 'Slo' field must be a dictionary with 'SloName' key."
|
|
475
|
+
)
|
|
476
|
+
elif isinstance(slo_data, dict):
|
|
477
|
+
slo_name = slo_data.get('SloName', '')
|
|
478
|
+
else:
|
|
479
|
+
# Handle other unexpected types
|
|
480
|
+
raise ValueError(
|
|
481
|
+
f"Invalid SLO target format. The 'Slo' field must be a dictionary with 'SloName' key, "
|
|
482
|
+
f'but got {type(slo_data).__name__}: {slo_data}'
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
if '*' in slo_name:
|
|
486
|
+
wildcard_patterns.append((target, slo_name))
|
|
487
|
+
else:
|
|
488
|
+
expanded_targets.append(target)
|
|
489
|
+
else:
|
|
490
|
+
expanded_targets.append(target)
|
|
491
|
+
else:
|
|
492
|
+
expanded_targets.append(target)
|
|
493
|
+
|
|
494
|
+
# Expand wildcard patterns for SLOs
|
|
495
|
+
if wildcard_patterns:
|
|
496
|
+
logger.debug(f'Expanding {len(wildcard_patterns)} SLO wildcard patterns')
|
|
497
|
+
try:
|
|
498
|
+
# Get all SLOs to expand patterns
|
|
499
|
+
slos_response = appsignals_client.list_service_level_objectives(
|
|
500
|
+
MaxResults=50, IncludeLinkedAccounts=True
|
|
501
|
+
)
|
|
502
|
+
all_slos = slos_response.get('SloSummaries', [])
|
|
503
|
+
|
|
504
|
+
for original_target, pattern in wildcard_patterns:
|
|
505
|
+
search_term = pattern.strip('*').lower() if pattern != '*' else ''
|
|
506
|
+
matches_found = 0
|
|
507
|
+
|
|
508
|
+
for slo in all_slos:
|
|
509
|
+
slo_name = slo.get('Name', '')
|
|
510
|
+
if search_term == '' or search_term in slo_name.lower():
|
|
511
|
+
expanded_targets.append(
|
|
512
|
+
{
|
|
513
|
+
'Type': 'slo',
|
|
514
|
+
'Data': {
|
|
515
|
+
'Slo': {'SloName': slo_name, 'SloArn': slo.get('Arn', '')}
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
)
|
|
519
|
+
matches_found += 1
|
|
520
|
+
|
|
521
|
+
logger.debug(f"SLO pattern '{pattern}' expanded to {matches_found} targets")
|
|
522
|
+
|
|
523
|
+
except Exception as e:
|
|
524
|
+
logger.warning(f'Failed to expand SLO patterns: {e}')
|
|
525
|
+
raise ValueError(f'Failed to expand SLO wildcard patterns. {str(e)}')
|
|
526
|
+
|
|
527
|
+
return expanded_targets
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def expand_service_operation_wildcard_patterns(
|
|
531
|
+
targets: List[dict], unix_start: int, unix_end: int, appsignals_client=None
|
|
532
|
+
) -> List[dict]:
|
|
533
|
+
"""Expand wildcard patterns for service operation targets only."""
|
|
534
|
+
if appsignals_client is None:
|
|
535
|
+
from .aws_clients import appsignals_client
|
|
536
|
+
|
|
537
|
+
expanded_targets = []
|
|
538
|
+
wildcard_patterns = []
|
|
539
|
+
|
|
540
|
+
for target in targets:
|
|
541
|
+
if isinstance(target, dict):
|
|
542
|
+
ttype = target.get('Type', '').lower()
|
|
543
|
+
if ttype == 'service_operation':
|
|
544
|
+
# Check for wildcard patterns in service names OR operation names
|
|
545
|
+
service_op_data = target.get('Data', {}).get('ServiceOperation', {})
|
|
546
|
+
service_data = service_op_data.get('Service', {})
|
|
547
|
+
service_name = service_data.get('Name', '')
|
|
548
|
+
operation = service_op_data.get('Operation', '')
|
|
549
|
+
|
|
550
|
+
# Check if either service name or operation has wildcards
|
|
551
|
+
if '*' in service_name or '*' in operation:
|
|
552
|
+
wildcard_patterns.append((target, service_name, operation))
|
|
553
|
+
else:
|
|
554
|
+
expanded_targets.append(target)
|
|
555
|
+
else:
|
|
556
|
+
expanded_targets.append(target)
|
|
557
|
+
else:
|
|
558
|
+
expanded_targets.append(target)
|
|
559
|
+
|
|
560
|
+
# Expand wildcard patterns for service operations
|
|
561
|
+
if wildcard_patterns:
|
|
562
|
+
logger.debug(f'Expanding {len(wildcard_patterns)} service operation wildcard patterns')
|
|
563
|
+
try:
|
|
564
|
+
# Get all services to expand patterns
|
|
565
|
+
services_response = appsignals_client.list_services(
|
|
566
|
+
StartTime=datetime.fromtimestamp(unix_start, tz=timezone.utc),
|
|
567
|
+
EndTime=datetime.fromtimestamp(unix_end, tz=timezone.utc),
|
|
568
|
+
MaxResults=100,
|
|
569
|
+
)
|
|
570
|
+
all_services = services_response.get('ServiceSummaries', [])
|
|
571
|
+
|
|
572
|
+
for original_target, service_pattern, operation_pattern in wildcard_patterns:
|
|
573
|
+
service_search_term = (
|
|
574
|
+
service_pattern.strip('*').lower() if service_pattern != '*' else ''
|
|
575
|
+
)
|
|
576
|
+
operation_search_term = (
|
|
577
|
+
operation_pattern.strip('*').lower() if operation_pattern != '*' else ''
|
|
578
|
+
)
|
|
579
|
+
matches_found = 0
|
|
580
|
+
|
|
581
|
+
# Get the original metric type from the pattern
|
|
582
|
+
service_op_data = original_target.get('Data', {}).get('ServiceOperation', {})
|
|
583
|
+
metric_type = service_op_data.get('MetricType', 'Latency')
|
|
584
|
+
|
|
585
|
+
# Find matching services
|
|
586
|
+
matching_services = []
|
|
587
|
+
for service in all_services:
|
|
588
|
+
service_attrs = service.get('KeyAttributes', {})
|
|
589
|
+
service_name = service_attrs.get('Name', '')
|
|
590
|
+
service_type = service_attrs.get('Type', '')
|
|
591
|
+
|
|
592
|
+
# Filter out services without proper names or that are not actual services
|
|
593
|
+
if not service_name or service_name == 'Unknown' or service_type != 'Service':
|
|
594
|
+
continue
|
|
595
|
+
|
|
596
|
+
# Check if service matches the pattern
|
|
597
|
+
if '*' not in service_pattern:
|
|
598
|
+
# Exact service name match
|
|
599
|
+
if service_name == service_pattern:
|
|
600
|
+
matching_services.append(service)
|
|
601
|
+
else:
|
|
602
|
+
# Wildcard service name match
|
|
603
|
+
if (
|
|
604
|
+
service_search_term == ''
|
|
605
|
+
or service_search_term in service_name.lower()
|
|
606
|
+
):
|
|
607
|
+
matching_services.append(service)
|
|
608
|
+
|
|
609
|
+
logger.debug(
|
|
610
|
+
f"Found {len(matching_services)} services matching pattern '{service_pattern}'"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
# For each matching service, get operations and expand operation patterns
|
|
614
|
+
for service in matching_services:
|
|
615
|
+
service_attrs = service.get('KeyAttributes', {})
|
|
616
|
+
service_name = service_attrs.get('Name', '')
|
|
617
|
+
environment = service_attrs.get('Environment', '')
|
|
618
|
+
|
|
619
|
+
try:
|
|
620
|
+
# Get operations for this service
|
|
621
|
+
operations_response = appsignals_client.list_service_operations(
|
|
622
|
+
StartTime=datetime.fromtimestamp(unix_start, tz=timezone.utc),
|
|
623
|
+
EndTime=datetime.fromtimestamp(unix_end, tz=timezone.utc),
|
|
624
|
+
KeyAttributes=service_attrs,
|
|
625
|
+
MaxResults=100,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
operations = operations_response.get('Operations', [])
|
|
629
|
+
logger.debug(
|
|
630
|
+
f"Found {len(operations)} operations for service '{service_name}'"
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# Filter operations based on operation pattern
|
|
634
|
+
for operation in operations:
|
|
635
|
+
operation_name = operation.get('Name', '')
|
|
636
|
+
|
|
637
|
+
# Check if operation matches the pattern
|
|
638
|
+
operation_matches = False
|
|
639
|
+
if '*' not in operation_pattern:
|
|
640
|
+
# Exact operation name match
|
|
641
|
+
operation_matches = operation_name == operation_pattern
|
|
642
|
+
else:
|
|
643
|
+
# Wildcard operation name match
|
|
644
|
+
if operation_search_term == '':
|
|
645
|
+
# Match all operations
|
|
646
|
+
operation_matches = True
|
|
647
|
+
else:
|
|
648
|
+
# Check if operation contains the search term
|
|
649
|
+
operation_matches = (
|
|
650
|
+
operation_search_term in operation_name.lower()
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
if operation_matches:
|
|
654
|
+
# Check if this operation has the required metric type
|
|
655
|
+
metric_refs = operation.get('MetricReferences', [])
|
|
656
|
+
has_metric_type = any(
|
|
657
|
+
ref.get('MetricType', '') == metric_type for ref in metric_refs
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
if has_metric_type:
|
|
661
|
+
service_target = _create_service_target(
|
|
662
|
+
service_name, environment
|
|
663
|
+
)
|
|
664
|
+
expanded_targets.append(
|
|
665
|
+
{
|
|
666
|
+
'Type': 'service_operation',
|
|
667
|
+
'Data': {
|
|
668
|
+
'ServiceOperation': {
|
|
669
|
+
'Service': service_target['Data']['Service'],
|
|
670
|
+
'Operation': operation_name,
|
|
671
|
+
'MetricType': metric_type,
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
)
|
|
676
|
+
matches_found += 1
|
|
677
|
+
logger.debug(
|
|
678
|
+
f'Added operation: {service_name} -> {operation_name} ({metric_type})'
|
|
679
|
+
)
|
|
680
|
+
else:
|
|
681
|
+
logger.debug(
|
|
682
|
+
f'Skipping operation {operation_name} - no {metric_type} metric available'
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
except Exception as e:
|
|
686
|
+
logger.warning(
|
|
687
|
+
f"Failed to get operations for service '{service_name}': {e}"
|
|
688
|
+
)
|
|
689
|
+
continue
|
|
690
|
+
|
|
691
|
+
logger.debug(
|
|
692
|
+
f"Service operation pattern '{service_pattern}' + '{operation_pattern}' expanded to {matches_found} targets"
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
except Exception as e:
|
|
696
|
+
logger.warning(f'Failed to expand service operation patterns: {e}')
|
|
697
|
+
raise ValueError(f'Failed to expand service operation wildcard patterns. {str(e)}')
|
|
698
|
+
|
|
699
|
+
return expanded_targets
|