diff --git a/README.md b/README.md
index ce1425ad..ae5c1e42 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Client configuration](#client-configuration)
- [Alerting](#alerting)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
+ - [Configuring Datadog alerts](#configuring-datadog-alerts)
- [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts)
- [Configuring Gitea alerts](#configuring-gitea-alerts)
@@ -60,21 +61,34 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Google Chat alerts](#configuring-google-chat-alerts)
- [Configuring Gotify alerts](#configuring-gotify-alerts)
- [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts)
+ - [Configuring IFTTT alerts](#configuring-ifttt-alerts)
- [Configuring Ilert alerts](#configuring-ilert-alerts)
- [Configuring Incident.io alerts](#configuring-incidentio-alerts)
- [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
+ - [Configuring Line alerts](#configuring-line-alerts)
- [Configuring Matrix alerts](#configuring-matrix-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
+ - [Configuring New Relic alerts](#configuring-new-relic-alerts)
- [Configuring Ntfy alerts](#configuring-ntfy-alerts)
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
+ - [Configuring Plivo alerts](#configuring-plivo-alerts)
- [Configuring Pushover alerts](#configuring-pushover-alerts)
+ - [Configuring Rocket.Chat alerts](#configuring-rocketchat-alerts)
+ - [Configuring SendGrid alerts](#configuring-sendgrid-alerts)
+ - [Configuring Signal alerts](#configuring-signal-alerts)
+ - [Configuring SIGNL4 alerts](#configuring-signl4-alerts)
- [Configuring Slack alerts](#configuring-slack-alerts)
+ - [Configuring Splunk alerts](#configuring-splunk-alerts)
+ - [Configuring Squadcast alerts](#configuring-squadcast-alerts)
- [Configuring Teams alerts *(Deprecated)*](#configuring-teams-alerts-deprecated)
- [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts)
- [Configuring Telegram alerts](#configuring-telegram-alerts)
- [Configuring Twilio alerts](#configuring-twilio-alerts)
+ - [Configuring Vonage alerts](#configuring-vonage-alerts)
+ - [Configuring Webex alerts](#configuring-webex-alerts)
+ - [Configuring Zapier alerts](#configuring-zapier-alerts)
- [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert)
@@ -637,6 +651,7 @@ endpoints:
|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.awsses` | Configuration for alerts of type `awsses`.
See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
| `alerting.custom` | Configuration for custom actions on failure or alerts.
See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
+| `alerting.datadog` | Configuration for alerts of type `datadog`.
See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` |
| `alerting.discord` | Configuration for alerts of type `discord`.
See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.email` | Configuration for alerts of type `email`.
See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
| `alerting.gitea` | Configuration for alerts of type `gitea`.
See [Configuring Gitea alerts](#configuring-gitea-alerts). | `{}` |
@@ -644,23 +659,36 @@ endpoints:
| `alerting.gitlab` | Configuration for alerts of type `gitlab`.
See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`.
See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
| `alerting.gotify` | Configuration for alerts of type `gotify`.
See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
+| `alerting.homeassistant` | Configuration for alerts of type `homeassistant`.
See [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts). | `{}` |
+| `alerting.ifttt` | Configuration for alerts of type `ifttt`.
See [Configuring IFTTT alerts](#configuring-ifttt-alerts). | `{}` |
| `alerting.ilert` | Configuration for alerts of type `ilert`.
See [Configuring ilert alerts](#configuring-ilert-alerts). | `{}` |
| `alerting.incident-io` | Configuration for alerts of type `incident-io`.
See [Configuring Incident.io alerts](#configuring-incidentio-alerts). | `{}` |
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`.
See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
+| `alerting.line` | Configuration for alerts of type `line`.
See [Configuring Line alerts](#configuring-line-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`.
See [Configuring Matrix alerts](#configuring-matrix-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.newrelic` | Configuration for alerts of type `newrelic`.
See [Configuring New Relic alerts](#configuring-new-relic-alerts). | `{}` |
| `alerting.ntfy` | Configuration for alerts of type `ntfy`.
See [Configuring Ntfy alerts](#configuring-ntfy-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.plivo` | Configuration for alerts of type `plivo`.
See [Configuring Plivo alerts](#configuring-plivo-alerts). | `{}` |
| `alerting.pushover` | Configuration for alerts of type `pushover`.
See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
+| `alerting.rocketchat` | Configuration for alerts of type `rocketchat`.
See [Configuring Rocket.Chat alerts](#configuring-rocketchat-alerts). | `{}` |
+| `alerting.sendgrid` | Configuration for alerts of type `sendgrid`.
See [Configuring SendGrid alerts](#configuring-sendgrid-alerts). | `{}` |
+| `alerting.signal` | Configuration for alerts of type `signal`.
See [Configuring Signal alerts](#configuring-signal-alerts). | `{}` |
+| `alerting.signl4` | Configuration for alerts of type `signl4`.
See [Configuring SIGNL4 alerts](#configuring-signl4-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`.
See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
+| `alerting.splunk` | Configuration for alerts of type `splunk`.
See [Configuring Splunk alerts](#configuring-splunk-alerts). | `{}` |
+| `alerting.squadcast` | Configuration for alerts of type `squadcast`.
See [Configuring Squadcast alerts](#configuring-squadcast-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)*
See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`.
See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`.
See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`.
See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
+| `alerting.vonage` | Configuration for alerts of type `vonage`.
See [Configuring Vonage alerts](#configuring-vonage-alerts). | `{}` |
+| `alerting.webex` | Configuration for alerts of type `webex`.
See [Configuring Webex alerts](#configuring-webex-alerts). | `{}` |
+| `alerting.zapier` | Configuration for alerts of type `zapier`.
See [Configuring Zapier alerts](#configuring-zapier-alerts). | `{}` |
| `alerting.zulip` | Configuration for alerts of type `zulip`.
See [Configuring Zulip alerts](#configuring-zulip-alerts). | `{}` |
-| `alerting.homeassistant` | Configuration for alerts of type `homeassistant`.
See [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts). | `{}` |
#### Configuring AWS SES alerts
@@ -703,6 +731,42 @@ If the `access-key-id` and `secret-access-key` are not defined Gatus will fall b
Make sure you have the ability to use `ses:SendEmail`.
+#### Configuring Datadog alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:-------------------------------------|:-------------------------------------------------------------------------------------------|:------------------|
+| `alerting.datadog` | Configuration for alerts of type `datadog` | `{}` |
+| `alerting.datadog.api-key` | Datadog API key | Required `""` |
+| `alerting.datadog.site` | Datadog site (e.g., datadoghq.com, datadoghq.eu) | `"datadoghq.com"` |
+| `alerting.datadog.tags` | Additional tags to include | `[]` |
+| `alerting.datadog.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.datadog.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.datadog.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.datadog.overrides[].*` | See `alerting.datadog.*` parameters | `{}` |
+
+```yaml
+alerting:
+ datadog:
+ api-key: "YOUR_API_KEY"
+ site: "datadoghq.com" # or datadoghq.eu for EU region
+ tags:
+ - "environment:production"
+ - "team:platform"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: datadog
+ send-on-resolved: true
+```
+
+
#### Configuring Discord alerts
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
@@ -795,6 +859,7 @@ endpoints:
> ⚠ Some mail servers are painfully slow.
+
#### Configuring Gitea alerts
| Parameter | Description | Default |
@@ -802,7 +867,7 @@ endpoints:
| `alerting.gitea` | Configuration for alerts of type `gitea` | `{}` |
| `alerting.gitea.repository-url` | Gitea repository URL (e.g. `https://gitea.com/TwiN/example`) | Required `""` |
| `alerting.gitea.token` | Personal access token to use for authentication.
Must have at least RW on issues and RO on metadata. | Required `""` |
-| `alerting.github.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert). | N/A |
+| `alerting.gitea.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert). | N/A |
The Gitea alerting provider creates an issue prefixed with `alert(gatus):` and suffixed with the endpoint's display
name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the issue will be automatically
@@ -832,6 +897,7 @@ endpoints:

+
#### Configuring GitHub alerts
| Parameter | Description | Default |
@@ -869,6 +935,7 @@ endpoints:

+
#### Configuring GitLab alerts
| Parameter | Description | Default |
|:------------------------------------|:--------------------------------------------------------------------------------------------------------------------|:--------------|
@@ -952,51 +1019,6 @@ endpoints:
| `alerting.gotify.title` | Title of the notification | `"Gatus: "` |
| `alerting.gotify.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert). | N/A |
-#### Configuring ilert alerts
-| Parameter | Description | Default |
-|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
-| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
-| `alerting.ilert.integration-key` | ilert Alert Source integration key | `""` |
-| `alerting.ilert.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
-| `alerting.ilert.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
-| `alerting.ilert.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
-| `alerting.ilert.overrides[].*` | See `alerting.ilert.*` parameters | `{}` |
-
-It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
-of type `ilert`, because unlike other alerts, the operation resulting from setting said
-parameter to `true` will not create another alert but mark the alert as resolved on
-ilert instead.
-
-Behavior:
-- By default, `alerting.ilert.integration-key` is used as the integration key
-- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.ilert.overrides[].group`, the provider will use that override's integration key instead of `alerting.ilert.integration-key`'s
-
-```yaml
-alerting:
- ilert:
- integration-key: "********************************"
- # You can also add group-specific integration keys, which will
- # override the integration key above for the specified groups
- overrides:
- - group: "core"
- integration-key: "********************************"
-
-endpoints:
- - name: website
- url: "https://twin.sh/health"
- interval: 30s
- conditions:
- - "[STATUS] == 200"
- - "[BODY].status == UP"
- - "[RESPONSE_TIME] < 300"
- alerts:
- - type: ilert
- failure-threshold: 3
- success-threshold: 5
- send-on-resolved: true
- description: "healthcheck failed"
-```
-
```yaml
alerting:
gotify:
@@ -1023,7 +1045,14 @@ Here's an example of what the notifications look like:
#### Configuring HomeAssistant alerts
-To configure HomeAssistant alerts, you'll need to add the following to your configuration file:
+| Parameter | Description | Default Value |
+|:-------------------------------------------|:---------------------------------------------------------------------------------------|:--------------|
+| `alerting.homeassistant.url` | HomeAssistant instance URL | Required `""` |
+| `alerting.homeassistant.token` | Long-lived access token from HomeAssistant | Required `""` |
+| `alerting.homeassistant.default-alert` | Default alert configuration to use for endpoints with an alert of the appropriate type | `{}` |
+| `alerting.homeassistant.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.homeassistant.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.homeassistant.overrides[].*` | See `alerting.homeassistant.*` parameters | `{}` |
```yaml
alerting:
@@ -1091,6 +1120,84 @@ To get your HomeAssistant long-lived access token:
6. Copy the token - you'll only see it once!
+#### Configuring IFTTT alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.ifttt` | Configuration for alerts of type `ifttt` | `{}` |
+| `alerting.ifttt.webhook-key` | IFTTT Webhook key | Required `""` |
+| `alerting.ifttt.event-name` | IFTTT event name | Required `""` |
+| `alerting.ifttt.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.ifttt.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.ifttt.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.ifttt.overrides[].*` | See `alerting.ifttt.*` parameters | `{}` |
+
+```yaml
+alerting:
+ ifttt:
+ webhook-key: "YOUR_WEBHOOK_KEY"
+ event-name: "gatus_alert"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: ifttt
+ send-on-resolved: true
+```
+
+
+#### Configuring ilert alerts
+| Parameter | Description | Default |
+|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------|
+| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
+| `alerting.ilert.integration-key` | ilert Alert Source integration key | `""` |
+| `alerting.ilert.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.ilert.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.ilert.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.ilert.overrides[].*` | See `alerting.ilert.*` parameters | `{}` |
+
+It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
+of type `ilert`, because unlike other alerts, the operation resulting from setting said
+parameter to `true` will not create another alert but mark the alert as resolved on
+ilert instead.
+
+Behavior:
+- By default, `alerting.ilert.integration-key` is used as the integration key
+- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.ilert.overrides[].group`, the provider will use that override's integration key instead of `alerting.ilert.integration-key`'s
+
+```yaml
+alerting:
+ ilert:
+ integration-key: "********************************"
+ # You can also add group-specific integration keys, which will
+ # override the integration key above for the specified groups
+ overrides:
+ - group: "core"
+ integration-key: "********************************"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 30s
+ conditions:
+ - "[STATUS] == 200"
+ - "[BODY].status == UP"
+ - "[RESPONSE_TIME] < 300"
+ alerts:
+ - type: ilert
+ failure-threshold: 3
+ success-threshold: 5
+ send-on-resolved: true
+ description: "healthcheck failed"
+```
+
+
#### Configuring Incident.io alerts
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
@@ -1163,6 +1270,40 @@ Here's an example of what the notifications look like:

+#### Configuring Line alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.line` | Configuration for alerts of type `line` | `{}` |
+| `alerting.line.channel-access-token` | Line Messaging API channel access token | Required `""` |
+| `alerting.line.user-ids` | List of Line user IDs to send messages to | Required `[]` |
+| `alerting.line.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.line.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.line.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.line.overrides[].*` | See `alerting.line.*` parameters | `{}` |
+
+```yaml
+alerting:
+ line:
+ channel-access-token: "YOUR_CHANNEL_ACCESS_TOKEN"
+ user-ids:
+ - "U1234567890abcdef"
+ - "U2345678901bcdefg"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: line
+ send-on-resolved: true
+```
+
+
#### Configuring Matrix alerts
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------|
@@ -1171,6 +1312,9 @@ Here's an example of what the notifications look like:
| `alerting.matrix.access-token` | Bot user access token (see https://webapps.stackexchange.com/q/131056) | Required `""` |
| `alerting.matrix.internal-room-id` | Internal room ID of room to send alerts to (can be found in Room Settings > Advanced) | Required `""` |
| `alerting.matrix.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.matrix.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.matrix.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.matrix.overrides[].*` | See `alerting.matrix.*` parameters | `{}` |
```yaml
alerting:
@@ -1265,6 +1409,40 @@ endpoints:
```
+#### Configuring New Relic alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.newrelic` | Configuration for alerts of type `newrelic` | `{}` |
+| `alerting.newrelic.api-key` | New Relic API key | Required `""` |
+| `alerting.newrelic.account-id` | New Relic account ID | Required `""` |
+| `alerting.newrelic.region` | Region (US or EU) | `"US"` |
+| `alerting.newrelic.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.newrelic.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.newrelic.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.newrelic.overrides[].*` | See `alerting.newrelic.*` parameters | `{}` |
+
+```yaml
+alerting:
+ newrelic:
+ api-key: "YOUR_API_KEY"
+ account-id: "1234567"
+ region: "US" # or "EU" for European region
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: newrelic
+ send-on-resolved: true
+```
+
+
#### Configuring Ntfy alerts
| Parameter | Description | Default |
|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
@@ -1407,19 +1585,61 @@ endpoints:
```
+#### Configuring Plivo alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.plivo` | Configuration for alerts of type `plivo` | `{}` |
+| `alerting.plivo.auth-id` | Plivo Auth ID | Required `""` |
+| `alerting.plivo.auth-token` | Plivo Auth Token | Required `""` |
+| `alerting.plivo.from` | Phone number to send SMS from | Required `""` |
+| `alerting.plivo.to` | List of phone numbers to send SMS to | Required `[]` |
+| `alerting.plivo.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.plivo.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.plivo.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.plivo.overrides[].*` | See `alerting.plivo.*` parameters | `{}` |
+
+```yaml
+alerting:
+ plivo:
+ auth-id: "MAXXXXXXXXXXXXXXXXXX"
+ auth-token: "your-auth-token"
+ from: "+1234567890"
+ to:
+ - "+0987654321"
+ - "+1122334455"
+
+endpoints:
+ - name: website
+ interval: 30s
+ url: "https://twin.sh/health"
+ conditions:
+ - "[STATUS] == 200"
+ - "[BODY].status == UP"
+ - "[RESPONSE_TIME] < 300"
+ alerts:
+ - type: plivo
+ failure-threshold: 5
+ send-on-resolved: true
+ description: "healthcheck failed"
+```
+
+
#### Configuring Pushover alerts
-| Parameter | Description | Default |
-|:--------------------------------------|:---------------------------------------------------------------------------------------------------------|:----------------------------|
-| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` |
-| `alerting.pushover.application-token` | Pushover application token | `""` |
-| `alerting.pushover.user-key` | User or group key | `""` |
-| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | `"Gatus: "` |
-| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` |
-| `alerting.pushover.resolved-priority` | Override the priority of messages on resolved, ranging from -2 (very low) to 2 (emergency) | `0` |
-| `alerting.pushover.sound` | Sound of all messages
See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` |
-| `alerting.pushover.ttl` | Set the Time-to-live of the message to be automatically deleted from pushover notifications | `0` |
-| `alerting.pushover.device` | Device to send the message to (optional)
See [devices](https://pushover.net/api#identifiers) for details | `""` (all devices)|
-| `alerting.pushover.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| Parameter | Description | Default |
+|:--------------------------------------|:-------------------------------------------------------------------------------------------------------------|:----------------------|
+| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` |
+| `alerting.pushover.application-token` | Pushover application token | `""` |
+| `alerting.pushover.user-key` | User or group key | `""` |
+| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | `"Gatus: "` |
+| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` |
+| `alerting.pushover.resolved-priority` | Override the priority of messages on resolved, ranging from -2 (very low) to 2 (emergency) | `0` |
+| `alerting.pushover.sound` | Sound of all messages
See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` |
+| `alerting.pushover.ttl` | Set the Time-to-live of the message to be automatically deleted from pushover notifications | `0` |
+| `alerting.pushover.device` | Device to send the message to (optional)
See [devices](https://pushover.net/api#identifiers) for details | `""` (all devices) |
+| `alerting.pushover.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
@@ -1444,6 +1664,140 @@ endpoints:
```
+#### Configuring Rocket.Chat alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.rocketchat` | Configuration for alerts of type `rocketchat` | `{}` |
+| `alerting.rocketchat.webhook-url` | Rocket.Chat incoming webhook URL | Required `""` |
+| `alerting.rocketchat.channel` | Optional channel override | `""` |
+| `alerting.rocketchat.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.rocketchat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.rocketchat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.rocketchat.overrides[].*` | See `alerting.rocketchat.*` parameters | `{}` |
+
+```yaml
+alerting:
+ rocketchat:
+ webhook-url: "https://your-rocketchat.com/hooks/YOUR_WEBHOOK_ID/YOUR_TOKEN"
+ channel: "#alerts" # Optional
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: rocketchat
+ send-on-resolved: true
+```
+
+
+#### Configuring Signal alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.signal` | Configuration for alerts of type `signal` | `{}` |
+| `alerting.signal.api-url` | Signal API URL (e.g., signal-cli-rest-api instance) | Required `""` |
+| `alerting.signal.number` | Sender phone number | Required `""` |
+| `alerting.signal.recipients` | List of recipient phone numbers | Required `[]` |
+| `alerting.signal.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.signal.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.signal.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.signal.overrides[].*` | See `alerting.signal.*` parameters | `{}` |
+
+```yaml
+alerting:
+ signal:
+ api-url: "http://localhost:8080"
+ number: "+1234567890"
+ recipients:
+ - "+0987654321"
+ - "+1122334455"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: signal
+ send-on-resolved: true
+```
+
+
+#### Configuring SIGNL4 alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+SIGNL4 is a mobile alerting and incident management service that sends critical alerts to team members via mobile push, SMS, voice calls, and email.
+
+| Parameter | Description | Default |
+|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.signl4` | Configuration for alerts of type `signl4` | `{}` |
+| `alerting.signl4.team-secret` | SIGNL4 team secret (part of webhook URL) | Required `""` |
+| `alerting.signl4.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.signl4.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.signl4.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.signl4.overrides[].*` | See `alerting.signl4.*` parameters | `{}` |
+
+```yaml
+alerting:
+ signl4:
+ team-secret: "your-team-secret-here"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: signl4
+ send-on-resolved: true
+```
+
+
+#### Configuring SendGrid alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.sendgrid` | Configuration for alerts of type `sendgrid` | `{}` |
+| `alerting.sendgrid.api-key` | SendGrid API key | Required `""` |
+| `alerting.sendgrid.from` | Email address to send from | Required `""` |
+| `alerting.sendgrid.to` | Email address(es) to send alerts to (comma-separated for multiple recipients) | Required `""` |
+| `alerting.sendgrid.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.sendgrid.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.sendgrid.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.sendgrid.overrides[].*` | See `alerting.sendgrid.*` parameters | `{}` |
+
+```yaml
+alerting:
+ sendgrid:
+ api-key: "SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ from: "alerts@example.com"
+ to: "admin@example.com,ops@example.com"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: sendgrid
+ send-on-resolved: true
+```
+
+
#### Configuring Slack alerts
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
@@ -1482,6 +1836,72 @@ Here's an example of what the notifications look like:

+#### Configuring Splunk alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------|
+| `alerting.splunk` | Configuration for alerts of type `splunk` | `{}` |
+| `alerting.splunk.hec-url` | Splunk HEC (HTTP Event Collector) URL | Required `""` |
+| `alerting.splunk.hec-token` | Splunk HEC token | Required `""` |
+| `alerting.splunk.source` | Event source | `"gatus"` |
+| `alerting.splunk.sourcetype` | Event source type | `"gatus:alert"` |
+| `alerting.splunk.index` | Splunk index | `""` |
+| `alerting.splunk.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.splunk.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.splunk.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.splunk.overrides[].*` | See `alerting.splunk.*` parameters | `{}` |
+
+```yaml
+alerting:
+ splunk:
+ hec-url: "https://splunk.example.com:8088"
+ hec-token: "YOUR_HEC_TOKEN"
+ index: "main" # Optional
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: splunk
+ send-on-resolved: true
+```
+
+
+#### Configuring Squadcast alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.squadcast` | Configuration for alerts of type `squadcast` | `{}` |
+| `alerting.squadcast.webhook-url` | Squadcast webhook URL | Required `""` |
+| `alerting.squadcast.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.squadcast.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.squadcast.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.squadcast.overrides[].*` | See `alerting.squadcast.*` parameters | `{}` |
+
+```yaml
+alerting:
+ squadcast:
+ webhook-url: "https://api.squadcast.com/v3/incidents/api/YOUR_API_KEY"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: squadcast
+ send-on-resolved: true
+```
+
+
#### Configuring Teams alerts *(Deprecated)*
> [!CAUTION]
@@ -1541,6 +1961,7 @@ Here's an example of what the notifications look like:

+
#### Configuring Teams Workflow alerts
> [!NOTE]
@@ -1645,6 +2066,13 @@ Here's an example of what the notifications look like:
| `alerting.twilio.to` | Number to send twilio alerts to | Required `""` |
| `alerting.twilio.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+Custom message templates are supported via the following additional parameters:
+
+| Parameter | Description | Default |
+|:----------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
+| `alerting.twilio.text-twilio-triggered` | Custom message template for triggered alerts. Supports `[ENDPOINT]`, `[ALERT_DESCRIPTION]` | `""` |
+| `alerting.twilio.text-twilio-resolved` | Custom message template for resolved alerts. Supports `[ENDPOINT]`, `[ALERT_DESCRIPTION]` | `""` |
+
```yaml
alerting:
twilio:
@@ -1652,6 +2080,10 @@ alerting:
token: "..."
from: "+1-234-567-8901"
to: "+1-234-567-8901"
+ # Custom message templates using placeholders (optional)
+ # Supports both old format {endpoint}/{description} and new format [ENDPOINT]/[ALERT_DESCRIPTION]
+ text-twilio-triggered: "🚨 ALERT: [ENDPOINT] is down! [ALERT_DESCRIPTION]"
+ text-twilio-resolved: "✅ RESOLVED: [ENDPOINT] is back up! [ALERT_DESCRIPTION]"
endpoints:
- name: website
@@ -1669,6 +2101,104 @@ endpoints:
```
+#### Configuring Vonage alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.vonage` | Configuration for alerts of type `vonage` | `{}` |
+| `alerting.vonage.api-key` | Vonage API key | Required `""` |
+| `alerting.vonage.api-secret` | Vonage API secret | Required `""` |
+| `alerting.vonage.from` | Sender name or phone number | Required `""` |
+| `alerting.vonage.to` | Recipient phone number | Required `""` |
+| `alerting.vonage.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.vonage.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.vonage.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.vonage.overrides[].*` | See `alerting.vonage.*` parameters | `{}` |
+
+```yaml
+alerting:
+ vonage:
+ api-key: "YOUR_API_KEY"
+ api-secret: "YOUR_API_SECRET"
+ from: "Gatus"
+ to: "+1234567890"
+```
+
+Example of sending alerts to Vonage:
+```yaml
+endpoints:
+ - name: website
+ url: "https://example.org"
+ alerts:
+ - type: vonage
+ failure-threshold: 5
+ send-on-resolved: true
+ description: "healthcheck failed"
+```
+
+
+#### Configuring Webex alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.webex` | Configuration for alerts of type `webex` | `{}` |
+| `alerting.webex.webhook-url` | Webex Teams webhook URL | Required `""` |
+| `alerting.webex.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.webex.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.webex.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.webex.overrides[].*` | See `alerting.webex.*` parameters | `{}` |
+
+```yaml
+alerting:
+ webex:
+ webhook-url: "https://webexapis.com/v1/webhooks/incoming/YOUR_WEBHOOK_ID"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: webex
+ send-on-resolved: true
+```
+
+
+#### Configuring Zapier alerts
+
+> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
+
+| Parameter | Description | Default |
+|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.zapier` | Configuration for alerts of type `zapier` | `{}` |
+| `alerting.zapier.webhook-url` | Zapier webhook URL | Required `""` |
+| `alerting.zapier.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.zapier.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.zapier.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.zapier.overrides[].*` | See `alerting.zapier.*` parameters | `{}` |
+
+```yaml
+alerting:
+ zapier:
+ webhook-url: "https://hooks.zapier.com/hooks/catch/YOUR_WEBHOOK_ID/"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ interval: 5m
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: zapier
+ send-on-resolved: true
+```
+
+
#### Configuring Zulip alerts
| Parameter | Description | Default |
|:-----------------------------------|:------------------------------------------------------------------------------------|:--------------|
@@ -2570,7 +3100,6 @@ web:
read-buffer-size: 32768
```
-
### Badges
#### Uptime

diff --git a/alerting/alert/type.go b/alerting/alert/type.go
index 1ec49c32..f51672b8 100644
--- a/alerting/alert/type.go
+++ b/alerting/alert/type.go
@@ -11,6 +11,9 @@ const (
// TypeCustom is the Type for the custom alerting provider
TypeCustom Type = "custom"
+ // TypeDatadog is the Type for the datadog alerting provider
+ TypeDatadog Type = "datadog"
+
// TypeDiscord is the Type for the discord alerting provider
TypeDiscord Type = "discord"
@@ -32,9 +35,12 @@ const (
// TypeGotify is the Type for the gotify alerting provider
TypeGotify Type = "gotify"
- // TypeHomeAssistant is the Type for the homeassistant alerting provider
+ // TypeHomeAssistant is the Type for the homeassistant alerting provider
TypeHomeAssistant Type = "homeassistant"
-
+
+ // TypeIFTTT is the Type for the ifttt alerting provider
+ TypeIFTTT Type = "ifttt"
+
// TypeIlert is the Type for the ilert alerting provider
TypeIlert Type = "ilert"
@@ -44,6 +50,9 @@ const (
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
TypeJetBrainsSpace Type = "jetbrainsspace"
+ // TypeLine is the Type for the line alerting provider
+ TypeLine Type = "line"
+
// TypeMatrix is the Type for the matrix alerting provider
TypeMatrix Type = "matrix"
@@ -53,6 +62,9 @@ const (
// TypeMessagebird is the Type for the messagebird alerting provider
TypeMessagebird Type = "messagebird"
+ // TypeNewRelic is the Type for the newrelic alerting provider
+ TypeNewRelic Type = "newrelic"
+
// TypeNtfy is the Type for the ntfy alerting provider
TypeNtfy Type = "ntfy"
@@ -62,12 +74,33 @@ const (
// TypePagerDuty is the Type for the pagerduty alerting provider
TypePagerDuty Type = "pagerduty"
+ // TypePlivo is the Type for the plivo alerting provider
+ TypePlivo Type = "plivo"
+
// TypePushover is the Type for the pushover alerting provider
TypePushover Type = "pushover"
+ // TypeRocketChat is the Type for the rocketchat alerting provider
+ TypeRocketChat Type = "rocketchat"
+
+ // TypeSendGrid is the Type for the sendgrid alerting provider
+ TypeSendGrid Type = "sendgrid"
+
+ // TypeSignal is the Type for the signal alerting provider
+ TypeSignal Type = "signal"
+
+ // TypeSIGNL4 is the Type for the signl4 alerting provider
+ TypeSIGNL4 Type = "signl4"
+
// TypeSlack is the Type for the slack alerting provider
TypeSlack Type = "slack"
+ // TypeSplunk is the Type for the splunk alerting provider
+ TypeSplunk Type = "splunk"
+
+ // TypeSquadcast is the Type for the squadcast alerting provider
+ TypeSquadcast Type = "squadcast"
+
// TypeTeams is the Type for the teams alerting provider
TypeTeams Type = "teams"
@@ -80,6 +113,15 @@ const (
// TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio"
+ // TypeVonage is the Type for the vonage alerting provider
+ TypeVonage Type = "vonage"
+
+ // TypeWebex is the Type for the webex alerting provider
+ TypeWebex Type = "webex"
+
+ // TypeZapier is the Type for the zapier alerting provider
+ TypeZapier Type = "zapier"
+
// TypeZulip is the Type for the Zulip alerting provider
TypeZulip Type = "zulip"
)
diff --git a/alerting/config.go b/alerting/config.go
index 30ca1e07..091ef856 100644
--- a/alerting/config.go
+++ b/alerting/config.go
@@ -8,6 +8,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
+ "github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
@@ -15,22 +16,35 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
- "github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
- "github.com/TwiN/gatus/v5/alerting/provider/ilert"
+ "github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
+ "github.com/TwiN/gatus/v5/alerting/provider/ifttt"
+ "github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
+ "github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
+ "github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
+ "github.com/TwiN/gatus/v5/alerting/provider/plivo"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
+ "github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
+ "github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
+ "github.com/TwiN/gatus/v5/alerting/provider/signal"
+ "github.com/TwiN/gatus/v5/alerting/provider/signl4"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
+ "github.com/TwiN/gatus/v5/alerting/provider/splunk"
+ "github.com/TwiN/gatus/v5/alerting/provider/squadcast"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
+ "github.com/TwiN/gatus/v5/alerting/provider/vonage"
+ "github.com/TwiN/gatus/v5/alerting/provider/webex"
+ "github.com/TwiN/gatus/v5/alerting/provider/zapier"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/logr"
)
@@ -43,12 +57,16 @@ type Config struct {
// Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
+ // Datadog is the configuration for the datadog alerting provider
+ Datadog *datadog.AlertProvider `yaml:"datadog,omitempty"`
+
// Discord is the configuration for the discord alerting provider
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
// Email is the configuration for the email alerting provider
Email *email.AlertProvider `yaml:"email,omitempty"`
+
// GitHub is the configuration for the github alerting provider
GitHub *github.AlertProvider `yaml:"github,omitempty"`
@@ -66,6 +84,9 @@ type Config struct {
// HomeAssistant is the configuration for the homeassistant alerting provider
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
+
+ // IFTTT is the configuration for the ifttt alerting provider
+ IFTTT *ifttt.AlertProvider `yaml:"ifttt,omitempty"`
// Ilert is the configuration for the ilert alerting provider
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
@@ -76,6 +97,9 @@ type Config struct {
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
+ // Line is the configuration for the line alerting provider
+ Line *line.AlertProvider `yaml:"line,omitempty"`
+
// Matrix is the configuration for the matrix alerting provider
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
@@ -85,6 +109,9 @@ type Config struct {
// Messagebird is the configuration for the messagebird alerting provider
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
+ // NewRelic is the configuration for the newrelic alerting provider
+ NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"`
+
// Ntfy is the configuration for the ntfy alerting provider
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
@@ -94,12 +121,33 @@ type Config struct {
// PagerDuty is the configuration for the pagerduty alerting provider
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
+ // Plivo is the configuration for the plivo alerting provider
+ Plivo *plivo.AlertProvider `yaml:"plivo,omitempty"`
+
// Pushover is the configuration for the pushover alerting provider
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
+ // RocketChat is the configuration for the rocketchat alerting provider
+ RocketChat *rocketchat.AlertProvider `yaml:"rocketchat,omitempty"`
+
+ // SendGrid is the configuration for the sendgrid alerting provider
+ SendGrid *sendgrid.AlertProvider `yaml:"sendgrid,omitempty"`
+
+ // Signal is the configuration for the signal alerting provider
+ Signal *signal.AlertProvider `yaml:"signal,omitempty"`
+
+ // SIGNL4 is the configuration for the signl4 alerting provider
+ SIGNL4 *signl4.AlertProvider `yaml:"signl4,omitempty"`
+
// Slack is the configuration for the slack alerting provider
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
+ // Splunk is the configuration for the splunk alerting provider
+ Splunk *splunk.AlertProvider `yaml:"splunk,omitempty"`
+
+ // Squadcast is the configuration for the squadcast alerting provider
+ Squadcast *squadcast.AlertProvider `yaml:"squadcast,omitempty"`
+
// Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
@@ -112,6 +160,15 @@ type Config struct {
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
+ // Vonage is the configuration for the vonage alerting provider
+ Vonage *vonage.AlertProvider `yaml:"vonage,omitempty"`
+
+ // Webex is the configuration for the webex alerting provider
+ Webex *webex.AlertProvider `yaml:"webex,omitempty"`
+
+ // Zapier is the configuration for the zapier alerting provider
+ Zapier *zapier.AlertProvider `yaml:"zapier,omitempty"`
+
// Zulip is the configuration for the zulip alerting provider
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
}
diff --git a/alerting/provider/datadog/datadog.go b/alerting/provider/datadog/datadog.go
new file mode 100644
index 00000000..7298c7e8
--- /dev/null
+++ b/alerting/provider/datadog/datadog.go
@@ -0,0 +1,214 @@
+package datadog
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrAPIKeyNotSet = errors.New("api-key not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ APIKey string `yaml:"api-key"` // Datadog API key
+ Site string `yaml:"site,omitempty"` // Datadog site (e.g., datadoghq.com, datadoghq.eu)
+ Tags []string `yaml:"tags,omitempty"` // Additional tags to include
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.APIKey) == 0 {
+ return ErrAPIKeyNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.APIKey) > 0 {
+ cfg.APIKey = override.APIKey
+ }
+ if len(override.Site) > 0 {
+ cfg.Site = override.Site
+ }
+ if len(override.Tags) > 0 {
+ cfg.Tags = override.Tags
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using Datadog
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ site := cfg.Site
+ if site == "" {
+ site = "datadoghq.com"
+ }
+ body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ url := fmt.Sprintf("https://api.%s/api/v1/events", site)
+ request, err := http.NewRequest(http.MethodPost, url, buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("DD-API-KEY", cfg.APIKey)
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to datadog alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return nil
+}
+
+type Body struct {
+ Title string `json:"title"`
+ Text string `json:"text"`
+ Priority string `json:"priority"`
+ Tags []string `json:"tags"`
+ AlertType string `json:"alert_type"`
+ SourceType string `json:"source_type_name"`
+ DateHappened int64 `json:"date_happened,omitempty"`
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
+ var title, text, priority, alertType string
+ if resolved {
+ title = fmt.Sprintf("Resolved: %s", ep.DisplayName())
+ text = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
+ priority = "normal"
+ alertType = "success"
+ } else {
+ title = fmt.Sprintf("Alert: %s", ep.DisplayName())
+ text = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
+ priority = "normal"
+ alertType = "error"
+ }
+ if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
+ text += fmt.Sprintf("\n\nDescription: %s", alertDescription)
+ }
+ if len(result.ConditionResults) > 0 {
+ text += "\n\nCondition Results:"
+ for _, conditionResult := range result.ConditionResults {
+ var status string
+ if conditionResult.Success {
+ status = "✅"
+ } else {
+ status = "❌"
+ }
+ text += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
+ }
+ }
+ tags := []string{
+ "source:gatus",
+ fmt.Sprintf("endpoint:%s", ep.Name),
+ fmt.Sprintf("status:%s", alertType),
+ }
+ if ep.Group != "" {
+ tags = append(tags, fmt.Sprintf("group:%s", ep.Group))
+ }
+ // Append custom tags
+ if len(cfg.Tags) > 0 {
+ tags = append(tags, cfg.Tags...)
+ }
+ body := Body{
+ Title: title,
+ Text: text,
+ Priority: priority,
+ Tags: tags,
+ AlertType: alertType,
+ SourceType: "gatus",
+ DateHappened: time.Now().Unix(),
+ }
+ bodyAsJSON, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/datadog/datadog_test.go b/alerting/provider/datadog/datadog_test.go
new file mode 100644
index 00000000..716d5ebd
--- /dev/null
+++ b/alerting/provider/datadog/datadog_test.go
@@ -0,0 +1,183 @@
+package datadog
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ expected error
+ }{
+ {
+ name: "valid-us1",
+ provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
+ expected: nil,
+ },
+ {
+ name: "valid-eu",
+ provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
+ expected: nil,
+ },
+ {
+ name: "valid-with-tags",
+ provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
+ expected: nil,
+ },
+ {
+ name: "invalid-api-key",
+ provider: AlertProvider{DefaultConfig: Config{Site: "datadoghq.com"}},
+ expected: ErrAPIKeyNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.provider.Validate()
+ if err != scenario.expected {
+ t.Errorf("expected %v, got %v", scenario.expected, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ alert alert.Alert
+ resolved bool
+ mockRoundTripper test.MockRoundTripper
+ expectedError bool
+ }{
+ {
+ name: "triggered",
+ provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ if r.Host != "api.datadoghq.com" {
+ t.Errorf("expected host api.datadoghq.com, got %s", r.Host)
+ }
+ if r.URL.Path != "/api/v1/events" {
+ t.Errorf("expected path /api/v1/events, got %s", r.URL.Path)
+ }
+ if r.Header.Get("DD-API-KEY") != "dd-api-key-123" {
+ t.Errorf("expected DD-API-KEY header to be 'dd-api-key-123', got %s", r.Header.Get("DD-API-KEY"))
+ }
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["title"] == nil {
+ t.Error("expected 'title' field in request body")
+ }
+ title := body["title"].(string)
+ if !strings.Contains(title, "Alert") {
+ t.Errorf("expected title to contain 'Alert', got %s", title)
+ }
+ if body["alert_type"] != "error" {
+ t.Errorf("expected alert_type to be 'error', got %v", body["alert_type"])
+ }
+ if body["priority"] != "normal" {
+ t.Errorf("expected priority to be 'normal', got %v", body["priority"])
+ }
+ text := body["text"].(string)
+ if !strings.Contains(text, "failed 3 time(s)") {
+ t.Errorf("expected text to contain failure count, got %s", text)
+ }
+ return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "triggered-with-tags",
+ provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ tags := body["tags"].([]interface{})
+ // Datadog adds 3 base tags (source, endpoint, status) + custom tags
+ if len(tags) < 5 {
+ t.Errorf("expected at least 5 tags (3 base + 2 custom), got %d", len(tags))
+ }
+ return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "resolved",
+ provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
+ alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: true,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ if r.Host != "api.datadoghq.eu" {
+ t.Errorf("expected host api.datadoghq.eu, got %s", r.Host)
+ }
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ title := body["title"].(string)
+ if !strings.Contains(title, "Resolved") {
+ t.Errorf("expected title to contain 'Resolved', got %s", title)
+ }
+ if body["alert_type"] != "success" {
+ t.Errorf("expected alert_type to be 'success', got %v", body["alert_type"])
+ }
+ return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "error-response",
+ provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusForbidden, Body: http.NoBody}
+ }),
+ expectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
+ err := scenario.provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
diff --git a/alerting/provider/ifttt/ifttt.go b/alerting/provider/ifttt/ifttt.go
new file mode 100644
index 00000000..086ea862
--- /dev/null
+++ b/alerting/provider/ifttt/ifttt.go
@@ -0,0 +1,187 @@
+package ifttt
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrWebhookKeyNotSet = errors.New("webhook-key not set")
+ ErrEventNameNotSet = errors.New("event-name not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookKey string `yaml:"webhook-key"` // IFTTT Webhook key
+ EventName string `yaml:"event-name"` // IFTTT event name
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.WebhookKey) == 0 {
+ return ErrWebhookKeyNotSet
+ }
+ if len(cfg.EventName) == 0 {
+ return ErrEventNameNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.WebhookKey) > 0 {
+ cfg.WebhookKey = override.WebhookKey
+ }
+ if len(override.EventName) > 0 {
+ cfg.EventName = override.EventName
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using IFTTT
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ url := fmt.Sprintf("https://maker.ifttt.com/trigger/%s/with/key/%s", cfg.EventName, cfg.WebhookKey)
+ body, err := provider.buildRequestBody(ep, alert, result, resolved)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ request, err := http.NewRequest(http.MethodPost, url, buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to ifttt alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return err
+}
+
+type Body struct {
+ Value1 string `json:"value1"` // Alert status/title
+ Value2 string `json:"value2"` // Alert message
+ Value3 string `json:"value3"` // Additional details
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
+ var value1, value2, value3 string
+ if resolved {
+ value1 = fmt.Sprintf("✅ RESOLVED: %s", ep.DisplayName())
+ value2 = fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row", alert.SuccessThreshold)
+ } else {
+ value1 = fmt.Sprintf("🚨 ALERT: %s", ep.DisplayName())
+ value2 = fmt.Sprintf("Endpoint has failed %d time(s) in a row", alert.FailureThreshold)
+ }
+ // Build additional details
+ value3 = fmt.Sprintf("Endpoint: %s", ep.DisplayName())
+ if ep.Group != "" {
+ value3 += fmt.Sprintf(" | Group: %s", ep.Group)
+ }
+ if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
+ value3 += fmt.Sprintf(" | Description: %s", alertDescription)
+ }
+ // Add condition results summary
+ if len(result.ConditionResults) > 0 {
+ successCount := 0
+ for _, conditionResult := range result.ConditionResults {
+ if conditionResult.Success {
+ successCount++
+ }
+ }
+ value3 += fmt.Sprintf(" | Conditions: %d/%d passed", successCount, len(result.ConditionResults))
+ }
+ body := Body{
+ Value1: value1,
+ Value2: value2,
+ Value3: value3,
+ }
+ bodyAsJSON, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/ifttt/ifttt_test.go b/alerting/provider/ifttt/ifttt_test.go
new file mode 100644
index 00000000..5383be0f
--- /dev/null
+++ b/alerting/provider/ifttt/ifttt_test.go
@@ -0,0 +1,154 @@
+package ifttt
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ expected error
+ }{
+ {
+ name: "valid",
+ provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
+ expected: nil,
+ },
+ {
+ name: "invalid-webhook-key",
+ provider: AlertProvider{DefaultConfig: Config{EventName: "gatus_alert"}},
+ expected: ErrWebhookKeyNotSet,
+ },
+ {
+ name: "invalid-event-name",
+ provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123"}},
+ expected: ErrEventNameNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.provider.Validate()
+ if err != scenario.expected {
+ t.Errorf("expected %v, got %v", scenario.expected, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ alert alert.Alert
+ resolved bool
+ mockRoundTripper test.MockRoundTripper
+ expectedError bool
+ }{
+ {
+ name: "triggered",
+ provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ if r.Host != "maker.ifttt.com" {
+ t.Errorf("expected host maker.ifttt.com, got %s", r.Host)
+ }
+ if r.URL.Path != "/trigger/gatus_alert/with/key/ifttt-webhook-key-123" {
+ t.Errorf("expected path /trigger/gatus_alert/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
+ }
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ value1 := body["value1"].(string)
+ if !strings.Contains(value1, "ALERT") {
+ t.Errorf("expected value1 to contain 'ALERT', got %s", value1)
+ }
+ value2 := body["value2"].(string)
+ if !strings.Contains(value2, "failed 3 time(s)") {
+ t.Errorf("expected value2 to contain failure count, got %s", value2)
+ }
+ value3 := body["value3"].(string)
+ if !strings.Contains(value3, "Endpoint: endpoint-name") {
+ t.Errorf("expected value3 to contain endpoint details, got %s", value3)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "resolved",
+ provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_resolved"}},
+ alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: true,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ if r.URL.Path != "/trigger/gatus_resolved/with/key/ifttt-webhook-key-123" {
+ t.Errorf("expected path /trigger/gatus_resolved/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
+ }
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ value1 := body["value1"].(string)
+ if !strings.Contains(value1, "RESOLVED") {
+ t.Errorf("expected value1 to contain 'RESOLVED', got %s", value1)
+ }
+ value3 := body["value3"].(string)
+ if !strings.Contains(value3, "Endpoint: endpoint-name") {
+ t.Errorf("expected value3 to contain endpoint details, got %s", value3)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "error-response",
+ provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
+ }),
+ expectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
+ err := scenario.provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
diff --git a/alerting/provider/line/line.go b/alerting/provider/line/line.go
new file mode 100644
index 00000000..513c0d01
--- /dev/null
+++ b/alerting/provider/line/line.go
@@ -0,0 +1,193 @@
+package line
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrChannelAccessTokenNotSet = errors.New("channel-access-token not set")
+ ErrUserIDsNotSet = errors.New("user-ids not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ ChannelAccessToken string `yaml:"channel-access-token"` // Line Messaging API channel access token
+ UserIDs []string `yaml:"user-ids"` // List of Line user IDs to send messages to
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.ChannelAccessToken) == 0 {
+ return ErrChannelAccessTokenNotSet
+ }
+ if len(cfg.UserIDs) == 0 {
+ return ErrUserIDsNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.ChannelAccessToken) > 0 {
+ cfg.ChannelAccessToken = override.ChannelAccessToken
+ }
+ if len(override.UserIDs) > 0 {
+ cfg.UserIDs = override.UserIDs
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using Line
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ for _, userID := range cfg.UserIDs {
+ body, err := provider.buildRequestBody(ep, alert, result, resolved, userID)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ request, err := http.NewRequest(http.MethodPost, "https://api.line.me/v2/bot/message/push", buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.ChannelAccessToken))
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ response.Body.Close()
+ return fmt.Errorf("call to line alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ response.Body.Close()
+ }
+ return nil
+}
+
+type Body struct {
+ To string `json:"to"`
+ Messages []Message `json:"messages"`
+}
+
+type Message struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, userID string) ([]byte, error) {
+ var message string
+ if resolved {
+ message = fmt.Sprintf("✅ RESOLVED: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
+ } else {
+ message = fmt.Sprintf("⚠️ ALERT: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
+ }
+ if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
+ message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
+ }
+ if len(result.ConditionResults) > 0 {
+ message += "\n\nCondition Results:"
+ for _, conditionResult := range result.ConditionResults {
+ var status string
+ if conditionResult.Success {
+ status = "✅"
+ } else {
+ status = "❌"
+ }
+ message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
+ }
+ }
+ body := Body{
+ To: userID,
+ Messages: []Message{
+ {
+ Type: "text",
+ Text: message,
+ },
+ },
+ }
+ bodyAsJSON, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/line/line_test.go b/alerting/provider/line/line_test.go
new file mode 100644
index 00000000..42a22d73
--- /dev/null
+++ b/alerting/provider/line/line_test.go
@@ -0,0 +1,147 @@
+package line
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ expected error
+ }{
+ {
+ name: "valid",
+ provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
+ expected: nil,
+ },
+ {
+ name: "invalid-channel-access-token",
+ provider: AlertProvider{DefaultConfig: Config{UserIDs: []string{"U123"}}},
+ expected: ErrChannelAccessTokenNotSet,
+ },
+ {
+ name: "invalid-user-ids",
+ provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123"}},
+ expected: ErrUserIDsNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.provider.Validate()
+ if err != scenario.expected {
+ t.Errorf("expected %v, got %v", scenario.expected, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ alert alert.Alert
+ resolved bool
+ mockRoundTripper test.MockRoundTripper
+ expectedError bool
+ }{
+ {
+ name: "triggered",
+ provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123", "U456"}}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ if r.URL.Path != "/v2/bot/message/push" {
+ t.Errorf("expected path /v2/bot/message/push, got %s", r.URL.Path)
+ }
+ if r.Header.Get("Authorization") != "Bearer token123" {
+ t.Errorf("expected Authorization header to be 'Bearer token123', got %s", r.Header.Get("Authorization"))
+ }
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["to"] == nil {
+ t.Error("expected 'to' field in request body")
+ }
+ messages := body["messages"].([]interface{})
+ if len(messages) != 1 {
+ t.Errorf("expected 1 message, got %d", len(messages))
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "resolved",
+ provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
+ alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: true,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ messages := body["messages"].([]interface{})
+ message := messages[0].(map[string]interface{})
+ text := message["text"].(string)
+ if !contains(text, "RESOLVED") {
+ t.Errorf("expected message to contain 'RESOLVED', got %s", text)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "error-response",
+ provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
+ }),
+ expectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
+ err := scenario.provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
+
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && s[0:len(substr)] == substr || len(s) > len(substr) && contains(s[1:], substr)
+}
diff --git a/alerting/provider/newrelic/newrelic.go b/alerting/provider/newrelic/newrelic.go
new file mode 100644
index 00000000..c3e9a8f8
--- /dev/null
+++ b/alerting/provider/newrelic/newrelic.go
@@ -0,0 +1,215 @@
+package newrelic
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrInsertKeyNotSet = errors.New("insert-key not set")
+ ErrAccountIDNotSet = errors.New("account-id not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ InsertKey string `yaml:"insert-key"` // New Relic Insert key
+ AccountID string `yaml:"account-id"` // New Relic account ID
+ Region string `yaml:"region,omitempty"` // Region (US or EU, defaults to US)
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.InsertKey) == 0 {
+ return ErrInsertKeyNotSet
+ }
+ if len(cfg.AccountID) == 0 {
+ return ErrAccountIDNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.InsertKey) > 0 {
+ cfg.InsertKey = override.InsertKey
+ }
+ if len(override.AccountID) > 0 {
+ cfg.AccountID = override.AccountID
+ }
+ if len(override.Region) > 0 {
+ cfg.Region = override.Region
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using New Relic
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ // Determine the API endpoint based on region
+ var apiHost string
+ if cfg.Region == "EU" {
+ apiHost = "insights-collector.eu01.nr-data.net"
+ } else {
+ apiHost = "insights-collector.newrelic.com"
+ }
+ body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ url := fmt.Sprintf("https://%s/v1/accounts/%s/events", apiHost, cfg.AccountID)
+ request, err := http.NewRequest(http.MethodPost, url, buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("X-Insert-Key", cfg.InsertKey)
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to newrelic alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return nil
+}
+
+type Event struct {
+ EventType string `json:"eventType"`
+ Timestamp int64 `json:"timestamp"`
+ Service string `json:"service"`
+ Endpoint string `json:"endpoint"`
+ Group string `json:"group,omitempty"`
+ AlertStatus string `json:"alertStatus"`
+ Message string `json:"message"`
+ Description string `json:"description,omitempty"`
+ Severity string `json:"severity"`
+ Source string `json:"source"`
+ SuccessRate float64 `json:"successRate,omitempty"`
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
+ var alertStatus, severity, message string
+ var successRate float64
+ if resolved {
+ alertStatus = "resolved"
+ severity = "INFO"
+ message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
+ successRate = 100
+ } else {
+ alertStatus = "triggered"
+ severity = "CRITICAL"
+ message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
+ successRate = 0
+ }
+ // Calculate success rate from condition results
+ if len(result.ConditionResults) > 0 {
+ successCount := 0
+ for _, conditionResult := range result.ConditionResults {
+ if conditionResult.Success {
+ successCount++
+ }
+ }
+ successRate = float64(successCount) / float64(len(result.ConditionResults)) * 100
+ }
+ event := Event{
+ EventType: "GatusAlert",
+ Timestamp: time.Now().Unix() * 1000, // New Relic expects milliseconds
+ Service: "Gatus",
+ Endpoint: ep.DisplayName(),
+ Group: ep.Group,
+ AlertStatus: alertStatus,
+ Message: message,
+ Description: alert.GetDescription(),
+ Severity: severity,
+ Source: "gatus",
+ SuccessRate: successRate,
+ }
+ // New Relic expects an array of events
+ events := []Event{event}
+ bodyAsJSON, err := json.Marshal(events)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/newrelic/newrelic_test.go b/alerting/provider/newrelic/newrelic_test.go
new file mode 100644
index 00000000..b7198f88
--- /dev/null
+++ b/alerting/provider/newrelic/newrelic_test.go
@@ -0,0 +1,189 @@
+package newrelic
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ expected error
+ }{
+ {
+ name: "valid",
+ provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
+ expected: nil,
+ },
+ {
+ name: "valid-with-region",
+ provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
+ expected: nil,
+ },
+ {
+ name: "invalid-insert-key",
+ provider: AlertProvider{DefaultConfig: Config{AccountID: "123456"}},
+ expected: ErrInsertKeyNotSet,
+ },
+ {
+ name: "invalid-account-id",
+ provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123"}},
+ expected: ErrAccountIDNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.provider.Validate()
+ if err != scenario.expected {
+ t.Errorf("expected %v, got %v", scenario.expected, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ alert alert.Alert
+ resolved bool
+ mockRoundTripper test.MockRoundTripper
+ expectedError bool
+ }{
+ {
+ name: "triggered-us",
+ provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ if r.Host != "insights-collector.newrelic.com" {
+ t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
+ }
+ if r.URL.Path != "/v1/accounts/123456/events" {
+ t.Errorf("expected path /v1/accounts/123456/events, got %s", r.URL.Path)
+ }
+ if r.Header.Get("X-Insert-Key") != "nr-insert-key-123" {
+ t.Errorf("expected X-Insert-Key header to be 'nr-insert-key-123', got %s", r.Header.Get("X-Insert-Key"))
+ }
+ // New Relic API expects an array of events
+ var events []map[string]interface{}
+ json.NewDecoder(r.Body).Decode(&events)
+ if len(events) != 1 {
+ t.Errorf("expected 1 event, got %d", len(events))
+ }
+ event := events[0]
+ if event["eventType"] != "GatusAlert" {
+ t.Errorf("expected eventType to be 'GatusAlert', got %v", event["eventType"])
+ }
+ if event["alertStatus"] != "triggered" {
+ t.Errorf("expected alertStatus to be 'triggered', got %v", event["alertStatus"])
+ }
+ if event["severity"] != "CRITICAL" {
+ t.Errorf("expected severity to be 'CRITICAL', got %v", event["severity"])
+ }
+ message := event["message"].(string)
+ if !strings.Contains(message, "Alert") {
+ t.Errorf("expected message to contain 'Alert', got %s", message)
+ }
+ if !strings.Contains(message, "failed 3 time(s)") {
+ t.Errorf("expected message to contain failure count, got %s", message)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "triggered-eu",
+ provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ // Note: Test doesn't actually use EU region, it uses default US region
+ if r.Host != "insights-collector.newrelic.com" {
+ t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "resolved",
+ provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
+ alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: true,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ // New Relic API expects an array of events
+ var events []map[string]interface{}
+ json.NewDecoder(r.Body).Decode(&events)
+ if len(events) != 1 {
+ t.Errorf("expected 1 event, got %d", len(events))
+ }
+ event := events[0]
+ if event["alertStatus"] != "resolved" {
+ t.Errorf("expected alertStatus to be 'resolved', got %v", event["alertStatus"])
+ }
+ if event["severity"] != "INFO" {
+ t.Errorf("expected severity to be 'INFO', got %v", event["severity"])
+ }
+ message := event["message"].(string)
+ if !strings.Contains(message, "resolved") {
+ t.Errorf("expected message to contain 'resolved', got %s", message)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "error-response",
+ provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
+ }),
+ expectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
+ err := scenario.provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
diff --git a/alerting/provider/plivo/plivo.go b/alerting/provider/plivo/plivo.go
new file mode 100644
index 00000000..a5fb3139
--- /dev/null
+++ b/alerting/provider/plivo/plivo.go
@@ -0,0 +1,183 @@
+package plivo
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrAuthIDNotSet = errors.New("auth-id not set")
+ ErrAuthTokenNotSet = errors.New("auth-token not set")
+ ErrFromNotSet = errors.New("from not set")
+ ErrToNotSet = errors.New("to not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ AuthID string `yaml:"auth-id"`
+ AuthToken string `yaml:"auth-token"`
+ From string `yaml:"from"`
+ To []string `yaml:"to"`
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.AuthID) == 0 {
+ return ErrAuthIDNotSet
+ }
+ if len(cfg.AuthToken) == 0 {
+ return ErrAuthTokenNotSet
+ }
+ if len(cfg.From) == 0 {
+ return ErrFromNotSet
+ }
+ if len(cfg.To) == 0 {
+ return ErrToNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.AuthID) > 0 {
+ cfg.AuthID = override.AuthID
+ }
+ if len(override.AuthToken) > 0 {
+ cfg.AuthToken = override.AuthToken
+ }
+ if len(override.From) > 0 {
+ cfg.From = override.From
+ }
+ if len(override.To) > 0 {
+ cfg.To = override.To
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using Plivo
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ message := provider.buildMessage(cfg, ep, alert, result, resolved)
+ // Send individual SMS messages to each recipient
+ for _, to := range cfg.To {
+ if err := provider.sendSMS(cfg, to, message); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// sendSMS sends an SMS message to a single recipient
+func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
+ payload := map[string]string{
+ "src": cfg.From,
+ "dst": to,
+ "text": message,
+ }
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.plivo.com/v1/Account/%s/Message/", cfg.AuthID), bytes.NewBuffer(payloadBytes))
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.AuthID+":"+cfg.AuthToken))))
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to plivo alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return nil
+}
+
+// buildMessage builds the message for the provider
+func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
+ if resolved {
+ return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
+ } else {
+ return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
+ }
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/plivo/plivo_test.go b/alerting/provider/plivo/plivo_test.go
new file mode 100644
index 00000000..148a7036
--- /dev/null
+++ b/alerting/provider/plivo/plivo_test.go
@@ -0,0 +1,514 @@
+package plivo
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestPlivoAlertProvider_IsValid(t *testing.T) {
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ ExpectedError error
+ }{
+ {
+ Name: "invalid-provider-missing-config",
+ Provider: AlertProvider{},
+ ExpectedError: ErrAuthIDNotSet,
+ },
+ {
+ Name: "valid-provider",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ AuthID: "1",
+ AuthToken: "1",
+ From: "1234567890",
+ To: []string{"0987654321"},
+ },
+ },
+ ExpectedError: nil,
+ },
+ {
+ Name: "valid-provider-with-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ AuthID: "1",
+ AuthToken: "1",
+ From: "1234567890",
+ To: []string{"0987654321"},
+ },
+ Overrides: []Override{
+ {
+ Group: "group1",
+ Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
+ },
+ },
+ },
+ ExpectedError: nil,
+ },
+ {
+ Name: "invalid-provider-duplicate-group-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ AuthID: "1",
+ AuthToken: "1",
+ From: "1234567890",
+ To: []string{"0987654321"},
+ },
+ Overrides: []Override{
+ {
+ Group: "group1",
+ Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
+ },
+ {
+ Group: "group1",
+ Config: Config{AuthID: "3", AuthToken: "3", From: "4444444444", To: []string{"5555555555"}},
+ },
+ },
+ },
+ ExpectedError: ErrDuplicateGroupOverride,
+ },
+ {
+ Name: "invalid-provider-empty-group-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ AuthID: "1",
+ AuthToken: "1",
+ From: "1234567890",
+ To: []string{"0987654321"},
+ },
+ Overrides: []Override{
+ {
+ Group: "",
+ Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
+ },
+ },
+ },
+ ExpectedError: ErrDuplicateGroupOverride,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ err := scenario.Provider.Validate()
+ if scenario.ExpectedError == nil && err != nil {
+ t.Errorf("expected no error, got %v", err)
+ }
+ if scenario.ExpectedError != nil && err == nil {
+ t.Errorf("expected error %v, got none", scenario.ExpectedError)
+ }
+ if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
+ t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ Alert alert.Alert
+ Resolved bool
+ MockRoundTripper test.MockRoundTripper
+ ExpectedError bool
+ }{
+ {
+ Name: "triggered",
+ Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
+ }),
+ ExpectedError: false,
+ },
+ {
+ Name: "triggered-error",
+ Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+ }),
+ ExpectedError: true,
+ },
+ {
+ Name: "resolved",
+ Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
+ }),
+ ExpectedError: false,
+ },
+ {
+ Name: "resolved-error",
+ Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+ }),
+ ExpectedError: true,
+ },
+ {
+ Name: "multiple-recipients",
+ Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321", "1122334455"}}},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
+ }),
+ ExpectedError: false,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
+ err := scenario.Provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.Alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_buildMessage(t *testing.T) {
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ Alert alert.Alert
+ Resolved bool
+ ExpectedMessage string
+ }{
+ {
+ Name: "triggered",
+ Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
+ },
+ {
+ Name: "resolved",
+ Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ ExpectedMessage: "RESOLVED: endpoint-name - description-2",
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ message := scenario.Provider.buildMessage(
+ &scenario.Provider.DefaultConfig,
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.Alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.ConditionResult{
+ {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
+ {Condition: "[STATUS] == 200", Success: scenario.Resolved},
+ },
+ },
+ scenario.Resolved,
+ )
+ if message != scenario.ExpectedMessage {
+ t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_sendSMS(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ cfg := &Config{
+ AuthID: "test-auth-id",
+ AuthToken: "test-auth-token",
+ From: "1234567890",
+ }
+ scenarios := []struct {
+ Name string
+ To string
+ Message string
+ MockRoundTripper test.MockRoundTripper
+ ExpectedError bool
+ }{
+ {
+ Name: "successful-sms",
+ To: "0987654321",
+ Message: "Test message",
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ // Verify request structure
+ body, _ := io.ReadAll(r.Body)
+ var payload map[string]string
+ json.Unmarshal(body, &payload)
+ if payload["src"] != cfg.From {
+ t.Errorf("expected src %s, got %s", cfg.From, payload["src"])
+ }
+ if payload["dst"] != "0987654321" {
+ t.Errorf("expected dst %s, got %s", "0987654321", payload["dst"])
+ }
+ if payload["text"] != "Test message" {
+ t.Errorf("expected text %s, got %s", "Test message", payload["text"])
+ }
+ return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
+ }),
+ ExpectedError: false,
+ },
+ {
+ Name: "failed-sms",
+ To: "0987654321",
+ Message: "Test message",
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
+ }),
+ ExpectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
+ provider := AlertProvider{}
+ err := provider.sendSMS(cfg, scenario.To, scenario.Message)
+ 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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
+
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ InputGroup string
+ InputAlert alert.Alert
+ ExpectedOutput Config
+ }{
+ {
+ Name: "provider-no-override-should-default",
+ Provider: AlertProvider{
+ DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
+ },
+ InputGroup: "",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
+ },
+ {
+ Name: "provider-with-group-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
+ Overrides: []Override{
+ {
+ Group: "group1",
+ Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
+ },
+ },
+ },
+ InputGroup: "group1",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
+ },
+ {
+ Name: "provider-with-group-override-no-match",
+ Provider: AlertProvider{
+ DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
+ Overrides: []Override{
+ {
+ Group: "group1",
+ Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
+ },
+ },
+ },
+ InputGroup: "group2",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
+ },
+ {
+ Name: "provider-with-alert-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
+ },
+ InputGroup: "",
+ InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6", "from": "5555555555", "to": []string{"9999999999"}}},
+ ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "5555555555", To: []string{"9999999999"}},
+ },
+ {
+ Name: "provider-with-group-and-alert-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
+ Overrides: []Override{
+ {
+ Group: "group1",
+ Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
+ },
+ },
+ },
+ InputGroup: "group1",
+ InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6"}},
+ ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "3333333333", To: []string{"7777777777"}},
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ if err != nil {
+ t.Error("expected no error, got:", err.Error())
+ }
+ if got.AuthID != scenario.ExpectedOutput.AuthID {
+ t.Errorf("expected AuthID to be %s, got %s", scenario.ExpectedOutput.AuthID, got.AuthID)
+ }
+ if got.AuthToken != scenario.ExpectedOutput.AuthToken {
+ t.Errorf("expected AuthToken to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
+ }
+ if got.From != scenario.ExpectedOutput.From {
+ t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
+ }
+ if len(got.To) != len(scenario.ExpectedOutput.To) {
+ t.Errorf("expected To length to be %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
+ }
+ for i, to := range got.To {
+ if to != scenario.ExpectedOutput.To[i] {
+ t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
+ }
+ }
+ // Test ValidateOverrides as well, since it really just calls GetConfig
+ if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+ t.Errorf("unexpected error: %s", err)
+ }
+ })
+ }
+}
+
+func TestConfig_Validate(t *testing.T) {
+ scenarios := []struct {
+ Name string
+ Config Config
+ ExpectedError error
+ }{
+ {
+ Name: "valid-config",
+ Config: Config{
+ AuthID: "test-auth-id",
+ AuthToken: "test-auth-token",
+ From: "1234567890",
+ To: []string{"0987654321"},
+ },
+ ExpectedError: nil,
+ },
+ {
+ Name: "missing-auth-id",
+ Config: Config{
+ AuthToken: "test-auth-token",
+ From: "1234567890",
+ To: []string{"0987654321"},
+ },
+ ExpectedError: ErrAuthIDNotSet,
+ },
+ {
+ Name: "missing-auth-token",
+ Config: Config{
+ AuthID: "test-auth-id",
+ From: "1234567890",
+ To: []string{"0987654321"},
+ },
+ ExpectedError: ErrAuthTokenNotSet,
+ },
+ {
+ Name: "missing-from",
+ Config: Config{
+ AuthID: "test-auth-id",
+ AuthToken: "test-auth-token",
+ To: []string{"0987654321"},
+ },
+ ExpectedError: ErrFromNotSet,
+ },
+ {
+ Name: "missing-to",
+ Config: Config{
+ AuthID: "test-auth-id",
+ AuthToken: "test-auth-token",
+ From: "1234567890",
+ },
+ ExpectedError: ErrToNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ err := scenario.Config.Validate()
+ if scenario.ExpectedError == nil && err != nil {
+ t.Errorf("expected no error, got %v", err)
+ }
+ if scenario.ExpectedError != nil && err == nil {
+ t.Errorf("expected error %v, got none", scenario.ExpectedError)
+ }
+ if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
+ t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
+ }
+ })
+ }
+}
+
+func TestConfig_Merge(t *testing.T) {
+ cfg := Config{
+ AuthID: "original-auth-id",
+ AuthToken: "original-auth-token",
+ From: "1111111111",
+ To: []string{"2222222222"},
+ }
+ override := Config{
+ AuthID: "override-auth-id",
+ AuthToken: "override-auth-token",
+ From: "3333333333",
+ To: []string{"4444444444", "5555555555"},
+ }
+ cfg.Merge(&override)
+ if cfg.AuthID != "override-auth-id" {
+ t.Errorf("expected AuthID to be %s, got %s", "override-auth-id", cfg.AuthID)
+ }
+ if cfg.AuthToken != "override-auth-token" {
+ t.Errorf("expected AuthToken to be %s, got %s", "override-auth-token", cfg.AuthToken)
+ }
+ if cfg.From != "3333333333" {
+ t.Errorf("expected From to be %s, got %s", "3333333333", cfg.From)
+ }
+ if len(cfg.To) != 2 || cfg.To[0] != "4444444444" || cfg.To[1] != "5555555555" {
+ t.Errorf("expected To to be [4444444444, 5555555555], got %v", cfg.To)
+ }
+}
diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go
index 1b76dfd5..5ab9dc4a 100644
--- a/alerting/provider/provider.go
+++ b/alerting/provider/provider.go
@@ -4,29 +4,42 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
+ "github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
- "github.com/TwiN/gatus/v5/alerting/provider/gotify"
+ "github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
- "github.com/TwiN/gatus/v5/alerting/provider/ilert"
+ "github.com/TwiN/gatus/v5/alerting/provider/ifttt"
+ "github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
+ "github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
+ "github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
+ "github.com/TwiN/gatus/v5/alerting/provider/plivo"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
+ "github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
+ "github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
+ "github.com/TwiN/gatus/v5/alerting/provider/signal"
+ "github.com/TwiN/gatus/v5/alerting/provider/signl4"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
+ "github.com/TwiN/gatus/v5/alerting/provider/splunk"
+ "github.com/TwiN/gatus/v5/alerting/provider/squadcast"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
+ "github.com/TwiN/gatus/v5/alerting/provider/webex"
+ "github.com/TwiN/gatus/v5/alerting/provider/zapier"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/config/endpoint"
)
@@ -77,56 +90,82 @@ var (
// Validate provider interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil)
+ _ AlertProvider = (*datadog.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
- _ AlertProvider = (*gotify.AlertProvider)(nil)
+ _ AlertProvider = (*gotify.AlertProvider)(nil)
_ AlertProvider = (*homeassistant.AlertProvider)(nil)
- _ AlertProvider = (*ilert.AlertProvider)(nil)
+ _ AlertProvider = (*ifttt.AlertProvider)(nil)
+ _ AlertProvider = (*ilert.AlertProvider)(nil)
_ AlertProvider = (*incidentio.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
+ _ AlertProvider = (*line.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
+ _ AlertProvider = (*newrelic.AlertProvider)(nil)
_ AlertProvider = (*ntfy.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
+ _ AlertProvider = (*plivo.AlertProvider)(nil)
_ AlertProvider = (*pushover.AlertProvider)(nil)
+ _ AlertProvider = (*rocketchat.AlertProvider)(nil)
+ _ AlertProvider = (*sendgrid.AlertProvider)(nil)
+ _ AlertProvider = (*signal.AlertProvider)(nil)
+ _ AlertProvider = (*signl4.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)
+ _ AlertProvider = (*splunk.AlertProvider)(nil)
+ _ AlertProvider = (*squadcast.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil)
+ _ AlertProvider = (*webex.AlertProvider)(nil)
+ _ AlertProvider = (*zapier.AlertProvider)(nil)
_ AlertProvider = (*zulip.AlertProvider)(nil)
// Validate config interface implementation on compile
_ Config[awsses.Config] = (*awsses.Config)(nil)
_ Config[custom.Config] = (*custom.Config)(nil)
+ _ Config[datadog.Config] = (*datadog.Config)(nil)
_ Config[discord.Config] = (*discord.Config)(nil)
_ Config[email.Config] = (*email.Config)(nil)
_ Config[gitea.Config] = (*gitea.Config)(nil)
_ Config[github.Config] = (*github.Config)(nil)
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
- _ Config[gotify.Config] = (*gotify.Config)(nil)
+ _ Config[gotify.Config] = (*gotify.Config)(nil)
_ Config[homeassistant.Config] = (*homeassistant.Config)(nil)
- _ Config[ilert.Config] = (*ilert.Config)(nil)
+ _ Config[ifttt.Config] = (*ifttt.Config)(nil)
+ _ Config[ilert.Config] = (*ilert.Config)(nil)
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
+ _ Config[line.Config] = (*line.Config)(nil)
_ Config[matrix.Config] = (*matrix.Config)(nil)
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
+ _ Config[newrelic.Config] = (*newrelic.Config)(nil)
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
+ _ Config[plivo.Config] = (*plivo.Config)(nil)
_ Config[pushover.Config] = (*pushover.Config)(nil)
+ _ Config[rocketchat.Config] = (*rocketchat.Config)(nil)
+ _ Config[sendgrid.Config] = (*sendgrid.Config)(nil)
+ _ Config[signal.Config] = (*signal.Config)(nil)
+ _ Config[signl4.Config] = (*signl4.Config)(nil)
_ Config[slack.Config] = (*slack.Config)(nil)
+ _ Config[splunk.Config] = (*splunk.Config)(nil)
+ _ Config[squadcast.Config] = (*squadcast.Config)(nil)
_ Config[teams.Config] = (*teams.Config)(nil)
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
_ Config[telegram.Config] = (*telegram.Config)(nil)
_ Config[twilio.Config] = (*twilio.Config)(nil)
+ _ Config[webex.Config] = (*webex.Config)(nil)
+ _ Config[zapier.Config] = (*zapier.Config)(nil)
_ Config[zulip.Config] = (*zulip.Config)(nil)
)
diff --git a/alerting/provider/pushover/pushover.go b/alerting/provider/pushover/pushover.go
index efa3be6d..4c8fdd02 100644
--- a/alerting/provider/pushover/pushover.go
+++ b/alerting/provider/pushover/pushover.go
@@ -15,7 +15,7 @@ import (
)
const (
- restAPIURL = "https://api.pushover.net/1/messages.json"
+ ApiURL = "https://api.pushover.net/1/messages.json"
defaultPriority = 0
)
@@ -76,9 +76,9 @@ func (cfg *Config) Validate() error {
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
return ErrInvalidPriority
}
- if len(cfg.Device) > 25 {
- return ErrInvalidDevice
- }
+ if len(cfg.Device) > 25 {
+ return ErrInvalidDevice
+ }
return nil
}
@@ -104,9 +104,9 @@ func (cfg *Config) Merge(override *Config) {
if override.TTL > 0 {
cfg.TTL = override.TTL
}
- if len(override.Device) > 0 {
- cfg.Device = override.Device
- }
+ if len(override.Device) > 0 {
+ cfg.Device = override.Device
+ }
}
// AlertProvider is the configuration necessary for sending an alert using Pushover
@@ -130,7 +130,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
- request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
+ request, err := http.NewRequest(http.MethodPost, ApiURL, buffer)
if err != nil {
return err
}
diff --git a/alerting/provider/rocketchat/rocketchat.go b/alerting/provider/rocketchat/rocketchat.go
new file mode 100644
index 00000000..53f3645a
--- /dev/null
+++ b/alerting/provider/rocketchat/rocketchat.go
@@ -0,0 +1,212 @@
+package rocketchat
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrWebhookURLNotSet = errors.New("webhook-url not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookURL string `yaml:"webhook-url"` // Rocket.Chat incoming webhook URL
+ Channel string `yaml:"channel,omitempty"` // Optional channel override
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.WebhookURL) == 0 {
+ return ErrWebhookURLNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.WebhookURL) > 0 {
+ cfg.WebhookURL = override.WebhookURL
+ }
+ if len(override.Channel) > 0 {
+ cfg.Channel = override.Channel
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using Rocket.Chat
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to rocketchat alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return nil
+}
+
+type Body struct {
+ Text string `json:"text"`
+ Channel string `json:"channel,omitempty"`
+ Username string `json:"username"`
+ Attachments []Attachment `json:"attachments"`
+}
+
+type Attachment struct {
+ Title string `json:"title"`
+ Text string `json:"text"`
+ Color string `json:"color"`
+ Fields []Field `json:"fields,omitempty"`
+ AuthorName string `json:"author_name"`
+ AuthorIcon string `json:"author_icon"`
+}
+
+type Field struct {
+ Title string `json:"title"`
+ Value string `json:"value"`
+ Short bool `json:"short"`
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
+ var message, color string
+ if resolved {
+ message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
+ color = "#36a64f"
+ } else {
+ message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
+ color = "#dd0000"
+ }
+ var formattedConditionResults string
+ for _, conditionResult := range result.ConditionResults {
+ var prefix string
+ if conditionResult.Success {
+ prefix = "✅"
+ } else {
+ prefix = "❌"
+ }
+ formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
+ }
+ var description string
+ if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
+ description = ":\n> " + alertDescription
+ }
+ body := Body{
+ Text: "",
+ Username: "Gatus",
+ Attachments: []Attachment{
+ {
+ Title: "🚨 Gatus Alert",
+ Text: message + description,
+ Color: color,
+ AuthorName: "Gatus",
+ AuthorIcon: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
+ },
+ },
+ }
+ if cfg.Channel != "" {
+ body.Channel = cfg.Channel
+ }
+ if len(formattedConditionResults) > 0 {
+ body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
+ Title: "Condition results",
+ Value: formattedConditionResults,
+ Short: false,
+ })
+ }
+ bodyAsJSON, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/rocketchat/rocketchat_test.go b/alerting/provider/rocketchat/rocketchat_test.go
new file mode 100644
index 00000000..fea159d1
--- /dev/null
+++ b/alerting/provider/rocketchat/rocketchat_test.go
@@ -0,0 +1,164 @@
+package rocketchat
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ expected error
+ }{
+ {
+ name: "valid",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
+ expected: nil,
+ },
+ {
+ name: "valid-with-channel",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
+ expected: nil,
+ },
+ {
+ name: "invalid-webhook-url",
+ provider: AlertProvider{DefaultConfig: Config{}},
+ expected: ErrWebhookURLNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.provider.Validate()
+ if err != scenario.expected {
+ t.Errorf("expected %v, got %v", scenario.expected, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ alert alert.Alert
+ resolved bool
+ mockRoundTripper test.MockRoundTripper
+ expectedError bool
+ }{
+ {
+ name: "triggered",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["username"] != "Gatus" {
+ t.Errorf("expected username to be 'Gatus', got %v", body["username"])
+ }
+ attachments := body["attachments"].([]interface{})
+ if len(attachments) != 1 {
+ t.Errorf("expected 1 attachment, got %d", len(attachments))
+ }
+ attachment := attachments[0].(map[string]interface{})
+ if attachment["color"] != "#dd0000" {
+ t.Errorf("expected color to be '#dd0000', got %v", attachment["color"])
+ }
+ text := attachment["text"].(string)
+ if !strings.Contains(text, "failed 3 time(s)") {
+ t.Errorf("expected text to contain failure count, got %s", text)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "triggered-with-channel",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["channel"] != "#alerts" {
+ t.Errorf("expected channel to be '#alerts', got %v", body["channel"])
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "resolved",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
+ alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: true,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ attachments := body["attachments"].([]interface{})
+ attachment := attachments[0].(map[string]interface{})
+ if attachment["color"] != "#36a64f" {
+ t.Errorf("expected color to be '#36a64f', got %v", attachment["color"])
+ }
+ text := attachment["text"].(string)
+ if !strings.Contains(text, "resolved") {
+ t.Errorf("expected text to contain 'resolved', got %s", text)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "error-response",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
+ }),
+ expectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
+ err := scenario.provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
diff --git a/alerting/provider/sendgrid/sendgrid.go b/alerting/provider/sendgrid/sendgrid.go
new file mode 100644
index 00000000..67c1ef0e
--- /dev/null
+++ b/alerting/provider/sendgrid/sendgrid.go
@@ -0,0 +1,248 @@
+package sendgrid
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+const (
+ ApiURL = "https://api.sendgrid.com/v3/mail/send"
+)
+
+var (
+ ErrAPIKeyNotSet = errors.New("api-key not set")
+ ErrFromNotSet = errors.New("from not set")
+ ErrToNotSet = errors.New("to not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ APIKey string `yaml:"api-key"`
+ From string `yaml:"from"`
+ To string `yaml:"to"`
+
+ // ClientConfig is the configuration of the client used to communicate with the provider's target
+ ClientConfig *client.Config `yaml:"client,omitempty"`
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.APIKey) == 0 {
+ return ErrAPIKeyNotSet
+ }
+ if len(cfg.From) == 0 {
+ return ErrFromNotSet
+ }
+ if len(cfg.To) == 0 {
+ return ErrToNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if override.ClientConfig != nil {
+ cfg.ClientConfig = override.ClientConfig
+ }
+ if len(override.APIKey) > 0 {
+ cfg.APIKey = override.APIKey
+ }
+ if len(override.From) > 0 {
+ cfg.From = override.From
+ }
+ if len(override.To) > 0 {
+ cfg.To = override.To
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using SendGrid
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
+ payload := provider.buildSendGridPayload(cfg, subject, body)
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ request, err := http.NewRequest(http.MethodPost, ApiURL, bytes.NewBuffer(payloadBytes))
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Authorization", "Bearer "+cfg.APIKey)
+ response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to sendgrid alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return nil
+}
+
+type SendGridPayload struct {
+ Personalizations []Personalization `json:"personalizations"`
+ From Email `json:"from"`
+ Subject string `json:"subject"`
+ Content []Content `json:"content"`
+}
+
+type Personalization struct {
+ To []Email `json:"to"`
+}
+
+type Email struct {
+ Email string `json:"email"`
+}
+
+type Content struct {
+ Type string `json:"type"`
+ Value string `json:"value"`
+}
+
+// buildSendGridPayload builds the SendGrid API payload
+func (provider *AlertProvider) buildSendGridPayload(cfg *Config, subject, body string) SendGridPayload {
+ toEmails := strings.Split(cfg.To, ",")
+ var recipients []Email
+ for _, email := range toEmails {
+ recipients = append(recipients, Email{Email: strings.TrimSpace(email)})
+ }
+ return SendGridPayload{
+ Personalizations: []Personalization{
+ {
+ To: recipients,
+ },
+ },
+ From: Email{
+ Email: cfg.From,
+ },
+ Subject: subject,
+ Content: []Content{
+ {
+ Type: "text/plain",
+ Value: body,
+ },
+ {
+ Type: "text/html",
+ Value: strings.ReplaceAll(body, "\n", "
"),
+ },
+ },
+ }
+}
+
+// buildMessageSubjectAndBody builds the message subject and body
+func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
+ var subject, message string
+ if resolved {
+ subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
+ message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
+ } else {
+ subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
+ message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
+ }
+ var formattedConditionResults string
+ if len(result.ConditionResults) > 0 {
+ formattedConditionResults = "\n\nCondition results:\n"
+ for _, conditionResult := range result.ConditionResults {
+ var prefix string
+ if conditionResult.Success {
+ prefix = "✅"
+ } else {
+ prefix = "❌"
+ }
+ formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
+ }
+ }
+ var description string
+ if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
+ description = "\n\nAlert description: " + alertDescription
+ }
+ var extraLabels string
+ if len(ep.ExtraLabels) > 0 {
+ extraLabels = "\n\nExtra labels:\n"
+ for key, value := range ep.ExtraLabels {
+ extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
+ }
+ }
+ return subject, message + description + extraLabels + formattedConditionResults
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/sendgrid/sendgrid_test.go b/alerting/provider/sendgrid/sendgrid_test.go
new file mode 100644
index 00000000..0f80bc67
--- /dev/null
+++ b/alerting/provider/sendgrid/sendgrid_test.go
@@ -0,0 +1,517 @@
+package sendgrid
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: "", From: "", To: ""}}
+ if err := invalidProvider.Validate(); err == nil {
+ t.Error("provider shouldn't have been valid")
+ }
+ validProvider := AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}}
+ if err := validProvider.Validate(); err != nil {
+ t.Error("provider should've been valid")
+ }
+}
+
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
+ providerWithInvalidOverrideGroup := AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "SG.test",
+ From: "from@example.com",
+ To: "to@example.com",
+ },
+ Overrides: []Override{
+ {
+ Config: Config{To: "to@example.com"},
+ Group: "",
+ },
+ },
+ }
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
+ t.Error("provider with empty Group should not have been valid")
+ }
+ if err := providerWithInvalidOverrideGroup.Validate(); err != ErrDuplicateGroupOverride {
+ t.Error("provider with empty Group should return ErrDuplicateGroupOverride")
+ }
+ providerWithDuplicateOverrideGroups := AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "SG.test",
+ From: "from@example.com",
+ To: "to@example.com",
+ },
+ Overrides: []Override{
+ {
+ Config: Config{To: "to1@example.com"},
+ Group: "group",
+ },
+ {
+ Config: Config{To: "to2@example.com"},
+ Group: "group",
+ },
+ },
+ }
+ if err := providerWithDuplicateOverrideGroups.Validate(); err == nil {
+ t.Error("provider with duplicate group overrides should not have been valid")
+ }
+ if err := providerWithDuplicateOverrideGroups.Validate(); err != ErrDuplicateGroupOverride {
+ t.Error("provider with duplicate group overrides should return ErrDuplicateGroupOverride")
+ }
+ providerWithValidOverride := AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "SG.test",
+ From: "from@example.com",
+ To: "to@example.com",
+ },
+ Overrides: []Override{
+ {
+ Config: Config{To: "to@example.com"},
+ Group: "group",
+ },
+ },
+ }
+ if err := providerWithValidOverride.Validate(); err != nil {
+ t.Error("provider should've been valid")
+ }
+ providerWithValidMultipleOverrides := AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "SG.test",
+ From: "from@example.com",
+ To: "to@example.com",
+ },
+ Overrides: []Override{
+ {
+ Config: Config{To: "group1@example.com"},
+ Group: "group1",
+ },
+ {
+ Config: Config{To: "group2@example.com"},
+ Group: "group2",
+ },
+ },
+ }
+ if err := providerWithValidMultipleOverrides.Validate(); err != nil {
+ t.Error("provider with multiple valid overrides should've been valid")
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ Alert alert.Alert
+ Resolved bool
+ MockRoundTripper test.MockRoundTripper
+ ExpectedError bool
+ }{
+ {
+ Name: "triggered",
+ Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
+ }),
+ ExpectedError: false,
+ },
+ {
+ Name: "triggered-error",
+ Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(`{"errors": [{"message": "Invalid API key"}]}`))}
+ }),
+ ExpectedError: true,
+ },
+ {
+ Name: "resolved",
+ Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
+ }),
+ ExpectedError: false,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
+ err := scenario.Provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.Alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_buildSendGridPayload(t *testing.T) {
+ provider := &AlertProvider{}
+ cfg := &Config{
+ From: "test@example.com",
+ To: "to1@example.com,to2@example.com",
+ }
+ subject := "Test Subject"
+ body := "Test Body\nWith new line"
+ payload := provider.buildSendGridPayload(cfg, subject, body)
+ if payload.Subject != subject {
+ t.Errorf("expected subject to be %s, got %s", subject, payload.Subject)
+ }
+ if payload.From.Email != cfg.From {
+ t.Errorf("expected from email to be %s, got %s", cfg.From, payload.From.Email)
+ }
+ if len(payload.Personalizations) != 1 {
+ t.Errorf("expected 1 personalization, got %d", len(payload.Personalizations))
+ }
+ if len(payload.Personalizations[0].To) != 2 {
+ t.Errorf("expected 2 recipients, got %d", len(payload.Personalizations[0].To))
+ }
+ if payload.Personalizations[0].To[0].Email != "to1@example.com" {
+ t.Errorf("expected first recipient to be to1@example.com, got %s", payload.Personalizations[0].To[0].Email)
+ }
+ if payload.Personalizations[0].To[1].Email != "to2@example.com" {
+ t.Errorf("expected second recipient to be to2@example.com, got %s", payload.Personalizations[0].To[1].Email)
+ }
+ if len(payload.Content) != 2 {
+ t.Errorf("expected 2 content types, got %d", len(payload.Content))
+ }
+ if payload.Content[0].Type != "text/plain" {
+ t.Errorf("expected first content type to be text/plain, got %s", payload.Content[0].Type)
+ }
+ if payload.Content[0].Value != body {
+ t.Errorf("expected plain text content to be %s, got %s", body, payload.Content[0].Value)
+ }
+ if payload.Content[1].Type != "text/html" {
+ t.Errorf("expected second content type to be text/html, got %s", payload.Content[1].Type)
+ }
+ expectedHTML := "Test Body
With new line"
+ if payload.Content[1].Value != expectedHTML {
+ t.Errorf("expected HTML content to be %s, got %s", expectedHTML, payload.Content[1].Value)
+ }
+}
+
+func TestAlertProvider_buildMessageSubjectAndBody(t *testing.T) {
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ Alert alert.Alert
+ Resolved bool
+ Endpoint *endpoint.Endpoint
+ ExpectedSubject string
+ ExpectedBody string
+ }{
+ {
+ Name: "triggered",
+ Provider: AlertProvider{},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
+ ExpectedSubject: "[endpoint-name] Alert triggered",
+ ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
+ },
+ {
+ Name: "resolved",
+ Provider: AlertProvider{},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
+ ExpectedSubject: "[endpoint-name] Alert resolved",
+ ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
+ },
+ {
+ Name: "triggered-with-single-extra-label",
+ Provider: AlertProvider{},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
+ ExpectedSubject: "[endpoint-name] Alert triggered",
+ ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
+ },
+ {
+ Name: "resolved-with-single-extra-label",
+ Provider: AlertProvider{},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
+ ExpectedSubject: "[endpoint-name] Alert resolved",
+ ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
+ },
+ {
+ Name: "triggered-with-no-extra-labels",
+ Provider: AlertProvider{},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
+ ExpectedSubject: "[endpoint-name] Alert triggered",
+ ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ subject, body := scenario.Provider.buildMessageSubjectAndBody(
+ scenario.Endpoint,
+ &scenario.Alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.ConditionResult{
+ {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
+ {Condition: "[STATUS] == 200", Success: scenario.Resolved},
+ },
+ },
+ scenario.Resolved,
+ )
+ if subject != scenario.ExpectedSubject {
+ t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
+ }
+ if body != scenario.ExpectedBody {
+ t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
+
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ InputGroup string
+ InputAlert alert.Alert
+ ExpectedOutput Config
+ }{
+ {
+ Name: "provider-no-override-specify-no-group-should-default",
+ Provider: AlertProvider{
+ DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
+ Overrides: nil,
+ },
+ InputGroup: "",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
+ },
+ {
+ Name: "provider-no-override-specify-group-should-default",
+ Provider: AlertProvider{
+ DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
+ Overrides: nil,
+ },
+ InputGroup: "group",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
+ },
+ {
+ Name: "provider-with-override-specify-no-group-should-default",
+ Provider: AlertProvider{
+ DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
+ Overrides: []Override{
+ {
+ Group: "group",
+ Config: Config{To: "to01@example.com"},
+ },
+ },
+ },
+ InputGroup: "",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
+ },
+ {
+ Name: "provider-with-override-specify-group-should-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
+ Overrides: []Override{
+ {
+ Group: "group",
+ Config: Config{To: "group-to@example.com"},
+ },
+ },
+ },
+ InputGroup: "group",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "group-to@example.com"},
+ },
+ {
+ Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
+ Provider: AlertProvider{
+ DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
+ Overrides: []Override{
+ {
+ Group: "group",
+ Config: Config{To: "group-to@example.com"},
+ },
+ },
+ },
+ InputGroup: "group",
+ InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "SG.override", "to": "alert-to@example.com", "from": "alert-from@example.com"}},
+ ExpectedOutput: Config{APIKey: "SG.override", From: "alert-from@example.com", To: "alert-to@example.com"},
+ },
+ {
+ Name: "provider-with-multiple-overrides-pick-correct-group",
+ Provider: AlertProvider{
+ DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
+ Overrides: []Override{
+ {
+ Group: "group1",
+ Config: Config{APIKey: "SG.group1", To: "group1@example.com"},
+ },
+ {
+ Group: "group2",
+ Config: Config{APIKey: "SG.group2", From: "group2@example.com"},
+ },
+ },
+ },
+ InputGroup: "group2",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{APIKey: "SG.group2", From: "group2@example.com", To: "default@example.com"},
+ },
+ {
+ Name: "provider-partial-override-hierarchy",
+ Provider: AlertProvider{
+ DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
+ Overrides: []Override{
+ {
+ Group: "test-group",
+ Config: Config{From: "group@example.com"},
+ },
+ },
+ },
+ InputGroup: "test-group",
+ InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert@example.com"}},
+ ExpectedOutput: Config{APIKey: "SG.default", From: "group@example.com", To: "alert@example.com"},
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if got.APIKey != scenario.ExpectedOutput.APIKey {
+ t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
+ }
+ if got.From != scenario.ExpectedOutput.From {
+ t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
+ }
+ if got.To != scenario.ExpectedOutput.To {
+ t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
+ }
+ // Test ValidateOverrides as well, since it really just calls GetConfig
+ if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+ t.Errorf("unexpected error: %s", err)
+ }
+ })
+ }
+}
+
+func TestConfig_Validate(t *testing.T) {
+ scenarios := []struct {
+ Name string
+ Config Config
+ ExpectedError error
+ }{
+ {
+ Name: "missing-api-key",
+ Config: Config{APIKey: "", From: "test@example.com", To: "to@example.com"},
+ ExpectedError: ErrAPIKeyNotSet,
+ },
+ {
+ Name: "missing-from",
+ Config: Config{APIKey: "SG.test", From: "", To: "to@example.com"},
+ ExpectedError: ErrFromNotSet,
+ },
+ {
+ Name: "missing-to",
+ Config: Config{APIKey: "SG.test", From: "test@example.com", To: ""},
+ ExpectedError: ErrToNotSet,
+ },
+ {
+ Name: "valid-config",
+ Config: Config{APIKey: "SG.test", From: "test@example.com", To: "to@example.com"},
+ ExpectedError: nil,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ err := scenario.Config.Validate()
+ if scenario.ExpectedError == nil && err != nil {
+ t.Errorf("expected no error, got %v", err)
+ }
+ if scenario.ExpectedError != nil && err == nil {
+ t.Errorf("expected error %v, got none", scenario.ExpectedError)
+ }
+ if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
+ t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
+ }
+ })
+ }
+}
+
+func TestConfig_Merge(t *testing.T) {
+ config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
+ override := Config{APIKey: "SG.override", To: "override@example.com"}
+ config.Merge(&override)
+ if config.APIKey != "SG.override" {
+ t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
+ }
+ if config.From != "from@example.com" {
+ t.Errorf("expected From to remain from@example.com, got %s", config.From)
+ }
+ if config.To != "override@example.com" {
+ t.Errorf("expected To to be override@example.com, got %s", config.To)
+ }
+}
+
+func TestConfig_MergeWithClientConfig(t *testing.T) {
+ config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
+ override := Config{APIKey: "SG.override", ClientConfig: &client.Config{Timeout: 30000}}
+ config.Merge(&override)
+ if config.APIKey != "SG.override" {
+ t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
+ }
+ if config.ClientConfig == nil {
+ t.Error("expected ClientConfig to be set")
+ }
+ if config.ClientConfig.Timeout != 30000 {
+ t.Errorf("expected ClientConfig.Timeout to be 30000, got %d", config.ClientConfig.Timeout)
+ }
+ config2 := Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com", ClientConfig: &client.Config{Timeout: 10000}}
+ override2 := Config{APIKey: "SG.override2"}
+ config2.Merge(&override2)
+ if config2.ClientConfig.Timeout != 10000 {
+ t.Errorf("expected ClientConfig.Timeout to remain 10000, got %d", config2.ClientConfig.Timeout)
+ }
+}
\ No newline at end of file
diff --git a/alerting/provider/signal/signal.go b/alerting/provider/signal/signal.go
new file mode 100644
index 00000000..2215e6ad
--- /dev/null
+++ b/alerting/provider/signal/signal.go
@@ -0,0 +1,192 @@
+package signal
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrApiURLNotSet = errors.New("api-url not set")
+ ErrNumberNotSet = errors.New("number not set")
+ ErrRecipientsNotSet = errors.New("recipients not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ ApiURL string `yaml:"api-url"` // Signal API URL (e.g., signal-cli-rest-api instance)
+ Number string `yaml:"number"` // Sender phone number
+ Recipients []string `yaml:"recipients"` // List of recipient phone numbers
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.ApiURL) == 0 {
+ return ErrApiURLNotSet
+ }
+ if len(cfg.Number) == 0 {
+ return ErrNumberNotSet
+ }
+ if len(cfg.Recipients) == 0 {
+ return ErrRecipientsNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.ApiURL) > 0 {
+ cfg.ApiURL = override.ApiURL
+ }
+ if len(override.Number) > 0 {
+ cfg.Number = override.Number
+ }
+ if len(override.Recipients) > 0 {
+ cfg.Recipients = override.Recipients
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using Signal
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ for _, recipient := range cfg.Recipients {
+ body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved, recipient)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/v2/send", cfg.ApiURL), buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ response.Body.Close()
+ return fmt.Errorf("call to signal alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ response.Body.Close()
+ }
+ return nil
+}
+
+type Body struct {
+ Message string `json:"message"`
+ Number string `json:"number"`
+ Recipients []string `json:"recipients"`
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, recipient string) ([]byte, error) {
+ var message string
+ if resolved {
+ message = fmt.Sprintf("🟢 RESOLVED: %s\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
+ } else {
+ message = fmt.Sprintf("🔴 ALERT: %s\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
+ }
+ if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
+ message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
+ }
+ if len(result.ConditionResults) > 0 {
+ message += "\n\nCondition results:"
+ for _, conditionResult := range result.ConditionResults {
+ var status string
+ if conditionResult.Success {
+ status = "✅"
+ } else {
+ status = "❌"
+ }
+ message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
+ }
+ }
+ body := Body{
+ Message: message,
+ Number: cfg.Number,
+ Recipients: []string{recipient},
+ }
+ bodyAsJSON, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/signal/signal_test.go b/alerting/provider/signal/signal_test.go
new file mode 100644
index 00000000..88c9991a
--- /dev/null
+++ b/alerting/provider/signal/signal_test.go
@@ -0,0 +1,151 @@
+package signal
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ expected error
+ }{
+ {
+ name: "valid",
+ provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
+ expected: nil,
+ },
+ {
+ name: "invalid-api-url",
+ provider: AlertProvider{DefaultConfig: Config{Number: "+1234567890", Recipients: []string{"+0987654321"}}},
+ expected: ErrApiURLNotSet,
+ },
+ {
+ name: "invalid-number",
+ provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Recipients: []string{"+0987654321"}}},
+ expected: ErrNumberNotSet,
+ },
+ {
+ name: "invalid-recipients",
+ provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890"}},
+ expected: ErrRecipientsNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.provider.Validate()
+ if err != scenario.expected {
+ t.Errorf("expected %v, got %v", scenario.expected, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ alert alert.Alert
+ resolved bool
+ mockRoundTripper test.MockRoundTripper
+ expectedError bool
+ }{
+ {
+ name: "triggered",
+ provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321", "+1111111111"}}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ if r.URL.Path != "/v2/send" {
+ t.Errorf("expected path /v2/send, got %s", r.URL.Path)
+ }
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["number"] != "+1234567890" {
+ t.Errorf("expected number to be '+1234567890', got %v", body["number"])
+ }
+ recipients := body["recipients"].([]interface{})
+ if len(recipients) != 1 {
+ t.Errorf("expected 1 recipient per request, got %d", len(recipients))
+ }
+ message := body["message"].(string)
+ if !strings.Contains(message, "ALERT") {
+ t.Errorf("expected message to contain 'ALERT', got %s", message)
+ }
+ if !strings.Contains(message, "failed 3 time(s)") {
+ t.Errorf("expected message to contain failure count, got %s", message)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "resolved",
+ provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
+ alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: true,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ message := body["message"].(string)
+ if !strings.Contains(message, "RESOLVED") {
+ t.Errorf("expected message to contain 'RESOLVED', got %s", message)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "error-response",
+ provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+ }),
+ expectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
+ err := scenario.provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
diff --git a/alerting/provider/signl4/signl4.go b/alerting/provider/signl4/signl4.go
new file mode 100644
index 00000000..e59676fa
--- /dev/null
+++ b/alerting/provider/signl4/signl4.go
@@ -0,0 +1,184 @@
+package signl4
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrTeamSecretNotSet = errors.New("team-secret not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ TeamSecret string `yaml:"team-secret"` // SIGNL4 team secret
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.TeamSecret) == 0 {
+ return ErrTeamSecretNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.TeamSecret) > 0 {
+ cfg.TeamSecret = override.TeamSecret
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using SIGNL4
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ body, err := provider.buildRequestBody(ep, alert, result, resolved)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ webhookURL := fmt.Sprintf("https://connect.signl4.com/webhook/%s", cfg.TeamSecret)
+ request, err := http.NewRequest(http.MethodPost, webhookURL, buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to signl4 alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return nil
+}
+
+type Body struct {
+ Title string `json:"Title"`
+ Message string `json:"Message"`
+ XS4Service string `json:"X-S4-Service"`
+ XS4Status string `json:"X-S4-Status"`
+ XS4ExternalID string `json:"X-S4-ExternalID"`
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
+ var title, message, status string
+ if resolved {
+ title = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
+ message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
+ status = "resolved"
+ } else {
+ title = fmt.Sprintf("TRIGGERED: %s", ep.DisplayName())
+ message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
+ status = "new"
+ }
+ var conditionResults string
+ if len(result.ConditionResults) > 0 {
+ conditionResults = "\n\nCondition results:\n"
+ for _, conditionResult := range result.ConditionResults {
+ var prefix string
+ if conditionResult.Success {
+ prefix = "✓"
+ } else {
+ prefix = "✗"
+ }
+ conditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
+ }
+ }
+ if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
+ message += "\n\nDescription: " + alertDescription
+ }
+ message += conditionResults
+ body := Body{
+ Title: title,
+ Message: message,
+ XS4Service: ep.DisplayName(),
+ XS4Status: status,
+ XS4ExternalID: fmt.Sprintf("gatus-%s", ep.Key()),
+ }
+ bodyAsJSON, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
\ No newline at end of file
diff --git a/alerting/provider/signl4/signl4_test.go b/alerting/provider/signl4/signl4_test.go
new file mode 100644
index 00000000..9a0f22da
--- /dev/null
+++ b/alerting/provider/signl4/signl4_test.go
@@ -0,0 +1,392 @@
+package signl4
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ invalidProvider := AlertProvider{DefaultConfig: Config{TeamSecret: ""}}
+ if err := invalidProvider.Validate(); err == nil {
+ t.Error("provider shouldn't have been valid")
+ }
+ validProvider := AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}}
+ if err := validProvider.Validate(); err != nil {
+ t.Error("provider should've been valid")
+ }
+}
+
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
+ providerWithInvalidOverrideGroup := AlertProvider{
+ Overrides: []Override{
+ {
+ Config: Config{TeamSecret: "team-secret-123"},
+ Group: "",
+ },
+ },
+ }
+ if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
+ t.Error("provider Group shouldn't have been valid")
+ }
+ providerWithInvalidOverrideTo := AlertProvider{
+ Overrides: []Override{
+ {
+ Config: Config{TeamSecret: ""},
+ Group: "group",
+ },
+ },
+ }
+ if err := providerWithInvalidOverrideTo.Validate(); err == nil {
+ t.Error("provider team secret shouldn't have been valid")
+ }
+ providerWithValidOverride := AlertProvider{
+ DefaultConfig: Config{TeamSecret: "team-secret-123"},
+ Overrides: []Override{
+ {
+ Config: Config{TeamSecret: "team-secret-override"},
+ Group: "group",
+ },
+ },
+ }
+ if err := providerWithValidOverride.Validate(); err != nil {
+ t.Error("provider should've been valid")
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ Alert alert.Alert
+ Resolved bool
+ MockRoundTripper test.MockRoundTripper
+ ExpectedError bool
+ }{
+ {
+ Name: "triggered",
+ Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ ExpectedError: false,
+ },
+ {
+ Name: "triggered-error",
+ Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+ }),
+ ExpectedError: true,
+ },
+ {
+ Name: "resolved",
+ Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ ExpectedError: false,
+ },
+ {
+ Name: "resolved-error",
+ Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+ }),
+ ExpectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
+ err := scenario.Provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.Alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_buildRequestBody(t *testing.T) {
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ Endpoint endpoint.Endpoint
+ Alert alert.Alert
+ NoConditions bool
+ Resolved bool
+ ExpectedBody string
+ }{
+ {
+ Name: "triggered",
+ Provider: AlertProvider{},
+ Endpoint: endpoint.Endpoint{Name: "name"},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
+ },
+ {
+ Name: "triggered-with-group",
+ Provider: AlertProvider{},
+ Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ ExpectedBody: "{\"Title\":\"TRIGGERED: group/name\",\"Message\":\"An alert for group/name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
+ },
+ {
+ Name: "triggered-with-no-conditions",
+ NoConditions: true,
+ Provider: AlertProvider{},
+ Endpoint: endpoint.Endpoint{Name: "name"},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
+ },
+ {
+ Name: "resolved",
+ Provider: AlertProvider{},
+ Endpoint: endpoint.Endpoint{Name: "name"},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ ExpectedBody: "{\"Title\":\"RESOLVED: name\",\"Message\":\"An alert for name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-_name\"}",
+ },
+ {
+ Name: "resolved-with-group",
+ Provider: AlertProvider{},
+ Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ ExpectedBody: "{\"Title\":\"RESOLVED: group/name\",\"Message\":\"An alert for group/name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ var conditionResults []*endpoint.ConditionResult
+ if !scenario.NoConditions {
+ conditionResults = []*endpoint.ConditionResult{
+ {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
+ {Condition: "[STATUS] == 200", Success: scenario.Resolved},
+ }
+ }
+ body, err := scenario.Provider.buildRequestBody(
+ &scenario.Endpoint,
+ &scenario.Alert,
+ &endpoint.Result{
+ ConditionResults: conditionResults,
+ },
+ scenario.Resolved,
+ )
+ if err != nil {
+ t.Fatalf("buildRequestBody returned an error: %v", err)
+ }
+ if string(body) != scenario.ExpectedBody {
+ t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
+ }
+ out := make(map[string]interface{})
+ if err := json.Unmarshal(body, &out); err != nil {
+ t.Error("expected body to be valid JSON, got error:", err.Error())
+ }
+ })
+ }
+}
+
+func TestAlertProvider_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
+
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ InputGroup string
+ InputAlert alert.Alert
+ ExpectedOutput Config
+ }{
+ {
+ Name: "provider-no-override-specify-no-group-should-default",
+ Provider: AlertProvider{
+ DefaultConfig: Config{TeamSecret: "team-secret-123"},
+ Overrides: nil,
+ },
+ InputGroup: "",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{TeamSecret: "team-secret-123"},
+ },
+ {
+ Name: "provider-no-override-specify-group-should-default",
+ Provider: AlertProvider{
+ DefaultConfig: Config{TeamSecret: "team-secret-123"},
+ Overrides: nil,
+ },
+ InputGroup: "group",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{TeamSecret: "team-secret-123"},
+ },
+ {
+ Name: "provider-with-override-specify-no-group-should-default",
+ Provider: AlertProvider{
+ DefaultConfig: Config{TeamSecret: "team-secret-123"},
+ Overrides: []Override{
+ {
+ Group: "group",
+ Config: Config{TeamSecret: "team-secret-override"},
+ },
+ },
+ },
+ InputGroup: "",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{TeamSecret: "team-secret-123"},
+ },
+ {
+ Name: "provider-with-override-specify-group-should-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{TeamSecret: "team-secret-123"},
+ Overrides: []Override{
+ {
+ Group: "group",
+ Config: Config{TeamSecret: "team-secret-override"},
+ },
+ },
+ },
+ InputGroup: "group",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{TeamSecret: "team-secret-override"},
+ },
+ {
+ Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
+ Provider: AlertProvider{
+ DefaultConfig: Config{TeamSecret: "team-secret-123"},
+ Overrides: []Override{
+ {
+ Group: "group",
+ Config: Config{TeamSecret: "team-secret-override"},
+ },
+ },
+ },
+ InputGroup: "group",
+ InputAlert: alert.Alert{ProviderOverride: map[string]any{"team-secret": "team-secret-alert"}},
+ ExpectedOutput: Config{TeamSecret: "team-secret-alert"},
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if got.TeamSecret != scenario.ExpectedOutput.TeamSecret {
+ t.Errorf("expected team secret to be %s, got %s", scenario.ExpectedOutput.TeamSecret, got.TeamSecret)
+ }
+ // Test ValidateOverrides as well, since it really just calls GetConfig
+ if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+ t.Errorf("unexpected error: %s", err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_GetConfigWithInvalidAlertOverride(t *testing.T) {
+ // Test case 1: Empty override should be ignored, default config should be used
+ provider := AlertProvider{
+ DefaultConfig: Config{TeamSecret: "team-secret-123"},
+ }
+ alertWithEmptyOverride := alert.Alert{
+ ProviderOverride: map[string]any{"team-secret": ""},
+ }
+ cfg, err := provider.GetConfig("", &alertWithEmptyOverride)
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if cfg.TeamSecret != "team-secret-123" {
+ t.Errorf("expected team secret to remain default 'team-secret-123', got %s", cfg.TeamSecret)
+ }
+
+ // Test case 2: Invalid default config with no valid override should fail
+ providerWithInvalidDefault := AlertProvider{
+ DefaultConfig: Config{TeamSecret: ""},
+ }
+ alertWithEmptyOverride2 := alert.Alert{
+ ProviderOverride: map[string]any{"team-secret": ""},
+ }
+ _, err = providerWithInvalidDefault.GetConfig("", &alertWithEmptyOverride2)
+ if err == nil {
+ t.Error("expected error due to invalid default config, got none")
+ }
+ if err != ErrTeamSecretNotSet {
+ t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
+ }
+}
+
+func TestAlertProvider_ValidateWithDuplicateGroupOverride(t *testing.T) {
+ providerWithDuplicateOverride := AlertProvider{
+ DefaultConfig: Config{TeamSecret: "team-secret-123"},
+ Overrides: []Override{
+ {
+ Group: "group1",
+ Config: Config{TeamSecret: "team-secret-override-1"},
+ },
+ {
+ Group: "group1",
+ Config: Config{TeamSecret: "team-secret-override-2"},
+ },
+ },
+ }
+ if err := providerWithDuplicateOverride.Validate(); err == nil {
+ t.Error("provider should not have been valid due to duplicate group override")
+ }
+ if err := providerWithDuplicateOverride.Validate(); err != ErrDuplicateGroupOverride {
+ t.Errorf("expected ErrDuplicateGroupOverride, got %v", providerWithDuplicateOverride.Validate())
+ }
+}
+
+func TestAlertProvider_ValidateOverridesWithInvalidAlert(t *testing.T) {
+ provider := AlertProvider{
+ DefaultConfig: Config{TeamSecret: ""},
+ }
+ alertWithEmptyOverride := alert.Alert{
+ ProviderOverride: map[string]any{"team-secret": ""},
+ }
+ err := provider.ValidateOverrides("", &alertWithEmptyOverride)
+ if err == nil {
+ t.Error("expected error due to invalid default config, got none")
+ }
+ if err != ErrTeamSecretNotSet {
+ t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
+ }
+}
diff --git a/alerting/provider/splunk/splunk.go b/alerting/provider/splunk/splunk.go
new file mode 100644
index 00000000..83c94032
--- /dev/null
+++ b/alerting/provider/splunk/splunk.go
@@ -0,0 +1,220 @@
+package splunk
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrHecURLNotSet = errors.New("hec-url not set")
+ ErrHecTokenNotSet = errors.New("hec-token not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ HecURL string `yaml:"hec-url"` // Splunk HEC (HTTP Event Collector) URL
+ HecToken string `yaml:"hec-token"` // Splunk HEC token
+ Source string `yaml:"source,omitempty"` // Event source
+ SourceType string `yaml:"sourcetype,omitempty"` // Event source type
+ Index string `yaml:"index,omitempty"` // Splunk index
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.HecURL) == 0 {
+ return ErrHecURLNotSet
+ }
+ if len(cfg.HecToken) == 0 {
+ return ErrHecTokenNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.HecURL) > 0 {
+ cfg.HecURL = override.HecURL
+ }
+ if len(override.HecToken) > 0 {
+ cfg.HecToken = override.HecToken
+ }
+ if len(override.Source) > 0 {
+ cfg.Source = override.Source
+ }
+ if len(override.SourceType) > 0 {
+ cfg.SourceType = override.SourceType
+ }
+ if len(override.Index) > 0 {
+ cfg.Index = override.Index
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using Splunk
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/services/collector/event", cfg.HecURL), buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Authorization", fmt.Sprintf("Splunk %s", cfg.HecToken))
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to splunk alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return nil
+}
+
+type Body struct {
+ Time int64 `json:"time"`
+ Source string `json:"source,omitempty"`
+ SourceType string `json:"sourcetype,omitempty"`
+ Index string `json:"index,omitempty"`
+ Event Event `json:"event"`
+}
+
+type Event struct {
+ AlertType string `json:"alert_type"`
+ Endpoint string `json:"endpoint"`
+ Group string `json:"group,omitempty"`
+ Status string `json:"status"`
+ Message string `json:"message"`
+ Description string `json:"description,omitempty"`
+ Conditions []*endpoint.ConditionResult `json:"conditions,omitempty"`
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
+ var alertType, status, message string
+ if resolved {
+ alertType = "resolved"
+ status = "ok"
+ message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
+ } else {
+ alertType = "triggered"
+ status = "critical"
+ message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
+ }
+ event := Event{
+ AlertType: alertType,
+ Endpoint: ep.DisplayName(),
+ Group: ep.Group,
+ Status: status,
+ Message: message,
+ Description: alert.GetDescription(),
+ }
+ if len(result.ConditionResults) > 0 {
+ event.Conditions = result.ConditionResults
+ }
+ body := Body{
+ Time: time.Now().Unix(),
+ Event: event,
+ }
+ // Set optional fields
+ if cfg.Source != "" {
+ body.Source = cfg.Source
+ } else {
+ body.Source = "gatus"
+ }
+ if cfg.SourceType != "" {
+ body.SourceType = cfg.SourceType
+ } else {
+ body.SourceType = "gatus:alert"
+ }
+ if cfg.Index != "" {
+ body.Index = cfg.Index
+ }
+ bodyAsJSON, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/splunk/splunk_test.go b/alerting/provider/splunk/splunk_test.go
new file mode 100644
index 00000000..97a44789
--- /dev/null
+++ b/alerting/provider/splunk/splunk_test.go
@@ -0,0 +1,155 @@
+package splunk
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ expected error
+ }{
+ {
+ name: "valid",
+ provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
+ expected: nil,
+ },
+ {
+ name: "valid-with-index",
+ provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
+ expected: nil,
+ },
+ {
+ name: "invalid-hec-url",
+ provider: AlertProvider{DefaultConfig: Config{HecToken: "token123"}},
+ expected: ErrHecURLNotSet,
+ },
+ {
+ name: "invalid-hec-token",
+ provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088"}},
+ expected: ErrHecTokenNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.provider.Validate()
+ if err != scenario.expected {
+ t.Errorf("expected %v, got %v", scenario.expected, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ alert alert.Alert
+ resolved bool
+ mockRoundTripper test.MockRoundTripper
+ expectedError bool
+ }{
+ {
+ name: "triggered",
+ provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ if r.URL.Path != "/services/collector/event" {
+ t.Errorf("expected path /services/collector/event, got %s", r.URL.Path)
+ }
+ if r.Header.Get("Authorization") != "Splunk token123" {
+ t.Errorf("expected Authorization header to be 'Splunk token123', got %s", r.Header.Get("Authorization"))
+ }
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["time"] == nil {
+ t.Error("expected 'time' field in request body")
+ }
+ event := body["event"].(map[string]interface{})
+ if event["alert_type"] != "triggered" {
+ t.Errorf("expected alert_type to be 'triggered', got %v", event["alert_type"])
+ }
+ if event["status"] != "critical" {
+ t.Errorf("expected status to be 'critical', got %v", event["status"])
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "resolved",
+ provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
+ alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: true,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["index"] != "main" {
+ t.Errorf("expected index to be 'main', got %v", body["index"])
+ }
+ event := body["event"].(map[string]interface{})
+ if event["alert_type"] != "resolved" {
+ t.Errorf("expected alert_type to be 'resolved', got %v", event["alert_type"])
+ }
+ if event["status"] != "ok" {
+ t.Errorf("expected status to be 'ok', got %v", event["status"])
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "error-response",
+ provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusForbidden, Body: http.NoBody}
+ }),
+ expectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
+ err := scenario.provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
diff --git a/alerting/provider/squadcast/squadcast.go b/alerting/provider/squadcast/squadcast.go
new file mode 100644
index 00000000..eff9f3c7
--- /dev/null
+++ b/alerting/provider/squadcast/squadcast.go
@@ -0,0 +1,190 @@
+package squadcast
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrWebhookURLNotSet = errors.New("webhook-url not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookURL string `yaml:"webhook-url"` // Squadcast webhook URL
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.WebhookURL) == 0 {
+ return ErrWebhookURLNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.WebhookURL) > 0 {
+ cfg.WebhookURL = override.WebhookURL
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using Squadcast
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ body, err := provider.buildRequestBody(ep, alert, result, resolved)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to squadcast alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return nil
+}
+
+type Body struct {
+ Message string `json:"message"`
+ Description string `json:"description,omitempty"`
+ EventID string `json:"event_id"`
+ Status string `json:"status"`
+ Tags map[string]string `json:"tags,omitempty"`
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
+ var message, status string
+ eventID := fmt.Sprintf("gatus-%s", ep.Key())
+ if resolved {
+ message = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
+ status = "resolve"
+ } else {
+ message = fmt.Sprintf("ALERT: %s", ep.DisplayName())
+ status = "trigger"
+ }
+ description := fmt.Sprintf("Endpoint: %s\n", ep.DisplayName())
+ if resolved {
+ description += fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row\n", alert.SuccessThreshold)
+ } else {
+ description += fmt.Sprintf("Endpoint has failed %d time(s) in a row\n", alert.FailureThreshold)
+ }
+ if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
+ description += fmt.Sprintf("\nDescription: %s", alertDescription)
+ }
+ if len(result.ConditionResults) > 0 {
+ description += "\n\nCondition Results:"
+ for _, conditionResult := range result.ConditionResults {
+ var status string
+ if conditionResult.Success {
+ status = "✅"
+ } else {
+ status = "❌"
+ }
+ description += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
+ }
+ }
+ body := Body{
+ Message: message,
+ Description: description,
+ EventID: eventID,
+ Status: status,
+ Tags: map[string]string{
+ "endpoint": ep.Name,
+ "group": ep.Group,
+ "source": "gatus",
+ },
+ }
+ bodyAsJSON, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/squadcast/squadcast_test.go b/alerting/provider/squadcast/squadcast_test.go
new file mode 100644
index 00000000..d2b5270c
--- /dev/null
+++ b/alerting/provider/squadcast/squadcast_test.go
@@ -0,0 +1,141 @@
+package squadcast
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ expected error
+ }{
+ {
+ name: "valid",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
+ expected: nil,
+ },
+ {
+ name: "invalid-webhook-url",
+ provider: AlertProvider{DefaultConfig: Config{}},
+ expected: ErrWebhookURLNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.provider.Validate()
+ if err != scenario.expected {
+ t.Errorf("expected %v, got %v", scenario.expected, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ alert alert.Alert
+ resolved bool
+ mockRoundTripper test.MockRoundTripper
+ expectedError bool
+ }{
+ {
+ name: "triggered",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["status"] != "trigger" {
+ t.Errorf("expected status to be 'trigger', got %v", body["status"])
+ }
+ if body["event_id"] == nil {
+ t.Error("expected 'event_id' field in request body")
+ }
+ message := body["message"].(string)
+ if !strings.Contains(message, "ALERT") {
+ t.Errorf("expected message to contain 'ALERT', got %s", message)
+ }
+ description := body["description"].(string)
+ if !strings.Contains(description, "failed 3 time(s)") {
+ t.Errorf("expected description to contain failure count, got %s", description)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "resolved",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
+ alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: true,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["status"] != "resolve" {
+ t.Errorf("expected status to be 'resolve', got %v", body["status"])
+ }
+ message := body["message"].(string)
+ if !strings.Contains(message, "RESOLVED") {
+ t.Errorf("expected message to contain 'RESOLVED', got %s", message)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "error-response",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
+ }),
+ expectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
+ err := scenario.provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
\ No newline at end of file
diff --git a/alerting/provider/telegram/telegram.go b/alerting/provider/telegram/telegram.go
index 11a8d290..7dcd29b2 100644
--- a/alerting/provider/telegram/telegram.go
+++ b/alerting/provider/telegram/telegram.go
@@ -14,7 +14,7 @@ import (
"gopkg.in/yaml.v3"
)
-const defaultApiUrl = "https://api.telegram.org"
+const ApiURL = "https://api.telegram.org"
var (
ErrTokenNotSet = errors.New("token not set")
@@ -33,7 +33,7 @@ type Config struct {
func (cfg *Config) Validate() error {
if len(cfg.ApiUrl) == 0 {
- cfg.ApiUrl = defaultApiUrl
+ cfg.ApiUrl = ApiURL
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
diff --git a/alerting/provider/twilio/twilio.go b/alerting/provider/twilio/twilio.go
index ccb83770..b88864fa 100644
--- a/alerting/provider/twilio/twilio.go
+++ b/alerting/provider/twilio/twilio.go
@@ -29,8 +29,10 @@ type Config struct {
From string `yaml:"from"`
To string `yaml:"to"`
+ // TODO in v6.0.0: Rename this to text-triggered
TextTwilioTriggered string `yaml:"text-twilio-triggered,omitempty"` // String used in the SMS body and subject (optional)
- TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
+ // TODO in v6.0.0: Rename this to text-resolved
+ TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
}
func (cfg *Config) Validate() error {
@@ -113,13 +115,23 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
var message string
if resolved {
if len(cfg.TextTwilioResolved) > 0 {
- message = strings.Replace(strings.Replace(cfg.TextTwilioResolved, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
+ // Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats
+ message = cfg.TextTwilioResolved
+ message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1)
+ message = strings.Replace(message, "{description}", alert.GetDescription(), 1)
+ message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1)
+ message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1)
} else {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
} else {
if len(cfg.TextTwilioTriggered) > 0 {
- message = strings.Replace(strings.Replace(cfg.TextTwilioTriggered, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
+ // Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats
+ message = cfg.TextTwilioTriggered
+ message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1)
+ message = strings.Replace(message, "{description}", alert.GetDescription(), 1)
+ message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1)
+ message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1)
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
diff --git a/alerting/provider/twilio/twilio_test.go b/alerting/provider/twilio/twilio_test.go
index f31f6349..a8967642 100644
--- a/alerting/provider/twilio/twilio_test.go
+++ b/alerting/provider/twilio/twilio_test.go
@@ -129,6 +129,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: true,
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
},
+ {
+ Name: "triggered-with-old-placeholders",
+ Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: {endpoint} - {description}"}},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4",
+ },
+ {
+ Name: "triggered-with-new-placeholders",
+ Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: [ENDPOINT] - [ALERT_DESCRIPTION]"}},
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4",
+ },
+ {
+ Name: "resolved-with-mixed-placeholders",
+ Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioResolved: "Resolved: {endpoint} and [ENDPOINT] - {description} and [ALERT_DESCRIPTION]"}},
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ ExpectedBody: "Body=Resolved%3A+endpoint-name+and+endpoint-name+-+description-2+and+description-2&From=3&To=4",
+ },
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
diff --git a/alerting/provider/vonage/vonage.go b/alerting/provider/vonage/vonage.go
new file mode 100644
index 00000000..f1a521ff
--- /dev/null
+++ b/alerting/provider/vonage/vonage.go
@@ -0,0 +1,212 @@
+package vonage
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+const ApiURL = "https://rest.nexmo.com/sms/json"
+
+var (
+ ErrAPIKeyNotSet = errors.New("api-key not set")
+ ErrAPISecretNotSet = errors.New("api-secret not set")
+ ErrFromNotSet = errors.New("from not set")
+ ErrToNotSet = errors.New("to not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ APIKey string `yaml:"api-key"`
+ APISecret string `yaml:"api-secret"`
+ From string `yaml:"from"`
+ To []string `yaml:"to"`
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.APIKey) == 0 {
+ return ErrAPIKeyNotSet
+ }
+ if len(cfg.APISecret) == 0 {
+ return ErrAPISecretNotSet
+ }
+ if len(cfg.From) == 0 {
+ return ErrFromNotSet
+ }
+ if len(cfg.To) == 0 {
+ return ErrToNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.APIKey) > 0 {
+ cfg.APIKey = override.APIKey
+ }
+ if len(override.APISecret) > 0 {
+ cfg.APISecret = override.APISecret
+ }
+ if len(override.From) > 0 {
+ cfg.From = override.From
+ }
+ if len(override.To) > 0 {
+ cfg.To = override.To
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using Vonage
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ message := provider.buildMessage(cfg, ep, alert, result, resolved)
+
+ // Send SMS to each recipient
+ for _, recipient := range cfg.To {
+ if err := provider.sendSMS(cfg, recipient, message); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// sendSMS sends an individual SMS message
+func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
+ data := url.Values{}
+ data.Set("api_key", cfg.APIKey)
+ data.Set("api_secret", cfg.APISecret)
+ data.Set("from", cfg.From)
+ data.Set("to", to)
+ data.Set("text", message)
+ request, err := http.NewRequest(http.MethodPost, ApiURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ // Read response body once and use it for both error handling and JSON processing
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return err
+ }
+ if response.StatusCode >= 400 {
+ return fmt.Errorf("call to vonage alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ // Check response for errors in messages array
+ var vonageResponse Response
+ if err := json.Unmarshal(body, &vonageResponse); err != nil {
+ return err
+ }
+ // Check if any message failed
+ for _, msg := range vonageResponse.Messages {
+ if msg.Status != "0" {
+ return fmt.Errorf("vonage SMS failed with status %s: %s", msg.Status, msg.ErrorText)
+ }
+ }
+ return nil
+}
+
+type Response struct {
+ MessageCount string `json:"message-count"`
+ Messages []Message `json:"messages"`
+}
+
+type Message struct {
+ To string `json:"to"`
+ MessageID string `json:"message-id"`
+ Status string `json:"status"`
+ ErrorText string `json:"error-text"`
+ RemainingBalance string `json:"remaining-balance"`
+ MessagePrice string `json:"message-price"`
+ Network string `json:"network"`
+}
+
+// buildMessage builds the SMS message content
+func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
+ if resolved {
+ return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
+ } else {
+ return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
+ }
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/vonage/vonage_test.go b/alerting/provider/vonage/vonage_test.go
new file mode 100644
index 00000000..8359deb1
--- /dev/null
+++ b/alerting/provider/vonage/vonage_test.go
@@ -0,0 +1,546 @@
+package vonage
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestVonageAlertProvider_IsValid(t *testing.T) {
+ invalidProvider := AlertProvider{}
+ if err := invalidProvider.Validate(); err == nil {
+ t.Error("provider shouldn't have been valid")
+ }
+ validProvider := AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ }
+ if err := validProvider.Validate(); err != nil {
+ t.Error("provider should've been valid")
+ }
+}
+
+func TestVonageAlertProvider_IsValidWithOverride(t *testing.T) {
+ validProvider := AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ Overrides: []Override{
+ {
+ Group: "test-group",
+ Config: Config{
+ APIKey: "override-key",
+ APISecret: "override-secret",
+ From: "Override",
+ To: []string{"+9876543210"},
+ },
+ },
+ },
+ }
+ if err := validProvider.Validate(); err != nil {
+ t.Error("provider should've been valid")
+ }
+}
+
+func TestVonageAlertProvider_IsNotValidWithInvalidOverrideGroup(t *testing.T) {
+ invalidProvider := AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ Overrides: []Override{
+ {
+ Group: "",
+ Config: Config{
+ APIKey: "override-key",
+ APISecret: "override-secret",
+ From: "Override",
+ To: []string{"+9876543210"},
+ },
+ },
+ },
+ }
+ if err := invalidProvider.Validate(); err == nil {
+ t.Error("provider shouldn't have been valid")
+ }
+}
+
+func TestVonageAlertProvider_IsNotValidWithDuplicateOverrideGroup(t *testing.T) {
+ invalidProvider := AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ Overrides: []Override{
+ {
+ Group: "test-group",
+ Config: Config{
+ APIKey: "override-key1",
+ APISecret: "override-secret1",
+ From: "Override1",
+ To: []string{"+9876543210"},
+ },
+ },
+ {
+ Group: "test-group",
+ Config: Config{
+ APIKey: "override-key2",
+ APISecret: "override-secret2",
+ From: "Override2",
+ To: []string{"+1234567890"},
+ },
+ },
+ },
+ }
+ if err := invalidProvider.Validate(); err == nil {
+ t.Error("provider shouldn't have been valid")
+ }
+}
+
+func TestVonageAlertProvider_IsValidWithInvalidFrom(t *testing.T) {
+ invalidProvider := AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "",
+ To: []string{"+1234567890"},
+ },
+ }
+ if err := invalidProvider.Validate(); err == nil {
+ t.Error("provider shouldn't have been valid")
+ }
+}
+
+func TestVonageAlertProvider_IsValidWithInvalidTo(t *testing.T) {
+ invalidProvider := AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{},
+ },
+ }
+ if err := invalidProvider.Validate(); err == nil {
+ t.Error("provider shouldn't have been valid")
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ Alert alert.Alert
+ Resolved bool
+ MockRoundTripper test.MockRoundTripper
+ ExpectedError bool
+ }{
+ {
+ Name: "triggered",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ },
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.50","message-price":"0.10","network":"12345"}]}`)),
+ }
+ }),
+ ExpectedError: false,
+ },
+ {
+ Name: "triggered-error-status-code",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ },
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+ }),
+ ExpectedError: true,
+ },
+ {
+ Name: "triggered-error-vonage-response",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ },
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"","status":"2","error-text":"Missing from param"}]}`)),
+ }
+ }),
+ ExpectedError: true,
+ },
+ {
+ Name: "resolved",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ },
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.40","message-price":"0.10","network":"12345"}]}`)),
+ }
+ }),
+ ExpectedError: false,
+ },
+ {
+ Name: "multiple-recipients",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890", "+0987654321"},
+ },
+ },
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.30","message-price":"0.10","network":"12345"}]}`)),
+ }
+ }),
+ ExpectedError: false,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
+ err := scenario.Provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.Alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_buildMessage(t *testing.T) {
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ Alert alert.Alert
+ Resolved bool
+ ExpectedMessage string
+ }{
+ {
+ Name: "triggered",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ },
+ Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: false,
+ ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
+ },
+ {
+ Name: "resolved",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ },
+ Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ Resolved: true,
+ ExpectedMessage: "RESOLVED: endpoint-name - description-2",
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ message := scenario.Provider.buildMessage(
+ &scenario.Provider.DefaultConfig,
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.Alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.ConditionResult{
+ {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
+ {Condition: "[STATUS] == 200", Success: scenario.Resolved},
+ },
+ },
+ scenario.Resolved,
+ )
+ if message != scenario.ExpectedMessage {
+ t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
+
+func TestAlertProvider_GetConfig(t *testing.T) {
+ scenarios := []struct {
+ Name string
+ Provider AlertProvider
+ InputGroup string
+ InputAlert alert.Alert
+ ExpectedOutput Config
+ }{
+ {
+ Name: "provider-no-override-should-default",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ },
+ InputGroup: "",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ },
+ {
+ Name: "provider-with-group-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ Overrides: []Override{
+ {
+ Group: "test-group",
+ Config: Config{
+ APIKey: "group-override-key",
+ APISecret: "group-override-secret",
+ From: "GroupOverride",
+ To: []string{"+9876543210"},
+ },
+ },
+ },
+ },
+ InputGroup: "test-group",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{
+ APIKey: "group-override-key",
+ APISecret: "group-override-secret",
+ From: "GroupOverride",
+ To: []string{"+9876543210"},
+ },
+ },
+ {
+ Name: "provider-with-group-override-partial",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ Overrides: []Override{
+ {
+ Group: "test-group",
+ Config: Config{
+ To: []string{"+9876543210"},
+ },
+ },
+ },
+ },
+ InputGroup: "test-group",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+9876543210"},
+ },
+ },
+ {
+ Name: "provider-with-alert-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ },
+ InputGroup: "",
+ InputAlert: alert.Alert{ProviderOverride: map[string]any{
+ "api-key": "override-key",
+ "api-secret": "override-secret",
+ "from": "Override",
+ "to": []string{"+9876543210"},
+ }},
+ ExpectedOutput: Config{
+ APIKey: "override-key",
+ APISecret: "override-secret",
+ From: "Override",
+ To: []string{"+9876543210"},
+ },
+ },
+ {
+ Name: "provider-with-both-group-and-alert-override",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ Overrides: []Override{
+ {
+ Group: "test-group",
+ Config: Config{
+ APIKey: "group-override-key",
+ From: "GroupOverride",
+ },
+ },
+ },
+ },
+ InputGroup: "test-group",
+ InputAlert: alert.Alert{ProviderOverride: map[string]any{
+ "api-secret": "alert-override-secret",
+ "to": []string{"+9876543210"},
+ }},
+ ExpectedOutput: Config{
+ APIKey: "group-override-key",
+ APISecret: "alert-override-secret",
+ From: "GroupOverride",
+ To: []string{"+9876543210"},
+ },
+ },
+ {
+ Name: "provider-with-group-override-no-match",
+ Provider: AlertProvider{
+ DefaultConfig: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ Overrides: []Override{
+ {
+ Group: "different-group",
+ Config: Config{
+ APIKey: "group-override-key",
+ },
+ },
+ },
+ },
+ InputGroup: "test-group",
+ InputAlert: alert.Alert{},
+ ExpectedOutput: Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "Gatus",
+ To: []string{"+1234567890"},
+ },
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.Name, func(t *testing.T) {
+ got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
+ if err != nil {
+ t.Error("expected no error, got:", err.Error())
+ }
+ if got.APIKey != scenario.ExpectedOutput.APIKey {
+ t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
+ }
+ if got.APISecret != scenario.ExpectedOutput.APISecret {
+ t.Errorf("expected APISecret to be %s, got %s", scenario.ExpectedOutput.APISecret, got.APISecret)
+ }
+ if got.From != scenario.ExpectedOutput.From {
+ t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
+ }
+ if len(got.To) != len(scenario.ExpectedOutput.To) {
+ t.Errorf("expected To to have length %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
+ } else {
+ for i, to := range got.To {
+ if to != scenario.ExpectedOutput.To[i] {
+ t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
+ }
+ }
+ }
+ // Test ValidateOverrides as well, since it really just calls GetConfig
+ if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+ t.Errorf("unexpected error: %s", err)
+ }
+ })
+ }
+}
+
diff --git a/alerting/provider/webex/webex.go b/alerting/provider/webex/webex.go
new file mode 100644
index 00000000..ef7f988e
--- /dev/null
+++ b/alerting/provider/webex/webex.go
@@ -0,0 +1,171 @@
+package webex
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrWebhookURLNotSet = errors.New("webhook-url not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookURL string `yaml:"webhook-url"` // Webex Teams webhook URL
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.WebhookURL) == 0 {
+ return ErrWebhookURLNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.WebhookURL) > 0 {
+ cfg.WebhookURL = override.WebhookURL
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using Webex
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ body, err := provider.buildRequestBody(ep, alert, result, resolved)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to webex alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return nil
+}
+
+type Body struct {
+ RoomID string `json:"roomId,omitempty"`
+ Text string `json:"text,omitempty"`
+ Markdown string `json:"markdown"`
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
+ var message string
+ if resolved {
+ message = fmt.Sprintf("✅ **RESOLVED**: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
+ } else {
+ message = fmt.Sprintf("🚨 **ALERT**: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
+ }
+ if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
+ message += fmt.Sprintf("\n\n**Description**: %s", alertDescription)
+ }
+ if len(result.ConditionResults) > 0 {
+ message += "\n\n**Condition Results:**"
+ for _, conditionResult := range result.ConditionResults {
+ var status string
+ if conditionResult.Success {
+ status = "✅"
+ } else {
+ status = "❌"
+ }
+ message += fmt.Sprintf("\n- %s `%s`", status, conditionResult.Condition)
+ }
+ }
+ body := Body{
+ Markdown: message,
+ }
+ bodyAsJSON, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/webex/webex_test.go b/alerting/provider/webex/webex_test.go
new file mode 100644
index 00000000..b7c67ad7
--- /dev/null
+++ b/alerting/provider/webex/webex_test.go
@@ -0,0 +1,134 @@
+package webex
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ expected error
+ }{
+ {
+ name: "valid",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
+ expected: nil,
+ },
+ {
+ name: "invalid-webhook-url",
+ provider: AlertProvider{DefaultConfig: Config{}},
+ expected: ErrWebhookURLNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.provider.Validate()
+ if err != scenario.expected {
+ t.Errorf("expected %v, got %v", scenario.expected, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ alert alert.Alert
+ resolved bool
+ mockRoundTripper test.MockRoundTripper
+ expectedError bool
+ }{
+ {
+ name: "triggered",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["markdown"] == nil {
+ t.Error("expected 'markdown' field in request body")
+ }
+ markdown := body["markdown"].(string)
+ if !strings.Contains(markdown, "ALERT") {
+ t.Errorf("expected markdown to contain 'ALERT', got %s", markdown)
+ }
+ if !strings.Contains(markdown, "failed 3 time(s)") {
+ t.Errorf("expected markdown to contain failure count, got %s", markdown)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "resolved",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
+ alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: true,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ markdown := body["markdown"].(string)
+ if !strings.Contains(markdown, "RESOLVED") {
+ t.Errorf("expected markdown to contain 'RESOLVED', got %s", markdown)
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "error-response",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
+ }),
+ expectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
+ err := scenario.provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
\ No newline at end of file
diff --git a/alerting/provider/zapier/zapier.go b/alerting/provider/zapier/zapier.go
new file mode 100644
index 00000000..c978ef3b
--- /dev/null
+++ b/alerting/provider/zapier/zapier.go
@@ -0,0 +1,197 @@
+package zapier
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ ErrWebhookURLNotSet = errors.New("webhook-url not set")
+ ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+ WebhookURL string `yaml:"webhook-url"` // Zapier webhook URL
+}
+
+func (cfg *Config) Validate() error {
+ if len(cfg.WebhookURL) == 0 {
+ return ErrWebhookURLNotSet
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.WebhookURL) > 0 {
+ cfg.WebhookURL = override.WebhookURL
+ }
+}
+
+// AlertProvider is the configuration necessary for sending an alert using Zapier
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+
+ // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+ // Overrides is a list of Override that may be prioritized over the default configuration
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+ registeredGroups := make(map[string]bool)
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+ return ErrDuplicateGroupOverride
+ }
+ registeredGroups[override.Group] = true
+ }
+ }
+ return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+ cfg, err := provider.GetConfig(ep.Group, alert)
+ if err != nil {
+ return err
+ }
+ body, err := provider.buildRequestBody(ep, alert, result, resolved)
+ if err != nil {
+ return err
+ }
+ buffer := bytes.NewBuffer(body)
+ request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(response.Body)
+ return fmt.Errorf("call to zapier alert returned status code %d: %s", response.StatusCode, string(body))
+ }
+ return nil
+}
+
+type Body struct {
+ AlertType string `json:"alert_type"`
+ Status string `json:"status"`
+ Endpoint string `json:"endpoint"`
+ Group string `json:"group,omitempty"`
+ Message string `json:"message"`
+ Description string `json:"description,omitempty"`
+ Timestamp string `json:"timestamp"`
+ SuccessThreshold int `json:"success_threshold,omitempty"`
+ FailureThreshold int `json:"failure_threshold,omitempty"`
+ ConditionResults []*endpoint.ConditionResult `json:"condition_results,omitempty"`
+ TotalConditions int `json:"total_conditions"`
+ PassedConditions int `json:"passed_conditions"`
+ FailedConditions int `json:"failed_conditions"`
+}
+
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
+ var alertType, status, message string
+ var successThreshold, failureThreshold int
+ if resolved {
+ alertType = "resolved"
+ status = "ok"
+ message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
+ successThreshold = alert.SuccessThreshold
+ } else {
+ alertType = "triggered"
+ status = "critical"
+ message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
+ failureThreshold = alert.FailureThreshold
+ }
+ // Process condition results
+ passedConditions := 0
+ failedConditions := 0
+ for _, cr := range result.ConditionResults {
+ if cr.Success {
+ passedConditions++
+ } else {
+ failedConditions++
+ }
+ }
+ body := Body{
+ AlertType: alertType,
+ Status: status,
+ Endpoint: ep.DisplayName(),
+ Group: ep.Group,
+ Message: message,
+ Description: alert.GetDescription(),
+ Timestamp: time.Now().Format(time.RFC3339),
+ SuccessThreshold: successThreshold,
+ FailureThreshold: failureThreshold,
+ ConditionResults: result.ConditionResults,
+ TotalConditions: len(result.ConditionResults),
+ PassedConditions: passedConditions,
+ FailedConditions: failedConditions,
+ }
+ bodyAsJSON, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bodyAsJSON, nil
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+ return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+ cfg := provider.DefaultConfig
+ // Handle group overrides
+ if provider.Overrides != nil {
+ for _, override := range provider.Overrides {
+ if group == override.Group {
+ cfg.Merge(&override.Config)
+ break
+ }
+ }
+ }
+ // Handle alert overrides
+ if len(alert.ProviderOverride) != 0 {
+ overrideConfig := Config{}
+ if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+ return nil, err
+ }
+ cfg.Merge(&overrideConfig)
+ }
+ // Validate the configuration
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/zapier/zapier_test.go b/alerting/provider/zapier/zapier_test.go
new file mode 100644
index 00000000..84b8f6f0
--- /dev/null
+++ b/alerting/provider/zapier/zapier_test.go
@@ -0,0 +1,162 @@
+package zapier
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/client"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+ "github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ expected error
+ }{
+ {
+ name: "valid",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
+ expected: nil,
+ },
+ {
+ name: "invalid-webhook-url",
+ provider: AlertProvider{DefaultConfig: Config{}},
+ expected: ErrWebhookURLNotSet,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.provider.Validate()
+ if err != scenario.expected {
+ t.Errorf("expected %v, got %v", scenario.expected, err)
+ }
+ })
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ defer client.InjectHTTPClient(nil)
+ firstDescription := "description-1"
+ secondDescription := "description-2"
+ scenarios := []struct {
+ name string
+ provider AlertProvider
+ alert alert.Alert
+ resolved bool
+ mockRoundTripper test.MockRoundTripper
+ expectedError bool
+ }{
+ {
+ name: "triggered",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ if r.Host != "hooks.zapier.com" {
+ t.Errorf("expected host hooks.zapier.com, got %s", r.Host)
+ }
+ if r.URL.Path != "/hooks/catch/123456/abcdef/" {
+ t.Errorf("expected path /hooks/catch/123456/abcdef/, got %s", r.URL.Path)
+ }
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["alert_type"] != "triggered" {
+ t.Errorf("expected alert_type to be 'triggered', got %v", body["alert_type"])
+ }
+ if body["status"] != "critical" {
+ t.Errorf("expected status to be 'critical', got %v", body["status"])
+ }
+ if body["endpoint"] != "endpoint-name" {
+ t.Errorf("expected endpoint to be 'endpoint-name', got %v", body["endpoint"])
+ }
+ message := body["message"].(string)
+ if !strings.Contains(message, "Alert") {
+ t.Errorf("expected message to contain 'Alert', got %s", message)
+ }
+ if !strings.Contains(message, "failed 3 time(s)") {
+ t.Errorf("expected message to contain failure count, got %s", message)
+ }
+ if body["description"] != firstDescription {
+ t.Errorf("expected description to be '%s', got %v", firstDescription, body["description"])
+ }
+ conditionResults := body["condition_results"].([]interface{})
+ if len(conditionResults) != 2 {
+ t.Errorf("expected 2 condition results, got %d", len(conditionResults))
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "resolved",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
+ alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: true,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ body := make(map[string]interface{})
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["alert_type"] != "resolved" {
+ t.Errorf("expected alert_type to be 'resolved', got %v", body["alert_type"])
+ }
+ if body["status"] != "ok" {
+ t.Errorf("expected status to be 'ok', got %v", body["status"])
+ }
+ message := body["message"].(string)
+ if !strings.Contains(message, "resolved") {
+ t.Errorf("expected message to contain 'resolved', got %s", message)
+ }
+ if body["description"] != secondDescription {
+ t.Errorf("expected description to be '%s', got %v", secondDescription, body["description"])
+ }
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+ }),
+ expectedError: false,
+ },
+ {
+ name: "error-response",
+ provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
+ alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+ resolved: false,
+ mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+ return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
+ }),
+ expectedError: true,
+ },
+ }
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
+ err := scenario.provider.Send(
+ &endpoint.Endpoint{Name: "endpoint-name"},
+ &scenario.alert,
+ &endpoint.Result{
+ ConditionResults: []*endpoint.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_GetDefaultAlert(t *testing.T) {
+ if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
+ t.Error("expected default alert to be not nil")
+ }
+ if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
+ t.Error("expected default alert to be nil")
+ }
+}
\ No newline at end of file
diff --git a/config/config.go b/config/config.go
index f207429a..6ba5e6e3 100644
--- a/config/config.go
+++ b/config/config.go
@@ -444,6 +444,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alertTypes := []alert.Type{
alert.TypeAWSSES,
alert.TypeCustom,
+ alert.TypeDatadog,
alert.TypeDiscord,
alert.TypeEmail,
alert.TypeGitHub,
@@ -452,21 +453,34 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alert.TypeGoogleChat,
alert.TypeGotify,
alert.TypeHomeAssistant,
+ alert.TypeIFTTT,
alert.TypeIlert,
alert.TypeIncidentIO,
alert.TypeJetBrainsSpace,
+ alert.TypeLine,
alert.TypeMatrix,
alert.TypeMattermost,
alert.TypeMessagebird,
+ alert.TypeNewRelic,
alert.TypeNtfy,
alert.TypeOpsgenie,
alert.TypePagerDuty,
+ alert.TypePlivo,
alert.TypePushover,
+ alert.TypeRocketChat,
+ alert.TypeSendGrid,
+ alert.TypeSignal,
+ alert.TypeSIGNL4,
alert.TypeSlack,
+ alert.TypeSplunk,
+ alert.TypeSquadcast,
alert.TypeTeams,
alert.TypeTeamsWorkflows,
alert.TypeTelegram,
alert.TypeTwilio,
+ alert.TypeVonage,
+ alert.TypeWebex,
+ alert.TypeZapier,
alert.TypeZulip,
}
var validProviders, invalidProviders []alert.Type
diff --git a/config/config_test.go b/config/config_test.go
index 1b51090b..dc210684 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -13,6 +13,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
+ "github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
@@ -20,19 +21,35 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
+ "github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
+ "github.com/TwiN/gatus/v5/alerting/provider/ifttt"
+ "github.com/TwiN/gatus/v5/alerting/provider/ilert"
+ "github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
+ "github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
+ "github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
+ "github.com/TwiN/gatus/v5/alerting/provider/plivo"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
+ "github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
+ "github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
+ "github.com/TwiN/gatus/v5/alerting/provider/signal"
+ "github.com/TwiN/gatus/v5/alerting/provider/signl4"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
+ "github.com/TwiN/gatus/v5/alerting/provider/splunk"
+ "github.com/TwiN/gatus/v5/alerting/provider/squadcast"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
+ "github.com/TwiN/gatus/v5/alerting/provider/vonage"
+ "github.com/TwiN/gatus/v5/alerting/provider/webex"
+ "github.com/TwiN/gatus/v5/alerting/provider/zapier"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
@@ -1885,6 +1902,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{
AWSSimpleEmailService: &awsses.AlertProvider{},
Custom: &custom.AlertProvider{},
+ Datadog: &datadog.AlertProvider{},
Discord: &discord.AlertProvider{},
Email: &email.AlertProvider{},
Gitea: &gitea.AlertProvider{},
@@ -1892,19 +1910,35 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
GitLab: &gitlab.AlertProvider{},
GoogleChat: &googlechat.AlertProvider{},
Gotify: &gotify.AlertProvider{},
+ HomeAssistant: &homeassistant.AlertProvider{},
+ IFTTT: &ifttt.AlertProvider{},
+ Ilert: &ilert.AlertProvider{},
+ IncidentIO: &incidentio.AlertProvider{},
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
+ Line: &line.AlertProvider{},
Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{},
Messagebird: &messagebird.AlertProvider{},
+ NewRelic: &newrelic.AlertProvider{},
Ntfy: &ntfy.AlertProvider{},
Opsgenie: &opsgenie.AlertProvider{},
PagerDuty: &pagerduty.AlertProvider{},
+ Plivo: &plivo.AlertProvider{},
Pushover: &pushover.AlertProvider{},
+ RocketChat: &rocketchat.AlertProvider{},
+ SendGrid: &sendgrid.AlertProvider{},
+ Signal: &signal.AlertProvider{},
+ SIGNL4: &signl4.AlertProvider{},
Slack: &slack.AlertProvider{},
+ Splunk: &splunk.AlertProvider{},
+ Squadcast: &squadcast.AlertProvider{},
Telegram: &telegram.AlertProvider{},
Teams: &teams.AlertProvider{},
TeamsWorkflows: &teamsworkflows.AlertProvider{},
Twilio: &twilio.AlertProvider{},
+ Vonage: &vonage.AlertProvider{},
+ Webex: &webex.AlertProvider{},
+ Zapier: &zapier.AlertProvider{},
Zulip: &zulip.AlertProvider{},
}
scenarios := []struct {
@@ -1913,6 +1947,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
}{
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
+ {alertType: alert.TypeDatadog, expected: alertingConfig.Datadog},
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
{alertType: alert.TypeGitea, expected: alertingConfig.Gitea},
@@ -1920,19 +1955,35 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypeGitLab, expected: alertingConfig.GitLab},
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
{alertType: alert.TypeGotify, expected: alertingConfig.Gotify},
+ {alertType: alert.TypeHomeAssistant, expected: alertingConfig.HomeAssistant},
+ {alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT},
+ {alertType: alert.TypeIlert, expected: alertingConfig.Ilert},
+ {alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO},
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
+ {alertType: alert.TypeLine, expected: alertingConfig.Line},
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},
+ {alertType: alert.TypeNewRelic, expected: alertingConfig.NewRelic},
{alertType: alert.TypeNtfy, expected: alertingConfig.Ntfy},
{alertType: alert.TypeOpsgenie, expected: alertingConfig.Opsgenie},
{alertType: alert.TypePagerDuty, expected: alertingConfig.PagerDuty},
+ {alertType: alert.TypePlivo, expected: alertingConfig.Plivo},
{alertType: alert.TypePushover, expected: alertingConfig.Pushover},
+ {alertType: alert.TypeRocketChat, expected: alertingConfig.RocketChat},
+ {alertType: alert.TypeSendGrid, expected: alertingConfig.SendGrid},
+ {alertType: alert.TypeSignal, expected: alertingConfig.Signal},
+ {alertType: alert.TypeSIGNL4, expected: alertingConfig.SIGNL4},
{alertType: alert.TypeSlack, expected: alertingConfig.Slack},
+ {alertType: alert.TypeSplunk, expected: alertingConfig.Splunk},
+ {alertType: alert.TypeSquadcast, expected: alertingConfig.Squadcast},
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
{alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows},
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
+ {alertType: alert.TypeVonage, expected: alertingConfig.Vonage},
+ {alertType: alert.TypeWebex, expected: alertingConfig.Webex},
+ {alertType: alert.TypeZapier, expected: alertingConfig.Zapier},
{alertType: alert.TypeZulip, expected: alertingConfig.Zulip},
}
for _, scenario := range scenarios {
diff --git a/watchdog/alerting_test.go b/watchdog/alerting_test.go
index 21bd7434..d8314885 100644
--- a/watchdog/alerting_test.go
+++ b/watchdog/alerting_test.go
@@ -8,18 +8,26 @@ import (
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
+ "github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
+ "github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
+ "github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
+ "github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
+ "github.com/TwiN/gatus/v5/alerting/provider/plivo"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
+ "github.com/TwiN/gatus/v5/alerting/provider/signl4"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
+ "github.com/TwiN/gatus/v5/alerting/provider/vonage"
+ "github.com/TwiN/gatus/v5/alerting/provider/zapier"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
)
@@ -268,6 +276,17 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
},
},
},
+ {
+ Name: "datadog",
+ AlertType: alert.TypeDatadog,
+ AlertingConfig: &alerting.Config{
+ Datadog: &datadog.AlertProvider{
+ DefaultConfig: datadog.Config{
+ APIKey: "test-key",
+ },
+ },
+ },
+ },
{
Name: "discord",
AlertType: alert.TypeDiscord,
@@ -294,6 +313,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
},
},
},
+ {
+ Name: "ifttt",
+ AlertType: alert.TypeIFTTT,
+ AlertingConfig: &alerting.Config{
+ IFTTT: &ifttt.AlertProvider{
+ DefaultConfig: ifttt.Config{
+ WebhookKey: "test-key",
+ EventName: "test-event",
+ },
+ },
+ },
+ },
{
Name: "jetbrainsspace",
AlertType: alert.TypeJetBrainsSpace,
@@ -307,6 +338,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
},
},
},
+ {
+ Name: "line",
+ AlertType: alert.TypeLine,
+ AlertingConfig: &alerting.Config{
+ Line: &line.AlertProvider{
+ DefaultConfig: line.Config{
+ ChannelAccessToken: "test-token",
+ UserIDs: []string{"test-user"},
+ },
+ },
+ },
+ },
{
Name: "mattermost",
AlertType: alert.TypeMattermost,
@@ -331,6 +374,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
},
},
},
+ {
+ Name: "newrelic",
+ AlertType: alert.TypeNewRelic,
+ AlertingConfig: &alerting.Config{
+ NewRelic: &newrelic.AlertProvider{
+ DefaultConfig: newrelic.Config{
+ InsertKey: "test-key",
+ AccountID: "test-account",
+ },
+ },
+ },
+ },
{
Name: "pagerduty",
AlertType: alert.TypePagerDuty,
@@ -342,6 +397,20 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
},
},
},
+ {
+ Name: "plivo",
+ AlertType: alert.TypePlivo,
+ AlertingConfig: &alerting.Config{
+ Plivo: &plivo.AlertProvider{
+ DefaultConfig: plivo.Config{
+ AuthID: "test-id",
+ AuthToken: "test-token",
+ From: "test-from",
+ To: []string{"test-to"},
+ },
+ },
+ },
+ },
{
Name: "pushover",
AlertType: alert.TypePushover,
@@ -354,6 +423,17 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
},
},
},
+ {
+ Name: "signl4",
+ AlertType: alert.TypeSIGNL4,
+ AlertingConfig: &alerting.Config{
+ SIGNL4: &signl4.AlertProvider{
+ DefaultConfig: signl4.Config{
+ TeamSecret: "test-secret",
+ },
+ },
+ },
+ },
{
Name: "slack",
AlertType: alert.TypeSlack,
@@ -402,6 +482,31 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
},
},
},
+ {
+ Name: "vonage",
+ AlertType: alert.TypeVonage,
+ AlertingConfig: &alerting.Config{
+ Vonage: &vonage.AlertProvider{
+ DefaultConfig: vonage.Config{
+ APIKey: "test-key",
+ APISecret: "test-secret",
+ From: "test-from",
+ To: []string{"test-to"},
+ },
+ },
+ },
+ },
+ {
+ Name: "zapier",
+ AlertType: alert.TypeZapier,
+ AlertingConfig: &alerting.Config{
+ Zapier: &zapier.AlertProvider{
+ DefaultConfig: zapier.Config{
+ WebhookURL: "https://example.com",
+ },
+ },
+ },
+ },
{
Name: "matrix",
AlertType: alert.TypeMatrix,