fix(notifications): match health_status event alerts (#4790)

This commit is contained in:
Amir Raminfar
2026-06-16 05:39:34 -07:00
committed by GitHub
parent acaec204b6
commit 3c68345938
5 changed files with 121 additions and 2 deletions
@@ -17,7 +17,7 @@
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.event-fields-hint", {
fields: "name (start, stop, die, restart, health_status), attributes (exitCode, signal, etc.)",
fields: "name (start, stop, die, restart, health_status), attributes (healthStatus, exitCode, signal, etc.)",
})
}}
</p>
+8
View File
@@ -106,6 +106,8 @@ export function createEventHints(): Completion[] {
return [
{ label: "name", detail: "event name", type: "property" },
{ label: "attributes", detail: "event attributes map", type: "property" },
{ label: 'attributes["healthStatus"]', detail: "healthy or unhealthy (health_status events)", type: "property" },
{ label: 'attributes["exitCode"]', detail: "exit code (die events)", type: "property" },
...exprOperators,
{ label: '"start"', detail: "container started", type: "string" },
{ label: '"stop"', detail: "container stopped", type: "string" },
@@ -114,6 +116,12 @@ export function createEventHints(): Completion[] {
{ label: '"health_status"', detail: "health check changed", type: "string" },
{ label: 'name == "die"', detail: "match container death", type: "text", boost: 10 },
{ label: 'name == "health_status"', detail: "match health changes", type: "text", boost: 10 },
{
label: 'name == "health_status" && attributes["healthStatus"] == "unhealthy"',
detail: "match unhealthy containers",
type: "text",
boost: 10,
},
{ label: 'name in ["stop", "die"]', detail: "match stop or death", type: "text", boost: 10 },
];
}
+3 -1
View File
@@ -227,6 +227,8 @@ Available properties:
Common Docker event names include `start`, `stop`, `die`, `kill`, `oom`, `restart`, `destroy`, and `health_status`.
For `health_status` events, Dozzle exposes the current state as `attributes["healthStatus"]` (`healthy` or `unhealthy`).
### Event Examples
**Alert when any production container dies:**
@@ -247,7 +249,7 @@ Event: name == "oom"
```
Container: true
Event: name == "health_status" && attributes["health_status"] == "unhealthy"
Event: name == "health_status" && attributes["healthStatus"] == "unhealthy"
```
**Alert on unexpected exits (ignoring clean and graceful shutdowns):**
+18
View File
@@ -2,6 +2,7 @@ package notification
import (
"context"
"strings"
"sync"
"time"
@@ -81,6 +82,21 @@ func (l *ContainerEventListener) IsRunning() bool {
return l.cancelFunc != nil
}
// normalizeEvent rewrites Docker's health event so notification expressions can match it.
// Docker emits health events as "health_status: healthy" / "health_status: unhealthy".
// We collapse them to a bare "health_status" name and expose the status as a "healthStatus"
// attribute, so expressions like
// name == "health_status" && attributes["healthStatus"] == "unhealthy" work.
func normalizeEvent(event *container.ContainerEvent) {
if name, status, found := strings.Cut(event.Name, ": "); found && name == "health_status" {
event.Name = name
if event.ActorAttributes == nil {
event.ActorAttributes = map[string]string{}
}
event.ActorAttributes["healthStatus"] = status
}
}
func (l *ContainerEventListener) enrich(ctx context.Context, rawEvents <-chan container.ContainerEvent) {
for {
select {
@@ -91,6 +107,8 @@ func (l *ContainerEventListener) enrich(ctx context.Context, rawEvents <-chan co
return
}
normalizeEvent(&event)
if !allowedEventNames[event.Name] {
continue
}
@@ -0,0 +1,91 @@
package notification
import (
"testing"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/types"
"github.com/expr-lang/expr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeEvent(t *testing.T) {
tests := []struct {
name string
event container.ContainerEvent
wantName string
wantAttributes map[string]string
}{
{
name: "unhealthy health event is normalized",
event: container.ContainerEvent{Name: "health_status: unhealthy"},
wantName: "health_status",
wantAttributes: map[string]string{"healthStatus": "unhealthy"},
},
{
name: "healthy health event is normalized",
event: container.ContainerEvent{Name: "health_status: healthy"},
wantName: "health_status",
wantAttributes: map[string]string{"healthStatus": "healthy"},
},
{
name: "existing attributes are preserved",
event: container.ContainerEvent{
Name: "health_status: unhealthy",
ActorAttributes: map[string]string{"name": "postgres"},
},
wantName: "health_status",
wantAttributes: map[string]string{"name": "postgres", "healthStatus": "unhealthy"},
},
{
name: "non-health events are left untouched",
event: container.ContainerEvent{Name: "die", ActorAttributes: map[string]string{"exitCode": "1"}},
wantName: "die",
wantAttributes: map[string]string{"exitCode": "1"},
},
{
name: "bare health_status name is left untouched",
event: container.ContainerEvent{Name: "health_status"},
wantName: "health_status",
wantAttributes: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := tt.event
normalizeEvent(&event)
assert.Equal(t, tt.wantName, event.Name)
assert.Equal(t, tt.wantAttributes, event.ActorAttributes)
})
}
}
// Ensures the documented health alert expression actually matches a normalized event.
func TestNormalizedHealthEventMatchesSubscription(t *testing.T) {
event := container.ContainerEvent{Name: "health_status: unhealthy"}
normalizeEvent(&event)
require.True(t, allowedEventNames[event.Name], "normalized event must pass the allowlist")
sub := &Subscription{
EventExpression: `name == "health_status" && attributes["healthStatus"] == "unhealthy"`,
}
program, err := expr.Compile(sub.EventExpression, expr.Env(types.NotificationEvent{}))
require.NoError(t, err)
sub.EventProgram = program
notificationEvent := types.NotificationEvent{
Name: event.Name,
Attributes: event.ActorAttributes,
}
assert.True(t, sub.MatchesEvent(notificationEvent))
healthy := container.ContainerEvent{Name: "health_status: healthy"}
normalizeEvent(&healthy)
assert.False(t, sub.MatchesEvent(types.NotificationEvent{
Name: healthy.Name,
Attributes: healthy.ActorAttributes,
}))
}