mirror of
https://github.com/amir20/dozzle.git
synced 2026-06-23 04:10:12 +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">
|
<p class="text-base-content/50 mt-1 text-xs">
|
||||||
{{
|
{{
|
||||||
$t("notifications.alert-form.event-fields-hint", {
|
$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>
|
</p>
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export function createEventHints(): Completion[] {
|
|||||||
return [
|
return [
|
||||||
{ label: "name", detail: "event name", type: "property" },
|
{ label: "name", detail: "event name", type: "property" },
|
||||||
{ label: "attributes", detail: "event attributes map", 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,
|
...exprOperators,
|
||||||
{ label: '"start"', detail: "container started", type: "string" },
|
{ label: '"start"', detail: "container started", type: "string" },
|
||||||
{ label: '"stop"', detail: "container stopped", 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: '"health_status"', detail: "health check changed", type: "string" },
|
||||||
{ label: 'name == "die"', detail: "match container death", type: "text", boost: 10 },
|
{ 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"', 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 },
|
{ 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`.
|
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
|
### Event Examples
|
||||||
|
|
||||||
**Alert when any production container dies:**
|
**Alert when any production container dies:**
|
||||||
@@ -247,7 +249,7 @@ Event: name == "oom"
|
|||||||
|
|
||||||
```
|
```
|
||||||
Container: true
|
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):**
|
**Alert on unexpected exits (ignoring clean and graceful shutdowns):**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package notification
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -81,6 +82,21 @@ func (l *ContainerEventListener) IsRunning() bool {
|
|||||||
return l.cancelFunc != nil
|
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) {
|
func (l *ContainerEventListener) enrich(ctx context.Context, rawEvents <-chan container.ContainerEvent) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -91,6 +107,8 @@ func (l *ContainerEventListener) enrich(ctx context.Context, rawEvents <-chan co
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeEvent(&event)
|
||||||
|
|
||||||
if !allowedEventNames[event.Name] {
|
if !allowedEventNames[event.Name] {
|
||||||
continue
|
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