diff --git a/README.md b/README.md index e5bb2ed1..5c1f0f63 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,7 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once | `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` | | `endpoints[].ui.hide-errors` | Whether to hide errors from the results. | `false` | | `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` | +| `endpoints[].ui.resolve-successful-conditions` | Whether to resolve successful conditions for the UI (helpful to expose body assertions even when checks pass). | `false` | | `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` | | `endpoints[].extra-labels` | Extra labels to add to the metrics. Useful for grouping endpoints together. | `{}` | | `endpoints[].always-run` | (SUITES ONLY) Whether to execute this endpoint even if previous endpoints in the suite failed. | `false` | diff --git a/config/endpoint/condition.go b/config/endpoint/condition.go index 02feb575..6929fd55 100644 --- a/config/endpoint/condition.go +++ b/config/endpoint/condition.go @@ -26,7 +26,7 @@ type Condition string // Validate checks if the Condition is valid func (c Condition) Validate() error { r := &Result{} - c.evaluate(r, false, nil) + c.evaluate(r, false, false, nil) if len(r.Errors) != 0 { return errors.New(r.Errors[0]) } @@ -34,44 +34,50 @@ func (c Condition) Validate() error { } // evaluate the Condition with the Result and an optional context -func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, context *gontext.Gontext) bool { +func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, resolveSuccessfulConditions bool, context *gontext.Gontext) bool { condition := string(c) success := false conditionToDisplay := condition + shouldResolveCondition := func(success bool) bool { + if success { + return resolveSuccessfulConditions + } + return !dontResolveFailedConditions + } if strings.Contains(condition, " == ") { parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " == "), result, context) success = isEqual(resolvedParameters[0], resolvedParameters[1]) - if !success && !dontResolveFailedConditions { + if shouldResolveCondition(success) { conditionToDisplay = prettify(parameters, resolvedParameters, "==") } } else if strings.Contains(condition, " != ") { parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " != "), result, context) success = !isEqual(resolvedParameters[0], resolvedParameters[1]) - if !success && !dontResolveFailedConditions { + if shouldResolveCondition(success) { conditionToDisplay = prettify(parameters, resolvedParameters, "!=") } } else if strings.Contains(condition, " <= ") { parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " <= "), result, context) success = resolvedParameters[0] <= resolvedParameters[1] - if !success && !dontResolveFailedConditions { + if shouldResolveCondition(success) { conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=") } } else if strings.Contains(condition, " >= ") { parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " >= "), result, context) success = resolvedParameters[0] >= resolvedParameters[1] - if !success && !dontResolveFailedConditions { + if shouldResolveCondition(success) { conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=") } } else if strings.Contains(condition, " > ") { parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " > "), result, context) success = resolvedParameters[0] > resolvedParameters[1] - if !success && !dontResolveFailedConditions { + if shouldResolveCondition(success) { conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">") } } else if strings.Contains(condition, " < ") { parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " < "), result, context) success = resolvedParameters[0] < resolvedParameters[1] - if !success && !dontResolveFailedConditions { + if shouldResolveCondition(success) { conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<") } } else { diff --git a/config/endpoint/condition_bench_test.go b/config/endpoint/condition_bench_test.go index db2519ba..4cb0652a 100644 --- a/config/endpoint/condition_bench_test.go +++ b/config/endpoint/condition_bench_test.go @@ -8,7 +8,7 @@ func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) { condition := Condition("[BODY].name == any(john.doe, jane.doe)") for n := 0; n < b.N; n++ { result := &Result{Body: []byte("{\"name\": \"john.doe\"}")} - condition.evaluate(result, false, nil) + condition.evaluate(result, false, false, nil) } b.ReportAllocs() } @@ -17,7 +17,7 @@ func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) { condition := Condition("[BODY].name == any(john.doe, jane.doe)") for n := 0; n < b.N; n++ { result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} - condition.evaluate(result, false, nil) + condition.evaluate(result, false, false, nil) } b.ReportAllocs() } @@ -26,7 +26,7 @@ func BenchmarkCondition_evaluateWithBodyString(b *testing.B) { condition := Condition("[BODY].name == john.doe") for n := 0; n < b.N; n++ { result := &Result{Body: []byte("{\"name\": \"john.doe\"}")} - condition.evaluate(result, false, nil) + condition.evaluate(result, false, false, nil) } b.ReportAllocs() } @@ -35,7 +35,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) { condition := Condition("[BODY].name == john.doe") for n := 0; n < b.N; n++ { result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} - condition.evaluate(result, false, nil) + condition.evaluate(result, false, false, nil) } b.ReportAllocs() } @@ -44,7 +44,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) { condition := Condition("[BODY].user.name == bob.doe") for n := 0; n < b.N; n++ { result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} - condition.evaluate(result, false, nil) + condition.evaluate(result, false, false, nil) } b.ReportAllocs() } @@ -53,7 +53,7 @@ func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) { condition := Condition("len([BODY].name) == 8") for n := 0; n < b.N; n++ { result := &Result{Body: []byte("{\"name\": \"john.doe\"}")} - condition.evaluate(result, false, nil) + condition.evaluate(result, false, false, nil) } b.ReportAllocs() } @@ -62,7 +62,7 @@ func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) { condition := Condition("len([BODY].name) == 8") for n := 0; n < b.N; n++ { result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")} - condition.evaluate(result, false, nil) + condition.evaluate(result, false, false, nil) } b.ReportAllocs() } @@ -71,7 +71,7 @@ func BenchmarkCondition_evaluateWithStatus(b *testing.B) { condition := Condition("[STATUS] == 200") for n := 0; n < b.N; n++ { result := &Result{HTTPStatus: 200} - condition.evaluate(result, false, nil) + condition.evaluate(result, false, false, nil) } b.ReportAllocs() } @@ -80,7 +80,7 @@ func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) { condition := Condition("[STATUS] == 200") for n := 0; n < b.N; n++ { result := &Result{HTTPStatus: 400} - condition.evaluate(result, false, nil) + condition.evaluate(result, false, false, nil) } b.ReportAllocs() } diff --git a/config/endpoint/condition_test.go b/config/endpoint/condition_test.go index 074def48..3abe45e5 100644 --- a/config/endpoint/condition_test.go +++ b/config/endpoint/condition_test.go @@ -62,6 +62,7 @@ func TestCondition_evaluate(t *testing.T) { Condition Condition Result *Result DontResolveFailedConditions bool + ResolveSuccessfulConditions bool ExpectedSuccess bool ExpectedOutput string }{ @@ -184,6 +185,14 @@ func TestCondition_evaluate(t *testing.T) { ExpectedSuccess: true, ExpectedOutput: "[BODY] == test", }, + { + Name: "body-resolved-on-success", + Condition: Condition("[BODY].status == UP"), + Result: &Result{Body: []byte("{\"status\":\"UP\"}")}, + ResolveSuccessfulConditions: true, + ExpectedSuccess: true, + ExpectedOutput: "[BODY].status (UP) == UP", + }, { Name: "body-numerical-equal", Condition: Condition("[BODY] == 123"), @@ -757,7 +766,7 @@ func TestCondition_evaluate(t *testing.T) { } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { - scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, nil) + scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, scenario.ResolveSuccessfulConditions, nil) if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess { t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess) } @@ -771,7 +780,7 @@ func TestCondition_evaluate(t *testing.T) { func TestCondition_evaluateWithInvalidOperator(t *testing.T) { condition := Condition("[STATUS] ? 201") result := &Result{HTTPStatus: 201} - condition.evaluate(result, false, nil) + condition.evaluate(result, false, false, nil) if result.Success { t.Error("condition was invalid, result should've been a failure") } @@ -791,7 +800,7 @@ func TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) { "max_response_time": 5000, }) // Simulate suite endpoint evaluation with context - success := condition.evaluate(result, false, ctx) // false = don't skip resolution (default) + success := condition.evaluate(result, false, false, ctx) // false = don't skip resolution (default) if success { t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist") } @@ -814,7 +823,7 @@ func TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) { "expected_status": 200, }) // Simulate suite endpoint evaluation with context - success := condition.evaluate(result, false, ctx) + success := condition.evaluate(result, false, false, ctx) if !success { t.Error("Condition should have succeeded") } @@ -839,7 +848,7 @@ func TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) { "valid_key": 5000, }) // Simulate suite endpoint evaluation with context - success := condition.evaluate(result, false, ctx) + success := condition.evaluate(result, false, false, ctx) if success { t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist") } diff --git a/config/endpoint/endpoint.go b/config/endpoint/endpoint.go index 2014e509..b049052e 100644 --- a/config/endpoint/endpoint.go +++ b/config/endpoint/endpoint.go @@ -332,7 +332,7 @@ func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result { } // Evaluate the conditions for _, condition := range processedEndpoint.Conditions { - success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, context) + success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, processedEndpoint.UIConfig.ResolveSuccessfulConditions, context) if !success { result.Success = false } diff --git a/config/endpoint/endpoint_test.go b/config/endpoint/endpoint_test.go index e4e3a35b..c6aee630 100644 --- a/config/endpoint/endpoint_test.go +++ b/config/endpoint/endpoint_test.go @@ -268,6 +268,31 @@ func TestEndpoint(t *testing.T) { } } +func TestEndpoint_ResolveSuccessfulConditions(t *testing.T) { + defer client.InjectHTTPClient(nil) + endpoint := Endpoint{ + Name: "test-endpoint", + URL: "https://example.com/health", + Conditions: []Condition{"[BODY].status == UP"}, + UIConfig: &ui.Config{ResolveSuccessfulConditions: true}, + } + mockResponse := test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status":"UP"}`))} + }) + client.InjectHTTPClient(&http.Client{Transport: mockResponse}) + if err := endpoint.ValidateAndSetDefaults(); err != nil { + t.Fatalf("ValidateAndSetDefaults failed: %v", err) + } + result := endpoint.EvaluateHealth() + if len(result.ConditionResults) != 1 { + t.Fatalf("expected 1 condition result, got %d", len(result.ConditionResults)) + } + expectedCondition := "[BODY].status (UP) == UP" + if result.ConditionResults[0].Condition != expectedCondition { + t.Errorf("expected condition to be '%s', got '%s'", expectedCondition, result.ConditionResults[0].Condition) + } +} + func TestEndpoint_IsEnabled(t *testing.T) { if !(&Endpoint{Enabled: nil}).IsEnabled() { t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil") diff --git a/config/endpoint/ui/ui.go b/config/endpoint/ui/ui.go index 7494f172..8ea4c8f2 100644 --- a/config/endpoint/ui/ui.go +++ b/config/endpoint/ui/ui.go @@ -22,6 +22,9 @@ type Config struct { // DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"` + // ResolveSuccessfulConditions whether to resolve successful conditions in the Result for display in the UI + ResolveSuccessfulConditions bool `yaml:"resolve-successful-conditions"` + // Badge is the configuration for the badges generated Badge *Badge `yaml:"badge"` } @@ -63,6 +66,7 @@ func GetDefaultConfig() *Config { HidePort: false, HideErrors: false, DontResolveFailedConditions: false, + ResolveSuccessfulConditions: false, HideConditions: false, Badge: &Badge{ ResponseTime: &ResponseTime{