Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
cba9daa
add lambda code
juliareynolds-nava Nov 19, 2025
68de2b1
deployment changes
juliareynolds-nava Nov 20, 2025
4a65bb2
adding sqs permission
juliareynolds-nava Nov 20, 2025
2f0d4af
adding sqs permission policy
juliareynolds-nava Nov 20, 2025
9d1e109
python lint fix to use context
juliareynolds-nava Nov 20, 2025
d917f9e
testing
juliareynolds-nava Nov 20, 2025
658dfc8
Merge branch 'main' into plt-1361_ca_lambda
gsf Nov 24, 2025
ce86bed
Trigger python-checks on all lambda_src directories
gsf Nov 24, 2025
424b6e0
Add conf.sh for cost-anomaly
gsf Nov 24, 2025
8f8c8c5
Merge branch 'main' into plt-1361_ca_lambda
juliareynolds-nava Dec 10, 2025
b859e62
update platform hash reference
juliareynolds-nava Dec 10, 2025
ff32001
tf fmt
juliareynolds-nava Dec 10, 2025
dfb4732
add boto3 to python-checks
juliareynolds-nava Dec 10, 2025
a6de5c6
revert add boto3 to python-checks needs its own pr
juliareynolds-nava Dec 10, 2025
81c306e
Add requirements.txt for boto3.
juliareynolds-nava Dec 10, 2025
62fba0a
revert python-checks.yml
juliareynolds-nava Dec 10, 2025
b733d7b
[PLT-1390] Removing DPC sns topic key references. (#355)
jscott-nava Dec 12, 2025
59be9b7
PLT-1482: Add Necessary KMS Keys to WAF Sync Lambda Permissions (#353)
gfreeman-navapbc Dec 12, 2025
6c3751f
Added contract name to ab2d_prod_benes_searched (#357)
Sadibhatla Dec 12, 2025
0f580ce
BCDA-9633: add health_check var to ecs_service module (#354)
michaeljvaldes Dec 16, 2025
c4439ff
[PLT-1425] Add the key used to decrypt cdap sops files to correct fai…
juliareynolds-nava Dec 18, 2025
3485294
[PLT-1418] Update web module to support DPC, leverage STS headers and…
mianava Dec 22, 2025
d26cf49
PLT-1456: Bootstrap cdap-test and cdap-prod environments (#362)
gfreeman-navapbc Jan 5, 2026
4fd15ac
Additional permissions to lambda role policy
juliareynolds-nava Jan 6, 2026
b1035a1
Revert "Additional permissions to lambda role policy"
juliareynolds-nava Jan 6, 2026
718c600
Merge branch 'main' into plt-1361_ca_lambda
juliareynolds-nava Jan 6, 2026
4f00df9
update branch
juliareynolds-nava Jan 6, 2026
e48debf
update branch
juliareynolds-nava Jan 6, 2026
9f19889
update branch
juliareynolds-nava Jan 6, 2026
4153a1f
update branch
juliareynolds-nava Jan 6, 2026
58b02a0
add permissions
juliareynolds-nava Jan 6, 2026
10c945e
remove logger
juliareynolds-nava Jan 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions terraform/services/cost-anomaly/conf.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TARGET_ENVS=account
303 changes: 303 additions & 0 deletions terraform/services/cost-anomaly/lambda_src/lambda_function.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
"""
Receives messages from Cost Anomaly Monitor via SQS subscription to SNS.
Forwards the message to Slack channel #dasg_metrics_and_insights.
"""

from datetime import datetime, timezone
import json
import os
from urllib import request
from urllib.error import URLError
import boto3
from botocore.exceptions import ClientError

SSM_PARAMETER_CACHE = {}

# pylint: disable=too-few-public-methods
class Field:
"""Represents a field object from SNS JSON."""

def __init__(self, field_type, text, emoji):
"""
Initialize a Field object.

Args:
field_type: The type of the field
text: Text to be displayed
emoji: Boolean indicating if emoji should be used
"""
self.type = field_type
self.text = text
self.emoji = emoji

# pylint: disable=too-few-public-methods
class Block:
"""Represents a block object from SNS JSON."""

def __init__(self, block_type, **kwargs):
"""
Initialize a Block object.

Args:
block_type: The type of the block
**kwargs: Optional fields (fields, text)
"""
self.type = block_type
if kwargs.get("fields"):
self.fields = kwargs.get("fields")
if kwargs.get("text"):
self.text = kwargs.get("text")

# pylint: disable=too-few-public-methods
class Text:
"""Represents a text object from SNS JSON."""

def __init__(self, text_type, text, **kwargs):
"""
Initialize a Text object.

Args:
text_type: The type of the text
text: Text to be displayed
**kwargs: Optional emoji parameter
"""
self.type = text_type
self.text = text
if kwargs.get("emoji"):
self.emoji = kwargs.get("emoji")


def get_ssm_client():
"""
Lazy initialization of boto3 SSM client.
Prevents global instantiation to avoid NoRegionError during tests.

Returns:
boto3.client: SSM client instance
"""
return boto3.client('ssm')


def get_ssm_parameter(name):
"""
Retrieve an SSM parameter and cache the value to prevent duplicate API calls.
Caches None if the parameter is not found or an error occurs.

Args:
name: The name of the SSM parameter

Returns:
str or None: The parameter value or None if not found
"""
if name not in SSM_PARAMETER_CACHE:
try:
ssm_client = get_ssm_client()
response = ssm_client.get_parameter(Name=name, WithDecryption=True)
value = response['Parameter']['Value']
SSM_PARAMETER_CACHE[name] = value
except ClientError as error:
print({'msg': f'Error getting SSM parameter {name}: {error}'})
SSM_PARAMETER_CACHE[name] = None

return SSM_PARAMETER_CACHE[name]


def is_ignore_ok():
"""
Return the current value of the IGNORE_OK environment variable.
This allows tests to patch the environment dynamically.

Returns:
bool: True if IGNORE_OK is set to 'true', False otherwise
"""
return os.environ.get('IGNORE_OK', 'false').lower() == 'true'

# pylint: disable=too-many-locals
def lambda_handler(event, context):
"""
Parse AWS Cost Anomaly Detection SNS messages
"""
print(f"Received event: {json.dumps(event)}")

message = "test"

try:
# Handle SQS trigger (SNS messages delivered via SQS)
if 'Records' in event:
for record in event['Records']:
# Extract SNS message from SQS
if 'body' in record:
body = json.loads(record['body'])

# Check if it's an SNS message
if 'Message' in body:
sns_message = json.loads(body['Message'])
message = process_cost_anomaly(sns_message)
else:
print("No SNS Message found in SQS body")

# Handle direct SNS trigger
elif 'Records' in event and event['Records'][0].get('EventSource') == 'aws:sns':
for record in event['Records']:
sns_message = json.loads(record['Sns']['Message'])
message = process_cost_anomaly(sns_message)

# Handle direct invocation with message
else:
message = process_cost_anomaly(event)

webhook = get_ssm_parameter("/cdap/sensitive/webhook/cost-anomaly")
send_message_to_slack(webhook,message,0)

return {
'statusCode': 200,
'body': json.dumps('Successfully processed cost anomaly alert')
}

except Exception as e:
print(f"Error processing message: {str(e)}")
raise

def process_cost_anomaly(message):
"""
Process and parse the cost anomaly detection message
"""
print("Processing cost anomaly message")

# Extract key information
account_id = message.get('accountId', 'Unknown')
anomaly_id = message.get('anomalyId', 'Unknown')
anomaly_score = message.get('anomalyScore', 0)

# Get impact details
impact = message.get('impact', {})
max_impact = impact.get('maxImpact', 0)
total_impact = impact.get('totalImpact', 0)

# Get date information
anomaly_start = message.get('anomalyStartDate', 'Unknown')
anomaly_end = message.get('anomalyEndDate', 'Unknown')

# Get root causes
root_causes = message.get('rootCauses', [])

# Get dimension details
dimension_value = message.get('dimensionValue', 'Unknown')

# Format the parsed data
parsed_data = {
'account_id': account_id,
'anomaly_id': anomaly_id,
'anomaly_score': anomaly_score,
'max_impact': max_impact,
'total_impact': total_impact,
'start_date': anomaly_start,
'end_date': anomaly_end,
'dimension_value': dimension_value,
'root_causes': root_causes,
'severity': get_severity(anomaly_score),
'timestamp': datetime.utcnow().isoformat()
}

print(f"Parsed anomaly data: {json.dumps(parsed_data, indent=2)}")

# Format alert message
alert_message = format_alert_message(parsed_data)
print(f"Alert message:\n{alert_message}")


return parsed_data

def get_severity(score):
"""
Determine severity based on anomaly score
"""
if score["currentScore"] >= 80:
return "CRITICAL"
elif score["currentScore"] >= 60:
return "HIGH"
elif score["currentScore"] >= 40:
return "MEDIUM"
else:
return "LOW"

def format_alert_message(data):
"""
Format a human-readable alert message
"""
message = f"""
🚨 AWS Cost Anomaly Detected

Severity: {data['severity']}
Anomaly Score: {data['anomaly_score']}

💰 Financial Impact:
- Max Impact: ${data['max_impact']:.2f}
- Total Impact: ${data['total_impact']:.2f}

📅 Time Period:
- Start: {data['start_date']}
- End: {data['end_date']}

🔍 Details:
- Account ID: {data['account_id']}
- Anomaly ID: {data['anomaly_id']}
- Dimension: {data['dimension_value']}

📊 Root Causes:
"""

for i, cause in enumerate(data['root_causes'], 1):
service = cause.get('service', 'Unknown')
region = cause.get('region', 'Unknown')
usage_type = cause.get('usageType', 'Unknown')
message += f"\n {i}. Service: {service}"
message += f"\n Region: {region}"
message += f"\n Usage Type: {usage_type}"

return message

def send_message_to_slack(webhook, message, message_id):
"""
Call the Slack webhook with the formatted message.

Args:
webhook: Slack webhook URL
message: Message content to send
message_id: Identifier for the message

Returns:
bool: True if successful, False otherwise
"""
if not webhook:
print({
'msg': 'Unable to send to Slack as webhook URL is not set',
'messageId': message_id
})
return False

jsondata = json.dumps(message)
jsondataasbytes = jsondata.encode('utf-8')
req = request.Request(webhook)
req.add_header('Content-Type', 'application/json; charset=utf-8')
req.add_header('Content-Length', str(len(jsondataasbytes)))

try:
with request.urlopen(req, jsondataasbytes) as resp:
if resp.status == 200:
print({
'msg': 'Successfully sent message to Slack',
'messageId': message_id
})
return True
print({
'msg': f'Unsuccessful attempt to send message to Slack ({resp.status})',
'messageId': message_id
})
return False
except URLError as error:
print({
'msg': f'Unsuccessful attempt to send message to Slack ({error.reason})',
'messageId': message_id
})
return False
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
boto3==1.40.52
Loading
Loading