feat(observability): event triggers + log scanner backend
Two paired backends sharing the events.Bus seam:
Event triggers (consumer-side):
- internal/store/event_triggers.go — CRUD with action_secret
redaction on read (placeholder echo treated as "no change" on
PATCH so secrets aren't accidentally wiped).
- internal/events/dispatcher.go — bus subscriber, AND-composed
filters (severity CSV, source CSV, message regex with memoized
compile cache). Structural loop-prevention: never writes to
event_log. Sends via notifier.SendPayload.
- internal/notify: SendPayload + SendSyncForTestPayload methods,
TierEventTrigger constant, doSendRaw shared with the legacy
Event-shaped path.
- internal/api/event_triggers.go — admin-gated CRUD + /test
sending the real TriggerWebhookPayload shape. SSRF guard
rejects loopback / link-local / unspecified targets. PATCH
uses pointer-typed DTO for partial updates.
Log scanner (producer-side):
- internal/logscanner/ — engine (per-rule cooldown +
per-container token bucket, atomic drop counters), tail
(multiplexed docker frame demuxer with TTY fallback + 16 MiB
payload cap + 1 MiB reassembly cap + RFC3339Nano-validated
timestamp strip + UTF-8-safe message truncation), manager
(5s container polling, atomic.Pointer[Snapshot] hot-reload,
HitEmitter writes event_log + publishes EventLog so the
trigger dispatcher picks them up immediately).
- internal/docker/container.go — ContainerLogsOpts exposes
stream selection for stderr-only / stdout-only rules.
- internal/store: log_scan_rules table + CRUD with
EffectiveLogScanRules resolver (globals minus per-workload
overrides plus workload-only additions). Transactional
cascade-delete of overrides when a global rule is removed.
- internal/api/log_scan_rules.go — admin-gated CRUD + /test
(sample_line → matched/captures) + /stats (drop counters +
active tail count + last-snapshot compile errors) +
GET /api/workloads/{id}/effective-rules.
cmd/server/main.go wires both subsystems next to the existing
RegisterPersistentLogger. Coverage spans engine cooldown / bucket
counter tests, snapshot effective-set semantics, manager compile-
error capture, dispatcher matching, store validation +
cascade-delete, API URL validator + secret redaction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package logscanner
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestTruncateUTF8(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
maxBytes int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "shorter than cap untouched",
|
||||
in: "hello",
|
||||
maxBytes: 100,
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "ASCII truncation",
|
||||
in: "abcdefghij",
|
||||
maxBytes: 5,
|
||||
want: "abcde…",
|
||||
},
|
||||
{
|
||||
name: "cuts on rune boundary inside multibyte",
|
||||
in: "abcdé",
|
||||
maxBytes: 5,
|
||||
want: "abcd…",
|
||||
},
|
||||
{
|
||||
name: "preserves valid utf-8 when cap lands mid-codepoint",
|
||||
in: "ééééééé",
|
||||
maxBytes: 5,
|
||||
want: "éé…",
|
||||
},
|
||||
{
|
||||
name: "empty input untouched",
|
||||
in: "",
|
||||
maxBytes: 5,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := truncateUTF8(c.in, c.maxBytes)
|
||||
if got != c.want {
|
||||
t.Errorf("got %q want %q", got, c.want)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("result not valid UTF-8: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonEmpty(t *testing.T) {
|
||||
if nonEmpty("a", "b") != "a" {
|
||||
t.Error("first non-empty wins")
|
||||
}
|
||||
if nonEmpty("", "b") != "b" {
|
||||
t.Error("fallback when first empty")
|
||||
}
|
||||
if nonEmpty("", "") != "" {
|
||||
t.Error("both empty yields empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexName_UnamedGroupsStable(t *testing.T) {
|
||||
// Each unnamed group should get a distinct fallback name so
|
||||
// JSON serialization doesn't collapse $4..$N onto a single key.
|
||||
re := mustCompile(t, `(\w+) (\w+) (\w+) (\w+) (\w+)`)
|
||||
seen := map[string]bool{}
|
||||
for i := 1; i <= 5; i++ {
|
||||
name := indexName(re, i)
|
||||
if seen[name] {
|
||||
t.Errorf("indexName(%d) = %q collides with prior group", i, name)
|
||||
}
|
||||
seen[name] = true
|
||||
if !strings.HasPrefix(name, "$") {
|
||||
t.Errorf("unnamed group %d should fall back to $N form, got %q", i, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexName_NamedGroupWins(t *testing.T) {
|
||||
re := mustCompile(t, `(?P<code>\d+) (\w+)`)
|
||||
if got := indexName(re, 1); got != "code" {
|
||||
t.Errorf("named group should win: %q", got)
|
||||
}
|
||||
if got := indexName(re, 2); got != "$2" {
|
||||
t.Errorf("second (unnamed) group: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// mustCompile is a local helper so the test file is self-contained.
|
||||
func mustCompile(t *testing.T, pattern string) interface{ SubexpNames() []string } {
|
||||
t.Helper()
|
||||
r, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("compile %q: %v", pattern, err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user