Compare commits

..

8 Commits

Author SHA1 Message Date
srikanthccv
2bf6f10ebe chore: temp commit 2026-05-27 00:28:51 +05:30
Srikanth Chekuri
2a7e7afc83 Merge branch 'main' into google-chat-alert-channel#5095 2026-05-21 20:36:37 +05:30
Niladri Adhikary
cab18fb844 Merge branch 'main' into google-chat-alert-channel#5095 2026-05-12 19:50:55 +05:30
Niladri Adhikary
3ed0e4f28b fix: ci lint & template test fixes
Signed-off-by: Niladri Adhikary <niladrix719@gmail.com>
2026-05-12 19:50:29 +05:30
Niladri Adhikary
ef57a95f6c chore: remove unused import
Signed-off-by: Niladri Adhikary <niladrix719@gmail.com>
2026-05-12 16:54:24 +05:30
Niladri Adhikary
4e33586f28 Merge branch 'main' into google-chat-alert-channel#5095 2026-05-12 13:31:57 +05:30
Niladri Adhikary
1ea76147e5 Merge branch 'main' into google-chat-alert-channel#5095 2026-05-12 13:30:48 +05:30
Niladri Adhikary
7653793e22 feat(alertmanager): add support for google chat alert channel
Signed-off-by: Niladri Adhikary <niladrix719@gmail.com>
2026-05-12 13:01:14 +05:30
49 changed files with 1453 additions and 582 deletions

View File

@@ -1544,6 +1544,17 @@ components:
webhook_url_file:
type: string
type: object
ConfigGoogleChatConfig:
properties:
send_resolved:
type: boolean
text:
type: string
title:
type: string
webhook_url:
$ref: '#/components/schemas/ConfigSecretURL'
type: object
ConfigMattermostAttachment:
properties:
author_icon:
@@ -1847,6 +1858,10 @@ components:
items:
$ref: '#/components/schemas/ConfigEmailConfig'
type: array
googlechat_configs:
items:
$ref: '#/components/schemas/ConfigGoogleChatConfig'
type: array
incidentio_configs:
items:
$ref: '#/components/schemas/ConfigIncidentioConfig'

View File

@@ -24,9 +24,12 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_googlechat_url": "The URL of the Google Chat [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"field_googlechat_title": "Title",
"field_googlechat_description": "Description",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
"placeholder_opsgenie_description": "Description",
@@ -77,6 +80,7 @@
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
"webhook_url_required": "Webhook URL is mandatory",
"googlechat_webhook_url_required": "Google Chat webhook URL is mandatory",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",

View File

@@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createGoogleChat';
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
googlechat_configs: [
{
send_resolved: props.send_resolved,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editGoogleChat';
const editGoogleChat = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
googlechat_configs: [
{
send_resolved: props.send_resolved,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editGoogleChat;

View File

@@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createGoogleChat';
const testGoogleChat = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
googlechat_configs: [
{
send_resolved: true,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testGoogleChat;

View File

@@ -1,6 +1,8 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import {
googleChatTextDefaultValue,
googleChatTitleDefaultValue,
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
@@ -419,5 +421,47 @@ describe('Create Alert Channel', () => {
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
});
describe('Google Chat', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.GoogleChat} />);
});
it('Should check if the selected item in the type dropdown has text "Google Chat"', () => {
expect(screen.getByText('Google Chat')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly ', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title label and text area are displayed properly ', () => {
testLabelInputAndHelpValue({
labelText: 'field_googlechat_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(googleChatTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly ', () => {
testLabelInputAndHelpValue({
labelText: 'field_googlechat_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(googleChatTextDefaultValue);
});
});
});
});

View File

@@ -1,6 +1,8 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import {
googleChatTextDefaultValue,
googleChatTitleDefaultValue,
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
@@ -332,5 +334,42 @@ describe('Create Alert Channel (Normal User)', () => {
).toBeDisabled();
});
});
describe('Google Chat', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.GoogleChat} />);
});
it('Should check if the selected item in the type dropdown has text "Google Chat"', () => {
expect(screen.getByText('Google Chat')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_googlechat_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(googleChatTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_googlechat_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(googleChatTextDefaultValue);
});
});
});
});

View File

