Configurable logging levels (#16)

* Update screenshot to match current output
* Log level is configurable
* Alert name is provided in log line, where available.
* Capitalise Discord, check err in integration test, and other syntax issues
* Go formatting syntax fixes
* Log the common alert name or group alert name, in that precedence
This commit is contained in:
Iain Sproat
2022-11-21 21:04:05 +00:00
committed by GitHub
parent 6c6eef2854
commit f2872d6dea
12 changed files with 117 additions and 75 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 312 KiB

+14 -19
View File
@@ -10,7 +10,7 @@ This program is not a replacement to alertmanager, it accepts webhooks from aler
The standard "dataflow" should be:
```
```text
Prometheus -------------> alertmanager -------------------> alertmanager-discord
alerting: receivers:
@@ -18,11 +18,6 @@ alerting: receivers:
- static_configs: webhook_configs: - DISCORD_WEBHOOK=https://discordapp.com/api/we...
- targets: - url: 'http://localhost:9094'
- 127.0.0.1:9093
```
## Features
@@ -44,30 +39,30 @@ alerting: receivers:
## Example alertmanager config
```
```yaml
global:
# The smarthost and SMTP sender used for mail notifications.
smtp_smarthost: 'localhost:25'
smtp_from: 'alertmanager@example.org'
smtp_auth_username: 'alertmanager'
smtp_auth_password: 'password'
smtp_smarthost: "localhost:25"
smtp_from: "alertmanager@example.org"
smtp_auth_username: "alertmanager"
smtp_auth_password: "password"
# The directory from which notification templates are read.
templates:
- '/etc/alertmanager/template/*.tmpl'
- "/etc/alertmanager/template/*.tmpl"
# The root route on which each incoming alert enters.
route:
group_by: ['alertname']
group_by: ["alertname"]
group_wait: 20s
group_interval: 5m
repeat_interval: 3h
receiver: discord_webhook
receivers:
- name: 'discord_webhook'
webhook_configs:
- url: 'http://localhost:9094'
- name: "discord_webhook"
webhook_configs:
- url: "http://localhost:9094"
```
## Deployment
@@ -88,9 +83,9 @@ discord_webhook_url: https://discord.com/api/webhooks/123456789123456789/abc
go run . --configuration_file_path=/path/to/your/config.yaml
```
### Docker
### Docker or OCI-compatible container runtime
If you wish to deploy this to docker infra, you can find the docker hub repo here: https://hub.docker.com/r/speckle/alertmanager-discord/
If you wish to deploy this to Docker, or similar OCI-compatible container runtime, you can pull the OCI image from the [Docker Hub repository](https://hub.docker.com/r/speckle/alertmanager-discord/).
### Kubernetes Helm Chart
@@ -169,4 +164,4 @@ go test ./... -v -cover -test.shuffle on
## Acknowledgements
This repository is forked from https://github.com/benjojo/alertmanager-discord under the Apache 2.0 license
This repository is forked from [benjojo/alertmanager-discord](https://github.com/benjojo/alertmanager-discord) under the Apache 2.0 license
+49 -26
View File
@@ -5,7 +5,7 @@ import (
"strings"
"time"
. "github.com/specklesystems/alertmanager-discord/pkg/flags"
"github.com/specklesystems/alertmanager-discord/pkg/flags"
"github.com/specklesystems/alertmanager-discord/pkg/server"
"github.com/specklesystems/alertmanager-discord/pkg/version"
@@ -18,35 +18,30 @@ import (
const (
defaultConfigurationPath = "/etc/alertmanager-discord/config.yaml"
defaultMaxBackoffTimeSeconds = 10
defaultLogLevel = "info"
)
var (
configurationFilePath string
webhookURL string
listenAddress string
logLevel string
maximumBackoffTimeSeconds int
)
func init() {
viper.SetDefault(ConfigurationPathFlagKey, defaultConfigurationPath)
defineConfigurationVariable(&configurationFilePath, rootCmd.Flags().StringVarP, flags.ConfigurationPathFlagKey, "c", defaultConfigurationPath, "Path to the configuration file.")
defineConfigurationVariable(&webhookURL, rootCmd.Flags().StringVarP, flags.DiscordWebhookUrlFlagKey, "d", "", "Url to the Discord webhook API endpoint.")
defineConfigurationVariable(&listenAddress, rootCmd.Flags().StringVarP, flags.ListenAddressFlagKey, "l", server.DefaultListenAddress, "The address (host:port) which the server will attempt to bind to and listen on.")
defineConfigurationVariable(&logLevel, rootCmd.Flags().StringVarP, flags.LogLevelFlagKey, "", defaultLogLevel, "The minimum level of logging to be produced by the pod. Acceptable values, in ascending order, are 'trace', 'debug', 'info', 'warn', 'error', 'fatal', 'panic', or 'disabled'.")
defineConfigurationVariable(&maximumBackoffTimeSeconds, rootCmd.Flags().IntVarP, flags.MaxBackoffTimeSecondsFlagKey, "", defaultMaxBackoffTimeSeconds, "The maximum elapsed duration (expressed as an integer number of seconds) to allow the Discord client to continue retrying to send messages to the Discord API.")
}
viper.BindEnv(ConfigurationPathFlagKey, strings.ToUpper(ConfigurationPathFlagKey))
rootCmd.Flags().StringVarP(&configurationFilePath, ConfigurationPathFlagKey, "c", defaultConfigurationPath, "Path to the configuration file.")
viper.BindPFlag(ConfigurationPathFlagKey, rootCmd.Flags().Lookup(ConfigurationPathFlagKey))
viper.BindEnv(DiscordWebhookUrlFlagKey, strings.ToUpper(DiscordWebhookUrlFlagKey))
rootCmd.Flags().StringVarP(&webhookURL, DiscordWebhookUrlFlagKey, "d", "", "Url to the Discord webhook API endpoint.")
viper.BindPFlag(DiscordWebhookUrlFlagKey, rootCmd.Flags().Lookup(DiscordWebhookUrlFlagKey))
viper.SetDefault(ListenAddressFlagKey, server.DefaultListenAddress)
viper.BindEnv(ListenAddressFlagKey, strings.ToUpper(ListenAddressFlagKey))
rootCmd.Flags().StringVarP(&listenAddress, ListenAddressFlagKey, "l", "", "The address (host:port) which the server will attempt to bind to and listen on.")
viper.BindPFlag(ListenAddressFlagKey, rootCmd.Flags().Lookup(ListenAddressFlagKey))
viper.SetDefault(MaxBackoffTimeSecondsFlagKey, defaultMaxBackoffTimeSeconds)
viper.BindEnv(MaxBackoffTimeSecondsFlagKey, strings.ToUpper(MaxBackoffTimeSecondsFlagKey))
rootCmd.Flags().IntVarP(&maximumBackoffTimeSeconds, MaxBackoffTimeSecondsFlagKey, "", defaultMaxBackoffTimeSeconds, "The maximum elapsed duration (expressed as an integer number of seconds) to allow the Discord client to continue retrying to send messages to the Discord API.")
viper.BindPFlag(MaxBackoffTimeSecondsFlagKey, rootCmd.Flags().Lookup(MaxBackoffTimeSecondsFlagKey))
func defineConfigurationVariable[K int | string](variable *K, flagParser func(*K, string, string, K, string), flagKey string, shorthand string, defaultValue K, description string) {
viper.SetDefault(flagKey, defaultValue)
viper.BindEnv(flagKey, strings.ToUpper(flagKey))
flagParser(variable, flagKey, shorthand, defaultValue, description)
viper.BindPFlag(flagKey, rootCmd.Flags().Lookup(flagKey))
}
var rootCmd = &cobra.Command{
@@ -57,22 +52,27 @@ var rootCmd = &cobra.Command{
translates the data to match Discord's message specifications,
and forwards that to Discord's message API endpoint.`,
Run: func(cmd *cobra.Command, args []string) {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
zerolog.TimeFieldFormat = time.RFC3339
zerolog.SetGlobalLevel(zerolog.InfoLevel)
// these log messages are generated before the log level is set
log.Debug().Msgf("Attempting to read from configuration file path: ('%s')", configurationFilePath)
viper.SetConfigFile(configurationFilePath)
if err := viper.ReadInConfig(); err != nil {
log.Info().Err(err).Msgf("Unable to read configuration file at path ('%s'). Attempting to parse command line arguments or environment variables, the command line argument has higher order of precedence.", configurationFilePath)
}
if viper.GetString(DiscordWebhookUrlFlagKey) != "" {
webhookURL = viper.GetString(DiscordWebhookUrlFlagKey)
if viper.GetString(flags.DiscordWebhookUrlFlagKey) != "" {
webhookURL = viper.GetString(flags.DiscordWebhookUrlFlagKey)
}
if viper.GetString(ListenAddressFlagKey) != "" {
listenAddress = viper.GetString(ListenAddressFlagKey)
if viper.GetString(flags.ListenAddressFlagKey) != "" {
listenAddress = viper.GetString(flags.ListenAddressFlagKey)
}
if viper.GetString(MaxBackoffTimeSecondsFlagKey) != "" {
maximumBackoffTimeSeconds = viper.GetInt(MaxBackoffTimeSecondsFlagKey)
setGlobalLogLevel(viper.GetString(flags.LogLevelFlagKey))
if viper.GetString(flags.MaxBackoffTimeSecondsFlagKey) != "" {
maximumBackoffTimeSeconds = viper.GetInt(flags.MaxBackoffTimeSecondsFlagKey)
}
amds := server.AlertManagerDiscordServer{
@@ -100,3 +100,26 @@ func Execute() {
os.Exit(1)
}
}
func setGlobalLogLevel(logLevel string) {
switch logLevel {
case "trace":
zerolog.SetGlobalLevel(zerolog.TraceLevel)
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "info":
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
case "fatal":
zerolog.SetGlobalLevel(zerolog.FatalLevel)
case "panic":
zerolog.SetGlobalLevel(zerolog.PanicLevel)
case "disabled":
zerolog.SetGlobalLevel(zerolog.Disabled)
default:
break
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ A Helm chart to deploy alertmanager-discord to Kubernetes
| securityContext.readOnlyRootFilesystem | bool | `true` | |
| securityContext.runAsNonRoot | bool | `true` | |
| securityContext.runAsUser | int | `1000` | |
| server.configuration.key | string | `"config.yaml"` | |
| server.configuration.key | string | `"config.yaml"` | the key within the Kubernetes Secret. This key is expected to be a filename, as it will for the path for the configuration file when mounted to the container. |
| server.configuration.name | string | `"discord-config"` | name of the Kubernetes Secret containing the configuration file, will be mounted to the container. Must be in the same namespace as this helm chart is deployed. |
| service.port | int | `9094` | The port to which alertmanager should push alerts |
| service.type | string | `"ClusterIP"` | |
+1
View File
@@ -55,6 +55,7 @@ server:
configuration:
# -- name of the Kubernetes Secret containing the configuration file, will be mounted to the container. Must be in the same namespace as this helm chart is deployed.
name: discord-config
# -- the key within the Kubernetes Secret. This key is expected to be a filename, as it will for the path for the configuration file when mounted to the container.
key: config.yaml
# within the config.yaml data, it should be yaml formatted with the key `discord_webhook_url`, and optionally keys `listen_address` & `max_backoff_time_seconds`. An example of the data expected can be found at ./test/test-config.yaml
+28 -15
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/specklesystems/alertmanager-discord/pkg/alertmanager"
@@ -13,6 +14,7 @@ import (
"github.com/specklesystems/alertmanager-discord/pkg/prometheus"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -24,9 +26,9 @@ type AlertForwarderHandler struct {
af AlertForwarder
}
func NewAlertForwarderHandler(client *http.Client, webhookURL string, maximumBackoffTimeSeconds time.Duration) *AlertForwarderHandler {
func NewAlertForwarderHandler(client *http.Client, webhookURL string, maximumBackoffElapsedTime time.Duration) *AlertForwarderHandler {
return &AlertForwarderHandler{
af: NewAlertForwarder(client, webhookURL, maximumBackoffTimeSeconds),
af: NewAlertForwarder(client, webhookURL, maximumBackoffElapsedTime),
}
}
@@ -38,12 +40,20 @@ type AlertForwarder struct {
client *discord.Client
}
func NewAlertForwarder(client *http.Client, webhookURL string, maximumBackoffTimeSeconds time.Duration) AlertForwarder {
func NewAlertForwarder(client *http.Client, webhookURL string, maximumBackoffElapsedTime time.Duration) AlertForwarder {
return AlertForwarder{
client: discord.NewClient(client, webhookURL, maximumBackoffTimeSeconds),
client: discord.NewClient(client, webhookURL, maximumBackoffElapsedTime),
}
}
func (af *AlertForwarder) groupAlerts(amo *alertmanager.Out) map[string][]alertmanager.Alert {
groupedAlerts := make(map[string][]alertmanager.Alert)
for _, alert := range amo.Alerts {
groupedAlerts[alert.Status] = append(groupedAlerts[alert.Status], alert)
}
return groupedAlerts
}
func (af *AlertForwarder) sendWebhook(correlationId string, amo *alertmanager.Out, w http.ResponseWriter) {
if len(amo.Alerts) < 1 {
log.Debug().
@@ -53,31 +63,35 @@ func (af *AlertForwarder) sendWebhook(correlationId string, amo *alertmanager.Ou
return
}
groupedAlerts := make(map[string][]alertmanager.Alert)
for _, alert := range amo.Alerts {
groupedAlerts[alert.Status] = append(groupedAlerts[alert.Status], alert)
logger := zerolog.New(os.Stderr).With().
Timestamp().
Str(logging.FieldKeyCorrelationId, correlationId).Logger()
if amo.CommonLabels.Alertname != "" {
logger = logger.With().Str(logging.FieldKeyAlertName, amo.CommonLabels.Alertname).Logger()
} else if amo.GroupLabels.Alertname != "" {
logger = logger.With().Str(logging.FieldKeyAlertName, amo.GroupLabels.Alertname).Logger()
}
failedToPublishAtLeastOne := false
for status, alerts := range groupedAlerts {
for status, alerts := range af.groupAlerts(amo) {
DO := TranslateAlertManagerToDiscord(status, amo, alerts)
log.Info().
logger.Info().
Str(logging.FieldKeyEventType, logging.EventTypeRequestSending).
Str(logging.FieldKeyCorrelationId, correlationId).
Msg("Sending HTTP request to Discord.")
res, err := af.client.PublishMessage(DO)
if err != nil {
err = fmt.Errorf("Error encountered when publishing message to discord: %w", err)
log.Error().
err = fmt.Errorf("failed to publish message to Discord: %w", err)
logger.Error().
Str(logging.FieldKeyCorrelationId, correlationId).
Err(err).
Msg("Error when attempting to publish message to discord.")
Msg("Error when attempting to publish message to Discord.")
failedToPublishAtLeastOne = true
continue
}
log.Info().
logger.Info().
Str(logging.FieldKeyEventType, logging.EventTypeResponseReceived).
Str(logging.FieldKeyCorrelationId, correlationId).
Msg("HTTP response received from Discord")
@@ -124,7 +138,7 @@ or https://prometheus.io/docs/alerting/latest/configuration/#webhook_config`
Msg("Sending HTTP request to Discord.")
res, err := af.client.PublishMessage(DO)
if err != nil {
return nil, fmt.Errorf("Error encountered when publishing message to discord: %w", err)
return nil, fmt.Errorf("error encountered when publishing message to Discord: %w", err)
}
log.Info().
@@ -204,5 +218,4 @@ func (af *AlertForwarder) handleInvalidInput(correlationId string, b []byte, w h
}
w.WriteHeader(http.StatusBadRequest)
return
}
+3 -6
View File
@@ -17,13 +17,10 @@ func CheckWebhookURL(webhookURL string) (bool, *url.URL, error) {
parsedUrl, err := url.Parse(webhookURL)
if err != nil {
return false, &url.URL{}, fmt.Errorf("The Discord WebHook URL ('%s') cannot be parsed as a url: %w", webhookURL, err)
return false, &url.URL{}, fmt.Errorf("the Discord WebHook URL ('%s') cannot be parsed as a url: %w", webhookURL, err)
}
host, _, err := net.SplitHostPort(parsedUrl.Host)
if err != nil {
// return false, parsedUrl, fmt.Errorf("The Discord WebHook URL ('%s') host ('%s') cannot be separated into domain/ip and port components: %w", webhookURL, parsedUrl.Host, err)
}
host, _, _ := net.SplitHostPort(parsedUrl.Host)
if host == "" {
host = parsedUrl.Host
}
@@ -37,7 +34,7 @@ func CheckWebhookURL(webhookURL string) (bool, *url.URL, error) {
ok := re.Match([]byte(webhookURL))
if !ok {
return false, parsedUrl, fmt.Errorf("The Discord WebHook URL doesn't seem to be a valid Discord Webhook API url: '%s'", webhookURL)
return false, parsedUrl, fmt.Errorf("the Discord WebHook URL doesn't seem to be a valid Discord Webhook API url: '%s'", webhookURL)
}
return ok, parsedUrl, nil
+3 -3
View File
@@ -8,17 +8,17 @@ import (
var (
RequestsToDiscordInFlight = promauto.NewGauge(prometheus.GaugeOpts{
Name: "discord_client_requests_in_flight",
Help: "The current number of http requests being sent by the discord client.",
Help: "The current number of http requests being sent by the Discord client.",
})
RequestsToDiscordTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "discord_client_requests_total",
Help: "The total number of http requests sent by the discord client.",
Help: "The total number of http requests sent by the Discord client.",
}, []string{"code", "method"})
RequestsToDiscordDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "discord_client_request_duration_seconds",
Help: "Duration of all http requests sent by the discord client.",
Help: "Duration of all http requests sent by the Discord client.",
Buckets: prometheus.DefBuckets,
}, []string{"code"})
)
+1
View File
@@ -5,4 +5,5 @@ const (
DiscordWebhookUrlFlagKey = "discord_webhook_url"
ListenAddressFlagKey = "listen_address"
MaxBackoffTimeSecondsFlagKey = "max_backoff_time_seconds"
LogLevelFlagKey = "log_level"
)
+1
View File
@@ -6,6 +6,7 @@ const (
FieldKeyHttpMethod = "method"
FieldKeyHttpPath = "path"
FieldKeyEventType = "event_type"
FieldKeyAlertName = "alert_name"
FieldKeyCorrelationId = "correlation_id"
FieldKeyStatusCode = "status_code"
)
+13 -4
View File
@@ -22,11 +22,11 @@ const (
)
func Test_Serve_HappyPath(t *testing.T) {
// create a mock discord server to respond to our request
// create a mock Discord server to respond to our request
receivedRequest := make(chan bool, 1)
mockDiscordServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Discord mock server will always return with Status Code 200 OK
receivedRequest <- true // notify the channel that the discord server received the request
receivedRequest <- true // notify the channel that the Discord server received the request
}))
defer mockDiscordServer.Close()
@@ -44,19 +44,23 @@ func Test_Serve_HappyPath(t *testing.T) {
}
res, err := client.Get(fmt.Sprintf("http://%s/liveness", serverListenAddress))
assert.NoError(t, err)
assert.NotNil(t, res, "response to GET '/liveness' should not be nil")
assert.Equal(t, http.StatusOK, res.StatusCode, "GET liveness should return status code OK (200)")
res, err = client.Get(fmt.Sprintf("http://%s/readiness", serverListenAddress))
assert.NoError(t, err)
assert.NotNil(t, res, "response to GET '/readiness' should not be nil")
assert.Equal(t, http.StatusOK, res.StatusCode, "GET readiness should return status code OK (200)")
res, err = client.Get(fmt.Sprintf("http://%s/favicon.ico", serverListenAddress))
assert.NoError(t, err)
assert.NotNil(t, res, "response to GET '/favicon.ico' should not be nil")
assert.Equal(t, http.StatusOK, res.StatusCode, "GET favicon.ico should return status code OK (200)")
res, err = client.Get(fmt.Sprintf("http://%s/metrics", serverListenAddress))
assert.NoError(t, err)
assert.NotNil(t, res, "response to GET '/metrics' should not be nil")
assert.Equal(t, http.StatusOK, res.StatusCode, "GET favicon.ico should return status code OK (200)")
// assert mock discord server received expected json
// assert mock Discord server received expected json
ao := alertmanager.Out{
Alerts: []alertmanager.Alert{
{
@@ -68,6 +72,11 @@ func Test_Serve_HappyPath(t *testing.T) {
}{
Summary: "a_common_annotation_summary",
},
GroupLabels: struct {
Alertname string `json:"alertname"`
}{
Alertname: "testAlertName",
},
}
aoJson, err := json.Marshal(ao)
@@ -86,7 +95,7 @@ func Test_Serve_HappyPath(t *testing.T) {
assert.NoError(t, err, "sending request to alertmanager-discord server.")
assert.NotNil(t, res, "response to POST '/' should not be nil")
assert.Equal(t, http.StatusOK, res.StatusCode, "sending valid alertmanager data should expect http response status code")
assert.True(t, <-receivedRequest, "Mock discord server should have received response") // will wait until the request is received
assert.True(t, <-receivedRequest, "Mock Discord server should have received response") // will wait until the request is received
// TODO assert log lines were generated
+3 -1
View File
@@ -15,7 +15,9 @@ import (
"github.com/rs/zerolog/log"
)
const DefaultListenAddress = "0.0.0.0:9094"
const (
DefaultListenAddress = "0.0.0.0:9094"
)
const (
FaviconPath = "/favicon.ico"
LivenessPath = "/liveness"