Repository: TwiN/gatus
Pull request #284 (2022-05-12 11:52:50)

feat(client): Added client configuration option for using a custom DNS resolver
Added a new configuration option to the `client` to allow the user to specify a custom DNS resolver.

Example config:

```yaml
# use Cloudflare DNS to lookup the DNS name for the endpoint
endpoints:
  - name: front-end
    group: core
    url: "https://twin.sh/health"
    interval: 30s
    client:
      dns-resolver: "tcp://1.1.1.1:53"
    conditions:
      - "[STATUS] == 200"
      - "[BODY].status == UP"
      - "[RESPONSE_TIME] < 150"
```



## Checklist
<!-- Replace [ ] by [X] if you have completed the item -->
- [X] Tested and/or added tests to validate that the changes work as intended, if applicable.
- [X] Added the documentation in `README.md`, if applicable.


tiwood: Closes #273 

Patch:
diff --git a/.examples/kubernetes/gatus.yaml b/.examples/kubernetes/gatus.yaml
index 2c2550db..f5c22c14 100644
--- a/.examples/kubernetes/gatus.yaml
+++ b/.examples/kubernetes/gatus.yaml
@@ -54,10 +54,10 @@ spec:
       app: gatus
   template:
     metadata:
-      labels:
-        app: gatus
       name: gatus
       namespace: kube-system
+      labels:
+        app: gatus
     spec:
       serviceAccountName: gatus
       terminationGracePeriodSeconds: 5
@@ -76,6 +76,22 @@ spec:
             requests:
               cpu: 50m
               memory: 30M
+          readinessProbe:
+            httpGet:
+              path: /health
+              port: 8080
+            initialDelaySeconds: 5
+            periodSeconds: 10
+            successThreshold: 1
+            failureThreshold: 3
+          livenessProbe:
+            httpGet:
+              path: /health
+              port: 8080
+            initialDelaySeconds: 10
+            periodSeconds: 10
+            successThreshold: 1
+            failureThreshold: 5
           volumeMounts:
             - mountPath: /config
               name: gatus-config
diff --git a/Makefile b/Makefile
index ccb0f6be..5a782f34 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,8 @@
 BINARY=gatus
 
+# Because there's a folder called "test", we need to make the target "test" phony
+.PHONY: test
+
 install:
 	go build -mod vendor -o $(BINARY) .
 
@@ -10,7 +13,7 @@ clean:
 	rm $(BINARY)
 
 test:
-	sudo go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover
+	go test ./... -cover
 
 
 ##########
