mirror of
https://github.com/amir20/dozzle.git
synced 2026-06-22 20:00:11 +00:00
fix(notifications): match health_status event alerts (#4790)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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):**
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user