Skip to content

Heartbeat recovery does not send resolved notifications #34

Description

@Artesfrost

Fix missing resolved notifications for recovered heartbeats

Summary

During testing of the Heartbeats functionality in IncidentRelay, a notification gap was identified:

  • heartbeat overdue alerts were created correctly;
  • the initial notification was sent;
  • escalation and matcher-based channel selection worked;
  • the alert was resolved when the heartbeat recovered and Auto Resolve was enabled;
  • however, no resolved notification was sent to the configured notification channels.

The standard alert lifecycle already sends resolved notifications through the notification-policy pipeline. The heartbeat recovery flow resolved the alert but did not call that pipeline.


Target use case

  1. A heartbeat ping is received by IncidentRelay.
  2. The heartbeat stops sending pings.
  3. After the configured interval and grace period:
    • the heartbeat becomes overdue;
    • an alert is created;
    • the initial notification is sent;
    • escalation continues according to the escalation policy;
    • matcher-based notification rules select the required Teams channel.
  4. The heartbeat starts sending pings again.
  5. When Auto Resolve is enabled:
    • the heartbeat returns to ok;
    • the related alert is resolved;
    • a resolved notification is sent to the matching notification channels.
  6. When Auto Resolve is disabled:
    • the heartbeat returns to ok;
    • the related alert remains open;
    • no resolved notification is sent.

Example heartbeat labels:

{
  "Application": "DB",
  "source_type": "heartbeat"
}

Changed files

One application file was modified:

app/services/heartbeats/service.py

In addition, the existing notification-policy rule configuration was updated to include the resolved event type.


Change 1: send resolved notifications after heartbeat recovery

Modified file

app/services/heartbeats/service.py

Affected functions

_resolve_current_overdue_alert()
_resolve_current_instance_overdue_alert()

Previous behavior

The heartbeat recovery flow called:

resolved = resolve_alert(group.id, user_id=None)

This correctly:

  • changed the alert group status to resolved;
  • created the standard resolved timeline event;
  • refreshed service and business-impact state.

After that, the heartbeat-specific recovery event was created:

alerts_repo.create_alert_event(
    group_id=resolved.id,
    event_type="heartbeat_recovered",
    message=f"Heartbeat recovered: {heartbeat.name}",
)

However, the resolved notification pipeline was not called.

As a result, the alert was resolved in IncidentRelay, but Microsoft Teams did not receive a recovery/resolved message.

Standard lifecycle behavior

The standard incoming-alert lifecycle in:

app/services/alerts/lifecycle.py

sends resolved notifications using:

if group.last_notification_at:
    notify_alert(
        group,
        event_type="resolved",
    )

The heartbeat recovery flow did not use the same logic.

Updated behavior

The heartbeat recovery functions now call the existing notification delivery pipeline after resolving the alert:

resolved = resolve_alert(group.id, user_id=None)

if resolved.last_notification_at:
    notify_alert(
        resolved,
        event_type="resolved",
    )

The heartbeat-specific recovery event is then recorded as before.


Required import

Modified file

app/services/heartbeats/service.py

The following import was added:

 from app.services.integrations.auth import create_raw_token, hash_token
+from app.services.notifications.delivery import notify_alert

Change 2: resolved notification for a regular heartbeat

Modified function

_resolve_current_overdue_alert()

Previous code

resolved = resolve_alert(group.id, user_id=None)

alerts_repo.create_alert_event(
    group_id=resolved.id,
    event_type="heartbeat_recovered",
    message=f"Heartbeat recovered: {heartbeat.name}",
)

return resolved

Updated code

resolved = resolve_alert(group.id, user_id=None)

if resolved.last_notification_at:
    notify_alert(
        resolved,
        event_type="resolved",
    )

alerts_repo.create_alert_event(
    group_id=resolved.id,
    event_type="heartbeat_recovered",
    message=f"Heartbeat recovered: {heartbeat.name}",
)

return resolved

Conceptual diff — app/services/heartbeats/service.py

 resolved = resolve_alert(group.id, user_id=None)

+if resolved.last_notification_at:
+    notify_alert(
+        resolved,
+        event_type="resolved",
+    )
+
 alerts_repo.create_alert_event(
     group_id=resolved.id,
     event_type="heartbeat_recovered",
     message=f"Heartbeat recovered: {heartbeat.name}",
 )

Change 3: resolved notification for a heartbeat instance

Modified function

_resolve_current_instance_overdue_alert()

Previous code

resolved = resolve_alert(group.id, user_id=None)

alerts_repo.create_alert_event(
    group_id=resolved.id,
    event_type="heartbeat_instance_recovered",
    message=(
        f"Heartbeat recovered: "
        f"{instance.heartbeat.name} / {instance.instance_key}"
    ),
)

return resolved

Updated code

resolved = resolve_alert(group.id, user_id=None)

if resolved.last_notification_at:
    notify_alert(
        resolved,
        event_type="resolved",
    )

alerts_repo.create_alert_event(
    group_id=resolved.id,
    event_type="heartbeat_instance_recovered",
    message=(
        f"Heartbeat recovered: "
        f"{instance.heartbeat.name} / {instance.instance_key}"
    ),
)

return resolved

Conceptual diff — app/services/heartbeats/service.py

 resolved = resolve_alert(group.id, user_id=None)

+if resolved.last_notification_at:
+    notify_alert(
+        resolved,
+        event_type="resolved",
+    )
+
 alerts_repo.create_alert_event(
     group_id=resolved.id,
     event_type="heartbeat_instance_recovered",
     message=(
         f"Heartbeat recovered: "
         f"{instance.heartbeat.name} / {instance.instance_key}"
     ),
 )