diff --git a/README.md b/README.md
index fa06b637..5f3fb18b 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
   - [Security](#security)
     - [Basic](#basic)
     - [OIDC (ALPHA)](#oidc-alpha)
+  - [Metrics](#metrics)
 - [Deployment](#deployment)
   - [Docker](#docker)
   - [Helm Chart](#helm-chart)
@@ -219,15 +220,15 @@ Here are some examples of conditions you can use:
 
 
 #### Placeholders
-| Placeholder                | Description                                              | Example of resolved value                    |
-|:---------------------------|:---------------------------------------------------------|:---------------------------------------------|
-| `[STATUS]`                 | Resolves into the HTTP status of the request             | 404                                          |
-| `[RESPONSE_TIME]`          | Resolves into the response time the request took, in ms  | 10                                           |
-| `[IP]`                     | Resolves into the IP of the target host                  | 192.168.0.232                                |
-| `[BODY]`                   | Resolves into the response body. Supports JSONPath.      | `{"name":"john.doe"}`                        |
-| `[CONNECTED]`              | Resolves into whether a connection could be established  | `true`                                       |
-| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration | `24h`, `48h`, 0 (if not protocol with certs) |
-| `[DNS_RCODE]`              | Resolves into the DNS status of the response             | NOERROR                                      |
+| Placeholder                | Description                                                                               | Example of resolved value                    |
+|:---------------------------|:------------------------------------------------------------------------------------------|:---------------------------------------------|
+| `[STATUS]`                 | Resolves into the HTTP status of the request                                              | 404                                          |
+| `[RESPONSE_TIME]`          | Resolves into the response time the request took, in ms                                   | 10                                           |
+| `[IP]`                     | Resolves into the IP of the target host                                                   | 192.168.0.232                                |
+| `[BODY]`                   | Resolves into the response body. Supports JSONPath.                                       | `{"name":"john.doe"}`                        |
+| `[CONNECTED]`              | Resolves into whether a connection could be established                                   | `true`                                       |
+| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration (valid units are "s", "m", "h".) | `24h`, `48h`, 0 (if not protocol with certs) |
+| `[DNS_RCODE]`              | Resolves into the DNS status of the response                                              | NOERROR                                      |
 
 
 #### Functions
@@ -281,6 +282,7 @@ the client used to send the request.
 | `client.insecure`             | Whether to skip verifying the server's certificate chain and host name.    | `false`         |
 | `client.ignore-redirect`      | Whether to ignore redirects (true) or follow them (false, default).        | `false`         |
 | `client.timeout`              | Duration before timing out.                                                | `10s`           |
+| `client.dns-resolver`         | Override the DNS resolver using the format `{proto}://{host}:{port}`.      | `""`            |
 | `client.oauth2`               | OAuth2 client configuration.                                               | `{}`            |
 | `client.oauth2.token-url`     | The token endpoint URL                                                     | required `""`   |
 | `client.oauth2.client-id`     | The client id which should be used for the `Client credentials flow`       | required `""`   |
@@ -312,6 +314,17 @@ endpoints:
       - "[STATUS] == 200"
 ```
 
+This example shows how you can use a `custom DNS Resolver`:
+```yaml
+endpoints:
+  - name: website
+    url: "https://your.health.api/getHealth"
+    client:
+      dns-resolver: "tcp://1.1.1.1:53"
+    conditions:
+      - "[STATUS] == 200"
+```
+
 This example shows how you can use the `client.oauth2` configuration to query a backend API with `Bearer token`:
 ```yaml
 endpoints:
@@ -368,7 +381,7 @@ alerting:
 endpoints:
   - name: website
     url: "https://twin.sh/health"
-    interval: 30s
+    interval: 5m
     conditions:
       - "[STATUS] == 200"
       - "[BODY].status == UP"
@@ -444,7 +457,7 @@ endpoints:
 #### Configuring Google Chat alerts
 
 | Parameter                                     | Description                                                                                 | Default       |
-|:--------------------------------------------- |:------------------------------------------------------------------------------------------- |:------------- |
+|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
 | `alerting.googlechat`                         | Configuration for alerts of type `googlechat`                                               | `{}`          |
 | `alerting.googlechat.webhook-url`             | Google Chat Webhook URL                                                                     | Required `""` |
 | `alerting.googlechat.client`                  | Client configuration. <br />See [Client configuration](#client-configuration).              | `{}`          |
@@ -625,12 +638,14 @@ endpoints:
 
 
 #### Configuring Slack alerts
-| Parameter                      | Description                                                                                | Default       |
-|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
-| `alerting.slack`               | Configuration for alerts of type `slack`                                                   | `{}`          |
-| `alerting.slack.webhook-url`   | Slack Webhook URL                                                                          | Required `""` |
-| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |
-
+| Parameter                                 | Description                                                                                | Default       |
+|:------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.slack`                          | Configuration for alerts of type `slack`                                                   | `{}`          |
+| `alerting.slack.webhook-url`              | Slack Webhook URL                                                                          | Required `""` |
+| `alerting.slack.default-alert`            | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |
+| `alerting.slack.overrides`                | List of overrides that may be prioritized over the default configuration                   | `[]`          |
+| `alerting.slack.overrides[].group`        | Endpoint group for which the configuration will be overridden by this configuration        | `""`          |
+| `alerting.slack.overrides[].webhook-url`  | Slack Webhook URL                                                                          | `""`          |
 ```yaml
 alerting:
   slack: 
@@ -800,8 +815,11 @@ leveraging Gatus, you could have Gatus call that application endpoint when an en
 would then check if the endpoint that started failing was part of the recently deployed application, and if it was,
 then automatically roll it back.
 
-The placeholders `[ALERT_DESCRIPTION]` and `[ENDPOINT_NAME]` are automatically substituted for the alert description and
-the endpoint name. These placeholders can be used in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`).
+Furthermore, you may use the following placeholders in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`):
+- `[ALERT_DESCRIPTION]` (resolved from `endpoints[].alerts[].description`)
+- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
+- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
+- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
 
 If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the
 `[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications. 
@@ -816,7 +834,7 @@ alerting:
     method: "POST"
     body: |
       {
-        "text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]"
+        "text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION]"
       }
 endpoints:
   - name: website
@@ -1021,6 +1039,19 @@ security:
 **NOTE:** The OIDC feature is currently in Alpha. Breaking changes may occur. Use this feature at your own risk.
 
 
+### Metrics
+To enable metrics, you must set `metrics` to `true`. Doing so will expose Prometheus-friendly metrics at the `/metrics`
+endpoint on the same port your application is configured to run on (`web.port`).
+
+| Metric name                                  | Type    | Description                                                                | Labels                          | Relevant endpoint types |
+|:---------------------------------------------|:--------|:---------------------------------------------------------------------------|:--------------------------------|:------------------------|
+| gatus_results_total                          | counter | Number of results per endpoint                                             | key, group, name, type, success | All                     |
+| gatus_results_code_total                     | counter | Total number of results by code                                            | key, group, name, type, code    | DNS, HTTP               |
+| gatus_results_connected_total                | counter | Total number of results in which a connection was successfully established | key, group, name, type          | All                     |
+| gatus_results_duration_seconds               | gauge   | Duration of the request in seconds                                         | key, group, name, type          | All                     |
+| gatus_results_certificate_expiration_seconds | gauge   | Number of seconds until the certificate expires                            | key, group, name, type          | HTTP, STARTTLS          |
+
+
 ## Deployment
 Many examples can be found in the [.examples](.examples) folder, but this section will focus on the most popular ways of deploying Gatus.
 
@@ -1164,6 +1195,8 @@ endpoints:
 Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`, 
 `endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints.
 
+This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.).
+
 **NOTE**: `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's 
 something at the given address listening to the given port, and that a connection to that address was successfully 
 established.
diff --git a/alerting/config.go b/alerting/config.go
index a59047ec..564497db 100644
--- a/alerting/config.go
+++ b/alerting/config.go
@@ -65,12 +65,6 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
 			return nil
 		}
 		return config.Custom
-	case alert.TypeGoogleChat:
-		if config.GoogleChat == nil {
-			// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
-			return nil
-		}
-		return config.GoogleChat
 	case alert.TypeDiscord:
 		if config.Discord == nil {
 			// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
@@ -83,6 +77,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
 			return nil
 		}
 		return config.Email
+	case alert.TypeGoogleChat:
+		if config.GoogleChat == nil {
+			// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
+			return nil
+		}
+		return config.GoogleChat
 	case alert.TypeMattermost:
 		if config.Mattermost == nil {
 			// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
diff --git a/alerting/provider/custom/custom.go b/alerting/provider/custom/custom.go
index 147a9417..967afedc 100644
--- a/alerting/provider/custom/custom.go
+++ b/alerting/provider/custom/custom.go
@@ -50,48 +50,28 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
 	return status
 }
 
-func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription string, resolved bool) *http.Request {
-	body := provider.Body
-	providerURL := provider.URL
-	method := provider.Method
-
-	if strings.Contains(body, "[ALERT_DESCRIPTION]") {
-		body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alertDescription)
-	}
-	if strings.Contains(body, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
-		body = strings.ReplaceAll(body, "[SERVICE_NAME]", endpointName)
-	}
-	if strings.Contains(body, "[ENDPOINT_NAME]") {
-		body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpointName)
-	}
-	if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") {
-		if resolved {
-			body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
-		} else {
-			body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
-		}
-	}
-	if strings.Contains(providerURL, "[ALERT_DESCRIPTION]") {
-		providerURL = strings.ReplaceAll(providerURL, "[ALERT_DESCRIPTION]", alertDescription)
-	}
-	if strings.Contains(providerURL, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
-		providerURL = strings.ReplaceAll(providerURL, "[SERVICE_NAME]", endpointName)
-	}
-	if strings.Contains(providerURL, "[ENDPOINT_NAME]") {
-		providerURL = strings.ReplaceAll(providerURL, "[ENDPOINT_NAME]", endpointName)
-	}
-	if strings.Contains(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]") {
-		if resolved {
-			providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
-		} else {
-			providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
-		}
+func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request {
+	body, url, method := provider.Body, provider.URL, provider.Method
+	body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
+	url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
+	body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name)
+	url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name)
+	body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group)
+	url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group)
+	body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL)
+	url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL)
+	if resolved {
+		body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
+		url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
+	} else {
+		body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
+		url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
 	}
 	if len(method) == 0 {
 		method = http.MethodGet
 	}
 	bodyBuffer := bytes.NewBuffer([]byte(body))
