From 3c683459383a470e4837f4386e9f08aa8a77992f Mon Sep 17 00:00:00 2001 From: Amir Raminfar Date: Tue, 16 Jun 2026 05:39:34 -0700 Subject: [PATCH] fix(notifications): match health_status event alerts (#4790) --- .../Notification/EventAlertFields.vue | 2 +- assets/composable/exprEditor.ts | 8 ++ docs/guide/alerts-and-webhooks.md | 4 +- internal/notification/event_listener.go | 18 ++++ internal/notification/event_listener_test.go | 91 +++++++++++++++++++ 5 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 internal/notification/event_listener_test.go diff --git a/assets/components/Notification/EventAlertFields.vue b/assets/components/Notification/EventAlertFields.vue index 605aeb96..53cd9bc5 100644 --- a/assets/components/Notification/EventAlertFields.vue +++ b/assets/components/Notification/EventAlertFields.vue @@ -17,7 +17,7 @@

{{ $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.)", }) }}

diff --git a/assets/composable/exprEditor.ts b/assets/composable/exprEditor.ts index 6faf5e7a..e94a44d1 100644 --- a/assets/composable/exprEditor.ts +++ b/assets/composable/exprEditor.ts @@ -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 }, ]; } diff --git a/docs/guide/alerts-and-webhooks.md b/docs/guide/alerts-and-webhooks.md index 378c947c..b598c082 100644 --- a/docs/guide/alerts-and-webhooks.md +++ b/docs/guide/alerts-and-webhooks.md @@ -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):** diff --git a/internal/notification/event_listener.go b/internal/notification/event_listener.go index 882e1bad..f61203f0 100644 --- a/internal/notification/event_listener.go +++ b/internal/notification/event_listener.go @@ -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 } diff --git a/internal/notification/event_listener_test.go b/internal/notification/event_listener_test.go new file mode 100644 index 00000000..ae245123 --- /dev/null +++ b/internal/notification/event_listener_test.go @@ -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, + })) +}