@@ -104,6 +104,7 @@ export enum ChannelType {
Pagerduty = 'pagerduty',
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
GoogleChat = 'googlechat',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
@@ -125,3 +126,9 @@ export interface MsTeamsChannel extends Channel {
title?: string;
text?: string;
}
export interface GoogleChatChannel extends Channel {
webhook_url?: string;
title?: string;
text?: string;
}

View File

@@ -1,16 +1,32 @@
import { EmailChannel, OpsgenieChannel, PagerChannel } from './config';
import {
EmailChannel,
GoogleChatChannel,
OpsgenieChannel,
PagerChannel,
} from './config';
export const PagerInitialConfig: Partial<PagerChannel> = {
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
{{- range $index, $label := .SortedPairs -}}
{{ if $index }}, {{ end }}
{{- $label.Name }}="{{ $label.Value -}}"
{{- end }}
{{- end -}}
)
description: `{{ if gt (len .Alerts.Firing) 0 -}}
Alerts Firing:
{{ range .Alerts.Firing }}
- Message: {{ .Annotations.description }}
Labels:
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }} Annotations:
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }} Source: {{ .GeneratorURL }}
{{ end }}
{{- end }}
{{ if gt (len .Alerts.Resolved) 0 -}}
Alerts Resolved:
{{ range .Alerts.Resolved }}
- Message: {{ .Annotations.description }}
Labels:
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }} Annotations:
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }} Source: {{ .GeneratorURL }}
{{ end }}
{{- end }}`,
severity: '{{ (index .Alerts 0).Labels.severity }}',
client: 'SigNoz Alert Manager',
@@ -446,3 +462,20 @@ export const EmailInitialConfig: Partial<EmailChannel> = {
</body>
</html>`,
};
export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }}
*Severity:* {{ .Labels.severity }}{{ end }}{{ if .Annotations.summary }}
*Summary:* {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
*Description:* {{ .Annotations.description }}{{ end }}{{ if .Annotations.related_logs }}
*Related Logs:* {{ .Annotations.related_logs }}{{ end }}{{ if .Annotations.related_traces }}
*Related Traces:* {{ .Annotations.related_traces }}{{ end }}
*Labels:*
{{ range .Labels.SortedPairs -}}
\`{{ .Name }}\`: {{ .Value }}
{{ end }}
{{ end }}`,
};

View File

@@ -2,12 +2,14 @@ import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form } from 'antd';
import createEmail from 'api/channels/createEmail';
import createGoogleChat from 'api/channels/createGoogleChat';
import createMsTeamsApi from 'api/channels/createMsTeams';
import createOpsgenie from 'api/channels/createOpsgenie';
import createPagerApi from 'api/channels/createPager';
import createSlackApi from 'api/channels/createSlack';
import createWebhookApi from 'api/channels/createWebhook';
import testEmail from 'api/channels/testEmail';
import testGoogleChat from 'api/channels/testGoogleChat';
import testMsTeamsApi from 'api/channels/testMsTeams';
import testOpsGenie from 'api/channels/testOpsgenie';
import testPagerApi from 'api/channels/testPager';
@@ -24,6 +26,7 @@ import APIError from 'types/api/error';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -33,6 +36,7 @@ import {
} from './config';
import {
EmailInitialConfig,
GoogleChatInitialConfig,
OpsgenieInitialConfig,
PagerInitialConfig,
} from './defaults';
@@ -59,6 +63,7 @@ function CreateAlertChannels({
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel &
OpsgenieChannel &
EmailChannel
>
@@ -121,6 +126,14 @@ function CreateAlertChannels({
...EmailInitialConfig,
}));
}
// reset config to Google Chat defaults
if (value === ChannelType.GoogleChat && currentType !== value) {
setSelectedConfig((selectedConfig) => ({
...selectedConfig,
...GoogleChatInitialConfig,
}));
}
},
[type, selectedConfig],
);
@@ -406,7 +419,49 @@ function CreateAlertChannels({
prepareMsTeamsRequest,
showErrorModal,
]);
const prepareGoogleChatRequest = useCallback(
() => ({
webhook_url: selectedConfig?.webhook_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
}),
[selectedConfig],
);
const onGoogleChatHandler = useCallback(async () => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('googlechat_webhook_url_required'),
});
return;
}
setSavingState(true);
try {
await createGoogleChat(prepareGoogleChatRequest());
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
selectedConfig.webhook_url,
notifications,
t,
prepareGoogleChatRequest,
showErrorModal,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
if (!selectedConfig.name) {
@@ -424,6 +479,7 @@ function CreateAlertChannels({
[ChannelType.Opsgenie]: onOpsgenieHandler,
[ChannelType.MsTeams]: onMsTeamsHandler,
[ChannelType.Email]: onEmailHandler,
[ChannelType.GoogleChat]: onGoogleChatHandler,
};
if (isChannelType(value)) {
@@ -455,6 +511,7 @@ function CreateAlertChannels({
onOpsgenieHandler,
onMsTeamsHandler,
onEmailHandler,
onGoogleChatHandler,
notifications,
t,
],
@@ -492,6 +549,10 @@ function CreateAlertChannels({
request = prepareEmailRequest();
await testEmail(request);
break;
case ChannelType.GoogleChat:
request = prepareGoogleChatRequest();
await testGoogleChat(request);
break;
default:
notifications.error({
message: 'Error',
@@ -534,6 +595,7 @@ function CreateAlertChannels({
prepareOpsgenieRequest,
prepareSlackRequest,
prepareMsTeamsRequest,
prepareGoogleChatRequest,
prepareEmailRequest,
notifications,
],
@@ -546,6 +608,23 @@ function CreateAlertChannels({
[performChannelTest],
);
const getInitialConfigForType = (): Partial<
PagerChannel & OpsgenieChannel & EmailChannel & GoogleChatChannel
> => {
switch (type) {
case ChannelType.Pagerduty:
return PagerInitialConfig;
case ChannelType.Opsgenie:
return OpsgenieInitialConfig;
case ChannelType.Email:
return EmailInitialConfig;
case ChannelType.GoogleChat:
return GoogleChatInitialConfig;
default:
return {};
}
};
return (
<div className="create-alert-channels-container">
<FormAlertChannels
@@ -562,9 +641,7 @@ function CreateAlertChannels({
initialValue: {
type,
...selectedConfig,
...PagerInitialConfig,
...OpsgenieInitialConfig,
...EmailInitialConfig,
...getInitialConfigForType(),
},
}}
/>

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form } from 'antd';
import editGoogleChat from 'api/channels/editGoogleChat';
import editEmail from 'api/channels/editEmail';
import editMsTeamsApi from 'api/channels/editMsTeams';
import editOpsgenie from 'api/channels/editOpsgenie';
@@ -8,6 +9,7 @@ import editPagerApi from 'api/channels/editPager';
import editSlackApi from 'api/channels/editSlack';
import editWebhookApi from 'api/channels/editWebhook';
import testEmail from 'api/channels/testEmail';
import testGoogleChat from 'api/channels/testGoogleChat';
import testMsTeamsApi from 'api/channels/testMsTeams';
import testOpsgenie from 'api/channels/testOpsgenie';
import testPagerApi from 'api/channels/testPager';
@@ -18,6 +20,7 @@ import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -43,6 +46,7 @@ function EditAlertChannels({
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel &
OpsgenieChannel &
EmailChannel
>
@@ -333,6 +337,56 @@ function EditAlertChannels({
[id, selectedConfig],
);
const prepareGoogleChatRequest = useCallback(
() => ({
webhook_url: selectedConfig?.webhook_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
id,
}),
[id, selectedConfig],
);
const onGoogleChatEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.webhook_url === '') {
notifications.error({
message: 'Error',
description: t('googlechat_webhook_url_required'),
});
setSavingState(false);
return {
status: 'failed',
statusMessage: t('googlechat_webhook_url_required'),
};
}
try {
await editGoogleChat(prepareGoogleChatRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareGoogleChatRequest, notifications, selectedConfig, t]);
const onMsTeamsEditHandler = useCallback(async () => {
setSavingState(true);
@@ -383,6 +437,8 @@ function EditAlertChannels({
result = await onOpsgenieEditHandler();
} else if (value === ChannelType.Email) {
result = await onEmailEditHandler();
} else if (value === ChannelType.GoogleChat) {
result = await onGoogleChatEditHandler();
}
logEvent('Alert Channel: Save channel', {
type: value,
@@ -401,6 +457,7 @@ function EditAlertChannels({
onMsTeamsEditHandler,
onOpsgenieEditHandler,
onEmailEditHandler,
onGoogleChatEditHandler,
],
);
@@ -442,6 +499,12 @@ function EditAlertChannels({
await testEmail(request);
}
break;
case ChannelType.GoogleChat:
request = prepareGoogleChatRequest();
if (request) {
await testGoogleChat(request);
}
break;
default:
notifications.error({
message: 'Error',
@@ -484,6 +547,7 @@ function EditAlertChannels({
preparePagerRequest,
prepareSlackRequest,
prepareMsTeamsRequest,
prepareGoogleChatRequest,
prepareOpsgenieRequest,
prepareEmailRequest,
notifications,

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item
name="webhook_url"
label={t('field_webhook_url')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_googlechat_url')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
placeholder="https://chat.googleapis.com/v1/spaces/..."
/>
</Form.Item>
<Form.Item name="title" label={t('field_googlechat_title')}>
<Input.TextArea
rows={2}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
title: event.target.value,
}))
}
data-testid="title-textarea"
placeholder={`[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`}
/>
</Form.Item>
<Form.Item name="text" label={t('field_googlechat_description')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
text: event.target.value,
}))
}
data-testid="description-textarea"
placeholder={t('placeholder_slack_description')}
/>
</Form.Item>
</>
);
}
interface GoogleChatProps {
setSelectedConfig: React.Dispatch<
React.SetStateAction<Partial<GoogleChatChannel>>
>;
}
export default GoogleChat;

View File

@@ -8,6 +8,7 @@ import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
@@ -16,6 +17,7 @@ import {
import history from 'lib/history';
import EmailSettings from './Settings/Email';
import GoogleChatSettings from './Settings/GoogleChat';
import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
@@ -52,6 +54,8 @@ function FormAlertChannels({
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Email:
return <EmailSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.GoogleChat:
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
default:
return null;
}
@@ -128,6 +132,13 @@ function FormAlertChannels({
<Select.Option value="msteams" key="msteams" data-testid="select-option">
Microsoft Teams
</Select.Option>
<Select.Option
value="googlechat"
key="googlechat"
data-testid="select-option"
>
Google Chat
</Select.Option>
</Select>
</Form.Item>
@@ -172,6 +183,7 @@ interface FormAlertChannelsProps {
WebhookChannel &
PagerChannel &
OpsgenieChannel &
GoogleChatChannel &
EmailChannel
>
>

View File

@@ -1,84 +0,0 @@
import getLabelName from 'lib/getLabelName';
describe('getLabelName', () => {
describe('with a legend template', () => {
it('substitutes a single variable that exists on the series', () => {
const result = getLabelName(
{ 'service.name': 'frontend' },
'A',
'{{service.name}}',
);
expect(result).toBe('frontend');
});
it('substitutes a template with surrounding literal text', () => {
const result = getLabelName(
{ 'service.name': 'frontend' },
'A',
'rate for {{service.name}}',
);
expect(result).toBe('rate for frontend');
});
it('substitutes multiple variables when all are present', () => {
const result = getLabelName(
{ 'service.name': 'frontend', 'http.target': 'GET /api' },
'A',
'{{service.name}} / {{http.target}}',
);
expect(result).toBe('frontend / GET /api');
});
it('falls back to query name when a referenced variable is missing', () => {
const result = getLabelName(
{ 'http.target': 'GET /api' },
'F1',
'{{service.name}}',
);
expect(result).toBe('F1');
});
it('falls back to query name even if literal text would still render', () => {
const result = getLabelName(
{ 'http.target': 'GET /api' },
'F1',
'label = {{label}}',
);
expect(result).toBe('F1');
});
it('falls back to query name when any of multiple variables is missing', () => {
const result = getLabelName(
{ 'service.name': 'frontend' },
'F1',
'{{service.name}} / {{http.target}}',
);
expect(result).toBe('F1');
});
it('treats a null label value as missing', () => {
const result = getLabelName(
{ 'service.name': null } as unknown as Record<string, string>,
'F1',
'{{service.name}}',
);
expect(result).toBe('F1');
});
});
describe('without a legend template', () => {
it('returns key="value" pairs for plain labels', () => {
const result = getLabelName(
{ 'service.name': 'frontend', 'http.target': 'GET /api' },
'A',
'',
);
expect(result).toBe('{service.name="frontend",http.target="GET /api"}');
});
it('returns query name when labels are empty', () => {
const result = getLabelName({}, 'A', '');
expect(result).toBe('A');
});
});
});

View File

@@ -18,17 +18,6 @@ const getLabelName = (
const results = variables.map((variable) => metric[variable]);
// Fall back to query name if any `{{var}}` references a label that
// isn't on this series — avoids rendering "undefined" in the legend.
const hasMissingVariable = variables.some(
(variable, index) =>
legends.includes(`{{${variable}}}`) &&
(results[index] === undefined || results[index] === null),
);
if (hasMissingVariable) {
return query;
}
let endResult = legends;
variables.forEach((e, index) => {

View File

@@ -47,3 +47,7 @@ export const opsGeniePriorityDefaultValue =
export const pagerDutySeverityTextDefaultValue =
'{{ (index .Alerts 0).Labels.severity }}';
export const googleChatTitleDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`;
export const googleChatTextDefaultValue = `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} *Severity:* {{ .Labels.severity }}{{ end }}{{ if .Annotations.summary }} *Summary:* {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }} *Description:* {{ .Annotations.description }}{{ end }}{{ if .Annotations.related_logs }} *Related Logs:* {{ .Annotations.related_logs }}{{ end }}{{ if .Annotations.related_traces }} *Related Traces:* {{ .Annotations.related_traces }}{{ end }} *Labels:* {{ range .Labels.SortedPairs -}} • \`{{ .Name }}\`: {{ .Value }} {{ end }} {{ end }}`;

View File

@@ -7,6 +7,7 @@ import get from 'api/channels/get';
import Spinner from 'components/Spinner';
import {
ChannelType,
GoogleChatChannel,
MsTeamsChannel,
PagerChannel,
SlackChannel,
@@ -56,9 +57,17 @@ function ChannelsEdit(): JSX.Element {
const prepChannelConfig = (): {
type: string;
channel: SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel;
channel: SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel;
} => {
let channel: SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel = {
let channel: SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel = {
name: '',
};
if (value && 'slack_configs' in value) {
@@ -78,6 +87,15 @@ function ChannelsEdit(): JSX.Element {
channel,
};
}
if (value && 'googlechat_configs' in value) {
const googleChatConfig = value.googlechat_configs[0];
channel = googleChatConfig;
return {
type: ChannelType.GoogleChat,
channel,
};
}
if (value && 'pagerduty_configs' in value) {
const pagerConfig = value.pagerduty_configs[0];
channel = pagerConfig;

View File

@@ -1,77 +0,0 @@
import { ReactNode } from 'react';
import { Dock, PanelBottom, PanelRight } from '@signozhq/icons';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import {
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { SpanDetailVariant } from './constants';
interface DockOption {
value: SpanDetailVariant;
icon: ReactNode;
tooltip: string;
}
const DOCK_OPTIONS: DockOption[] = [
{
value: SpanDetailVariant.DIALOG,
icon: <Dock size={14} />,
tooltip: 'Open as floating panel',
},
{
value: SpanDetailVariant.DOCKED,
icon: <PanelBottom size={14} />,
tooltip: 'Dock at the bottom',
},
{
value: SpanDetailVariant.DOCKED_RIGHT,
icon: <PanelRight size={14} />,
tooltip: 'Dock on the right',
},
];
interface DockModeSwitcherProps {
value: SpanDetailVariant;
onChange: (value: SpanDetailVariant) => void;
tooltipClassName?: string;
}
function DockModeSwitcher({
value,
onChange,
tooltipClassName,
}: DockModeSwitcherProps): JSX.Element {
return (
<TooltipProvider>
<ToggleGroup
type="single"
value={value}
onChange={(v): void => {
if (v) {
onChange(v as SpanDetailVariant);
}
}}
size="sm"
>
{DOCK_OPTIONS.map((option) => (
<TooltipRoot key={option.value}>
<TooltipTrigger asChild>
<span>
<ToggleGroupItem value={option.value}>{option.icon}</ToggleGroupItem>
</span>
</TooltipTrigger>
<TooltipContent className={tooltipClassName}>
{option.tooltip}
</TooltipContent>
</TooltipRoot>
))}
</ToggleGroup>
</TooltipProvider>
);
}
export default DockModeSwitcher;

View File

@@ -3,10 +3,6 @@
display: flex;
flex-direction: column;
overflow: hidden;
:global(.details-header) {
height: 39px;
}
}
.body {

View File

@@ -1,16 +1,25 @@
import { useCallback, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import {
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import {
TooltipRoot,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import {
Bookmark,
CalendarClock,
ChartColumnBig,
Dock,
Link2,
List,
PanelBottom,
ScrollText,
Timer,
} from '@signozhq/icons';
@@ -52,7 +61,6 @@ import {
SpanDetailVariant,
VISIBLE_ACTIONS,
} from './constants';
import DockModeSwitcher from './DockModeSwitcher';
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
import {
@@ -484,14 +492,31 @@ function SpanDetailsPanel({
];
if (onVariantChange) {
const isDocked = variant === SpanDetailVariant.DOCKED;
actions.push({
key: 'dock-mode',
key: 'dock-toggle',
component: (
<DockModeSwitcher
value={variant}
onChange={onVariantChange}
tooltipClassName={styles.dockToggleTooltip}
/>
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent className={styles.dockToggleTooltip}>
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
</TooltipContent>
</TooltipRoot>
</TooltipProvider>
),
});
}
@@ -528,10 +553,7 @@ function SpanDetailsPanel({
</>
);
if (
variant === SpanDetailVariant.DOCKED ||
variant === SpanDetailVariant.DOCKED_RIGHT
) {
if (variant === SpanDetailVariant.DOCKED) {
return <div className={styles.root}>{content}</div>;
}

View File

@@ -22,7 +22,6 @@ export enum SpanDetailVariant {
DRAWER = 'drawer',
DIALOG = 'dialog',
DOCKED = 'docked',
DOCKED_RIGHT = 'right',
}
export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {

View File

@@ -4,28 +4,13 @@
flex-direction: column;
}
.layoutRow {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow: hidden;
}
.rightDock {
display: flex;
flex-direction: column;
border-left: 1px solid var(--l2-border);
min-width: 0;
}
// Shared Ant Collapse chrome reset for both the flamegraph and waterfall
// collapse panels.
.flameCollapse,

View File

@@ -893,7 +893,7 @@ function Success(props: ISuccessProps): JSX.Element {
/>
{/* Left panel - table with horizontal scroll */}
<ResizableBox
handle="right"
direction="horizontal"
defaultWidth={DEFAULT_SIDEBAR_WIDTH}
minWidth={MIN_SIDEBAR_WIDTH}
maxWidth={MAX_SIDEBAR_WIDTH}

View File

@@ -242,13 +242,9 @@ function TraceDetailsV3(): JSX.Element {
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
) as SpanDetailVariant) || SpanDetailVariant.DIALOG,
);
const RIGHT_DOCK_MIN = 480;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const handleVariantChange = useCallback(
(newVariant: SpanDetailVariant): void => {
setLocalStorageKey(
@@ -295,9 +291,7 @@ function TraceDetailsV3(): JSX.Element {
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
@@ -338,118 +332,94 @@ function TraceDetailsV3(): JSX.Element {
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
/>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
</span>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
/>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall')
? waterfallChildren
: null,
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
</div>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
{panelState.isOpen && !isDocked && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}

View File

@@ -23,36 +23,20 @@
background: var(--primary);
}
&--top,
&--bottom {
&--vertical {
bottom: 0;
left: 0;
right: 0;
height: 1px;
cursor: row-resize;
}
&--left,
&--right {
&--horizontal {
right: 0;
top: 0;
bottom: 0;
width: 1px;
cursor: col-resize;
}
&--top {
top: 0;
}
&--bottom {
bottom: 0;
}
&--left {
left: 0;
}
&--right {
right: 0;
}
}
}

View File

@@ -2,15 +2,9 @@ import { useCallback, useRef, useState } from 'react';
import './ResizableBox.styles.scss';
export type ResizableBoxHandle = 'top' | 'right' | 'bottom' | 'left';
export interface ResizableBoxProps {
children: React.ReactNode;
// Which edge the resize handle sits on. The edge determines the axis:
// 'top'/'bottom' → vertical resize (height), 'left'/'right' → horizontal
// resize (width). Dragging the handle away from the content grows the box;
// dragging it toward the content shrinks it.
handle?: ResizableBoxHandle;
direction?: 'vertical' | 'horizontal';
defaultHeight?: number;
minHeight?: number;
maxHeight?: number;
@@ -24,7 +18,7 @@ export interface ResizableBoxProps {
function ResizableBox({
children,
handle = 'bottom',
direction = 'vertical',
defaultHeight = 200,
minHeight = 50,
maxHeight = Infinity,
@@ -35,8 +29,7 @@ function ResizableBox({
disabled = false,
className,
}: ResizableBoxProps): JSX.Element {
const isHorizontal = handle === 'left' || handle === 'right';
const isStartHandle = handle === 'top' || handle === 'left';
const isHorizontal = direction === 'horizontal';
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
const containerRef = useRef<HTMLDivElement>(null);
@@ -47,13 +40,10 @@ function ResizableBox({
const startSize = size;
const min = isHorizontal ? minWidth : minHeight;
const max = isHorizontal ? maxWidth : maxHeight;
// Start-edge handle: pointer moving away from content (negative delta)
// grows the box, so invert the sign.
const deltaSign = isStartHandle ? -1 : 1;
const onMouseMove = (moveEvent: MouseEvent): void => {
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = (currentPos - startPos) * deltaSign;
const delta = currentPos - startPos;
const newSize = Math.min(max, Math.max(min, startSize + delta));
setSize(newSize);
onResize?.(newSize);
@@ -71,16 +61,7 @@ function ResizableBox({
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[
size,
isHorizontal,
isStartHandle,
minWidth,
maxWidth,
minHeight,
maxHeight,
onResize,
],
[size, isHorizontal, minWidth, maxWidth, minHeight, maxHeight, onResize],
);
const containerStyle = disabled
@@ -88,7 +69,7 @@ function ResizableBox({
: isHorizontal
? { width: size }
: { height: size };
const handleClass = `resizable-box__handle resizable-box__handle--${handle}`;
const handleClass = `resizable-box__handle resizable-box__handle--${direction}`;
return (
<div

View File

@@ -0,0 +1,8 @@
import { GoogleChatChannel } from 'container/CreateAlertChannels/config';
export type Props = GoogleChatChannel;
export interface PayloadProps {
data: string;
status: string;
}

View File

@@ -0,0 +1,10 @@
import { GoogleChatChannel } from 'container/CreateAlertChannels/config';
export interface Props extends GoogleChatChannel {
id: string;
}
export interface PayloadProps {
data: string;
status: string;
}

View File

@@ -25,7 +25,7 @@ type Alertmanager interface {
PutAlerts(context.Context, string, alertmanagertypes.PostableAlerts) error
// TestReceiver sends a test alert to a receiver.
TestReceiver(context.Context, string, alertmanagertypes.Receiver) error
TestReceiver(context.Context, string, *alertmanagertypes.Receiver) error
// TestAlert sends an alert to a list of receivers.
TestAlert(ctx context.Context, orgID string, ruleID string, receiversMap map[*alertmanagertypes.PostableAlert][]string) error
@@ -40,10 +40,10 @@ type Alertmanager interface {
GetChannelByID(context.Context, string, valuer.UUID) (*alertmanagertypes.Channel, error)
// UpdateChannel updates a channel for the organization.
UpdateChannelByReceiverAndID(context.Context, string, alertmanagertypes.Receiver, valuer.UUID) error
UpdateChannelByReceiverAndID(context.Context, string, *alertmanagertypes.Receiver, valuer.UUID) error
// CreateChannel creates a channel for the organization.
CreateChannel(context.Context, string, alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
CreateChannel(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
// DeleteChannelByID deletes a channel for the organization.
DeleteChannelByID(context.Context, string, valuer.UUID) error

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2026 SigNoz, Inc.
// SPDX-License-Identifier: Apache-2.0
package googlechat
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
const (
Integration = "googlechat"
)
// Notifier implements a Notifier for Google Chat notifications.
type Notifier struct {
config *alertmanagertypes.GoogleChatReceiverConfig
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
// New returns a new Google Chat notifier. The template is consumed via the
// templater, so the *template.Template argument is accepted only for signature
// parity with the other notifiers.
func New(cfg *alertmanagertypes.GoogleChatReceiverConfig, _ *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
if cfg == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "google chat config is required")
}
if cfg.WebhookURL == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "google chat webhook_url is required")
}
if err := validateWebhookURL(cfg.WebhookURL.String()); err != nil {
return nil, err
}
client, err := notify.NewClientWithTracing(commoncfg.DefaultHTTPClientConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
config: cfg,
logger: l,
client: client,
retrier: &notify.Retrier{},
templater: templater,
}, nil
}
func validateWebhookURL(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")
}
host := strings.ToLower(u.Hostname())
if host != "chat.googleapis.com" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "google chat webhook_url must use chat.googleapis.com")
}
return nil
}
// Message represents the payload sent to Google Chat webhook.
type Message struct {
Text string `json:"text"`
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
key, err := notify.ExtractGroupKey(ctx)
if err != nil {
return false, err
}
logger := n.logger.With(slog.Any("group_key", key))
logger.DebugContext(ctx, "extracted group key")
cfg := n.config
// Expand the title/body templates via the shared templater so that
// per-alert custom templates supplied through annotations override the
// receiver defaults (consistent with the other SigNoz notifiers).
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: cfg.Title,
DefaultBodyTemplate: cfg.Text,
}, alerts)
if err != nil {
return false, errors.NewInternalf(errors.CodeInternal, "failed to render templates: %v", err)
}
// Build the final message: the title followed by each alert's (non-empty)
// rendered body. Google Chat receives a single plain-text message.
finalText := result.Title
bodyParts := make([]string, 0, len(result.Body))
for _, body := range result.Body {
if body != "" {
bodyParts = append(bodyParts, body)
}
}
if len(bodyParts) > 0 {
finalText = result.Title + "\n" + strings.Join(bodyParts, "\n")
}
msg := &Message{
Text: finalText,
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
return false, err
}
// Add threading support
// https://developers.google.com/chat/how-tos/webhooks#start_a_message_thread
webhookURL := cfg.WebhookURL.String()
u, err := url.Parse(webhookURL)
if err != nil {
return false, errors.NewInternalf(errors.CodeInternal, "unable to parse googlechat url: %v", err)
}
q := u.Query()
q.Set("threadKey", key.Hash())
q.Set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD")
u.RawQuery = q.Encode()
webhookURL = u.String()
resp, err := notify.PostJSON(ctx, n.client, webhookURL, &buf) //nolint:bodyclose
if err != nil {
if ctx.Err() != nil {
err = errors.NewInternalf(errors.CodeInternal, "failed to post JSON to google chat: %v", context.Cause(ctx))
}
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
}

View File

@@ -0,0 +1,196 @@
// Copyright (c) 2026 SigNoz, Inc.
// SPDX-License-Identifier: Apache-2.0
package googlechat
import (
"bytes"
"context"
"crypto/tls"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
notifytest "github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
)
func newTestTemplater(t *testing.T) alertmanagertypes.Templater {
t.Helper()
return alertmanagertemplate.New(test.CreateTmpl(t), slog.New(slog.DiscardHandler))
}
func TestGoogleChatWebhook(t *testing.T) {
var payload bytes.Buffer
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
_, _ = io.Copy(&payload, r.Body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
notifier := newTestNotifier(t, server, "Alert: {{ .GroupLabels.alertname }}", "{{ range .Alerts -}} Alert: {{ .Labels.alertname }} {{ end }}")
retry, err := notifier.Notify(newTestContext(), newTestAlerts("TestAlert")...)
require.NoError(t, err)
require.False(t, retry)
require.Contains(t, payload.String(), `"text"`)
require.Contains(t, payload.String(), "Alert: TestAlert")
}
func TestGoogleChatTemplating(t *testing.T) {
var payload bytes.Buffer
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
_, _ = io.Copy(&payload, r.Body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
notifier := newTestNotifier(t, server, "Alert: {{ .GroupLabels.alertname }}", "Summary: {{ range .Alerts }}{{ .Annotations.summary }}{{ end }}")
retry, err := notifier.Notify(newTestContext(), newTestAlerts("CPU High")...)
require.NoError(t, err)
require.False(t, retry)
require.Contains(t, payload.String(), "CPU High")
require.Contains(t, payload.String(), "Summary:")
}
func TestGoogleChatRetry(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
notifier := newTestNotifier(t, server, "Alert", "Test message")
retry, err := notifier.Notify(newTestContext(), newTestAlerts("TestAlert")...)
require.Error(t, err)
require.True(t, retry)
}
func TestGoogleChatRetryCodes(t *testing.T) {
notifier, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
WebhookURL: secretURLFromString(t, "https://chat.googleapis.com/v1/spaces/test/messages"),
Title: "Alert",
Text: "Test message",
}, test.CreateTmpl(t), slog.New(slog.DiscardHandler), newTestTemplater(t))
require.NoError(t, err)
for statusCode, expected := range notifytest.RetryTests(notifytest.DefaultRetryCodes()) {
actual, _ := notifier.retrier.Check(statusCode, nil)
require.Equal(t, expected, actual, "retry - error on status %d", statusCode)
}
}
func TestGoogleChatWebhookValidation(t *testing.T) {
_, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
WebhookURL: secretURLFromString(t, "http://chat.googleapis.com/v1/spaces/test/messages"),
Title: "Alert",
Text: "Test message",
}, test.CreateTmpl(t), slog.New(slog.DiscardHandler), newTestTemplater(t))
require.Error(t, err)
require.Contains(t, err.Error(), "webhook_url must use https")
_, err = New(&alertmanagertypes.GoogleChatReceiverConfig{
WebhookURL: secretURLFromString(t, "https://example.com/v1/spaces/test/messages"),
Title: "Alert",
Text: "Test message",
}, test.CreateTmpl(t), slog.New(slog.DiscardHandler), newTestTemplater(t))
require.Error(t, err)
require.Contains(t, err.Error(), "webhook_url must use chat.googleapis.com")
}
func TestGoogleChatRedactedURL(t *testing.T) {
secret := "secret-token"
urlStr := "https://chat.googleapis.com/v1/spaces/test/messages?token=" + secret
notifier, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
WebhookURL: secretURLFromString(t, urlStr),
Title: "Alert",
Text: "Test message",
}, test.CreateTmpl(t), slog.New(slog.DiscardHandler), newTestTemplater(t))
require.NoError(t, err)
notifier.client = &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "request failed")
})}
_, err = notifier.Notify(newTestContext(), newTestAlerts("TestAlert")...)
require.Error(t, err)
require.NotContains(t, err.Error(), secret)
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func newTestNotifier(t *testing.T, server *httptest.Server, title, text string) *Notifier {
t.Helper()
webhookURL := "https://chat.googleapis.com/v1/spaces/test/messages"
notifier, err := New(&alertmanagertypes.GoogleChatReceiverConfig{
WebhookURL: secretURLFromString(t, webhookURL),
Title: title,
Text: text,
}, test.CreateTmpl(t), slog.New(slog.DiscardHandler), newTestTemplater(t))
require.NoError(t, err)
notifier.client = newTestHTTPClient(server)
return notifier
}
func newTestHTTPClient(server *httptest.Server) *http.Client {
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, server.Listener.Addr().String())
},
}
return &http.Client{Transport: transport}
}
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 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")
}

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,10 +25,11 @@ 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) {
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(*nc.Receiver, tmpl, logger)
if err != nil {
return nil, err
}
@@ -74,6 +76,11 @@ func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Templ
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

@@ -2,6 +2,8 @@ package alertmanagerserver
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
@@ -242,6 +244,8 @@ func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanager
func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertmanagertypes.Config) error {
config := alertmanagerConfig.AlertmanagerConfig()
jsun, kerr := json.Marshal(config)
fmt.Println("yo config here", string(jsun), kerr)
var err error
// Load SigNoz's alertmanager notification templates from the configured
@@ -275,7 +279,11 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
continue
}
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger, server.templater)
extendedRcv, err := alertmanagerConfig.GetReceiver(rcv.Name)
if err != nil {
return err
}
integrations, err := alertmanagernotify.NewReceiverIntegrations(extendedRcv, server.tmpl, server.logger, server.templater)
if err != nil {
return err
}
@@ -350,8 +358,8 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
return nil
}
func (server *Server) TestReceiver(ctx context.Context, receiver alertmanagertypes.Receiver) error {
testAlert := alertmanagertypes.NewTestAlert(receiver, time.Now(), time.Now())
func (server *Server) TestReceiver(ctx context.Context, receiver *alertmanagertypes.Receiver) error {
testAlert := alertmanagertypes.NewTestAlert(*receiver.Receiver, time.Now(), time.Now())
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, server.templater, testAlert.Labels, testAlert)
}
@@ -420,7 +428,7 @@ func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmana
receiverName := receiverName
g.Go(func() error {
receiver, err := server.alertmanagerConfig.GetReceiver(receiverName)
baseReceiver, err := server.alertmanagerConfig.GetReceiver(receiverName)
if err != nil {
mu.Lock()
errs = append(errs, errors.WrapInternalf(err, errors.CodeInternal, "failed to get receiver %q", receiverName))
@@ -430,7 +438,7 @@ func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmana
err = alertmanagertypes.TestReceiver(
gCtx,
receiver,
baseReceiver,
alertmanagernotify.NewReceiverIntegrations,
server.alertmanagerConfig,
server.tmpl,

View File

@@ -75,12 +75,14 @@ func TestServerTestReceiverTypeWebhook(t *testing.T) {
webhookURL, err := url.Parse("http://" + webhookListener.Addr().String() + "/webhook")
require.NoError(t, err)
err = server.TestReceiver(context.Background(), alertmanagertypes.Receiver{
Name: "test-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL(webhookURL.String()),
err = server.TestReceiver(context.Background(), &alertmanagertypes.Receiver{
Receiver: &config.Receiver{
Name: "test-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL(webhookURL.String()),
},
},
},
})
@@ -101,12 +103,14 @@ func TestServerPutAlerts(t *testing.T) {
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
require.NoError(t, err)
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
Name: "test-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL("http://localhost/test-receiver"),
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{
Receiver: &config.Receiver{
Name: "test-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL("http://localhost/test-receiver"),
},
},
},
}))
@@ -181,22 +185,26 @@ func TestServerTestAlert(t *testing.T) {
webhook2URL, err := url.Parse("http://" + webhook2Listener.Addr().String() + "/webhook")
require.NoError(t, err)
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
Name: "receiver-1",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL(webhook1URL.String()),
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{
Receiver: &config.Receiver{
Name: "receiver-1",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL(webhook1URL.String()),
},
},
},
}))
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
Name: "receiver-2",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL(webhook2URL.String()),
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{
Receiver: &config.Receiver{
Name: "receiver-2",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL(webhook2URL.String()),
},
},
},
}))
@@ -273,22 +281,26 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
webhookURL, err := url.Parse("http://" + webhookListener.Addr().String() + "/webhook")
require.NoError(t, err)
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
Name: "working-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL(webhookURL.String()),
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{
Receiver: &config.Receiver{
Name: "working-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL(webhookURL.String()),
},
},
},
}))
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
Name: "failing-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL("http://localhost:1/webhook"),
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{
Receiver: &config.Receiver{
Name: "failing-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: config.SecretTemplateURL("http://localhost:1/webhook"),
},
},
},
}))

View File

@@ -110,7 +110,7 @@ func (_c *MockAlertmanager_Collect_Call) RunAndReturn(run func(context1 context.
}
// CreateChannel provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
ret := _mock.Called(context1, s, v)
if len(ret) == 0 {
@@ -119,17 +119,17 @@ func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string,
var r0 *alertmanagertypes.Channel
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)); ok {
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)); ok {
return returnFunc(context1, s, v)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) *alertmanagertypes.Channel); ok {
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) *alertmanagertypes.Channel); ok {
r0 = returnFunc(context1, s, v)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.Channel)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, alertmanagertypes.Receiver) error); ok {
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
r1 = returnFunc(context1, s, v)
} else {
r1 = ret.Error(1)
@@ -145,12 +145,12 @@ type MockAlertmanager_CreateChannel_Call struct {
// CreateChannel is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - v alertmanagertypes.Receiver
// - v *alertmanagertypes.Receiver
func (_e *MockAlertmanager_Expecter) CreateChannel(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_CreateChannel_Call {
return &MockAlertmanager_CreateChannel_Call{Call: _e.mock.On("CreateChannel", context1, s, v)}
}
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -160,9 +160,9 @@ func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Con
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 alertmanagertypes.Receiver
var arg2 *alertmanagertypes.Receiver
if args[2] != nil {
arg2 = args[2].(alertmanagertypes.Receiver)
arg2 = args[2].(*alertmanagertypes.Receiver)
}
run(
arg0,
@@ -178,7 +178,7 @@ func (_c *MockAlertmanager_CreateChannel_Call) Return(channel *alertmanagertypes
return _c
}
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
_c.Call.Return(run)
return _c
}
@@ -1579,7 +1579,7 @@ func (_c *MockAlertmanager_TestAlert_Call) RunAndReturn(run func(ctx context.Con
}
// TestReceiver provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, v alertmanagertypes.Receiver) error {
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, v *alertmanagertypes.Receiver) error {
ret := _mock.Called(context1, s, v)
if len(ret) == 0 {
@@ -1587,7 +1587,7 @@ func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string,
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) error); ok {
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
r0 = returnFunc(context1, s, v)
} else {
r0 = ret.Error(0)
@@ -1603,12 +1603,12 @@ type MockAlertmanager_TestReceiver_Call struct {
// TestReceiver is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - v alertmanagertypes.Receiver
// - v *alertmanagertypes.Receiver
func (_e *MockAlertmanager_Expecter) TestReceiver(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_TestReceiver_Call {
return &MockAlertmanager_TestReceiver_Call{Call: _e.mock.On("TestReceiver", context1, s, v)}
}
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -1618,9 +1618,9 @@ func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Cont
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 alertmanagertypes.Receiver
var arg2 *alertmanagertypes.Receiver
if args[2] != nil {
arg2 = args[2].(alertmanagertypes.Receiver)
arg2 = args[2].(*alertmanagertypes.Receiver)
}
run(
arg0,
@@ -1636,7 +1636,7 @@ func (_c *MockAlertmanager_TestReceiver_Call) Return(err error) *MockAlertmanage
return _c
}
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
_c.Call.Return(run)
return _c
}
@@ -1705,7 +1705,7 @@ func (_c *MockAlertmanager_UpdateAllRoutePoliciesByRuleId_Call) RunAndReturn(run
}
// UpdateChannelByReceiverAndID provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID) error {
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error {
ret := _mock.Called(context1, s, v, uUID)
if len(ret) == 0 {
@@ -1713,7 +1713,7 @@ func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Con
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver, valuer.UUID) error); ok {
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver, valuer.UUID) error); ok {
r0 = returnFunc(context1, s, v, uUID)
} else {
r0 = ret.Error(0)
@@ -1729,13 +1729,13 @@ type MockAlertmanager_UpdateChannelByReceiverAndID_Call struct {
// UpdateChannelByReceiverAndID is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - v alertmanagertypes.Receiver
// - v *alertmanagertypes.Receiver
// - uUID valuer.UUID
func (_e *MockAlertmanager_Expecter) UpdateChannelByReceiverAndID(context1 interface{}, s interface{}, v interface{}, uUID interface{}) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
return &MockAlertmanager_UpdateChannelByReceiverAndID_Call{Call: _e.mock.On("UpdateChannelByReceiverAndID", context1, s, v, uUID)}
}
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -1745,9 +1745,9 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(conte
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 alertmanagertypes.Receiver
var arg2 *alertmanagertypes.Receiver
if args[2] != nil {
arg2 = args[2].(alertmanagertypes.Receiver)
arg2 = args[2].(*alertmanagertypes.Receiver)
}
var arg3 valuer.UUID
if args[3] != nil {
@@ -1768,7 +1768,7 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Return(err error)
return _c
}
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
_c.Call.Return(run)
return _c
}

View File

@@ -138,7 +138,7 @@ func (service *Service) PutAlerts(ctx context.Context, orgID string, alerts aler
return server.PutAlerts(ctx, alerts)
}
func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) error {
func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) error {
service.serversMtx.RLock()
defer service.serversMtx.RUnlock()

View File

@@ -110,7 +110,7 @@ func (provider *provider) PutAlerts(ctx context.Context, orgID string, alerts al
return provider.service.PutAlerts(ctx, orgID, alerts)
}
func (provider *provider) TestReceiver(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) error {
func (provider *provider) TestReceiver(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) error {
return provider.service.TestReceiver(ctx, orgID, receiver)
}
@@ -151,7 +151,7 @@ func (provider *provider) GetChannelByID(ctx context.Context, orgID string, chan
return provider.configStore.GetChannelByID(ctx, orgID, channelID)
}
func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver, id valuer.UUID) error {
func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver, id valuer.UUID) error {
channel, err := provider.configStore.GetChannelByID(ctx, orgID, id)
if err != nil {
return err
@@ -210,7 +210,7 @@ func (provider *provider) DeleteChannelByID(ctx context.Context, orgID string, c
}))
}
func (provider *provider) CreateChannel(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
func (provider *provider) CreateChannel(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
config, err := provider.configStore.Get(ctx, orgID)
if err != nil {
return nil, err

View File

@@ -269,7 +269,7 @@ func (migration *addAlertmanager) msTeamsChannelToMSTeamsV2Channel(c *alertmanag
return nil
}
func (migration *addAlertmanager) msTeamsReceiverToMSTeamsV2Receiver(receiver alertmanagertypes.Receiver) alertmanagertypes.Receiver {
func (migration *addAlertmanager) msTeamsReceiverToMSTeamsV2Receiver(receiver *alertmanagertypes.Receiver) *alertmanagertypes.Receiver {
if receiver.MSTeamsConfigs == nil {
return receiver
}

View File

@@ -13,6 +13,7 @@ import (
v2 "github.com/prometheus/alertmanager/api/v2"
"github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/api/v2/restapi/operations/alert"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/dispatch"
"github.com/prometheus/alertmanager/matcher/compat"
"github.com/prometheus/alertmanager/pkg/labels"
@@ -134,7 +135,7 @@ func NewAlertsFromPostableAlerts(ctx context.Context, postableAlerts PostableAle
return validAlerts, errs
}
func NewTestAlert(receiver Receiver, startsAt time.Time, updatedAt time.Time) *Alert {
func NewTestAlert(receiver config.Receiver, startsAt time.Time, updatedAt time.Time) *Alert {
return &Alert{
Alert: model.Alert{
StartsAt: startsAt,

View File

@@ -10,7 +10,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
"github.com/swaggest/jsonschema-go"
"github.com/uptrace/bun"
)
@@ -56,7 +55,7 @@ type Channel struct {
// NewChannelFromReceiver creates a new Channel from a Receiver.
// It can return nil if the receiver is the default receiver.
func NewChannelFromReceiver(receiver config.Receiver, orgID string) (*Channel, error) {
func NewChannelFromReceiver(receiver *Receiver, orgID string) (*Channel, error) {
if receiver.Name == DefaultReceiverName {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "cannot use %s name as a channel name", receiver.Name)
}
@@ -74,11 +73,34 @@ func NewChannelFromReceiver(receiver config.Receiver, orgID string) (*Channel, e
OrgID: orgID,
}
// Use reflection to examine receiver struct fields
receiverType := reflect.TypeOf(receiver)
receiverVal := reflect.ValueOf(receiver)
// The embedded *config.Receiver marshals inline, so this single Marshal emits
// both the upstream notifier configs and any SigNoz-native ones (e.g.
// googlechat_configs) — no separate merge step is required.
data, err := json.Marshal(receiver)
if err != nil {
return nil, err
}
channel.Data = string(data)
channel.Type = receiverChannelType(receiver)
if channel.Type == "" {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
}
return &channel, nil
}
// receiverChannelType derives the channel.Type discriminator from the configured
// notifier. SigNoz-native notifiers are checked first; upstream notifiers are
// derived by reflecting over config.Receiver's *_configs fields.
func receiverChannelType(receiver *Receiver) string {
if len(receiver.GoogleChatConfigs) > 0 {
return channelTypeGoogleChat
}
receiverType := reflect.TypeOf(*receiver.Receiver)
receiverVal := reflect.ValueOf(*receiver.Receiver)
// Iterate through fields looking for *Config fields
for i := 0; i < receiverType.NumField(); i++ {
field := receiverType.Field(i)
fieldVal := receiverVal.Field(i)
@@ -100,25 +122,10 @@ func NewChannelFromReceiver(receiver config.Receiver, orgID string) (*Channel, e
continue
}
channelType := matches[1]
// Marshal config data to JSON
configData, err := json.Marshal(receiver)
if err != nil {
continue
}
channel.Type = channelType
channel.Data = string(configData)
break
return matches[1]
}
// If we were unable to find the channel type, return an error
if channel.Type == "" {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
}
return &channel, nil
return ""
}
func NewConfigFromChannels(globalConfig GlobalConfig, routeConfig RouteConfig, channels Channels, orgID string) (*Config, error) {
@@ -182,7 +189,7 @@ func NewStatsFromChannels(channels Channels) map[string]any {
return stats
}
func (c *Channel) Update(receiver Receiver) error {
func (c *Channel) Update(receiver *Receiver) error {
channel, err := NewChannelFromReceiver(receiver, c.OrgID)
if err != nil {
return err
@@ -192,6 +199,7 @@ func (c *Channel) Update(receiver Receiver) error {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel name")
}
c.Type = channel.Type
c.Data = channel.Data
c.UpdatedAt = time.Now()

View File

@@ -240,29 +240,25 @@ func TestNewConfigFromChannels(t *testing.T) {
func TestNewChannelFromReceiver(t *testing.T) {
testCases := []struct {
name string
receiver config.Receiver
receiver *Receiver
expected *Channel
pass bool
}{
{
name: "InvalidReceiver_OnlyName",
receiver: config.Receiver{
Name: "test-receiver",
},
name: "InvalidReceiver_OnlyName",
receiver: &Receiver{Receiver: &config.Receiver{Name: "test-receiver"}},
expected: nil,
pass: false,
},
{
name: "InvalidReceiver_DefaultReceiver",
receiver: config.Receiver{
Name: DefaultReceiverName,
},
name: "InvalidReceiver_DefaultReceiver",
receiver: &Receiver{Receiver: &config.Receiver{Name: DefaultReceiverName}},
expected: nil,
pass: false,
},
{
name: "ValidReceiver_Slack",
receiver: config.Receiver{
receiver: &Receiver{Receiver: &config.Receiver{
Name: "test-receiver",
SlackConfigs: []*config.SlackConfig{
{
@@ -273,7 +269,7 @@ func TestNewChannelFromReceiver(t *testing.T) {
},
},
},
},
}},
expected: &Channel{
Name: "test-receiver",
Type: "slack",
@@ -281,6 +277,25 @@ func TestNewChannelFromReceiver(t *testing.T) {
},
pass: true,
},
{
name: "ValidReceiver_GoogleChat",
receiver: &Receiver{Receiver: &config.Receiver{
Name: "googlechat-receiver",
}, GoogleChatConfigs: []*GoogleChatReceiverConfig{
{
WebhookURL: &config.SecretURL{URL: &url.URL{Scheme: "https", Host: "chat.googleapis.com", Path: "/v1/spaces/test/messages"}},
Title: "Alert",
Text: "Body",
SendResolvedValue: true,
},
}},
expected: &Channel{
Name: "googlechat-receiver",
Type: "googlechat",
Data: `{"name":"googlechat-receiver","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body","send_resolved":true}]}`,
},
pass: true,
},
}
for _, testCase := range testCases {
@@ -294,7 +309,7 @@ func TestNewChannelFromReceiver(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, testCase.expected.Name, channel.Name)
assert.Equal(t, testCase.expected.Type, channel.Type)
assert.Equal(t, testCase.expected.Data, channel.Data)
assert.JSONEq(t, testCase.expected.Data, channel.Data)
})
}

View File

@@ -59,12 +59,41 @@ type Config struct {
// storeableConfig is the representation of the config in the store
storeableConfig *StoreableConfig
// customConfigs holds the SigNoz-native notifier configs (which the upstream
// config.Receiver cannot carry) keyed by receiver name. The upstream base of
// each receiver lives in alertmanagerConfig.Receivers; GetReceiver merges the
// two and newRawFromConfig serializes them together.
customConfigs map[string]customReceiverConfigs
}
// customReceiverConfigs are the SigNoz-native notifier configs for one receiver.
type customReceiverConfigs struct {
GoogleChat []*GoogleChatReceiverConfig
}
func (c customReceiverConfigs) isEmpty() bool {
return len(c.GoogleChat) == 0
}
// storedConfig is the serialization unit persisted to StoreableConfig.Config.
// Embedding *config.Config emits every upstream field (global, route,
// inhibit_rules, templates, ...); the outer Receivers field shadows the embedded
// config.Config.Receivers (both marshal to the JSON key "receivers", and the
// shallower outer field dominates per encoding/json's visibility rules), so the
// receivers are emitted as the extended *Receiver — carrying native configs —
// without any post-marshal patching.
type storedConfig struct {
*config.Config
Receivers []*Receiver `json:"receivers"`
}
func NewConfig(c *config.Config, orgID string) *Config {
raw := string(newRawFromConfig(c))
customConfigs := make(map[string]customReceiverConfigs)
raw := string(newRawFromConfig(c, customConfigs))
return &Config{
alertmanagerConfig: c,
customConfigs: customConfigs,
storeableConfig: &StoreableConfig{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -81,13 +110,14 @@ func NewConfig(c *config.Config, orgID string) *Config {
}
func NewConfigFromStoreableConfig(sc *StoreableConfig) (*Config, error) {
alertmanagerConfig, err := newConfigFromString(sc.Config)
alertmanagerConfig, customConfigs, err := newConfigFromString(sc.Config)
if err != nil {
return nil, err
}
return &Config{
alertmanagerConfig: alertmanagerConfig,
customConfigs: customConfigs,
storeableConfig: sc,
}, nil
}
@@ -113,32 +143,45 @@ func NewDefaultConfig(globalConfig GlobalConfig, routeConfig RouteConfig, orgID
}, orgID), nil
}
func newConfigFromString(s string) (*config.Config, error) {
config := new(config.Config)
err := json.Unmarshal([]byte(s), config)
if err != nil {
return nil, err
func newConfigFromString(s string) (*config.Config, map[string]customReceiverConfigs, error) {
stored := storedConfig{Config: new(config.Config)}
if err := json.Unmarshal([]byte(s), &stored); err != nil {
return nil, nil, err
}
for i, receiver := range config.Receivers {
bytes, err := json.Marshal(receiver)
if err != nil {
return nil, err
}
amConfig := stored.Config
amConfig.Receivers = make([]config.Receiver, len(stored.Receivers))
customConfigs := make(map[string]customReceiverConfigs)
receiver, err := NewReceiver(string(bytes))
// Re-run each receiver through NewReceiver so upstream defaults are applied
// (mirrors the create path) and native configs are split off via the embed.
for i, rcv := range stored.Receivers {
rcvJSON, err := json.Marshal(rcv)
if err != nil {
return nil, err
return nil, nil, err
}
parsed, err := NewReceiver(string(rcvJSON))
if err != nil {
return nil, nil, err
}
amConfig.Receivers[i] = *parsed.Receiver
if custom := customConfigsOf(parsed); !custom.isEmpty() {
customConfigs[parsed.Name] = custom
}
config.Receivers[i] = receiver
}
return config, nil
return amConfig, customConfigs, nil
}
func newRawFromConfig(c *config.Config) []byte {
b, err := json.Marshal(c)
func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverConfigs) []byte {
receivers := make([]*Receiver, len(c.Receivers))
for i := range c.Receivers {
base := c.Receivers[i]
custom := customConfigs[base.Name]
receivers[i] = &Receiver{Receiver: &base, GoogleChatConfigs: custom.GoogleChat}
}
b, err := json.Marshal(storedConfig{Config: c, Receivers: receivers})
if err != nil {
// Taking inspiration from the upstream. This is never expected to happen.
return []byte(fmt.Sprintf("<error creating config string: %s>", err))
@@ -147,10 +190,25 @@ func newRawFromConfig(c *config.Config) []byte {
return b
}
// customConfigsOf extracts the SigNoz-native configs carried on a Receiver.
func customConfigsOf(receiver *Receiver) customReceiverConfigs {
return customReceiverConfigs{GoogleChat: receiver.GoogleChatConfigs}
}
func newConfigHash(s string) [16]byte {
return md5.Sum([]byte(s))
}
// flush re-serializes the config into the storeable representation and refreshes
// its hash and timestamp. Call it after every mutation of alertmanagerConfig or
// customConfigs.
func (c *Config) flush() {
raw := string(newRawFromConfig(c.alertmanagerConfig, c.customConfigs))
c.storeableConfig.Config = raw
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(raw))
c.storeableConfig.UpdatedAt = time.Now()
}
func (c *Config) CopyWithReset() (*Config, error) {
newConfig, err := NewDefaultConfig(
*c.alertmanagerConfig.Global,
@@ -179,9 +237,7 @@ func (c *Config) SetGlobalConfig(globalConfig GlobalConfig) error {
globalConfig.SMTPRequireTLS = smtpRequireTLS
c.alertmanagerConfig.Global = &globalConfig
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -193,9 +249,7 @@ func (c *Config) SetRouteConfig(routeConfig RouteConfig) error {
}
c.alertmanagerConfig.Route = route
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -207,9 +261,7 @@ func (c *Config) AddInhibitRules(rules []config.InhibitRule) error {
c.alertmanagerConfig.InhibitRules = append(c.alertmanagerConfig.InhibitRules, rules...)
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -222,7 +274,7 @@ func (c *Config) StoreableConfig() *StoreableConfig {
return c.storeableConfig
}
func (c *Config) CreateReceiver(receiver config.Receiver) error {
func (c *Config) CreateReceiver(receiver *Receiver) error {
// check that receiver name is not already used
for _, existingReceiver := range c.alertmanagerConfig.Receivers {
if existingReceiver.Name == receiver.Name {
@@ -230,39 +282,45 @@ func (c *Config) CreateReceiver(receiver config.Receiver) error {
}
}
route, err := NewRouteFromReceiver(receiver)
route, err := NewRouteFromReceiver(receiver.Receiver)
if err != nil {
return err
}
c.alertmanagerConfig.Route.Routes = append(c.alertmanagerConfig.Route.Routes, route)
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, receiver)
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, *receiver.Receiver)
c.setCustomConfigs(receiver)
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return err
}
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
func (c *Config) GetReceiver(name string) (Receiver, error) {
for _, receiver := range c.alertmanagerConfig.Receivers {
if receiver.Name == name {
return receiver, nil
func (c *Config) GetReceiver(name string) (*Receiver, error) {
for i := range c.alertmanagerConfig.Receivers {
if c.alertmanagerConfig.Receivers[i].Name == name {
// Copy the base out of the slice to avoid handing callers an aliased
// element (the slice may later be appended to / reallocated).
base := c.alertmanagerConfig.Receivers[i]
return &Receiver{
Receiver: &base,
GoogleChatConfigs: c.customConfigs[name].GoogleChat,
}, nil
}
}
return Receiver{}, errors.Newf(errors.TypeNotFound, ErrCodeAlertmanagerChannelNotFound, "channel with name %q not found", name)
return nil, errors.Newf(errors.TypeNotFound, ErrCodeAlertmanagerChannelNotFound, "channel with name %q not found", name)
}
func (c *Config) UpdateReceiver(receiver config.Receiver) error {
func (c *Config) UpdateReceiver(receiver *Receiver) error {
// find and update receiver
for i, existingReceiver := range c.alertmanagerConfig.Receivers {
if existingReceiver.Name == receiver.Name {
c.alertmanagerConfig.Receivers[i] = receiver
c.alertmanagerConfig.Receivers[i] = *receiver.Receiver
c.setCustomConfigs(receiver)
break
}
}
@@ -271,13 +329,20 @@ func (c *Config) UpdateReceiver(receiver config.Receiver) error {
return err
}
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
// setCustomConfigs records (or clears) the SigNoz-native configs for a receiver.
func (c *Config) setCustomConfigs(receiver *Receiver) {
if custom := customConfigsOf(receiver); !custom.isEmpty() {
c.customConfigs[receiver.Name] = custom
} else {
delete(c.customConfigs, receiver.Name)
}
}
func (c *Config) DeleteReceiver(name string) error {
if name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeAlertmanagerConfigInvalid, "delete receiver requires the receiver name")
@@ -298,9 +363,9 @@ func (c *Config) DeleteReceiver(name string) error {
}
}
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
delete(c.customConfigs, name)
c.flush()
return nil
}
@@ -318,9 +383,7 @@ func (c *Config) CreateRuleIDMatcher(ruleID string, receiverNames []string) erro
}
}
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -339,9 +402,7 @@ func (c *Config) DeleteRuleIDInhibitor(ruleID string) error {
}
}
c.alertmanagerConfig.InhibitRules = filteredRules
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}
@@ -362,9 +423,7 @@ func (c *Config) DeleteRuleIDMatcher(ruleID string) error {
}
}
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
c.flush()
return nil
}

View File

@@ -108,7 +108,8 @@ func TestCreateRuleIDMatcher(t *testing.T) {
require.NoError(t, err)
for _, receiver := range tc.receivers {
err := cfg.CreateReceiver(receiver)
receiver := receiver
err := cfg.CreateReceiver(&Receiver{Receiver: &receiver})
require.NoError(t, err)
}
@@ -203,7 +204,8 @@ func TestDeleteRuleIDMatcher(t *testing.T) {
require.NoError(t, err)
for _, receiver := range tc.receivers {
err := cfg.CreateReceiver(receiver)
receiver := receiver
err := cfg.CreateReceiver(&Receiver{Receiver: &receiver})
require.NoError(t, err)
}
@@ -329,3 +331,78 @@ func TestSetGlobalConfigPreservesSMTPRequireTLS(t *testing.T) {
})
}
}
func TestConfigPreservesCustomReceiverConfigs(t *testing.T) {
webhookURL, err := url.Parse("https://chat.googleapis.com/v1/spaces/test/messages")
require.NoError(t, err)
cfg, err := NewDefaultConfig(
GlobalConfig{SMTPSmarthost: config.HostPort{Host: "localhost", Port: "25"}, SMTPFrom: "test@example.com"},
RouteConfig{GroupInterval: time.Minute, GroupWait: time.Minute, RepeatInterval: time.Minute},
"1",
)
require.NoError(t, err)
receiver := &Receiver{
Receiver: &config.Receiver{Name: "googlechat-receiver"},
GoogleChatConfigs: []*GoogleChatReceiverConfig{
{
WebhookURL: &config.SecretURL{URL: webhookURL},
Title: "Alert",
Text: "Body",
SendResolvedValue: true,
},
},
}
err = cfg.CreateReceiver(receiver)
require.NoError(t, err)
assertCustomConfigInStoreable(t, cfg.StoreableConfig().Config, "googlechat-receiver", "Alert")
reloaded, err := NewConfigFromStoreableConfig(cfg.StoreableConfig())
require.NoError(t, err)
reloadedReceiver, err := reloaded.GetReceiver("googlechat-receiver")
require.NoError(t, err)
require.Len(t, reloadedReceiver.GoogleChatConfigs, 1)
assert.Equal(t, "Alert", reloadedReceiver.GoogleChatConfigs[0].Title)
receiver.GoogleChatConfigs[0].Title = "Updated"
err = cfg.UpdateReceiver(receiver)
require.NoError(t, err)
assertCustomConfigInStoreable(t, cfg.StoreableConfig().Config, "googlechat-receiver", "Updated")
}
func assertCustomConfigInStoreable(t *testing.T, rawConfig string, receiverName string, expectedTitle string) {
t.Helper()
var configData map[string]any
err := json.Unmarshal([]byte(rawConfig), &configData)
require.NoError(t, err)
receiversRaw, ok := configData["receivers"].([]interface{})
require.True(t, ok)
var receiverMap map[string]any
for _, receiver := range receiversRaw {
receiverData, ok := receiver.(map[string]any)
if !ok {
continue
}
if receiverData["name"] == receiverName {
receiverMap = receiverData
break
}
}
require.NotNil(t, receiverMap)
configsRaw, ok := receiverMap["googlechat_configs"].([]interface{})
require.True(t, ok)
require.NotEmpty(t, configsRaw)
configEntry, ok := configsRaw[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, expectedTitle, configEntry["title"])
}

View File

@@ -0,0 +1,25 @@
package alertmanagertypes
import "github.com/prometheus/alertmanager/config"
// channelTypeGoogleChat is the channel.Type discriminator for Google Chat
// receivers. Unlike the upstream notifier types (slack, webhook, ...), Google
// Chat is a SigNoz-native notifier, so it is not derivable by reflecting over
// config.Receiver's fields.
const channelTypeGoogleChat = "googlechat"
// GoogleChatReceiverConfig is a SigNoz-native notifier config that upstream
// alertmanager does not know about. It is carried on Receiver alongside the
// embedded *config.Receiver and round-trips through JSON via that embed's
// struct tags — no separate registry or marshalling is required.
type GoogleChatReceiverConfig struct {
WebhookURL *config.SecretURL `json:"webhook_url" yaml:"webhook_url"`
Title string `json:"title" yaml:"title"`
Text string `json:"text" yaml:"text"`
SendResolvedValue bool `json:"send_resolved" yaml:"send_resolved"`
}
// SendResolved implements notify.ResolvedSender.
func (c *GoogleChatReceiverConfig) SendResolved() bool {
return c != nil && c.SendResolvedValue
}

View File

@@ -22,7 +22,7 @@ func TestAddRuleIDToRoute(t *testing.T) {
{
name: "Simple",
route: func() *config.Route {
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
route, err := NewRouteFromReceiver(&config.Receiver{Name: "test"})
require.NoError(t, err)
return route
@@ -33,7 +33,7 @@ func TestAddRuleIDToRoute(t *testing.T) {
{
name: "AlreadyExists",
route: func() *config.Route {
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
route, err := NewRouteFromReceiver(&config.Receiver{Name: "test"})
require.NoError(t, err)
err = addRuleIDToRoute(route, "1")
@@ -84,7 +84,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
{
name: "Simple",
route: func() *config.Route {
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
route, err := NewRouteFromReceiver(&config.Receiver{Name: "test"})
require.NoError(t, err)
err = addRuleIDToRoute(route, "1")
@@ -98,7 +98,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
{
name: "DoesNotExist",
route: func() *config.Route {
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
route, err := NewRouteFromReceiver(&config.Receiver{Name: "test"})
require.NoError(t, err)
return route
@@ -109,7 +109,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
{
name: "DeleteMatcher",
route: func() *config.Route {
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
route, err := NewRouteFromReceiver(&config.Receiver{Name: "test"})
require.NoError(t, err)
return route

View File

@@ -17,4 +17,4 @@ type Templater interface {
// ReceiverIntegrationsFunc constructs the notify.Integration list for a
// configured receiver.
type ReceiverIntegrationsFunc = func(nc Receiver, tmpl *template.Template, logger *slog.Logger, templater Templater) ([]notify.Integration, error)
type ReceiverIntegrationsFunc = func(nc *Receiver, tmpl *template.Template, logger *slog.Logger, templater Templater) ([]notify.Integration, error)

View File

@@ -17,40 +17,61 @@ import (
"github.com/prometheus/alertmanager/config"
)
type (
// Receiver is the type for the receiver configuration.
Receiver = config.Receiver
)
// Creates a new receiver from a string. The input is initialized with the default values from the upstream alertmanager.
// The only default value which is missed is `send_resolved` (as it is a bool) which if not set in the input will always be set to `false`.
func NewReceiver(input string) (Receiver, error) {
receiver := Receiver{}
err := json.Unmarshal([]byte(input), &receiver)
if err != nil {
return Receiver{}, err
}
// We marshal and unmarshal the receiver to ensure that the receiver is
// initialized with defaults from the upstream alertmanager.
bytes, err := yaml.Marshal(receiver)
if err != nil {
return Receiver{}, err
}
receiverWithDefaults := Receiver{}
if err := yaml.Unmarshal(bytes, &receiverWithDefaults); err != nil {
return Receiver{}, err
}
if err := receiverWithDefaults.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return Receiver{}, err
}
return receiverWithDefaults, nil
// Receiver extends the upstream alertmanager config.Receiver with SigNoz-native
// notifier configs (e.g. Google Chat) that upstream does not know about.
//
// The embedded *config.Receiver carries every upstream notifier field; the
// SigNoz-native configs are declared alongside it. Because config.Receiver has
// no custom (Un)MarshalJSON, encoding/json marshals the embed inline, so
// json.Marshal and json.Unmarshal of a *Receiver round-trip both the upstream
// and the native configs in a single pass — no side maps or field allow-lists
// are needed. To add another native channel, add a field here and a loop in
// alertmanagernotify.NewReceiverIntegrations.
type Receiver struct {
*config.Receiver
GoogleChatConfigs []*GoogleChatReceiverConfig `json:"googlechat_configs,omitempty" yaml:"googlechat_configs,omitempty"`
}
func TestReceiver(ctx context.Context, receiver Receiver, receiverIntegrationsFunc ReceiverIntegrationsFunc, config *Config, tmpl *template.Template, logger *slog.Logger, templater Templater, lSet model.LabelSet, alert ...*Alert) error {
// NewReceiver builds a Receiver from its JSON representation, applying the
// upstream alertmanager defaults to the base receiver. The only default missed
// is `send_resolved` (a bool) which, if absent from the input, stays false.
func NewReceiver(input string) (*Receiver, error) {
receiver := &Receiver{Receiver: &config.Receiver{}}
if err := json.Unmarshal([]byte(input), receiver); err != nil {
return nil, err
}
// Round-trip the base receiver through YAML so the upstream defaults are
// applied via each notifier config's UnmarshalYAML (mirrors upstream
// alertmanager). The native configs on the embed are unaffected.
withDefaults, err := defaultedBaseReceiver(receiver.Receiver)
if err != nil {
return nil, err
}
receiver.Receiver = withDefaults
return receiver, nil
}
func defaultedBaseReceiver(base *config.Receiver) (*config.Receiver, error) {
bytes, err := yaml.Marshal(base)
if err != nil {
return nil, err
}
withDefaults := &config.Receiver{}
if err := yaml.Unmarshal(bytes, withDefaults); err != nil {
return nil, err
}
if err := withDefaults.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return nil, err
}
return withDefaults, nil
}
func TestReceiver(ctx context.Context, receiver *Receiver, receiverIntegrationsFunc ReceiverIntegrationsFunc, config *Config, tmpl *template.Template, logger *slog.Logger, templater Templater, lSet model.LabelSet, alert ...*Alert) error {
ctx = notify.WithGroupKey(ctx, fmt.Sprintf("%s-%s-%d", receiver.Name, lSet.Fingerprint(), time.Now().Unix()))
ctx = notify.WithGroupLabels(ctx, lSet)
ctx = notify.WithReceiverName(ctx, receiver.Name)
@@ -67,12 +88,12 @@ func TestReceiver(ctx context.Context, receiver Receiver, receiverIntegrationsFu
return err
}
receiver, err = testConfig.GetReceiver(receiver.Name)
defaultedReceiver, err := testConfig.GetReceiver(receiver.Name)
if err != nil {
return err
}
integrations, err := receiverIntegrationsFunc(receiver, tmpl, logger, templater)
integrations, err := receiverIntegrationsFunc(defaultedReceiver, tmpl, logger, templater)
if err != nil {
return err
}

View File

@@ -21,6 +21,12 @@ func TestNewReceiver(t *testing.T) {
expected: `{"name":"telegram","telegram_configs":[{"send_resolved":false,"token":"1234567890","chat":12345,"message":"{{ template \"telegram.default.message\" . }}","parse_mode":"HTML"}]}`,
pass: true,
},
{
name: "GoogleChatConfig",
input: `{"name":"googlechat","googlechat_configs":[{"send_resolved":true,"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body"}]}`,
expected: `{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body","send_resolved":true}]}`,
pass: true,
},
}
for _, tc := range testCases {
@@ -31,7 +37,7 @@ func TestNewReceiver(t *testing.T) {
bytes, err := json.Marshal(receiver)
require.NoError(t, err)
assert.Equal(t, tc.expected, string(bytes))
assert.JSONEq(t, tc.expected, string(bytes))
return
}

View File

@@ -28,7 +28,7 @@ func NewRouteFromRouteConfig(route *config.Route, cfg RouteConfig) (*config.Rout
return route, nil
}
func NewRouteFromReceiver(receiver Receiver) (*config.Route, error) {
func NewRouteFromReceiver(receiver *config.Receiver) (*config.Route, error) {
route := &config.Route{Receiver: receiver.Name, Continue: true, Matchers: config.Matchers{noRuleIDMatcher}}
if err := route.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return nil, err