-	request, _ := http.NewRequest(method, providerURL, bodyBuffer)
+	request, _ := http.NewRequest(method, url, bodyBuffer)
 	for k, v := range provider.Headers {
 		request.Header.Set(k, v)
 	}
@@ -99,7 +79,7 @@ func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription s
 }
 
 func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
-	request := provider.buildHTTPRequest(endpoint.Name, alert.GetDescription(), resolved)
+	request := provider.buildHTTPRequest(endpoint, alert, resolved)
 	response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
 	if err != nil {
 		return err
diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go
index 9d1ee81a..4f2ff0f0 100644
--- a/alerting/provider/provider.go
+++ b/alerting/provider/provider.go
@@ -8,6 +8,7 @@ import (
 	"github.com/TwiN/gatus/v3/alerting/provider/googlechat"
 	"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
 	"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
+	"github.com/TwiN/gatus/v3/alerting/provider/opsgenie"
 	"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
 	"github.com/TwiN/gatus/v3/alerting/provider/slack"
 	"github.com/TwiN/gatus/v3/alerting/provider/teams"
@@ -58,6 +59,7 @@ var (
 	_ AlertProvider = (*googlechat.AlertProvider)(nil)
 	_ AlertProvider = (*mattermost.AlertProvider)(nil)
 	_ AlertProvider = (*messagebird.AlertProvider)(nil)
+	_ AlertProvider = (*opsgenie.AlertProvider)(nil)
 	_ AlertProvider = (*pagerduty.AlertProvider)(nil)
 	_ AlertProvider = (*slack.AlertProvider)(nil)
 	_ AlertProvider = (*teams.AlertProvider)(nil)
diff --git a/alerting/provider/slack/slack.go b/alerting/provider/slack/slack.go
index 2de9e5cb..fbde77f2 100644
--- a/alerting/provider/slack/slack.go
+++ b/alerting/provider/slack/slack.go
@@ -14,20 +14,36 @@ import (
 // AlertProvider is the configuration necessary for sending an alert using Slack
 type AlertProvider struct {
 	WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
-
 	// 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"`
+	WebhookURL string `yaml:"webhook-url"`
 }
 
 // IsValid returns whether the provider's configuration is valid
 func (provider *AlertProvider) IsValid() bool {
+	registeredGroups := make(map[string]bool)
+	if provider.Overrides != nil {
+		for _, override := range provider.Overrides {
+			if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
+				return false
+			}
+			registeredGroups[override.Group] = true
+		}
+	}
 	return len(provider.WebhookURL) > 0
 }
 
 // Send an alert using the provider
 func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
 	buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
-	request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
+	request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
 	if err != nil {
 		return err
 	}
@@ -86,6 +102,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
 }`, message, description, color, results)
 }
 
+// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
+func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
+	if provider.Overrides != nil {
+		for _, override := range provider.Overrides {
+			if group == override.Group {
+				return override.WebhookURL
+			}
+		}
+	}
+	return provider.WebhookURL
+}
+
 // GetDefaultAlert returns the provider's default alert configuration
 func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
 	return provider.DefaultAlert
diff --git a/client/config.go b/client/config.go
index 48830a70..b8e63813 100644
--- a/client/config.go
+++ b/client/config.go
@@ -4,7 +4,10 @@ import (
 	"context"
 	"crypto/tls"
 	"errors"
+	"fmt"
+	"net"
 	"net/http"
+	"regexp"
 	"time"
 
 	"golang.org/x/oauth2"
@@ -16,6 +19,7 @@ const (
 )
 
 var (
+	ErrInvalidDNSResolver        = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
 	ErrInvalidClientOAuth2Config = errors.New("invalid OAuth2 configuration, all fields are required")
 
 	defaultConfig = Config{
@@ -42,6 +46,10 @@ type Config struct {
 	// Timeout for the client
 	Timeout time.Duration `yaml:"timeout"`
 
+	// DNSResolver override for the HTTPClient
+	// Expected format is {protocol}://{host}:{port}
+	DNSResolver string `yaml:"dns-resolver,omitempty"`
+
 	// OAuth2Config is the OAuth2 configuration used for the client.
 	//
 	// If non-nil, the http.Client returned by getHTTPClient will automatically retrieve a token if necessary.
@@ -51,6 +59,13 @@ type Config struct {
 	httpClient *http.Client
 }
 
+// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
+type DNSResolverConfig struct {
+	Protocol string
+	Host     string
+	Port     string
+}
+
 // OAuth2Config is the configuration for the OAuth2 client credentials flow
 type OAuth2Config struct {
 	TokenURL     string   `yaml:"token-url"` // e.g. https://dev-12345678.okta.com/token
@@ -64,12 +79,44 @@ func (c *Config) ValidateAndSetDefaults() error {
 	if c.Timeout < time.Millisecond {
 		c.Timeout = 10 * time.Second
 	}
+	if c.HasCustomDNSResolver() {
+		_, err := c.ParseDNSResolver()
+		if err != nil {
+			return ErrInvalidDNSResolver
+		}
+	}
 	if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
 		return ErrInvalidClientOAuth2Config
 	}
 	return nil
 }
 
+// Returns true if the DNSResolver is set in the configuration
+func (c *Config) HasCustomDNSResolver() bool {
+	return len(c.DNSResolver) > 0
+}
+
+// Parses the DNSResolver configuration string into the DNSResolverConfig struct
+func (c *Config) ParseDNSResolver() (DNSResolverConfig, error) {
+	re := regexp.MustCompile(`^(?P<proto>(.*))://(?P<host>[A-Za-z0-9\-\.]+):(?P<port>[0-9]+)?(.*)$`)
+	matches := re.FindStringSubmatch(c.DNSResolver)
+	if len(matches) == 0 {
+		return DNSResolverConfig{}, errors.New("ParseError")
+	}
+	r := make(map[string]string)
+	for i, k := range re.SubexpNames() {
+		if i != 0 && k != "" {
+			r[k] = matches[i]
+		}
+	}
+
+	return DNSResolverConfig{
+		Protocol: r["proto"],
+		Host:     r["host"],
+		Port:     r["port"],
+	}, nil
+}
+
 // HasOAuth2Config returns true if the client has OAuth2 configuration parameters
 func (c *Config) HasOAuth2Config() bool {
 	return c.OAuth2Config != nil
@@ -102,6 +149,22 @@ func (c *Config) getHTTPClient() *http.Client {
 				return nil
 			},
 		}
+		if c.HasCustomDNSResolver() {
+			dnsResolver, _ := c.ParseDNSResolver()
+			dialer := &net.Dialer{
+				Resolver: &net.Resolver{
+					PreferGo: true,
+					Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+						d := net.Dialer{}
+						return d.DialContext(ctx, dnsResolver.Protocol, fmt.Sprintf("%s:%s", dnsResolver.Host, dnsResolver.Port))
+					},
+				},
+			}
+			dialCtx := func(ctx context.Context, network, addr string) (net.Conn, error) {
+				return dialer.DialContext(ctx, network, addr)
+			}
+			c.httpClient.Transport.(*http.Transport).DialContext = dialCtx
+		}
 		if c.HasOAuth2Config() {
 			c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
 		}
