Compare commits

...

8 Commits

Author SHA1 Message Date
nikhilmantri0902
a9051c4e9e chore: error on empty title body with non-retryable error and give 429 retryable error due to limit 2026-07-29 01:34:03 +05:30
nikhilmantri0902
17f4b4af91 chore: added one more test 2026-07-28 18:02:14 +05:30
nikhilmantri0902
759d2f6db5 chore: comment correction 2026-07-28 17:38:22 +05:30
nikhilmantri0902
b9156ac4b0 chore: added utf 8 sanitization and capping the limit 2026-07-28 17:35:32 +05:30
nikhilmantri0902
1300dbc1c2 chore: adding processing logic for makrdown to google chat format 2026-07-28 17:09:17 +05:30
nikhilmantri0902
0f657b946e chore: added tests 2026-07-28 16:49:24 +05:30
nikhilmantri0902
376c1d9fcc chore: added content extraction and formatting for google chat content 2026-07-28 16:02:32 +05:30
nikhilmantri0902
e90783d3ed chore: added googlechat notify function 2026-07-28 15:14:27 +05:30
11 changed files with 951 additions and 0 deletions

View 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: &notify.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
}

View File

@@ -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)
}

View 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
}

View File

@@ -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

View 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

View 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
}

View 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)
}
})
}
}

View File

@@ -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 {

View File

@@ -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
}

View 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)
}

View File

@@ -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 {