From 218c7dff2b2fc70ec636d76281efaf1cfa2c1728 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 05:10:59 +0000 Subject: [PATCH] feat: highly specific and formatted error reporting - Refactored `Evaluate` logic to return bullet-pointed, human-readable failures. - Added "Expected vs Got" details for all condition types. - Implemented indented sub-failure reporting for nested AND/OR groups. - Updated default templates to follow the requested multi-line style with mandatory timestamps. - Updated `sample.yaml` and `model/detailed_reason_test.go` to match the new format. - Added HTTP status text to status code mismatch reasons. Co-authored-by: mosishon <61285975+mosishon@users.noreply.github.com> --- healthcheck/healthcheck.go | 2 +- model/condition.go | 58 ++++++++++++++++++++++------------- model/detailed_reason_test.go | 15 ++++----- model/notification.go | 12 ++++---- sample.yaml | 20 ++++++------ 5 files changed, 59 insertions(+), 48 deletions(-) diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index 547f71b..31cd033 100644 --- a/healthcheck/healthcheck.go +++ b/healthcheck/healthcheck.go @@ -65,7 +65,7 @@ func (h *HealthChecker) performCheck(nextWait *time.Duration) { sCode := 0 if err != nil { - evaluationRes.Reason = fmt.Sprintf("Network/Connection Error: %v", err) + evaluationRes.Reason = fmt.Sprintf("- Reason: %v", err) evaluationRes.Type = model.NotificationNetworkError } else if resp != nil { sCode = resp.StatusCode diff --git a/model/condition.go b/model/condition.go index 96243a1..172d207 100644 --- a/model/condition.go +++ b/model/condition.go @@ -119,14 +119,22 @@ func (c *Condition) Validate(path string) error { func (c *Condition) Evaluate(resp *http.Response, body []byte, duration time.Duration) EvaluationResult { // 1. منطق AND if c.And != nil { - for i, cond := range c.And { + var failures []string + var firstType NotificationType + for _, cond := range c.And { res := cond.Evaluate(resp, body, duration) if !res.IsHealthy { - return EvaluationResult{ - IsHealthy: false, - Reason: fmt.Sprintf("AND condition failed (index %d): %s", i, res.Reason), - Type: res.Type, + if firstType == "" { + firstType = res.Type } + failures = append(failures, res.Reason) + } + } + if len(failures) > 0 { + return EvaluationResult{ + IsHealthy: false, + Reason: strings.Join(failures, "\n"), + Type: firstType, } } return EvaluationResult{IsHealthy: true} @@ -134,18 +142,20 @@ func (c *Condition) Evaluate(resp *http.Response, body []byte, duration time.Dur // 2. منطق OR if c.Or != nil { - var reasons []string - for i, cond := range c.Or { + var subFailures []string + for _, cond := range c.Or { res := cond.Evaluate(resp, body, duration) if res.IsHealthy { return EvaluationResult{IsHealthy: true} } - reasons = append(reasons, fmt.Sprintf("Sub-condition #%d failed: %s", i, res.Reason)) + // Indent sub-failures of OR + indented := " " + strings.ReplaceAll(res.Reason, "\n", "\n ") + subFailures = append(subFailures, indented) } return EvaluationResult{ IsHealthy: false, - Reason: fmt.Sprintf("All OR conditions failed:\n - %s", strings.Join(reasons, "\n - ")), - Type: NotificationConditionFailed, // Common case for OR + Reason: "- All OR conditions failed:\n" + strings.Join(subFailures, "\n"), + Type: NotificationConditionFailed, } } @@ -155,7 +165,7 @@ func (c *Condition) Evaluate(resp *http.Response, body []byte, duration time.Dur if res.IsHealthy { return EvaluationResult{ IsHealthy: false, - Reason: "NOT condition failed: the forbidden condition matched successfully", + Reason: "- NOT condition failed: Forbidden condition matched successfully", Type: NotificationConditionFailed, } } @@ -168,7 +178,7 @@ func (c *Condition) Evaluate(resp *http.Response, body []byte, duration time.Dur if !matched { return EvaluationResult{ IsHealthy: false, - Reason: fmt.Sprintf("Regex pattern '%s' not found in body", c.Regex.Regex), + Reason: fmt.Sprintf("- Body Match: Pattern '%s' not found in response", c.Regex.Regex), Type: NotificationConditionFailed, } } @@ -178,12 +188,12 @@ func (c *Condition) Evaluate(resp *http.Response, body []byte, duration time.Dur // 5. بررسی StatusCode if c.StatusCode != nil { if resp == nil { - return EvaluationResult{IsHealthy: false, Reason: "No response received", Type: NotificationHttpError} + return EvaluationResult{IsHealthy: false, Reason: "- Status Code: No response received", Type: NotificationHttpError} } if resp.StatusCode != c.StatusCode.Code { return EvaluationResult{ IsHealthy: false, - Reason: fmt.Sprintf("Expected status %d, but got %d", c.StatusCode.Code, resp.StatusCode), + Reason: fmt.Sprintf("- Status Code: Expected %d, Got %d\n- Reason: %s", c.StatusCode.Code, resp.StatusCode, http.StatusText(resp.StatusCode)), Type: NotificationHttpError, } } @@ -193,16 +203,20 @@ func (c *Condition) Evaluate(resp *http.Response, body []byte, duration time.Dur // 6. بررسی Headers if c.Header != nil { if resp == nil { - return EvaluationResult{IsHealthy: false, Reason: "No response headers available", Type: NotificationConditionFailed} + return EvaluationResult{IsHealthy: false, Reason: "- Header: No response headers available", Type: NotificationConditionFailed} } + var headerFailures []string for _, h := range *c.Header { actual := resp.Header.Get(h.Key) if actual != h.Value { - return EvaluationResult{ - IsHealthy: false, - Reason: fmt.Sprintf("Header '%s' expected '%s', got '%s'", h.Key, h.Value, actual), - Type: NotificationConditionFailed, - } + headerFailures = append(headerFailures, fmt.Sprintf("- Header [%s]: Expected '%s', Got '%s'", h.Key, h.Value, actual)) + } + } + if len(headerFailures) > 0 { + return EvaluationResult{ + IsHealthy: false, + Reason: strings.Join(headerFailures, "\n"), + Type: NotificationConditionFailed, } } return EvaluationResult{IsHealthy: true} @@ -214,14 +228,14 @@ func (c *Condition) Evaluate(resp *http.Response, body []byte, duration time.Dur if duration > max { return EvaluationResult{ IsHealthy: false, - Reason: fmt.Sprintf("Response time %v exceeded limit %v", duration, max), + Reason: fmt.Sprintf("- Latency: Expected <%s, Got %v", c.ResponseTime.MaxDuration, duration.Round(time.Millisecond)), Type: NotificationSlowResponse, } } return EvaluationResult{IsHealthy: true} } - return EvaluationResult{IsHealthy: false, Reason: "No valid condition defined", Type: NotificationDefault} + return EvaluationResult{IsHealthy: false, Reason: "- No valid condition defined", Type: NotificationDefault} } func (r *RegexCondition) Evaluate(body []byte) bool { diff --git a/model/detailed_reason_test.go b/model/detailed_reason_test.go index f7b8062..ee26fad 100644 --- a/model/detailed_reason_test.go +++ b/model/detailed_reason_test.go @@ -23,10 +23,7 @@ func TestEvaluate_DetailedReasons(t *testing.T) { if result.IsHealthy { t.Fatal("expected failure") } - if !strings.Contains(result.Reason, "AND condition failed (index 1)") { - t.Errorf("expected detailed AND failure message, got: %s", result.Reason) - } - if !strings.Contains(result.Reason, "Regex pattern 'UP' not found") { + if !strings.Contains(result.Reason, "- Body Match: Pattern 'UP' not found") { t.Errorf("expected original failure message to be included, got: %s", result.Reason) } }) @@ -44,14 +41,14 @@ func TestEvaluate_DetailedReasons(t *testing.T) { if result.IsHealthy { t.Fatal("expected failure") } - if !strings.Contains(result.Reason, "All OR conditions failed") { + if !strings.Contains(result.Reason, "- All OR conditions failed") { t.Errorf("expected OR failure message, got: %s", result.Reason) } - if !strings.Contains(result.Reason, "Sub-condition #0 failed: Expected status 200, but got 500") { - t.Errorf("expected reason 0, got: %s", result.Reason) + if !strings.Contains(result.Reason, "Status Code: Expected 200, Got 500") { + t.Errorf("expected status code failure, got: %s", result.Reason) } - if !strings.Contains(result.Reason, "Sub-condition #1 failed: Regex pattern 'UP' not found in body") { - t.Errorf("expected reason 1, got: %s", result.Reason) + if !strings.Contains(result.Reason, "Body Match: Pattern 'UP' not found in response") { + t.Errorf("expected regex failure, got: %s", result.Reason) } }) diff --git a/model/notification.go b/model/notification.go index 982b7ee..87d5a2d 100644 --- a/model/notification.go +++ b/model/notification.go @@ -28,12 +28,12 @@ type TemplateGroup struct { } const ( - DefaultNetworkErrorTemplate = "[🔌 Network Alert] {{.Metadata.ServiceName}} - Connection failed at {{.Metadata.Timestamp}}. Error: {{.Metadata.Reason}}" - DefaultHttpErrorTemplate = "[❌ HTTP Alert] {{.Metadata.ServiceName}} returned {{.Metadata.StatusCode}} at {{.Metadata.Timestamp}}. URL: {{.Metadata.ServiceURL}}" - DefaultSlowResponseTemplate = "[⏱️ Latency Alert] {{.Metadata.ServiceName}} is slow! Response time: {{.Metadata.ResponseTime}} (Threshold exceeded) at {{.Metadata.Timestamp}}." - DefaultConditionFailedTemplate = "[🔍 Validation Alert] {{.Metadata.ServiceName}} failed health criteria at {{.Metadata.Timestamp}}. Detail: {{.Metadata.Reason}}" - DefaultRecoveryTemplate = "[✅ Recovery] {{.Metadata.ServiceName}} is back online! Status: Healthy. Restored at: {{.Metadata.Timestamp}}." - DefaultNotificationTemplate = "[🔔 Alert] {{.Metadata.ServiceName}} status is {{.Metadata.Status}} at {{.Metadata.Timestamp}}." + DefaultNetworkErrorTemplate = "[🔌 Network Alert] {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n{{.Metadata.Reason}}" + DefaultHttpErrorTemplate = "[❌ HTTP Alert] {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n{{.Metadata.Reason}}" + DefaultSlowResponseTemplate = "[⏱️ Latency Alert] {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n{{.Metadata.Reason}}" + DefaultConditionFailedTemplate = "[🔍 Validation Alert] {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n{{.Metadata.Reason}}" + DefaultRecoveryTemplate = "[✅ Recovery] {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n- Status: Service is now Healthy" + DefaultNotificationTemplate = "[🔔 Alert] {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n- Status: {{.Metadata.Status}}" ) func GetDefaultTemplate(t NotificationType) string { diff --git a/sample.yaml b/sample.yaml index 451074d..fe1528a 100644 --- a/sample.yaml +++ b/sample.yaml @@ -42,12 +42,12 @@ notifiers: # Generic fallback text: "⚠️ Alert for {{.Metadata.ServiceName}}" templates: - network_error: '{"text": "🔌 *Network Error*: Cannot reach {{.Metadata.ServiceName}}! Reason: {{.Metadata.Reason}}"}' - http_error: '{"text": "❌ *HTTP Error*: {{.Metadata.ServiceName}} returned {{.Metadata.StatusCode}}."}' - slow_response: '{"text": "⏱️ *Latency Alert*: {{.Metadata.ServiceName}} took {{.Metadata.ResponseTime}}."}' - condition_failed: '{"text": "🔍 *Logic Failure*: {{.Metadata.ServiceName}} failed health criteria.\n```\n{{.Metadata.Reason}}\n```"}' - recovery: '{"text": "✅ *Service Restored*: {{.Metadata.ServiceName}} is back online!"}' - default: '{"text": "🔔 *Status Update*: {{.Metadata.ServiceName}} is {{.Metadata.Status}}"}' + network_error: '{"text": "🔌 *Network Alert* {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n{{.Metadata.Reason}}"}' + http_error: '{"text": "❌ *HTTP Alert* {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n{{.Metadata.Reason}}"}' + slow_response: '{"text": "⏱️ *Latency Alert* {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n{{.Metadata.Reason}}"}' + condition_failed: '{"text": "🔍 *Validation Alert* {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n{{.Metadata.Reason}}"}' + recovery: '{"text": "✅ *Recovery* {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n- Status: Service is now Healthy"}' + default: '{"text": "🔔 *Status Update* {{.Metadata.ServiceName}}\n- Time: {{.Metadata.Timestamp}}\n- Status: {{.Metadata.Status}}"}' # ------ Email (SMTP) ------ smtp: @@ -57,8 +57,8 @@ notifiers: server: "smtp.gmail.com" port: "587" templates: - recovery: "Subject: ✅ Service {{.Metadata.ServiceName}} Recovered\n\nGood news! Service is back up." - default: "Subject: 🚨 Alert: {{.Metadata.ServiceName}} is {{.Metadata.Status}}\n\nReason: {{.Metadata.Reason}}" + recovery: "Subject: ✅ Recovery {{.Metadata.ServiceName}}\n\n- Time: {{.Metadata.Timestamp}}\n- Status: Service is now Healthy" + default: "Subject: 🚨 Alert: {{.Metadata.ServiceName}}\n\n- Time: {{.Metadata.Timestamp}}\n{{.Metadata.Reason}}" # ------ SMS (MeliPayamak) ------ meli_payamak_panel: @@ -67,8 +67,8 @@ notifiers: password: "mypassword" sender: "50001234" templates: - recovery: "✅ سرویس {{.Metadata.ServiceName}} به وضعیت عادی بازگشت." - default: "⚠️ هشدار: {{.Metadata.ServiceName}} دچار اختلال شده است. وضعیت: {{.Metadata.Status}}" + recovery: "✅ Recovery: {{.Metadata.ServiceName}}\nTime: {{.Metadata.Timestamp}}\nStatus: Healthy" + default: "⚠️ Alert: {{.Metadata.ServiceName}}\nTime: {{.Metadata.Timestamp}}\n{{.Metadata.Reason}}" #================================================================# # Health Check Conditions #