diff --git a/core/endpoint.go b/core/endpoint.go
index acaed8ec..4006e7ea 100644
--- a/core/endpoint.go
+++ b/core/endpoint.go
@@ -18,6 +18,8 @@ import (
 	"github.com/TwiN/gatus/v3/util"
 )
 
+type EndpointType string
+
 const (
 	// HostHeader is the name of the header used to specify the host
 	HostHeader = "Host"
@@ -30,6 +32,14 @@ const (
 
 	// GatusUserAgent is the default user agent that Gatus uses to send requests.
 	GatusUserAgent = "Gatus/1.0"
+
+	// EndpointType enum for the endpoint type.
+	EndpointTypeDNS      EndpointType = "DNS"
+	EndpointTypeTCP      EndpointType = "TCP"
+	EndpointTypeICMP     EndpointType = "ICMP"
+	EndpointTypeSTARTTLS EndpointType = "STARTTLS"
+	EndpointTypeTLS      EndpointType = "TLS"
+	EndpointTypeHTTP     EndpointType = "HTTP"
 )
 
 var (
@@ -105,6 +115,24 @@ func (endpoint Endpoint) IsEnabled() bool {
 	return *endpoint.Enabled
 }
 
+// Type returns the endpoint type
+func (endpoint Endpoint) Type() EndpointType {
+	switch {
+	case endpoint.DNS != nil:
+		return EndpointTypeDNS
+	case strings.HasPrefix(endpoint.URL, "tcp://"):
+		return EndpointTypeTCP
+	case strings.HasPrefix(endpoint.URL, "icmp://"):
+		return EndpointTypeICMP
+	case strings.HasPrefix(endpoint.URL, "starttls://"):
+		return EndpointTypeSTARTTLS
+	case strings.HasPrefix(endpoint.URL, "tls://"):
+		return EndpointTypeTLS
+	default:
+		return EndpointTypeHTTP
+	}
+}
+
 // ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of fields that have one
 func (endpoint *Endpoint) ValidateAndSetDefaults() error {
 	// Set default values
@@ -229,21 +257,16 @@ func (endpoint *Endpoint) call(result *Result) {
 	var response *http.Response
 	var err error
 	var certificate *x509.Certificate
-	isTypeDNS := endpoint.DNS != nil
-	isTypeTCP := strings.HasPrefix(endpoint.URL, "tcp://")
-	isTypeICMP := strings.HasPrefix(endpoint.URL, "icmp://")
-	isTypeSTARTTLS := strings.HasPrefix(endpoint.URL, "starttls://")
-	isTypeTLS := strings.HasPrefix(endpoint.URL, "tls://")
-	isTypeHTTP := !isTypeDNS && !isTypeTCP && !isTypeICMP && !isTypeSTARTTLS && !isTypeTLS
-	if isTypeHTTP {
+	endpointType := endpoint.Type()
+	if endpointType == EndpointTypeHTTP {
 		request = endpoint.buildHTTPRequest()
 	}
 	startTime := time.Now()
-	if isTypeDNS {
+	if endpointType == EndpointTypeDNS {
 		endpoint.DNS.query(endpoint.URL, result)
 		result.Duration = time.Since(startTime)
-	} else if isTypeSTARTTLS || isTypeTLS {
-		if isTypeSTARTTLS {
+	} else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS {
+		if endpointType == EndpointTypeSTARTTLS {
 			result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig)
 		} else {
 			result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig)
@@ -254,10 +277,10 @@ func (endpoint *Endpoint) call(result *Result) {
 		}
 		result.Duration = time.Since(startTime)
 		result.CertificateExpiration = time.Until(certificate.NotAfter)
-	} else if isTypeTCP {
+	} else if endpointType == EndpointTypeTCP {
 		result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig)
 		result.Duration = time.Since(startTime)
-	} else if isTypeICMP {
+	} else if endpointType == EndpointTypeICMP {
 		result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig)
 	} else {
 		response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request)
diff --git a/metric/metric.go b/metric/metric.go
index a3e8dfcd..89edbe1a 100644
--- a/metric/metric.go
+++ b/metric/metric.go
@@ -8,20 +8,66 @@ import (
 	"github.com/prometheus/client_golang/prometheus/promauto"
 )
 
+const namespace = "gatus" // The prefix of the metrics
+
 var (
-	// This will be initialized once PublishMetricsForEndpoint.
-	// The reason why we're doing this is that if metrics are disabled, we don't want to initialize it unnecessarily.
-	resultCount *prometheus.CounterVec = nil
+	initializedMetrics bool // Whether the metrics have been initialized
+
+	resultTotal                        *prometheus.CounterVec
+	resultDurationSeconds              *prometheus.GaugeVec
+	resultConnectedTotal               *prometheus.CounterVec
+	resultCodeTotal                    *prometheus.CounterVec
+	resultCertificateExpirationSeconds *prometheus.GaugeVec
 )
 
+func initializePrometheusMetrics() {
+	resultTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: namespace,
+		Name:      "results_total",
+		Help:      "Number of results per endpoint",
+	}, []string{"key", "group", "name", "type", "success"})
+	resultDurationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
+		Namespace: namespace,
+		Name:      "results_duration_seconds",
+		Help:      "Duration of the request in seconds",
+	}, []string{"key", "group", "name", "type"})
+	resultConnectedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: namespace,
+		Name:      "results_connected_total",
+		Help:      "Total number of results in which a connection was successfully established",
+	}, []string{"key", "group", "name", "type"})
+	resultCodeTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: namespace,
+		Name:      "results_code_total",
+		Help:      "Total number of results by code",
+	}, []string{"key", "group", "name", "type", "code"})
+	resultCertificateExpirationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
+		Namespace: namespace,
+		Name:      "results_certificate_expiration_seconds",
+		Help:      "Number of seconds until the certificate expires",
+	}, []string{"key", "group", "name", "type"})
+}
+
 // PublishMetricsForEndpoint publishes metrics for the given endpoint and its result.
 // These metrics will be exposed at /metrics if the metrics are enabled
 func PublishMetricsForEndpoint(endpoint *core.Endpoint, result *core.Result) {
-	if resultCount == nil {
-		resultCount = promauto.NewCounterVec(prometheus.CounterOpts{
-			Name: "gatus_results_total",
-			Help: "Number of results per endpoint",
-		}, []string{"key", "group", "name", "success"})
+	if !initializedMetrics {
+		initializePrometheusMetrics()
+		initializedMetrics = true
+	}
+	endpointType := endpoint.Type()
+	resultTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), strconv.FormatBool(result.Success)).Inc()
+	resultDurationSeconds.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Set(result.Duration.Seconds())
+	if result.Connected {
+		resultConnectedTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Inc()
+	}
+	if result.DNSRCode != "" {
+		resultCodeTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), result.DNSRCode).Inc()
+	}
+	if result.HTTPStatus != 0 {
+		resultCodeTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)).Inc()
+	}
+	if result.CertificateExpiration != 0 {
+		resultCertificateExpirationSeconds.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Set(result.CertificateExpiration.Seconds())
 	}
-	resultCount.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, strconv.FormatBool(result.Success)).Inc()
 }


Test patch:
diff --git a/alerting/provider/custom/custom_test.go b/alerting/provider/custom/custom_test.go
index 050d72d0..24fe0e10 100644
--- a/alerting/provider/custom/custom_test.go
+++ b/alerting/provider/custom/custom_test.go
@@ -1,6 +1,7 @@
 package custom
 
 import (
+	"fmt"
 	"io"
 	"net/http"
 	"testing"
@@ -99,77 +100,103 @@ func TestAlertProvider_Send(t *testing.T) {
 	}
 }
 
-func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
-	const (
-		ExpectedURL  = "https://example.com/endpoint-name?event=RESOLVED&description=alert-description"
-		ExpectedBody = "endpoint-name,alert-description,RESOLVED"
-	)
+func TestAlertProvider_buildHTTPRequest(t *testing.T) {
 	customAlertProvider := &AlertProvider{
-		URL:     "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
-		Body:    "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
-		Headers: nil,
-	}
-	request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
-	if request.URL.String() != ExpectedURL {
-		t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
-	}
-	body, _ := io.ReadAll(request.Body)
-	if string(body) != ExpectedBody {
-		t.Error("expected body to be", ExpectedBody, "was", string(body))
-	}
-}
-
-func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
-	const (
-		ExpectedURL  = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
-		ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
-	)
-	customAlertProvider := &AlertProvider{
-		URL:     "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
-		Body:    "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
-		Headers: map[string]string{"Authorization": "Basic hunter2"},
+		URL:  "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
+		Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
 	}
-	request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false)
-	if request.URL.String() != ExpectedURL {
-		t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
+	alertDescription := "alert-description"
+	scenarios := []struct {
+		AlertProvider *AlertProvider
+		Resolved      bool
+		ExpectedURL   string
+		ExpectedBody  string
+	}{
+		{
+			AlertProvider: customAlertProvider,
+			Resolved:      true,
+			ExpectedURL:   "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
+			ExpectedBody:  "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
+		},
+		{
+			AlertProvider: customAlertProvider,
+			Resolved:      false,
+			ExpectedURL:   "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
+			ExpectedBody:  "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
+		},
 	}
-	body, _ := io.ReadAll(request.Body)
-	if string(body) != ExpectedBody {
-		t.Error("expected body to be", ExpectedBody, "was", string(body))
+	for _, scenario := range scenarios {
+		t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
+			request := customAlertProvider.buildHTTPRequest(
+				&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
+				&alert.Alert{Description: &alertDescription},
+				scenario.Resolved,
+			)
+			if request.URL.String() != scenario.ExpectedURL {
+				t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
+			}
+			body, _ := io.ReadAll(request.Body)
+			if string(body) != scenario.ExpectedBody {
+				t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
+			}
+		})
 	}
 }
 
 func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
-	const (
-		ExpectedURL  = "https://example.com/endpoint-name?event=test&description=alert-description"
-		ExpectedBody = "endpoint-name,alert-description,test"
-	)
 	customAlertProvider := &AlertProvider{
-		URL:     "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
-		Body:    "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
+		URL:     "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
+		Body:    "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
 		Headers: nil,
 		Placeholders: map[string]map[string]string{
 			"ALERT_TRIGGERED_OR_RESOLVED": {
-				"RESOLVED": "test",
+				"RESOLVED":  "fixed",
+				"TRIGGERED": "boom",
 			},
 		},
 	}
