Add configuration for whether to resolve failed conditions or not

This commit is contained in:
TwinProduction 2021-09-14 19:34:46 -04:00
parent d7de795a9f
commit f41560cd3e
6 changed files with 53 additions and 31 deletions

View File

@ -142,7 +142,7 @@ If you want to test it locally, see [Docker](#docker).
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- | |:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
| `debug` | Whether to enable debug logs. | `false` | | `debug` | Whether to enable debug logs. | `false` |
| `metrics` | Whether to expose metrics at /metrics. | `false` | | `metrics` | Whether to expose metrics at /metrics. | `false` |
| `storage` | Storage configuration. <br />See [Storage](#storage). | `{}` | | `storage` | [Storage configuration](#storage) | `{}` |
| `services` | List of services to monitor. | Required `[]` | | `services` | List of services to monitor. | Required `[]` |
| `services[].name` | Name of the service. Can be anything. | Required `""` | | `services[].name` | Name of the service. Can be anything. | Required `""` |
| `services[].group` | Group name. Used to group multiple services together on the dashboard. <br />See [Service groups](#service-groups). | `""` | | `services[].group` | Group name. Used to group multiple services together on the dashboard. <br />See [Service groups](#service-groups). | `""` |
@ -156,16 +156,17 @@ If you want to test it locally, see [Docker](#docker).
| `services[].dns` | Configuration for a service of type DNS. <br />See [Monitoring a service using DNS queries](#monitoring-a-service-using-dns-queries). | `""` | | `services[].dns` | Configuration for a service of type DNS. <br />See [Monitoring a service using DNS queries](#monitoring-a-service-using-dns-queries). | `""` |
| `services[].dns.query-type` | Query type for DNS service. | `""` | | `services[].dns.query-type` | Query type for DNS service. | `""` |
| `services[].dns.query-name` | Query name for DNS service. | `""` | | `services[].dns.query-name` | Query name for DNS service. | `""` |
| `services[].alerts[].type` | Type of alert. Valid types: `slack`, `discord`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` | | `services[].alerts[].type` | Type of alert. <br />Valid types: `slack`, `discord`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` |
| `services[].alerts[].enabled` | Whether to enable the alert. | `false` | | `services[].alerts[].enabled` | Whether to enable the alert. | `false` |
| `services[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` | | `services[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `services[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` | | `services[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `services[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` | | `services[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `services[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` | | `services[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `services[].client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` | | `services[].client` | [Client configuration](#client-configuration). | `{}` |
| `services[].ui` | UI configuration. | `{}` | | `services[].ui` | UI configuration at the service level. | `{}` |
| `services[].ui.hide-hostname` | Whether to include the hostname in the result. | `false` | | `services[].ui.hide-hostname` | Whether to include the hostname in the result. | `false` |
| `alerting` | Configuration for alerting. <br />See [Alerting](#alerting). | `{}` | | `services[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `security` | Security configuration. | `{}` | | `security` | Security configuration. | `{}` |
| `security.basic` | Basic authentication security configuration. | `{}` | | `security.basic` | Basic authentication security configuration. | `{}` |
| `security.basic.username` | Username for Basic authentication. | Required `""` | | `security.basic.username` | Username for Basic authentication. | Required `""` |

View File

@ -85,44 +85,44 @@ type Condition string
// evaluate the Condition with the Result of the health check // evaluate the Condition with the Result of the health check
// TODO: Add a mandatory space between each operators (e.g. " == " instead of "==") (BREAKING CHANGE) // TODO: Add a mandatory space between each operators (e.g. " == " instead of "==") (BREAKING CHANGE)
func (c Condition) evaluate(result *Result) bool { func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool) bool {
condition := string(c) condition := string(c)
success := false success := false
conditionToDisplay := condition conditionToDisplay := condition
if strings.Contains(condition, "==") { if strings.Contains(condition, "==") {
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "=="), result) parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "=="), result)
success = isEqual(resolvedParameters[0], resolvedParameters[1]) success = isEqual(resolvedParameters[0], resolvedParameters[1])
if !success { if !success && !dontResolveFailedConditions {
conditionToDisplay = prettify(parameters, resolvedParameters, "==") conditionToDisplay = prettify(parameters, resolvedParameters, "==")
} }
} else if strings.Contains(condition, "!=") { } else if strings.Contains(condition, "!=") {
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "!="), result) parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "!="), result)
success = !isEqual(resolvedParameters[0], resolvedParameters[1]) success = !isEqual(resolvedParameters[0], resolvedParameters[1])
if !success { if !success && !dontResolveFailedConditions {
conditionToDisplay = prettify(parameters, resolvedParameters, "!=") conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
} }
} else if strings.Contains(condition, "<=") { } else if strings.Contains(condition, "<=") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result) parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
success = resolvedParameters[0] <= resolvedParameters[1] success = resolvedParameters[0] <= resolvedParameters[1]
if !success { if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=") conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
} }
} else if strings.Contains(condition, ">=") { } else if strings.Contains(condition, ">=") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result) parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
success = resolvedParameters[0] >= resolvedParameters[1] success = resolvedParameters[0] >= resolvedParameters[1]
if !success { if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=") conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
} }
} else if strings.Contains(condition, ">") { } else if strings.Contains(condition, ">") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result) parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
success = resolvedParameters[0] > resolvedParameters[1] success = resolvedParameters[0] > resolvedParameters[1]
if !success { if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">") conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
} }
} else if strings.Contains(condition, "<") { } else if strings.Contains(condition, "<") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result) parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
success = resolvedParameters[0] < resolvedParameters[1] success = resolvedParameters[0] < resolvedParameters[1]
if !success { if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<") conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
} }
} else { } else {

View File

@ -6,7 +6,7 @@ func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)") condition := Condition("[BODY].name == any(john.doe, jane.doe)")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{body: []byte("{\"name\": \"john.doe\"}")} result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result) condition.evaluate(result, false)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@ -15,7 +15,7 @@ func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)") condition := Condition("[BODY].name == any(john.doe, jane.doe)")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")} result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result) condition.evaluate(result, false)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@ -24,7 +24,7 @@ func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
condition := Condition("[BODY].name == john.doe") condition := Condition("[BODY].name == john.doe")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{body: []byte("{\"name\": \"john.doe\"}")} result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result) condition.evaluate(result, false)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@ -33,7 +33,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
condition := Condition("[BODY].name == john.doe") condition := Condition("[BODY].name == john.doe")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")} result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result) condition.evaluate(result, false)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@ -42,7 +42,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
condition := Condition("[BODY].user.name == bob.doe") condition := Condition("[BODY].user.name == bob.doe")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")} result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result) condition.evaluate(result, false)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@ -51,7 +51,7 @@ func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
condition := Condition("len([BODY].name) == 8") condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{body: []byte("{\"name\": \"john.doe\"}")} result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result) condition.evaluate(result, false)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@ -60,7 +60,7 @@ func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
condition := Condition("len([BODY].name) == 8") condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")} result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result) condition.evaluate(result, false)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@ -69,7 +69,7 @@ func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
condition := Condition("[STATUS] == 200") condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 200} result := &Result{HTTPStatus: 200}
condition.evaluate(result) condition.evaluate(result, false)
} }
b.ReportAllocs() b.ReportAllocs()
} }
@ -78,7 +78,7 @@ func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
condition := Condition("[STATUS] == 200") condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 400} result := &Result{HTTPStatus: 400}
condition.evaluate(result) condition.evaluate(result, false)
} }
b.ReportAllocs() b.ReportAllocs()
} }