Why last_notification_at is checked

The standard IncidentRelay lifecycle sends a resolved notification only when the alert group had already produced an outbound notification.

The same guard was retained:

if resolved.last_notification_at:

This avoids sending a resolved message for an alert that never had an initial notification.

It also keeps the heartbeat recovery behavior consistent with:

app/services/alerts/lifecycle.py

Notification Policy configuration

The notification-policy rules used by the heartbeat service also need to allow the resolved event type.

Example event-type configuration:

[
  "notification",
  "reminder",
  "resolved"
]

For the Database Layer rule:

[
  "escalation",
  "resolved"
]

The exact event-type assignment depends on which channels should receive recovery notifications.

No direct Teams webhook call was added. Resolved notifications continue to pass through:

Heartbeat recovery
→ resolve_alert()
→ notify_alert(event_type="resolved")
→ Notification Policy resolver
→ matcher evaluation
→ configured notification channel

Expected result

When Auto Resolve is enabled:

  • the heartbeat returns to ok;
  • the related alert is resolved;
  • the standard resolved event is created;
  • the matching notification-policy rule is selected;
  • the configured Teams channel receives a resolved notification;
  • the heartbeat-specific heartbeat_recovered event is recorded.

When Auto Resolve is disabled:

  • the heartbeat returns to ok;
  • the existing alert remains open;
  • no resolved notification is sent.

Validation

Test configuration

Heartbeat interval: 60 seconds
Grace period: 30 seconds
Severity: critical
Priority: p1
Application label: DB
Notification channel: Microsoft Teams
Notification mode: service notification policy

Auto Resolve enabled

The end-to-end test completed successfully:

Heartbeat stops sending pings
→ heartbeat becomes overdue
→ alert is created
→ initial Duty Engineer notification
→ escalation after configured delay
→ Database Layer rotation
→ Database on-call engineer assignment
→ dedicated Database Teams notification
→ heartbeat ping is received
→ heartbeat status changes to ok
→ alert is resolved
→ resolved Teams notification is sent

Expected timeline events were observed:

heartbeat_overdue
notification_sent
escalated
notification_sent
resolved
heartbeat_recovered

The notification delivery also created the corresponding Teams delivery event.

Auto Resolve disabled

The second scenario also completed successfully:

Heartbeat stops sending pings
→ heartbeat becomes overdue
→ alert is created
→ notifications and escalation work
→ heartbeat ping is received
→ heartbeat status changes to ok
→ alert remains open
→ no resolved notification is sent

This behavior matches the Auto Resolve setting.


Request for review

Please review and confirm:

  1. Heartbeat recovery should use the same resolved-notification pipeline as the standard incoming-alert lifecycle.

  2. The update in:

    app/services/heartbeats/service.py
    

    is consistent with the intended heartbeat lifecycle.

  3. Checking last_notification_at is the correct condition before sending a resolved notification.

  4. Both regular heartbeats and heartbeat instances should use the same behavior.

  5. Notification-policy rules should explicitly include the resolved event type for channels that must receive recovery messages.

  6. Automated tests should cover:

    • heartbeat recovery with Auto Resolve enabled;
    • heartbeat recovery with Auto Resolve disabled;
    • recovery after the initial notification;
    • recovery before any notification was sent;
    • matcher-based resolved channel selection;
    • heartbeat-instance recovery;
    • prevention of duplicate resolved notifications.

Deployment details

The solution is deployed on-premises on Oracle Linux 9.

Architecture

Heartbeat client
   │
   │ HTTP ping using a heartbeat token
   ▼
Nginx
   │
   ▼
Gunicorn :8081
   │
   ▼
IncidentRelay Flask application
   │
   ├── PostgreSQL
   ├── Microsoft Teams via Power Automate webhook
   └── IncidentRelay scheduler worker

Components

Application version directory:
/var/www/incidentrelay-1.2.1

Active application path:
/var/www/incidentrelay

Modified application file:
/var/www/incidentrelay/app/services/heartbeats/service.py

Python virtual environment:
/var/www/incidentrelay/venv

Python executable:
/var/www/incidentrelay/venv/bin/python

Configuration file:
/etc/incidentrelay/incidentrelay.conf

Web application service:
incidentrelay.service

Scheduler service:
incidentrelay-scheduler.service

Database:
PostgreSQL

Database name:
incidentrelay

Application server:
Gunicorn

Application port:
8081

Reverse proxy:
Nginx

Local deployment procedure

A timestamped backup of the modified Python file was created before applying the change.

The notification-policy rule configuration was also backed up before adding the resolved event type.

Syntax validation:

/var/www/incidentrelay/venv/bin/python -m py_compile \
  /var/www/incidentrelay/app/services/heartbeats/service.py

Import validation:

cd /var/www/incidentrelay

sudo -u incidentrelay env \
  PYTHONPATH=/var/www/incidentrelay \
  INCIDENTRELAY_CONFIG_FILE=/etc/incidentrelay/incidentrelay.conf \
  /var/www/incidentrelay/venv/bin/python -c \
  'from app.services.heartbeats import service'

Service restart:

systemctl restart incidentrelay.service
systemctl restart incidentrelay-scheduler.service

Service verification:

systemctl status incidentrelay.service
systemctl status incidentrelay-scheduler.service

The final behavior was validated with new heartbeat overdue and recovery cycles.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions