awslabs.cloudwatch-appsignals-mcp-server 0.1.7__py3-none-any.whl → 0.1.9__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.
Files changed (19) hide show
  1. awslabs/cloudwatch_appsignals_mcp_server/__init__.py +1 -1
  2. awslabs/cloudwatch_appsignals_mcp_server/audit_presentation_utils.py +231 -0
  3. awslabs/cloudwatch_appsignals_mcp_server/audit_utils.py +699 -0
  4. awslabs/cloudwatch_appsignals_mcp_server/aws_clients.py +88 -0
  5. awslabs/cloudwatch_appsignals_mcp_server/server.py +675 -1220
  6. awslabs/cloudwatch_appsignals_mcp_server/service_audit_utils.py +231 -0
  7. awslabs/cloudwatch_appsignals_mcp_server/service_tools.py +659 -0
  8. awslabs/cloudwatch_appsignals_mcp_server/sli_report_client.py +5 -12
  9. awslabs/cloudwatch_appsignals_mcp_server/slo_tools.py +386 -0
  10. awslabs/cloudwatch_appsignals_mcp_server/trace_tools.py +658 -0
  11. awslabs/cloudwatch_appsignals_mcp_server/utils.py +172 -0
  12. awslabs_cloudwatch_appsignals_mcp_server-0.1.9.dist-info/METADATA +636 -0
  13. awslabs_cloudwatch_appsignals_mcp_server-0.1.9.dist-info/RECORD +18 -0
  14. awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info/METADATA +0 -350
  15. awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info/RECORD +0 -10
  16. {awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info → awslabs_cloudwatch_appsignals_mcp_server-0.1.9.dist-info}/WHEEL +0 -0
  17. {awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info → awslabs_cloudwatch_appsignals_mcp_server-0.1.9.dist-info}/entry_points.txt +0 -0
  18. {awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info → awslabs_cloudwatch_appsignals_mcp_server-0.1.9.dist-info}/licenses/LICENSE +0 -0
  19. {awslabs_cloudwatch_appsignals_mcp_server-0.1.7.dist-info → awslabs_cloudwatch_appsignals_mcp_server-0.1.9.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