-	request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
-	if request.URL.String() != ExpectedURL {
-		t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
+	alertDescription := "alert-description"
+	scenarios := []struct {
+		AlertProvider *AlertProvider
+		Resolved      bool
+		ExpectedURL   string
+		ExpectedBody  string
+	}{
+		{
+			AlertProvider: customAlertProvider,
+			Resolved:      true,
+			ExpectedURL:   "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
+			ExpectedBody:  "endpoint-name,endpoint-group,alert-description,fixed",
+		},
+		{
+			AlertProvider: customAlertProvider,
+			Resolved:      false,
+			ExpectedURL:   "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
+			ExpectedBody:  "endpoint-name,endpoint-group,alert-description,boom",
+		},
 	}
-	body, _ := io.ReadAll(request.Body)
-	if string(body) != ExpectedBody {
-		t.Error("expected body to be", ExpectedBody, "was", string(body))
+	for _, scenario := range scenarios {
+		t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
+			request := customAlertProvider.buildHTTPRequest(
+				&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
+				&alert.Alert{Description: &alertDescription},
+				scenario.Resolved,
+			)
+			if request.URL.String() != scenario.ExpectedURL {
+				t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
+			}
+			body, _ := io.ReadAll(request.Body)
+			if string(body) != scenario.ExpectedBody {
+				t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
+			}
+		})
 	}
 }
 
 func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
 	customAlertProvider := &AlertProvider{
-		URL:          "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
-		Body:         "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
-		Headers:      nil,
-		Placeholders: nil,
+		URL:  "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
+		Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
 	}
 	if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
 		t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
@@ -187,26 +214,3 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
 		t.Error("expected default alert to be nil")
 	}
 }
-
-// TestAlertProvider_isBackwardCompatibleWithServiceRename checks if the custom alerting provider still supports
-// service placeholders after the migration from "service" to "endpoint"
-//
-// XXX: Remove this in v4.0.0
-func TestAlertProvider_isBackwardCompatibleWithServiceRename(t *testing.T) {
-	const (
-		ExpectedURL  = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
-		ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
-	)
-	customAlertProvider := &AlertProvider{
-		URL:  "https://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
-		Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
-	}
-	request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false)
-	if request.URL.String() != ExpectedURL {
-		t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
-	}
-	body, _ := io.ReadAll(request.Body)
-	if string(body) != ExpectedBody {
-		t.Error("expected body to be", ExpectedBody, "was", string(body))
-	}
-}
diff --git a/alerting/provider/slack/slack_test.go b/alerting/provider/slack/slack_test.go
index 1f60f547..909aafff 100644
--- a/alerting/provider/slack/slack_test.go
+++ b/alerting/provider/slack/slack_test.go
@@ -11,7 +11,7 @@ import (
 	"github.com/TwiN/gatus/v3/test"
 )
 
