Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions cmd/fanout-docgen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import (
func main() {
var (
source = flag.String("source", "internal/config/config.go", "path to the file declaring config.Config")
alerts = flag.String("alert-source", "internal/alert/types.go", "path to the file declaring alert.AlertEnv")
alerts = flag.String("alert-source", "internal/alert/types.go", "path to the file declaring alert.Env")
// Every directory that registers routes. cmd/fanout carries the
// operational, protocol and SPA routes; internal/agent the investigator.
// A directory listed here that registers nothing is an error, so this
Expand Down Expand Up @@ -178,16 +178,16 @@ func run(source, alertSource, routeDirs, outDir, envExample string, check bool)
// without threading the slice back through run's return.
var alertEnvCount []field

// collectAlertEnv reads alert.AlertEnv the way collect reads config.Config:
// collectAlertEnv reads alert.Env the way collect reads config.Config:
// reflection for the contract, the source for the prose, and a cross-check that
// refuses to proceed when the two disagree.
func collectAlertEnv(source string) ([]field, error) {
docs, err := docComments(source, "AlertEnv")
docs, err := docComments(source, "Env")
if err != nil {
return nil, err
}

typ := reflect.TypeOf(alert.AlertEnv{})
typ := reflect.TypeOf(alert.Env{})
out := make([]field, 0, typ.NumField())
seen := make(map[string]bool, typ.NumField())

Expand All @@ -199,7 +199,7 @@ func collectAlertEnv(source string) ([]field, error) {
name := sf.Tag.Get("expr")
if name == "" {
return nil, fmt.Errorf(
"alert.AlertEnv field %s has no expr tag, so a rule author has no name to write for it",
"alert.Env field %s has no expr tag, so a rule author has no name to write for it",
sf.Name,
)
}
Expand All @@ -215,7 +215,7 @@ func collectAlertEnv(source string) ([]field, error) {
for name := range docs {
if !seen[name] {
return nil, fmt.Errorf(
"%s declares field %s, which is not in alert.AlertEnv — is --alert-source correct?",
"%s declares field %s, which is not in alert.Env — is --alert-source correct?",
source, name,
)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/agent/tools_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ type registryQueries struct{}

type registryIntelligence struct{}

func (registryIntelligence) LatestSnapshot() *intelligence.IntelligenceSnapshot {
return &intelligence.IntelligenceSnapshot{Summary: "healthy", HealthScore: 100}
func (registryIntelligence) LatestSnapshot() *intelligence.Snapshot {
return &intelligence.Snapshot{Summary: "healthy", HealthScore: 100}
}

func (registryQueries) Dependencies(context.Context, observability.Scope, observability.DependencyOptions) (observability.Result[observability.Dependencies], error) {
Expand Down
26 changes: 13 additions & 13 deletions internal/alert/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type Engine struct {
interval time.Duration
histDays int
lastPrune time.Time
envOverride map[string]AlertEnv // for testing — bypasses DuckDB query
envOverride map[string]Env // for testing — bypasses DuckDB query
}

// NewEngine creates an Engine ready to Run.
Expand Down Expand Up @@ -79,11 +79,11 @@ func (e *Engine) Run(ctx context.Context) {
}
}

// BuildAllEnvs returns all current AlertEnvs keyed by service name.
func (e *Engine) BuildAllEnvs(ctx context.Context) map[string]AlertEnv {
// BuildAllEnvs returns all current Envs keyed by service name.
func (e *Engine) BuildAllEnvs(ctx context.Context) map[string]Env {
envs := e.buildEnvs(ctx)
if envs == nil {
return map[string]AlertEnv{}
return map[string]Env{}
}
return envs
}
Expand Down Expand Up @@ -157,7 +157,7 @@ func (e *Engine) compileRules(rules []Rule) {

// resolveServices returns the sorted list of services to evaluate for a rule.
// '*' matches all services present in envs; a specific name targets just that one.
func (e *Engine) resolveServices(rule Rule, envs map[string]AlertEnv) []string {
func (e *Engine) resolveServices(rule Rule, envs map[string]Env) []string {
if rule.Service == "" || rule.Service == "*" {
svcs := make([]string, 0, len(envs))
for svc := range envs {
Expand All @@ -175,7 +175,7 @@ func (e *Engine) resolveServices(rule Rule, envs map[string]AlertEnv) []string {
}

// transition implements the alert state machine for a (rule, service) pair.
func (e *Engine) transition(rule Rule, svc string, triggered bool, env AlertEnv) {
func (e *Engine) transition(rule Rule, svc string, triggered bool, env Env) {
now := time.Now()
nowStr := now.Format(time.RFC3339)

Expand Down Expand Up @@ -339,7 +339,7 @@ func (e *Engine) transition(rule Rule, svc string, triggered bool, env AlertEnv)
// primaryValue picks the most relevant metric value based on the rule expression.
// If the expression mentions p95, use P95. If it mentions throughput, use that.
// Default to error_rate as the most common alert metric.
func primaryValue(rule Rule, env AlertEnv) float64 {
func primaryValue(rule Rule, env Env) float64 {
expr := rule.Expression
if strings.Contains(expr, "p95") {
return env.P95
Expand All @@ -357,7 +357,7 @@ func primaryValue(rule Rule, env AlertEnv) float64 {
}

// fireWebhookAsync fires the webhook for an alert in a goroutine and updates delivery status.
func (e *Engine) fireWebhookAsync(rule Rule, alert Alert, env AlertEnv, event string) {
func (e *Engine) fireWebhookAsync(rule Rule, alert Alert, env Env, event string) {
go func() {
defer func() {
if r := recover(); r != nil {
Expand All @@ -382,14 +382,14 @@ func (e *Engine) fireWebhookAsync(rule Rule, alert Alert, env AlertEnv, event st
}()
}

// buildEnvs returns AlertEnv keyed by service name. If envOverride is set it
// buildEnvs returns Env keyed by service name. If envOverride is set it
// is returned directly (for tests). Otherwise DuckDB is queried.
func (e *Engine) buildEnvs(ctx context.Context) map[string]AlertEnv {
func (e *Engine) buildEnvs(ctx context.Context) map[string]Env {
if e.envOverride != nil {
return e.envOverride
}
if e.duck == nil {
return map[string]AlertEnv{}
return map[string]Env{}
}

const envSQL = `
Expand Down Expand Up @@ -440,13 +440,13 @@ FROM current c LEFT JOIN previous p ON c.service = p.service`
}
}

envs := make(map[string]AlertEnv, len(resp.Results))
envs := make(map[string]Env, len(resp.Results))
for _, row := range resp.Results {
svc, _ := row["service"].(string)
if svc == "" {
continue
}
env := AlertEnv{
env := Env{
Service: svc,
ErrorRate: toFloat(row["error_rate"]),
P50: toFloat(row["p50"]),
Expand Down
24 changes: 12 additions & 12 deletions internal/alert/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ func TestEngine_Transition_NoneToFiring(t *testing.T) {

rule := makeTestRule(t, store, "error_rate > 0.1", 0)

env := AlertEnv{Service: "svc-a", ErrorRate: 0.5}
eng.envOverride = map[string]AlertEnv{"svc-a": env}
env := Env{Service: "svc-a", ErrorRate: 0.5}
eng.envOverride = map[string]Env{"svc-a": env}

if err := eng.RecompileRule(rule.ID, rule.Expression); err != nil {
t.Fatalf("RecompileRule: %v", err)
Expand All @@ -76,8 +76,8 @@ func TestEngine_Transition_NoneToPending(t *testing.T) {

rule := makeTestRule(t, store, "error_rate > 0.1", 60)

env := AlertEnv{Service: "svc-a", ErrorRate: 0.5}
eng.envOverride = map[string]AlertEnv{"svc-a": env}
env := Env{Service: "svc-a", ErrorRate: 0.5}
eng.envOverride = map[string]Env{"svc-a": env}

if err := eng.RecompileRule(rule.ID, rule.Expression); err != nil {
t.Fatalf("RecompileRule: %v", err)
Expand All @@ -104,8 +104,8 @@ func TestEngine_Transition_PendingClears(t *testing.T) {

rule := makeTestRule(t, store, "error_rate > 0.1", 60)

env := AlertEnv{Service: "svc-a", ErrorRate: 0.5}
eng.envOverride = map[string]AlertEnv{"svc-a": env}
env := Env{Service: "svc-a", ErrorRate: 0.5}
eng.envOverride = map[string]Env{"svc-a": env}

if err := eng.RecompileRule(rule.ID, rule.Expression); err != nil {
t.Fatalf("RecompileRule: %v", err)
Expand Down Expand Up @@ -138,8 +138,8 @@ func TestEngine_Transition_FiringToResolved(t *testing.T) {
rule := makeTestRule(t, store, "error_rate > 0.1", 0)
rule.NotifyOnResolve = true

env := AlertEnv{Service: "svc-a", ErrorRate: 0.5}
eng.envOverride = map[string]AlertEnv{"svc-a": env}
env := Env{Service: "svc-a", ErrorRate: 0.5}
eng.envOverride = map[string]Env{"svc-a": env}

if err := eng.RecompileRule(rule.ID, rule.Expression); err != nil {
t.Fatalf("RecompileRule: %v", err)
Expand All @@ -157,7 +157,7 @@ func TestEngine_Transition_FiringToResolved(t *testing.T) {
}

// Resolve it.
envClear := AlertEnv{Service: "svc-a", ErrorRate: 0.01}
envClear := Env{Service: "svc-a", ErrorRate: 0.01}
eng.transition(rule, "svc-a", false, envClear)

a, err = store.GetAlert(rule.ID, "svc-a")
Expand Down Expand Up @@ -197,7 +197,7 @@ func TestEngine_ResolveServices_Wildcard(t *testing.T) {
eng, _ := newTestEngine(t)

rule := Rule{Service: "*"}
envs := map[string]AlertEnv{
envs := map[string]Env{
"svc-a": {},
"svc-b": {},
"svc-c": {},
Expand All @@ -220,7 +220,7 @@ func TestEngine_ResolveServices_Specific(t *testing.T) {
eng, _ := newTestEngine(t)

rule := Rule{Service: "svc-b"}
envs := map[string]AlertEnv{
envs := map[string]Env{
"svc-a": {},
"svc-b": {},
"svc-c": {},
Expand Down Expand Up @@ -250,7 +250,7 @@ func TestEngine_RunOnce(t *testing.T) {
t.Fatalf("CreateRule: %v", err)
}

eng.envOverride = map[string]AlertEnv{
eng.envOverride = map[string]Env{
"api": {Service: "api", ErrorRate: 0.20},
}

Expand Down
12 changes: 6 additions & 6 deletions internal/alert/expr.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
// the package (and callers) refer only to alert.Program.
type Program = cel.Program

// alertCELEnv is the CEL environment shared by every compile. Each AlertEnv field
// alertCELEnv is the CEL environment shared by every compile. Each Env field
// is declared as a typed variable so the type checker rejects unknown names and
// non-boolean expressions at compile time. CrossTypeNumericComparisons lets a
// double variable be compared against an integer literal (e.g. `p95 > 500`)
Expand Down Expand Up @@ -62,12 +62,12 @@ func CompileExpression(expression string) (Program, error) {
return prog, nil
}

// celActivation binds the CEL variables to an AlertEnv's values. The keys must
// celActivation binds the CEL variables to an Env's values. The keys must
// stay in sync with the cel.Variable declarations in mustAlertCELEnv above;
// TestAlertEnvVariablesDeclaredAndBound enforces that against the AlertEnv `expr`
// TestEnvVariablesDeclaredAndBound enforces that against the Env `expr`
// field tags, so a drift fails the build instead of silently breaking a rule at
// eval time.
func celActivation(env AlertEnv) map[string]any {
func celActivation(env Env) map[string]any {
return map[string]any{
"error_rate": env.ErrorRate,
"p50": env.P50,
Expand All @@ -84,7 +84,7 @@ func celActivation(env AlertEnv) map[string]any {
}

// EvalExpression runs a compiled program with the given environment.
func EvalExpression(prog Program, env AlertEnv) (bool, error) {
func EvalExpression(prog Program, env Env) (bool, error) {
out, _, err := prog.Eval(celActivation(env))
if err != nil {
return false, fmt.Errorf("eval expression: %w", err)
Expand All @@ -98,7 +98,7 @@ func EvalExpression(prog Program, env AlertEnv) (bool, error) {

// SafeEval wraps EvalExpression with panic recovery, logging unexpected panics
// via slog. Returns false on panic.
func SafeEval(prog Program, env AlertEnv) (result bool, err error) {
func SafeEval(prog Program, env Env) (result bool, err error) {
defer func() {
if r := recover(); r != nil {
slog.Error("alert: expression eval panic", "panic", r, "stack", string(debug.Stack()))
Expand Down
38 changes: 19 additions & 19 deletions internal/alert/expr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ func TestEvalExpression(t *testing.T) {

cases := []struct {
name string
env AlertEnv
env Env
expected bool
}{
{"both above threshold", AlertEnv{ErrorRate: 0.1, P95: 600}, true},
{"error_rate below", AlertEnv{ErrorRate: 0.01, P95: 600}, false},
{"p95 below", AlertEnv{ErrorRate: 0.1, P95: 100}, false},
{"both below", AlertEnv{ErrorRate: 0.01, P95: 100}, false},
{"both above threshold", Env{ErrorRate: 0.1, P95: 600}, true},
{"error_rate below", Env{ErrorRate: 0.01, P95: 600}, false},
{"p95 below", Env{ErrorRate: 0.1, P95: 100}, false},
{"both below", Env{ErrorRate: 0.01, P95: 100}, false},
}

for _, tc := range cases {
Expand All @@ -99,7 +99,7 @@ func TestSafeEval_NoPanic(t *testing.T) {
t.Fatalf("CompileExpression: %v", err)
}

env := AlertEnv{ErrorRate: 0.5}
env := Env{ErrorRate: 0.5}
result, err := SafeEval(prog, env)
if err != nil {
t.Fatalf("SafeEval: unexpected error: %v", err)
Expand All @@ -109,29 +109,29 @@ func TestSafeEval_NoPanic(t *testing.T) {
}
}

// TestAlertEnvVariablesDeclaredAndBound makes the AlertEnv struct the single
// TestEnvVariablesDeclaredAndBound makes the Env struct the single
// source of truth for alert variables: every field's `expr` tag must be both a
// declared CEL variable (so a rule can reference it) and a key in celActivation
// (so it's bound at eval). Without this, adding a field to the struct + the
// cel.Variable list but forgetting celActivation would compile fine yet fail
// every 30s eval cycle with "no such attribute" — a silent, partial failure.
func TestAlertEnvVariablesDeclaredAndBound(t *testing.T) {
act := celActivation(AlertEnv{})
tp := reflect.TypeOf(AlertEnv{})
func TestEnvVariablesDeclaredAndBound(t *testing.T) {
act := celActivation(Env{})
tp := reflect.TypeOf(Env{})

tags := map[string]bool{}
for i := 0; i < tp.NumField(); i++ {
f := tp.Field(i)
name := f.Tag.Get("expr")
if name == "" {
t.Errorf("AlertEnv.%s has no `expr` tag", f.Name)
t.Errorf("Env.%s has no `expr` tag", f.Name)
continue
}
tags[name] = true

// (1) bound at eval time.
if _, ok := act[name]; !ok {
t.Errorf("variable %q (AlertEnv.%s) is not bound in celActivation", name, f.Name)
t.Errorf("variable %q (Env.%s) is not bound in celActivation", name, f.Name)
}

// (2) declared in the CEL env — a type-appropriate boolean expression
Expand All @@ -143,18 +143,18 @@ func TestAlertEnvVariablesDeclaredAndBound(t *testing.T) {
case reflect.String:
expr = name + ` == ""`
default:
t.Errorf("AlertEnv.%s has unsupported kind %s for a CEL variable", f.Name, f.Type.Kind())
t.Errorf("Env.%s has unsupported kind %s for a CEL variable", f.Name, f.Type.Kind())
continue
}
if _, err := CompileExpression(expr); err != nil {
t.Errorf("variable %q (AlertEnv.%s) not declared/usable in the CEL env: %v", name, f.Name, err)
t.Errorf("variable %q (Env.%s) not declared/usable in the CEL env: %v", name, f.Name, err)
}
}

// Reverse direction: no activation key without a backing struct field.
for k := range act {
if !tags[k] {
t.Errorf("celActivation binds %q with no matching AlertEnv `expr` tag", k)
t.Errorf("celActivation binds %q with no matching Env `expr` tag", k)
}
}
}
Expand All @@ -169,12 +169,12 @@ func TestEvalExpression_StringVar(t *testing.T) {
}
cases := []struct {
name string
env AlertEnv
env Env
want bool
}{
{"match + over threshold", AlertEnv{Service: "checkout", ErrorRate: 0.1}, true},
{"service mismatch", AlertEnv{Service: "cart", ErrorRate: 0.1}, false},
{"match but under threshold", AlertEnv{Service: "checkout", ErrorRate: 0.01}, false},
{"match + over threshold", Env{Service: "checkout", ErrorRate: 0.1}, true},
{"service mismatch", Env{Service: "cart", ErrorRate: 0.1}, false},
{"match but under threshold", Env{Service: "checkout", ErrorRate: 0.01}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
Loading
Loading