View File

@ -8,11 +8,12 @@ import (
func TestCondition_evaluate(t *testing.T) { func TestCondition_evaluate(t *testing.T) {
type scenario struct { type scenario struct {
Name string Name string
Condition Condition Condition Condition
Result *Result Result *Result
ExpectedSuccess bool DontResolveFailedConditions bool
ExpectedOutput string ExpectedSuccess bool
ExpectedOutput string
} }
scenarios := []scenario{ scenarios := []scenario{
{ {
@ -372,6 +373,14 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false, ExpectedSuccess: false,
ExpectedOutput: "[STATUS] (404) == any(200, 429)", ExpectedOutput: "[STATUS] (404) == any(200, 429)",
}, },
{
Name: "status-any-failure-but-dont-resolve",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 404},
DontResolveFailedConditions: true,
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] == any(200, 429)",
},
{ {
Name: "connected", Name: "connected",
Condition: Condition("[CONNECTED] == true"), Condition: Condition("[CONNECTED] == true"),
@ -435,6 +444,14 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false, ExpectedSuccess: false,
ExpectedOutput: "has([BODY].errors) (true) == false", ExpectedOutput: "has([BODY].errors) (true) == false",
}, },
{
Name: "has-failure-but-dont-resolve",
Condition: Condition("has([BODY].errors) == false"),
Result: &Result{body: []byte("{\"errors\": [\"1\"]}")},
DontResolveFailedConditions: true,
ExpectedSuccess: false,
ExpectedOutput: "has([BODY].errors) == false",
},
{ {
Name: "no-placeholders", Name: "no-placeholders",
Condition: Condition("1 == 2"), Condition: Condition("1 == 2"),
@ -445,7 +462,7 @@ func TestCondition_evaluate(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
scenario.Condition.evaluate(scenario.Result) scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions)
if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess { if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {
t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess) t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess)
} }
@ -459,7 +476,7 @@ func TestCondition_evaluate(t *testing.T) {
func TestCondition_evaluateWithInvalidOperator(t *testing.T) { func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
condition := Condition("[STATUS] ? 201") condition := Condition("[STATUS] ? 201")
result := &Result{HTTPStatus: 201} result := &Result{HTTPStatus: 201}
condition.evaluate(result) condition.evaluate(result, false)
if result.Success { if result.Success {
t.Error("condition was invalid, result should've been a failure") t.Error("condition was invalid, result should've been a failure")
} }

View File

@ -163,7 +163,7 @@ func (service *Service) EvaluateHealth() *Result {
result.Success = false result.Success = false
} }
for _, condition := range service.Conditions { for _, condition := range service.Conditions {
success := condition.evaluate(result) success := condition.evaluate(result, service.UIConfig.DontResolveFailedConditions)
if !success { if !success {
result.Success = false result.Success = false
} }

View File

@ -2,12 +2,16 @@ package ui
// Config is the UI configuration for services // Config is the UI configuration for services
type Config struct { type Config struct {
HideHostname bool `yaml:"hide-hostname"` // Whether to hide the hostname in the Result // HideHostname whether to hide the hostname in the Result
HideHostname bool `yaml:"hide-hostname"`
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
} }
// GetDefaultConfig retrieves the default UI configuration // GetDefaultConfig retrieves the default UI configuration
func GetDefaultConfig() *Config { func GetDefaultConfig() *Config {
return &Config{ return &Config{
HideHostname: false, HideHostname: false,
DontResolveFailedConditions: false,
} }
} }