diff --git a/README.md b/README.md
index 90a7f1fd..19b2ad29 100644
--- a/README.md
+++ b/README.md
@@ -311,8 +311,10 @@ ignored.
| Parameter | Description | Default |
|:-----------------------|:---------------------------------------------------------------------------------------------------------------------- |:-------|
| `alerting.discord` | Configuration for alerts of type `discord`.
See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
-| `alerting.mattermost` | Configuration for alerts of type `mattermost`.
See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
+| `alerting.email` | Configuration for alerts of type `email`.
See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
+| `alerting.mattermost` | Configuration for alerts of type `mattermost`.
See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`.
See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
+| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`.
See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`.
See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`.
See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`.
See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` |
@@ -455,6 +457,24 @@ endpoints:
description: "healthcheck failed"
```
+#### Configuring Opsgenie alerts
+| Parameter | Description | Default |
+|:------------------------------------------------------ |:----------------------------------------------------------------------------- |:-------------------- |
+| `alerting.opsgenie` | Configuration for alerts of type `opsgenie` | `{}` |
+| `alerting.opsgenie.api-key` | Opsgenie API Key | Required `""` |
+| `alerting.opsgenie.priority` | Priority level of the alert. | `P1` |
+| `alerting.opsgenie.source` | Source field of the alert. | `gatus` |
+| `alerting.opsgenie.entity-prefix` | Entity field prefix. | `gatus-` |
+| `alerting.opsgenie.alias-prefix` | Alias field prefix. | `gatus-healthcheck-` |
+| `alerting.opsgenie.tags` | Tags of alert. | `[]` |
+
+Opsgenie provider will automatically open and close alerts.
+
+```yaml
+alerting:
+ opsgenie:
+ api-key: "00000000-0000-0000-0000-000000000000"
+```
#### Configuring PagerDuty alerts
| Parameter | Description | Default |
diff --git a/alerting/alert/type.go b/alerting/alert/type.go
index 8ca233df..b921338e 100644
--- a/alerting/alert/type.go
+++ b/alerting/alert/type.go
@@ -34,4 +34,7 @@ const (
// TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio"
+
+ // TypeOpsgenie is the Type for the opsgenie alerting provider
+ TypeOpsgenie Type = "opsgenie"
)
diff --git a/alerting/config.go b/alerting/config.go
index 6554a380..6e614619 100644
--- a/alerting/config.go
+++ b/alerting/config.go
@@ -8,6 +8,7 @@ import (
"github.com/TwiN/gatus/v3/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
+ "github.com/TwiN/gatus/v3/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams"
@@ -46,6 +47,9 @@ type Config struct {
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
+
+ // Opsgenie is the configuration for the opsgenie alerting provider
+ Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
}
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
@@ -81,6 +85,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil
}
return config.Messagebird
+ case alert.TypeOpsgenie:
+ if config.Opsgenie == nil {
+ // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
+ return nil
+ }
+ return config.Opsgenie
case alert.TypePagerDuty:
if config.PagerDuty == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
diff --git a/alerting/provider/opsgenie/opsgenie.go b/alerting/provider/opsgenie/opsgenie.go
new file mode 100644
index 00000000..a24eff2b
--- /dev/null
+++ b/alerting/provider/opsgenie/opsgenie.go
@@ -0,0 +1,267 @@
+package opsgenie
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "github.com/TwiN/gatus/v3/alerting/alert"
+ "github.com/TwiN/gatus/v3/client"
+ "github.com/TwiN/gatus/v3/core"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+const (
+ restAPI = "https://api.opsgenie.com/v2/alerts"
+)
+
+type opsgenieAlertCreateRequest struct {
+ Message string `json:"message"`
+ Priority string `json:"priority"`
+ Source string `json:"source"`
+ Entity string `json:"entity"`
+ Alias string `json:"alias"`
+ Description string `json:"description"`
+ Tags []string `json:"tags,omitempty"`
+ Details map[string]string `json:"details"`
+}
+
+type opsgenieAlertCloseRequest struct {
+ Source string `json:"source"`
+ Note string `json:"note"`
+}
+
+type AlertProvider struct {
+ APIKey string `yaml:"api-key"`
+
+ //Priority define priority to be used in opsgenie alert payload
+ // defaults: P1
+ Priority string `yaml:"priority"`
+
+ //Source define source to be used in opsgenie alert payload
+ // defaults: gatus
+ Source string `yaml:"source"`
+
+ //EntityPrefix is a prefix to be used in entity argument in opsgenie alert payload
+ // defaults: gatus-
+ EntityPrefix string `yaml:"entity-prefix"`
+
+ //AliasPrefix is a prefix to be used in alias argument in opsgenie alert payload
+ // defaults: gatus-healthcheck-
+ AliasPrefix string `yaml:"alias-prefix"`
+
+ //tags define tags to be used in opsgenie alert payload
+ // defaults: []
+ Tags []string `yaml:"tags"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+}
+
+func (provider *AlertProvider) IsValid() bool {
+ return len(provider.APIKey) > 0
+}
+
+// Send an alert using the provider
+//
+// Relevant: https://docs.opsgenie.com/docs/alert-api
+func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
+ err := provider.createAlert(endpoint, alert, result, resolved)
+
+ if err != nil {
+ return err
+ }
+
+ if resolved {
+ err = provider.closeAlert(endpoint, alert)
+
+ if err != nil {
+ return err
+ }
+ }
+
+ if alert.IsSendingOnResolved() {
+ if resolved {
+ // The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
+ alert.ResolveKey = ""
+ } else {
+ alert.ResolveKey = provider.alias(buildKey(endpoint))
+ }
+ }
+
+ return nil
+}
+
+func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
+ payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved)
+
+ _, err := provider.sendRequest(restAPI, http.MethodPost, payload)
+
+ return err
+}
+
+func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error {
+ payload := provider.buildCloseRequestBody(endpoint, alert)
+ url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias"
+
+ _, err := provider.sendRequest(url, http.MethodPost, payload)
+
+ return err
+}
+
+func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) (*http.Response, error) {
+
+ body, err := json.Marshal(payload)
+
+ if err != nil {
+ return nil, fmt.Errorf("fail to build alert payload: %v", payload)
+ }
+
+ request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
+
+ if err != nil {
+ return nil, err
+ }
+
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
+
+ res, err := client.GetHTTPClient(nil).Do(request)
+
+ if err != nil {
+ return nil, err
+ }
+
+ if res.StatusCode > 399 {
+ rBody, _ := io.ReadAll(res.Body)
+ return nil, fmt.Errorf("call to provider alert returned status code %d: %s", res.StatusCode, string(rBody))
+ }
+
+ return res, nil
+}
+
+func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) opsgenieAlertCreateRequest {
+ var message, description, results string
+
+ if resolved {
+ message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
+ description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
+ } else {
+ message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription())
+ description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
+ }
+
+
+ if endpoint.Group != "" {
+ message = fmt.Sprintf("[%s] %s", endpoint.Group, message)
+ }
+
+ for _, conditionResult := range result.ConditionResults {
+ var prefix string
+ if conditionResult.Success {
+ prefix = "▣"
+ } else {
+ prefix = "▢"
+ }
+ results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
+ }
+
+ description = description + "\n" + results
+
+ key := buildKey(endpoint)
+ details := map[string]string{
+ "endpoint:url": endpoint.URL,
+ "endpoint:group": endpoint.Group,
+ "result:hostname": result.Hostname,
+ "result:ip": result.IP,
+ "result:dns_code": result.DNSRCode,
+ "result:errors": strings.Join(result.Errors, ","),
+ }
+
+ for k, v := range details {
+ if v == "" {
+ delete(details, k)
+ }
+ }
+
+ if result.HTTPStatus > 0 {
+ details["result:http_status"] = strconv.Itoa(result.HTTPStatus)
+ }
+
+ return opsgenieAlertCreateRequest{
+ Message: message,
+ Description: description,
+ Source: provider.source(),
+ Priority: provider.priority(),
+ Alias: provider.alias(key),
+ Entity: provider.entity(key),
+ Tags: provider.Tags,
+ Details: details,
+ }
+}
+
+func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) opsgenieAlertCloseRequest {
+ return opsgenieAlertCloseRequest{
+ Source: buildKey(endpoint),
+ Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()),
+ }
+}
+
+func (provider *AlertProvider) source() string {
+ source := provider.Source
+
+ if source == "" {
+ return "gatus"
+ }
+
+ return source
+}
+
+func (provider *AlertProvider) alias(key string) string {
+ alias := provider.AliasPrefix
+
+ if alias == "" {
+ alias = "gatus-healthcheck-"
+ }
+
+ return alias + key
+}
+
+func (provider *AlertProvider) entity(key string) string {
+ alias := provider.EntityPrefix
+ if alias == "" {
+ alias = "gatus-"
+ }
+
+ return alias + key
+}
+
+func (provider *AlertProvider) priority() string {
+ priority := provider.Priority
+
+ if priority == "" {
+ return "P1"
+ }
+
+ return priority
+}
+
+func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+func buildKey(endpoint *core.Endpoint) string {
+ name := toKebabCase(endpoint.Name)
+
+ if endpoint.Group == "" {
+ return name
+ }
+
+ return toKebabCase(endpoint.Group) + "-" + name
+}
+
+func toKebabCase(val string) string {
+ return strings.ToLower(strings.ReplaceAll(val, " ", "-"))
+}
diff --git a/alerting/provider/opsgenie/opsgenie_test.go b/alerting/provider/opsgenie/opsgenie_test.go
new file mode 100644
index 00000000..b9d65f5c
--- /dev/null
+++ b/alerting/provider/opsgenie/opsgenie_test.go
@@ -0,0 +1,331 @@
+package opsgenie
+
+import (
+ "github.com/TwiN/gatus/v3/alerting/alert"
+ "github.com/TwiN/gatus/v3/client"
+ "github.com/TwiN/gatus/v3/core"
+ "github.com/TwiN/gatus/v3/test"
+ "net/http"
+ "reflect"
+ "testing"
+)
+
+func TestAlertProvider_IsValid(t *testing.T) {
+ invalidProvider := AlertProvider{APIKey: ""}
+ if invalidProvider.IsValid() {
+ t.Error("provider shouldn't have been valid")
+ }
+ validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"}
+ if !validProvider.IsValid() {
+ t.Error("provider should've been valid")
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+
+ description := "my bad alert description"
+
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ Alert alert.Alert
+ Resolved bool
+ MockRoundTripper test.MockRoundTripper
+ ExpectedError bool
+ }{
+ {
+ Name: "triggered",
+ Provider: AlertProvider{},
+ Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
+ Resolved: false,
+ ExpectedError: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ },
+ {
+ Name: "triggered-error",
+ Provider: AlertProvider{},
+ Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ ExpectedError: true,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+ }),
+ },
+ {
+ Name: "resolved",
+ Provider: AlertProvider{},
+ Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ ExpectedError: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ },
+ {
+ Name: "resolved-error",
+ Provider: AlertProvider{},
+ Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ ExpectedError: true,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+ }),
+ },
+ }
+
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
+
+ err := scenario.Provider.Send(
+ &core.Endpoint{Name: "endpoint-name"},
+ &scenario.Alert,
+ &core.Result{
+ ConditionResults: []*core.ConditionResult{
+ {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
+ {Condition: "[STATUS] == 200", Success: scenario.Resolved},
+ },
+ },
+ scenario.Resolved,
+ )
+ if scenario.ExpectedError && err == nil {
+ t.Error("expected error, got none")
+ }
+ if !scenario.ExpectedError && err != nil {
+ t.Error("expected no error, got", err.Error())
+ }
+ })
+ }
+}
+
+func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
+ t.Parallel()
+
+ description := "alert description"
+
+ scenarios := []struct {
+ Name string
+ Provider *AlertProvider
+ Alert *alert.Alert
+ Endpoint *core.Endpoint
+ Result *core.Result
+ Resolved bool
+ want opsgenieAlertCreateRequest
+ }{
+ {
+ Name: "missing all params (unresolved)",
+ Provider: &AlertProvider{},
+ Alert: &alert.Alert{},
+ Endpoint: &core.Endpoint{},
+ Result: &core.Result{},
+ Resolved: false,
+ want: opsgenieAlertCreateRequest{
+ Message: " - ",
+ Priority: "P1",
+ Source: "gatus",
+ Entity: "gatus-",
+ Alias: "gatus-healthcheck-",
+ Description: "An alert for ** has been triggered due to having failed 0 time(s) in a row\n",
+ Tags: nil,
+ Details: map[string]string{},
+ },
+ },
+ {
+ Name: "missing all params (resolved)",
+ Provider: &AlertProvider{},
+ Alert: &alert.Alert{},
+ Endpoint: &core.Endpoint{},
+ Result: &core.Result{},
+ Resolved: true,
+ want: opsgenieAlertCreateRequest{
+ Message: "RESOLVED: - ",
+ Priority: "P1",
+ Source: "gatus",
+ Entity: "gatus-",
+ Alias: "gatus-healthcheck-",
+ Description: "An alert for ** has been resolved after passing successfully 0 time(s) in a row\n",
+ Tags: nil,
+ Details: map[string]string{},
+ },
+ },
+ {
+ Name: "with default options (unresolved)",
+ Provider: &AlertProvider{},
+ Alert: &alert.Alert{
+ Description: &description,
+ FailureThreshold: 3,
+ },
+ Endpoint: &core.Endpoint{
+ Name: "my supper app",
+ },
+ Result: &core.Result{
+ ConditionResults: []*core.ConditionResult{
+ {
+ Condition: "[STATUS] == 200",
+ Success: true,
+ },
+ {
+ Condition: "[BODY] == OK",
+ Success: false,
+ },
+ },
+ },
+ Resolved: false,
+ want: opsgenieAlertCreateRequest{
+ Message: "my supper app - " + description,
+ Priority: "P1",
+ Source: "gatus",
+ Entity: "gatus-my-supper-app",
+ Alias: "gatus-healthcheck-my-supper-app",
+ Description: "An alert for *my supper app* has been triggered due to having failed 3 time(s) in a row\n" +
+ "▣ - `[STATUS] == 200`\n" +
+ "▢ - `[BODY] == OK`\n",
+ Tags: nil,
+ Details: map[string]string{},
+ },
+ },
+ {
+ Name: "with custom options (resolved)",
+ Provider: &AlertProvider{
+ Priority: "P5",
+ EntityPrefix: "oompa-",
+ AliasPrefix: "loompa-",
+ Source: "gatus-hc",
+ Tags: []string{"do-ba-dee-doo"},
+ },
+ Alert: &alert.Alert{
+ Description: &description,
+ SuccessThreshold: 4,
+ },
+ Endpoint: &core.Endpoint{
+ Name: "my mega app",
+ },
+ Result: &core.Result{
+ ConditionResults: []*core.ConditionResult{
+ {
+ Condition: "[STATUS] == 200",
+ Success: true,
+ },
+ },
+ },
+ Resolved: true,
+ want: opsgenieAlertCreateRequest{
+ Message: "RESOLVED: my mega app - " + description,
+ Priority: "P5",
+ Source: "gatus-hc",
+ Entity: "oompa-my-mega-app",
+ Alias: "loompa-my-mega-app",
+ Description: "An alert for *my mega app* has been resolved after passing successfully 4 time(s) in a row\n" +
+ "▣ - `[STATUS] == 200`\n",
+ Tags: []string{"do-ba-dee-doo"},
+ Details: map[string]string{},
+ },
+ },
+ {
+ Name: "with default options and details (unresolved)",
+ Provider: &AlertProvider{
+ Tags: []string{"foo"},
+ },
+ Alert: &alert.Alert{
+ Description: &description,
+ FailureThreshold: 6,
+ },
+ Endpoint: &core.Endpoint{
+ Name: "my app",
+ Group: "end game",
+ URL: "https://my.go/app",
+ },
+ Result: &core.Result{
+ HTTPStatus: 400,
+ Hostname: "my.go",
+ Errors: []string{"error 01", "error 02"},
+ Success: false,
+ ConditionResults: []*core.ConditionResult{
+ {
+ Condition: "[STATUS] == 200",
+ Success: false,
+ },
+ },
+ },
+ Resolved: false,
+ want: opsgenieAlertCreateRequest{
+ Message: "[end game] my app - " + description,
+ Priority: "P1",
+ Source: "gatus",
+ Entity: "gatus-end-game-my-app",
+ Alias: "gatus-healthcheck-end-game-my-app",
+ Description: "An alert for *my app* has been triggered due to having failed 6 time(s) in a row\n" +
+ "▢ - `[STATUS] == 200`\n",
+ Tags: []string{"foo"},
+ Details: map[string]string{
+ "endpoint:url": "https://my.go/app",
+ "endpoint:group": "end game",
+ "result:hostname": "my.go",
+ "result:errors": "error 01,error 02",
+ "result:http_status": "400",
+ },
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ actual := scenario
+ t.Run(actual.Name, func(t *testing.T) {
+ if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
+ t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
+ t.Parallel()
+
+ description := "alert description"
+
+ scenarios := []struct {
+ Name string
+ Provider *AlertProvider
+ Alert *alert.Alert
+ Endpoint *core.Endpoint
+ want opsgenieAlertCloseRequest
+ }{
+ {
+ Name: "Missing all values",
+ Provider: &AlertProvider{},
+ Alert: &alert.Alert{},
+ Endpoint: &core.Endpoint{},
+ want: opsgenieAlertCloseRequest{
+ Source: "",
+ Note: "RESOLVED: - ",
+ },
+ },
+
+ {
+ Name: "Basic values",
+ Provider: &AlertProvider{},
+ Alert: &alert.Alert{
+ Description: &description,
+ },
+ Endpoint: &core.Endpoint{
+ Name: "endpoint name",
+ },
+ want: opsgenieAlertCloseRequest{
+ Source: "endpoint-name",
+ Note: "RESOLVED: endpoint name - alert description",
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ actual := scenario
+ t.Run(actual.Name, func(t *testing.T) {
+ if got := actual.Provider.buildCloseRequestBody(actual.Endpoint, actual.Alert); !reflect.DeepEqual(got, actual.want) {
+ t.Errorf("buildCloseRequestBody() = %v, want %v", got, actual.want)
+ }
+ })
+ }
+}