Summary
When a Lambda function errors and triggers the aggregate CloudWatch alarm, both the event consumer and the proactive poller independently launch a full agent investigation for the same root cause. This produces two sessions, two Slack notifications, and two alert rows in the DB for a single incident.
Observed in logs:
agent.event_consumer - Processing event: aws.cloudwatch / CloudWatch Alarm State Change
agent.poller - Poller: Lambda opendevops-test-failure error rate exceeds threshold
Two different dedup_key values were stored:
lambda_errors:opendevops-test-failure — from the poller
aws.cloudwatch:ae3abf074120 — from the event consumer
How dedup keys currently work
Event consumer (event_consumer.py)
The event consumer computes an MD5 fingerprint of the event's stable fields:
fingerprint = {
"source": source, # e.g. "aws.cloudwatch"
"detail-type": event.get("detail-type"), # e.g. "CloudWatch Alarm State Change"
"detail": _strip_volatile(event.get("detail", {})),
}
digest = hashlib.md5(json.dumps(fingerprint, sort_keys=True).encode()).hexdigest()[:12]
return f"{source}:{digest}" # → "aws.cloudwatch:ae3abf074120"
_strip_volatile removes time-varying keys (time, timestamp, eventTime, requestId, startTime, endTime, updatedAt, stateTransitionedAt, lastUpdatedTime).
Poller (poller.py)
The poller constructs semantic keys directly:
alarm:{alarmName} for CloudWatch alarms in ALARM state
lambda_errors:{functionName} for Lambda error rate checks
It has an explicit cross-check to avoid re-investigating when the event consumer already handled the aggregate alarm:
from agent.event_infra import ALARM_NAME
if await is_recent_alert(f"alarm:{ALARM_NAME}"):
logger.debug("Poller: skipping Lambda {} — aggregate alarm already handled", name)
Why the cross-check never fires
is_recent_alert does an exact key match in the alerts table. The event consumer stored the key as aws.cloudwatch:ae3abf074120. The poller checks for alarm:opendevops-aggregate-lambda-errors. These strings never match — the cross-check is effectively dead code.
Proposed fix
Change _event_dedup_key in event_consumer.py to use alarm:{alarmName} for CloudWatch alarm state change events instead of the MD5:
if source == "aws.cloudwatch" and "alarmName" in detail:
return f"alarm:{detail['alarmName']}"
This makes the event consumer and poller share the same key namespace for alarms, so the poller's existing is_recent_alert(f"alarm:{ALARM_NAME}") cross-check works as intended.
User's concern with the proposed fix
"This leads to: if two different errors happen in Lambda, only one will be investigated, right?"
The concern: if the aggregate alarm fires twice within the 3-minute dedup window (e.g., function A errors at 9:30 → investigated, function B errors at 9:31 → key alarm:opendevops-aggregate-lambda-errors is still recent → skipped).
Counter-argument: CloudWatch alarms only fire on state transitions (OK → ALARM). If the alarm is already in ALARM state when function B errors, no second EventBridge event fires. The investigation triggered at 9:30 runs against all current Lambda errors and should detect both function A and B.
The scenario where two separate investigations would genuinely be warranted is ALARM → OK → ALARM within 3 minutes — errors resolved and reappeared. This is unusual in practice.
The user remained suspicious of this reasoning and chose not to apply the fix. The concern is that the trade-off analysis relies on the aggregate alarm behaving predictably, and any edge case (e.g., partial OK states, per-function dimension alarms) could cause a legitimate incident to be silently skipped.
Why using MD5 in the poller isn't a solution
The two systems start from different data sources:
- Event consumer hashes the raw EventBridge event payload (structured JSON delivered via SQS)
- Poller fetches data from the CloudWatch DescribeAlarms API (different field names, different structure)
There is no way to make both sides produce the same MD5 without them sharing the exact same payload, which they never do.
Next step suggestions
-
Store a secondary normalized key alongside the MD5. Add a normalized_key TEXT column to alerts. The event consumer writes both its MD5 dedup_key and a human-readable normalized_key (e.g., alarm:{alarmName}). is_recent_alert checks both columns. This removes the trade-off entirely — MD5 dedup stays precise, cross-system dedup works via the normalized key.
-
Widen is_recent_alert to accept multiple keys. Pass [f"alarm:{ALARM_NAME}", f"lambda_errors:{name}"] and match on any. Requires a small schema/query change but no new columns.
-
Consolidate into event-driven only. Disable the poller's alarm check entirely when event infra is enabled (SQS + EventBridge already covers alarm state changes). The poller would only handle Lambda error rate polling, which the event consumer doesn't cover. This avoids the namespace problem by removing the overlap.
-
Use alarm:{alarmName} with a longer dedup window. Apply the proposed fix but extend the dedup window for alarm events (e.g., 10 minutes instead of 3), reducing the risk of the ALARM→OK→ALARM edge case being swallowed.
Relevant files
| File |
Role |
src/agent/event_consumer.py |
_event_dedup_key(), _process_event() |
src/agent/poller.py |
_check_alarms(), _check_lambda_errors(), cross-check logic |
src/agent/monitor_store.py |
is_recent_alert(), add_alert() |
src/agent/db/postgres.py |
is_recent_alert SQL query |
migrations/007_alerts_dedup_key.sql |
dedup_key column on alerts |
Summary
When a Lambda function errors and triggers the aggregate CloudWatch alarm, both the event consumer and the proactive poller independently launch a full agent investigation for the same root cause. This produces two sessions, two Slack notifications, and two alert rows in the DB for a single incident.
Observed in logs:
Two different
dedup_keyvalues were stored:lambda_errors:opendevops-test-failure— from the polleraws.cloudwatch:ae3abf074120— from the event consumerHow dedup keys currently work
Event consumer (
event_consumer.py)The event consumer computes an MD5 fingerprint of the event's stable fields:
_strip_volatileremoves time-varying keys (time,timestamp,eventTime,requestId,startTime,endTime,updatedAt,stateTransitionedAt,lastUpdatedTime).Poller (
poller.py)The poller constructs semantic keys directly:
alarm:{alarmName}for CloudWatch alarms in ALARM statelambda_errors:{functionName}for Lambda error rate checksIt has an explicit cross-check to avoid re-investigating when the event consumer already handled the aggregate alarm:
Why the cross-check never fires
is_recent_alertdoes an exact key match in thealertstable. The event consumer stored the key asaws.cloudwatch:ae3abf074120. The poller checks foralarm:opendevops-aggregate-lambda-errors. These strings never match — the cross-check is effectively dead code.Proposed fix
Change
_event_dedup_keyinevent_consumer.pyto usealarm:{alarmName}for CloudWatch alarm state change events instead of the MD5:This makes the event consumer and poller share the same key namespace for alarms, so the poller's existing
is_recent_alert(f"alarm:{ALARM_NAME}")cross-check works as intended.User's concern with the proposed fix
The concern: if the aggregate alarm fires twice within the 3-minute dedup window (e.g., function A errors at 9:30 → investigated, function B errors at 9:31 → key
alarm:opendevops-aggregate-lambda-errorsis still recent → skipped).Counter-argument: CloudWatch alarms only fire on state transitions (
OK → ALARM). If the alarm is already inALARMstate when function B errors, no second EventBridge event fires. The investigation triggered at 9:30 runs against all current Lambda errors and should detect both function A and B.The scenario where two separate investigations would genuinely be warranted is
ALARM → OK → ALARMwithin 3 minutes — errors resolved and reappeared. This is unusual in practice.The user remained suspicious of this reasoning and chose not to apply the fix. The concern is that the trade-off analysis relies on the aggregate alarm behaving predictably, and any edge case (e.g., partial OK states, per-function dimension alarms) could cause a legitimate incident to be silently skipped.
Why using MD5 in the poller isn't a solution
The two systems start from different data sources:
There is no way to make both sides produce the same MD5 without them sharing the exact same payload, which they never do.
Next step suggestions
Store a secondary normalized key alongside the MD5. Add a
normalized_key TEXTcolumn toalerts. The event consumer writes both its MD5dedup_keyand a human-readablenormalized_key(e.g.,alarm:{alarmName}).is_recent_alertchecks both columns. This removes the trade-off entirely — MD5 dedup stays precise, cross-system dedup works via the normalized key.Widen
is_recent_alertto accept multiple keys. Pass[f"alarm:{ALARM_NAME}", f"lambda_errors:{name}"]and match on any. Requires a small schema/query change but no new columns.Consolidate into event-driven only. Disable the poller's alarm check entirely when event infra is enabled (SQS + EventBridge already covers alarm state changes). The poller would only handle Lambda error rate polling, which the event consumer doesn't cover. This avoids the namespace problem by removing the overlap.
Use
alarm:{alarmName}with a longer dedup window. Apply the proposed fix but extend the dedup window for alarm events (e.g., 10 minutes instead of 3), reducing the risk of the ALARM→OK→ALARM edge case being swallowed.Relevant files
src/agent/event_consumer.py_event_dedup_key(),_process_event()src/agent/poller.py_check_alarms(),_check_lambda_errors(), cross-check logicsrc/agent/monitor_store.pyis_recent_alert(),add_alert()src/agent/db/postgres.pyis_recent_alertSQL querymigrations/007_alerts_dedup_key.sqldedup_keycolumn onalerts