mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-29 01:00:41 +01:00
Compare commits
8 Commits
main
...
infraM/goo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9051c4e9e | ||
|
|
17f4b4af91 | ||
|
|
759d2f6db5 | ||
|
|
b9156ac4b0 | ||
|
|
1300dbc1c2 | ||
|
|
0f657b946e | ||
|
|
376c1d9fcc | ||
|
|
e90783d3ed |
167
pkg/alertmanager/alertmanagernotify/googlechat/googlechat.go
Normal file
167
pkg/alertmanager/alertmanagernotify/googlechat/googlechat.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package googlechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func New(conf *alertmanagertypes.GoogleChatReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: conf,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
templater: templater,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
|
||||
if n.conf.WebhookURL == nil {
|
||||
return false, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url is empty")
|
||||
}
|
||||
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n.logger.DebugContext(ctx, "sending google chat notification", slog.Any("group_key", key))
|
||||
|
||||
c, err := n.prepareContent(ctx, alerts)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
text := c.title
|
||||
if c.body != "" {
|
||||
text = fmt.Sprintf("%s\n%s", c.title, c.body)
|
||||
}
|
||||
// Google Chat rejects a message with empty text and no cards. An empty
|
||||
// render means a misconfigured title/text template, so fail loudly and
|
||||
// non-retryably instead of sending a placeholder.
|
||||
if text == "" {
|
||||
return false, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat message rendered empty; check the channel title/text templates")
|
||||
}
|
||||
|
||||
msg := Message{Text: text}
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Truncate to Google Chat's byte limit. We measure the serialized buffer and
|
||||
// drop that many text bytes; each removed text byte drops >=1 serialized byte,
|
||||
// so a single pass always lands the payload within the limit.
|
||||
if buf.Len() > maxMessageBytes {
|
||||
over := buf.Len() - maxMessageBytes
|
||||
target := max(len(text)-over, 0)
|
||||
msg.Text = truncateToByteLimit(text, target)
|
||||
buf.Reset()
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Thread same-rule alerts together: threadKey is a stable hash of the
|
||||
// alert group key. Changing a rule's grouping starts a new thread.
|
||||
u, err := url.Parse(n.conf.WebhookURL.String())
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse google chat webhook url")
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("threadKey", key.Hash())
|
||||
q.Set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
resp, err := notify.PostJSON(ctx, n.client, u.String(), &buf) //nolint:bodyclose
|
||||
if err != nil {
|
||||
return true, notify.RedactURL(err)
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
return shouldRetry, err
|
||||
}
|
||||
|
||||
// prepareContent expands the title and body templates. Custom templates (from
|
||||
// alert annotations) override the channel defaults; result.IsDefaultBody tells
|
||||
// whether the body came from the default template.
|
||||
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (content, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Title,
|
||||
DefaultBodyTemplate: n.conf.Text,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return content{}, err
|
||||
}
|
||||
|
||||
title := result.Title
|
||||
body := strings.Join(result.Body, "\n\n")
|
||||
|
||||
// Default templates are already authored in Google Chat dialect. Custom
|
||||
// templates are standard markdown, so convert them. The templater only
|
||||
// reports IsDefaultBody, so the title is gated on the body's default-ness.
|
||||
if !result.IsDefaultBody {
|
||||
if body != "" {
|
||||
if body, err = markdownrenderer.RenderGoogleChatMarkdown(body); err != nil {
|
||||
return content{}, err
|
||||
}
|
||||
}
|
||||
if title != "" {
|
||||
if title, err = markdownrenderer.RenderGoogleChatMarkdown(title); err != nil {
|
||||
return content{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content{
|
||||
title: title,
|
||||
body: body,
|
||||
isDefaultBody: result.IsDefaultBody,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// truncateToByteLimit trims s to at most maxBytes bytes on a rune boundary,
|
||||
// appending an ellipsis when it truncates.
|
||||
func truncateToByteLimit(s string, maxBytes int) string {
|
||||
if len(s) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
const ellipsis = "..."
|
||||
target := maxBytes - len(ellipsis)
|
||||
if target <= 0 {
|
||||
return ellipsis[:maxBytes]
|
||||
}
|
||||
truncated := s
|
||||
for len(truncated) > target {
|
||||
_, size := utf8.DecodeLastRuneInString(truncated)
|
||||
if size == 0 {
|
||||
break
|
||||
}
|
||||
truncated = truncated[:len(truncated)-size]
|
||||
}
|
||||
return truncated + ellipsis
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package googlechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
func secretURLFromString(t *testing.T, rawURL string) *config.SecretURL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
return &config.SecretURL{URL: parsed}
|
||||
}
|
||||
|
||||
func newTestNotifier(t *testing.T, webhookURL, title, text string) *Notifier {
|
||||
t.Helper()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
WebhookURL: secretURLFromString(t, webhookURL),
|
||||
Title: title,
|
||||
Text: text,
|
||||
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
||||
func newTestAlerts(alertname string) []*types.Alert {
|
||||
return []*types.Alert{{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": model.LabelValue(alertname)},
|
||||
Annotations: model.LabelSet{"summary": model.LabelValue("summary for " + alertname)},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Minute),
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func newTestContext() context.Context {
|
||||
return notify.WithGroupKey(context.Background(), "test-receiver")
|
||||
}
|
||||
|
||||
// captureServer starts an httptest server that decodes the posted Google Chat
|
||||
// Message into got and replies 200.
|
||||
func captureServer(t *testing.T, got *Message) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(got)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestGoogleChatSend(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
n := newTestNotifier(t, server.URL, `[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}`, "")
|
||||
retry, err := n.Notify(newTestContext(), newTestAlerts("TestAlert")...)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, retry)
|
||||
require.Contains(t, got.Text, "FIRING")
|
||||
require.Contains(t, got.Text, "TestAlert")
|
||||
}
|
||||
|
||||
func TestGoogleChatTitleAndBody(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
// static templates → assert the exact title\nbody join.
|
||||
n := newTestNotifier(t, server.URL, "TITLE", "BODY")
|
||||
retry, err := n.Notify(newTestContext(), newTestAlerts("TestAlert")...)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, retry)
|
||||
require.Equal(t, "TITLE\nBODY", got.Text)
|
||||
}
|
||||
|
||||
func TestGoogleChatRetryCodes(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
WebhookURL: secretURLFromString(t, "https://chat.googleapis.com/v1/spaces/test/messages"),
|
||||
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
code int
|
||||
retry bool
|
||||
}{
|
||||
{http.StatusOK, false},
|
||||
{http.StatusBadRequest, false}, // 400: malformed payload, permanent
|
||||
{http.StatusTooManyRequests, true}, // 429: rate limited, retry (our RetryCodes)
|
||||
{http.StatusInternalServerError, true}, // 5xx: retry
|
||||
{http.StatusServiceUnavailable, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(http.StatusText(c.code), func(t *testing.T) {
|
||||
actual, _ := n.retrier.Check(c.code, nil)
|
||||
require.Equal(t, c.retry, actual, "retry mismatch on status %d", c.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleChatRedactedURL(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
ctx = notify.WithGroupKey(ctx, "test-receiver")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
n, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
WebhookURL: &config.SecretURL{URL: u},
|
||||
Title: "alert", // non-empty so it reaches the POST (not the empty-text guard)
|
||||
}, tmpl, slog.New(slog.DiscardHandler), newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, n, u.String())
|
||||
}
|
||||
|
||||
func TestTruncateToByteLimit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
max int
|
||||
wantLen int // upper bound on byte length of result
|
||||
wantHas string // substring the result must contain (or "")
|
||||
}{
|
||||
{"under limit passthrough", "hello", 100, 5, "hello"},
|
||||
{"over limit trims with ellipsis", strings.Repeat("a", 50), 10, 10, "..."},
|
||||
{"exact limit passthrough", "hello", 5, 5, "hello"},
|
||||
{"tiny limit", "hello", 2, 2, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := truncateToByteLimit(c.in, c.max)
|
||||
require.LessOrEqual(t, len(got), c.wantLen)
|
||||
if c.wantHas != "" {
|
||||
require.Contains(t, got, c.wantHas)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleChatMessageSizeLimit(t *testing.T) {
|
||||
var bodyLen int
|
||||
var valid bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
bodyLen = len(raw)
|
||||
valid = json.Valid(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Huge plain-ASCII title so one-time truncation lands deterministically under the limit.
|
||||
n := newTestNotifier(t, server.URL, strings.Repeat("A", 40000), "")
|
||||
retry, err := n.Notify(newTestContext(), newTestAlerts("Big")...)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, retry)
|
||||
require.True(t, valid, "posted body must be valid JSON")
|
||||
require.LessOrEqual(t, bodyLen, maxMessageBytes, "posted body must be within the size limit")
|
||||
}
|
||||
|
||||
func TestGoogleChatThreading(t *testing.T) {
|
||||
var query url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
query = r.URL.Query()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cases := []struct{ name, groupKey string }{
|
||||
{"rule a", "{ruleId=\"aaa\"}"},
|
||||
{"rule b", "{ruleId=\"bbb\"}"},
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
n := newTestNotifier(t, server.URL, "T", "")
|
||||
ctx := notify.WithGroupKey(context.Background(), c.groupKey)
|
||||
_, err := n.Notify(ctx, newTestAlerts("X")...)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD", query.Get("messageReplyOption"))
|
||||
threadKey := query.Get("threadKey")
|
||||
require.Equal(t, notify.Key(c.groupKey).Hash(), threadKey, "threadKey must be the group key hash")
|
||||
seen[c.name] = threadKey
|
||||
})
|
||||
}
|
||||
require.NotEqual(t, seen["rule a"], seen["rule b"], "distinct group keys must yield distinct threadKeys")
|
||||
}
|
||||
|
||||
func TestGoogleChatCustomTemplateMarkdown(t *testing.T) {
|
||||
var got Message
|
||||
server := captureServer(t, &got)
|
||||
defer server.Close()
|
||||
|
||||
// Custom body template (standard markdown) supplied via annotation → the
|
||||
// !IsDefaultBody path must convert it to Google Chat dialect.
|
||||
alerts := []*types.Alert{{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "X"},
|
||||
Annotations: model.LabelSet{ruletypes.AnnotationBodyTemplate: "**bold** and [link](https://x)"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Minute),
|
||||
},
|
||||
}}
|
||||
n := newTestNotifier(t, server.URL, "Alert", "default body")
|
||||
_, err := n.Notify(newTestContext(), alerts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, got.Text, "*bold*", "** should convert to *")
|
||||
require.Contains(t, got.Text, "<https://x|link>", "[t](u) should convert to <u|t>")
|
||||
require.NotContains(t, got.Text, "**bold**", "conversion must have happened")
|
||||
}
|
||||
|
||||
func TestGoogleChatSerializedSizeUnderLimit(t *testing.T) {
|
||||
var bodyLen int
|
||||
var valid bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
bodyLen = len(raw)
|
||||
valid = json.Valid(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Escaping-dense payload: <, >, & and newlines each expand under JSON
|
||||
// encoding, so this is the shape that would push the serialized body over
|
||||
// the limit if we measured the text bytes instead of the marshaled buffer.
|
||||
dense := strings.Repeat("<https://example.com/a?x=1&y=2|link>\n", 2000)
|
||||
n := newTestNotifier(t, server.URL, dense, "")
|
||||
_, err := n.Notify(newTestContext(), newTestAlerts("Dense")...)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, valid, "posted body must be valid JSON")
|
||||
require.LessOrEqual(t, bodyLen, maxMessageBytes, "serialized body must be within the limit")
|
||||
}
|
||||
|
||||
func TestGoogleChatEmptyText(t *testing.T) {
|
||||
server := captureServer(t, nil)
|
||||
defer server.Close()
|
||||
|
||||
// Empty title and body templates → must not POST; fail non-retryably.
|
||||
n := newTestNotifier(t, server.URL, "", "")
|
||||
retry, err := n.Notify(newTestContext(), newTestAlerts("X")...)
|
||||
|
||||
require.Error(t, err)
|
||||
require.False(t, retry)
|
||||
}
|
||||
38
pkg/alertmanager/alertmanagernotify/googlechat/types.go
Normal file
38
pkg/alertmanager/alertmanagernotify/googlechat/types.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package googlechat
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "googlechat"
|
||||
// maxMessageBytes is the Google Chat message payload limit.
|
||||
// https://developers.google.com/chat/api/guides/message-formats/basic#maximum_size
|
||||
maxMessageBytes = 32000
|
||||
)
|
||||
|
||||
// Notifier implements notify.Notifier for Google Chat.
|
||||
type Notifier struct {
|
||||
conf *alertmanagertypes.GoogleChatReceiverConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
// Message is the Google Chat webhook payload.
|
||||
type Message struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// content holds the rendered title and body for a Google Chat message.
|
||||
type content struct {
|
||||
title, body string
|
||||
isDefaultBody bool
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/googlechat"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
|
||||
@@ -24,6 +25,7 @@ var customNotifierIntegrations = []string{
|
||||
opsgenie.Integration,
|
||||
slack.Integration,
|
||||
msteamsv2.Integration,
|
||||
googlechat.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
@@ -74,6 +76,11 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
|
||||
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l, templater)
|
||||
})
|
||||
}
|
||||
for i, c := range nc.GoogleChatConfigs {
|
||||
add(googlechat.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return googlechat.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
|
||||
if errs.Len() > 0 {
|
||||
return nil, &errs
|
||||
|
||||
14
pkg/templating/markdownrenderer/googlechat/doc.go
Normal file
14
pkg/templating/markdownrenderer/googlechat/doc.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package googlechat provides a goldmark extension that renders markdown as
|
||||
// Google Chat's webhook text format.
|
||||
//
|
||||
// Google Chat webhooks support a limited markdown subset with different syntax
|
||||
// than standard markdown: *bold* (not **bold**), _italic_ (not *italic*),
|
||||
// ~strikethrough~ (not ~~strikethrough~~), and <url|text> links (not [text](url)).
|
||||
//
|
||||
// This renderer converts standard markdown to Google Chat's format, allowing
|
||||
// custom alert templates authored in standard markdown to display correctly
|
||||
// in Google Chat notifications.
|
||||
package googlechat
|
||||
226
pkg/templating/markdownrenderer/googlechat/googlechat.go
Normal file
226
pkg/templating/markdownrenderer/googlechat/googlechat.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package googlechat
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Extender is a goldmark extension that converts markdown to Google Chat format.
|
||||
var Extender = &extender{}
|
||||
|
||||
type extender struct{}
|
||||
|
||||
func (e *extender) Extend(m goldmark.Markdown) {
|
||||
// Add strikethrough extension to parse ~~text~~ properly
|
||||
extension.Strikethrough.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(&googlechatRenderer{}, 0)),
|
||||
)
|
||||
}
|
||||
|
||||
type googlechatRenderer struct{}
|
||||
|
||||
// RegisterFuncs implements renderer.NodeRenderer.
|
||||
func (r *googlechatRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Inline elements
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
reg.Register(ast.KindString, r.renderString)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
reg.Register(ast.KindAutoLink, r.renderAutoLink)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
|
||||
// Block elements
|
||||
reg.Register(ast.KindParagraph, r.renderParagraph)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
|
||||
// Document
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := node.(*ast.Text)
|
||||
_, _ = w.Write(n.Segment.Value(source))
|
||||
if n.SoftLineBreak() {
|
||||
_ = w.WriteByte('\n')
|
||||
} else if n.HardLineBreak() {
|
||||
_ = w.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := node.(*ast.String)
|
||||
_, _ = w.Write(n.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Emphasis)
|
||||
|
||||
if n.Level == 2 {
|
||||
// Strong/bold: **text** → *text*
|
||||
_ = w.WriteByte('*')
|
||||
} else {
|
||||
// Emphasis/italic: *text* or _text_ → _text_
|
||||
_ = w.WriteByte('_')
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_ = w.WriteByte('`')
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
segment := c.(*ast.Text).Segment
|
||||
_, _ = w.Write(segment.Value(source))
|
||||
}
|
||||
_ = w.WriteByte('`')
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
|
||||
if entering {
|
||||
// Convert [text](url) → <url|text>
|
||||
url := string(n.Destination)
|
||||
_ = w.WriteByte('<')
|
||||
_, _ = w.WriteString(url)
|
||||
_ = w.WriteByte('|')
|
||||
} else {
|
||||
_ = w.WriteByte('>')
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.AutoLink)
|
||||
if entering {
|
||||
// Auto links can be plain URLs
|
||||
_, _ = w.Write(n.URL(source))
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering && node.NextSibling() != nil {
|
||||
// Add blank line between paragraphs
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
// Google Chat doesn't support headings in webhooks, so render as bold text
|
||||
if entering {
|
||||
_ = w.WriteByte('*')
|
||||
} else {
|
||||
_ = w.WriteByte('*')
|
||||
if node.NextSibling() != nil {
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_ = w.WriteByte('>')
|
||||
} else if node.NextSibling() != nil {
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering && node.NextSibling() != nil {
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
// Use * for bullet points
|
||||
_, _ = w.WriteString("* ")
|
||||
} else {
|
||||
_ = w.WriteByte('\n')
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_, _ = w.WriteString("```\n")
|
||||
lines := node.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
_, _ = w.Write(line.Value(source))
|
||||
}
|
||||
_, _ = w.WriteString("```")
|
||||
if node.NextSibling() != nil {
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_, _ = w.WriteString("```\n")
|
||||
lines := node.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
_, _ = w.Write(line.Value(source))
|
||||
}
|
||||
_, _ = w.WriteString("```")
|
||||
if node.NextSibling() != nil {
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
// Google Chat doesn't have horizontal rules, use a line of dashes
|
||||
_, _ = w.WriteString("---")
|
||||
if node.NextSibling() != nil {
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
// Document doesn't render anything itself
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *googlechatRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
// Google Chat uses single tilde for strikethrough
|
||||
_ = w.WriteByte('~')
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
131
pkg/templating/markdownrenderer/googlechat/googlechat_test.go
Normal file
131
pkg/templating/markdownrenderer/googlechat/googlechat_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package googlechat_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/googlechat"
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func TestRenderer(t *testing.T) {
|
||||
md := goldmark.New(goldmark.WithExtensions(googlechat.Extender))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "simple paragraph",
|
||||
input: "Hello, world!",
|
||||
expected: "Hello, world!",
|
||||
},
|
||||
{
|
||||
name: "heading (rendered as bold)",
|
||||
input: "# My Heading",
|
||||
expected: "*My Heading*",
|
||||
},
|
||||
{
|
||||
name: "multiple paragraphs",
|
||||
input: "First paragraph.\n\nSecond paragraph.",
|
||||
expected: "First paragraph.\n\nSecond paragraph.",
|
||||
},
|
||||
{
|
||||
name: "bold text (** becomes *)",
|
||||
input: "This is **bold** text.",
|
||||
expected: "This is *bold* text.",
|
||||
},
|
||||
{
|
||||
name: "italic text (* becomes _)",
|
||||
input: "This is *italic* text.",
|
||||
expected: "This is _italic_ text.",
|
||||
},
|
||||
{
|
||||
name: "italic text (_ stays _)",
|
||||
input: "This is _italic_ text.",
|
||||
expected: "This is _italic_ text.",
|
||||
},
|
||||
{
|
||||
name: "bold and italic",
|
||||
input: "**bold** and *italic* together.",
|
||||
expected: "*bold* and _italic_ together.",
|
||||
},
|
||||
{
|
||||
name: "code span",
|
||||
input: "Use `code` here.",
|
||||
expected: "Use `code` here.",
|
||||
},
|
||||
{
|
||||
name: "link conversion ([text](url) becomes <url|text>)",
|
||||
input: "Check [this link](https://example.com).",
|
||||
expected: "Check <https://example.com|this link>.",
|
||||
},
|
||||
{
|
||||
name: "plain autolink",
|
||||
input: "<https://example.com>",
|
||||
expected: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "bullet list",
|
||||
input: "- Item 1\n- Item 2\n- Item 3",
|
||||
expected: "* Item 1\n* Item 2\n* Item 3\n",
|
||||
},
|
||||
{
|
||||
name: "fenced code block",
|
||||
input: "```\ncode line 1\ncode line 2\n```",
|
||||
expected: "```\ncode line 1\ncode line 2\n```",
|
||||
},
|
||||
{
|
||||
name: "fenced code block with language",
|
||||
input: "```go\nfunc main() {}\n```",
|
||||
expected: "```\nfunc main() {}\n```",
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
input: "> This is a quote",
|
||||
expected: ">This is a quote",
|
||||
},
|
||||
{
|
||||
name: "thematic break (--- for horizontal rule)",
|
||||
input: "Above\n\n---\n\nBelow",
|
||||
expected: "Above\n\n---\n\nBelow",
|
||||
},
|
||||
{
|
||||
name: "mixed inline formatting",
|
||||
input: "**bold**, *italic*, `code`, and [link](https://example.com).",
|
||||
expected: "*bold*, _italic_, `code`, and <https://example.com|link>.",
|
||||
},
|
||||
{
|
||||
name: "alert-like message",
|
||||
input: "# Alert: High CPU\n\n**Status:** Firing\n\n**Details:**\n- Host: server1\n- CPU: 95%",
|
||||
expected: "*Alert: High CPU*\n\n*Status:* Firing\n\n*Details:*\n\n* Host: server1\n* CPU: 95%\n",
|
||||
},
|
||||
{
|
||||
name: "strikethrough (~~text~~ becomes ~text~)",
|
||||
input: "This is ~~strikethrough~~ text.",
|
||||
expected: "This is ~strikethrough~ text.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(tt.input), &buf); err != nil {
|
||||
t.Fatalf("Convert failed: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if got != tt.expected {
|
||||
t.Errorf("expected:\n%q\n\ngot:\n%q", tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/googlechat"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
@@ -32,6 +33,11 @@ var (
|
||||
return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender))
|
||||
},
|
||||
}
|
||||
googlechatPool = sync.Pool{
|
||||
New: func() any {
|
||||
return goldmark.New(goldmark.WithExtensions(googlechat.Extender))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// RenderHTML converts markdown to HTML.
|
||||
@@ -53,6 +59,13 @@ func RenderSlackMrkdwn(markdown string) (string, error) {
|
||||
return render(md, markdown, "Slack mrkdwn")
|
||||
}
|
||||
|
||||
// RenderGoogleChatMarkdown converts markdown to Google Chat's webhook text format.
|
||||
func RenderGoogleChatMarkdown(markdown string) (string, error) {
|
||||
md := googlechatPool.Get().(goldmark.Markdown)
|
||||
defer googlechatPool.Put(md)
|
||||
return render(md, markdown, "Google Chat")
|
||||
}
|
||||
|
||||
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
@@ -32,3 +36,18 @@ func (c *GoogleChatReceiverConfig) UnmarshalYAML(unmarshal func(any) error) erro
|
||||
type plain GoogleChatReceiverConfig
|
||||
return unmarshal((*plain)(c))
|
||||
}
|
||||
|
||||
// ValidateGoogleChatWebhookURL validates the Google Chat webhook URL format.
|
||||
func ValidateGoogleChatWebhookURL(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid google chat webhook_url: %v", err)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url must use https")
|
||||
}
|
||||
if strings.ToLower(u.Hostname()) != "chat.googleapis.com" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url must use chat.googleapis.com")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
44
pkg/types/alertmanagertypes/googlechat_test.go
Normal file
44
pkg/types/alertmanagertypes/googlechat_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateGoogleChatWebhookURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", "https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t", false},
|
||||
{"http scheme rejected", "http://chat.googleapis.com/v1/spaces/AAA/messages", true},
|
||||
{"wrong host rejected", "https://example.com/v1/spaces/AAA/messages", true},
|
||||
{"empty rejected", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := ValidateGoogleChatWebhookURL(c.url)
|
||||
if c.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReceiverGoogleChatRejectsBadURL(t *testing.T) {
|
||||
// http scheme
|
||||
_, err := NewReceiver(`{"name":"gc","googlechat_configs":[{"webhook_url":"http://chat.googleapis.com/v1/spaces/x/messages"}]}`)
|
||||
require.Error(t, err)
|
||||
|
||||
// wrong host
|
||||
_, err = NewReceiver(`{"name":"gc","googlechat_configs":[{"webhook_url":"https://example.com/x"}]}`)
|
||||
require.Error(t, err)
|
||||
|
||||
// missing webhook_url
|
||||
_, err = NewReceiver(`{"name":"gc","googlechat_configs":[{"title":"x"}]}`)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -45,12 +45,23 @@ func NewReceiver(input string) (*Receiver, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateGoogleChatConfig(defaulted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiver.GoogleChatConfigs[i] = defaulted
|
||||
}
|
||||
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
// validateGoogleChatConfig validates a Google Chat receiver configuration.
|
||||
func validateGoogleChatConfig(cfg *GoogleChatReceiverConfig) error {
|
||||
if cfg.WebhookURL == nil {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url is required")
|
||||
}
|
||||
return ValidateGoogleChatWebhookURL(cfg.WebhookURL.String())
|
||||
}
|
||||
|
||||
func defaultedBaseReceiver(base *config.Receiver) (*config.Receiver, error) {
|
||||
bytes, err := yaml.Marshal(base)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user