-func TestAlertProvider_IsValid(t *testing.T) {
+func TestAlertDefaultProvider_IsValid(t *testing.T) {
 	invalidProvider := AlertProvider{WebhookURL: ""}
 	if invalidProvider.IsValid() {
 		t.Error("provider shouldn't have been valid")
@@ -20,8 +20,44 @@ func TestAlertProvider_IsValid(t *testing.T) {
 	if !validProvider.IsValid() {
 		t.Error("provider should've been valid")
 	}
-}
 
+}
+func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+	providerWithInvalidOverrideGroup := AlertProvider{
+		Overrides: []Override{
+			{
+				WebhookURL: "http://example.com",
+				Group:      "",
+			},
+		},
+	}
+	if providerWithInvalidOverrideGroup.IsValid() {
+		t.Error("provider Group shouldn't have been valid")
+	}
+	providerWithInvalidOverrideTo := AlertProvider{
+		Overrides: []Override{
+			{
+				WebhookURL: "",
+				Group:      "group",
+			},
+		},
+	}
+	if providerWithInvalidOverrideTo.IsValid() {
+		t.Error("provider integration key shouldn't have been valid")
+	}
+	providerWithValidOverride := AlertProvider{
+		WebhookURL: "http://example.com",
+		Overrides: []Override{
+			{
+				WebhookURL: "http://example.com",
+				Group:      "group",
+			},
+		},
+	}
+	if !providerWithValidOverride.IsValid() {
+		t.Error("provider should've been valid")
+	}
+}
 func TestAlertProvider_Send(t *testing.T) {
 	defer client.InjectHTTPClient(nil)
 	firstDescription := "description-1"
@@ -79,7 +115,7 @@ func TestAlertProvider_Send(t *testing.T) {
 		t.Run(scenario.Name, func(t *testing.T) {
 			client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
 			err := scenario.Provider.Send(
-				&core.Endpoint{Name: "name"},
+				&core.Endpoint{Name: "endpoint-name"},
 				&scenario.Alert,
 				&core.Result{
 					ConditionResults: []*core.ConditionResult{
@@ -175,3 +211,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
 		t.Error("expected default alert to be nil")
 	}
 }
+
+func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
+	tests := []struct {
+		Name           string
+		Provider       AlertProvider
+		InputGroup     string
+		ExpectedOutput string
+	}{
+		{
+			Name: "provider-no-override-specify-no-group-should-default",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides:  nil,
+			},
+			InputGroup:     "",
+			ExpectedOutput: "http://example.com",
+		},
+		{
+			Name: "provider-no-override-specify-group-should-default",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides:  nil,
+			},
+			InputGroup:     "group",
+			ExpectedOutput: "http://example.com",
+		},
+		{
+			Name: "provider-with-override-specify-no-group-should-default",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides: []Override{
+					{
+						Group:      "group",
+						WebhookURL: "http://example01.com",
+					},
+				},
+			},
+			InputGroup:     "",
+			ExpectedOutput: "http://example.com",
+		},
+		{
+			Name: "provider-with-override-specify-group-should-override",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides: []Override{
+					{
+						Group:      "group",
+						WebhookURL: "http://example01.com",
+					},
+				},
+			},
+			InputGroup:     "group",
+			ExpectedOutput: "http://example01.com",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.Name, func(t *testing.T) {
+			if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
+				t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
+			}
+		})
+	}
+}
diff --git a/client/client_test.go b/client/client_test.go
index 7e8dc311..fef97b39 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -2,7 +2,7 @@ package client
 
 import (
 	"bytes"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"testing"
 	"time"
@@ -15,6 +15,7 @@ func TestGetHTTPClient(t *testing.T) {
 		Insecure:       false,
 		IgnoreRedirect: false,
 		Timeout:        0,
+		DNSResolver:    "tcp://1.1.1.1:53",
 		OAuth2Config: &OAuth2Config{
 			ClientID:     "00000000-0000-0000-0000-000000000000",
 			ClientSecret: "secretsauce",
@@ -22,7 +23,10 @@ func TestGetHTTPClient(t *testing.T) {
 			Scopes:       []string{"https://application.local/.default"},
 		},
 	}
-	cfg.ValidateAndSetDefaults()
+	err := cfg.ValidateAndSetDefaults()
+	if err != nil {
+		t.Errorf("expected error to be nil, but got: `%s`", err)
+	}
 	if GetHTTPClient(cfg) == nil {
 		t.Error("expected client to not be nil")
 	}
@@ -52,6 +56,54 @@ func TestPing(t *testing.T) {
 	}
 }
 
+func TestDNSResolverConfig(t *testing.T) {
+	type args struct {
+		resolver string
+	}
+
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "Valid resolver",
+			args: args{
+				resolver: "tcp://1.1.1.1:53",
+			},
+			wantErr: false,
+		},
+		{
+			name: "Invalid resolver address/port",
+			args: args{
+				resolver: "tcp://127.0.0.1:99999",
+			},
+			wantErr: true,
+		},
+		{
+			name: "Invalid resolver format",
+			args: args{
+				resolver: "foobaz",
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cfg := &Config{
+				DNSResolver: tt.args.resolver,
+			}
+			client := GetHTTPClient(cfg)
+			_, err := client.Get("https://www.google.com")
+			if (err != nil) != tt.wantErr {
+				t.Errorf("TestDNSResolverConfig err=%v, wantErr=%v", err, tt.wantErr)
+				return
+			}
+		})
+	}
+}
+
 func TestCanPerformStartTLS(t *testing.T) {
 	type args struct {
 		address  string
@@ -180,7 +232,7 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
 			if r.Host == "token-server.local" {
 				return &http.Response{
 					StatusCode: http.StatusOK,
-					Body: ioutil.NopCloser(bytes.NewReader(
+					Body: io.NopCloser(bytes.NewReader(
 						[]byte(
 							`{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"secret-token"}`,
 						),
diff --git a/core/endpoint_test.go b/core/endpoint_test.go
index ddf5c552..03b00b1f 100644
--- a/core/endpoint_test.go
+++ b/core/endpoint_test.go
@@ -23,6 +23,62 @@ func TestEndpoint_IsEnabled(t *testing.T) {
 	}
 }
 
+func TestEndpoint_Type(t *testing.T) {
+	type fields struct {
+		URL string
+		DNS *DNS
+	}
+	tests := []struct {
+		fields fields
+		want   EndpointType
+	}{{
+		fields: fields{
+			URL: "8.8.8.8",
+			DNS: &DNS{
+				QueryType: "A",
+				QueryName: "example.com",
+			},
+		},
+		want: EndpointTypeDNS,
+	}, {
+		fields: fields{
+			URL: "tcp://127.0.0.1:6379",
+		},
+		want: EndpointTypeTCP,
+	}, {
+		fields: fields{
+			URL: "icmp://example.com",
+		},
+		want: EndpointTypeICMP,
+	}, {
+		fields: fields{
+			URL: "starttls://smtp.gmail.com:587",
+		},
+		want: EndpointTypeSTARTTLS,
+	}, {
+		fields: fields{
+			URL: "tls://example.com:443",
+		},
+		want: EndpointTypeTLS,
+	}, {
+		fields: fields{
+			URL: "https://twin.sh/health",
+		},
+		want: EndpointTypeHTTP,
+	}}
+	for _, tt := range tests {
+		t.Run(string(tt.want), func(t *testing.T) {
+			endpoint := Endpoint{
+				URL: tt.fields.URL,
+				DNS: tt.fields.DNS,
+			}
+			if got := endpoint.Type(); got != tt.want {
+				t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
 func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
 	condition := Condition("[STATUS] == 200")
 	endpoint := Endpoint{