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
138 changed files with 4946 additions and 1160 deletions

View File

@@ -123,3 +123,20 @@ jobs:
run: |
go run cmd/enterprise/*.go generate authz
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in authz permissions. Run go run cmd/enterprise/*.go generate authz locally and commit."; exit 1)
web-settings:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: generate-web-settings
run: |
go run cmd/enterprise/*.go generate config web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in web settings schema. Run go run cmd/enterprise/*.go generate config web-settings locally and commit."; exit 1)

View File

@@ -90,3 +90,26 @@ jobs:
run: |
cd frontend && pnpm generate:api
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
web-settings:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: install-frontend
run: cd frontend && pnpm install
- name: generate-web-settings
run: |
cd frontend && pnpm generate:config:web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated web settings types. Run pnpm generate:config:web-settings in frontend/ locally and commit."; exit 1)

View File

@@ -11,7 +11,7 @@ RUN apk update && \
COPY ./target/${OS}-${TARGETARCH}/signoz-community /root/signoz
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz

View File

@@ -12,7 +12,7 @@ RUN apk update && \
rm -rf /var/cache/apk/*
COPY ./target/${OS}-${ARCH}/signoz-community /root/signoz-community
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz-community

View File

@@ -11,7 +11,7 @@ RUN apk update && \
COPY ./target/${OS}-${TARGETARCH}/signoz /root/signoz
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz

View File

@@ -26,7 +26,7 @@ RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race

View File

@@ -12,7 +12,7 @@ RUN apk update && \
rm -rf /var/cache/apk/*
COPY ./target/${OS}-${ARCH}/signoz /root/signoz
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz

View File

@@ -35,7 +35,7 @@ RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
COPY ./templates /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race

61
cmd/genconfig.go Normal file
View File

@@ -0,0 +1,61 @@
package cmd
import (
"encoding/json"
"os"
"reflect"
"strings"
"github.com/SigNoz/signoz/pkg/web"
"github.com/spf13/cobra"
"github.com/swaggest/jsonschema-go"
)
const webSettingsSchemaPath = "docs/config/web-settings.json"
func registerGenerateConfig(parentCmd *cobra.Command) {
configCmd := &cobra.Command{
Use: "config",
Short: "Generate JSON Schema for config",
}
configCmd.AddCommand(&cobra.Command{
Use: "web-settings",
Short: "Generate JSON Schema for web settings",
RunE: func(currCmd *cobra.Command, args []string) error {
return generateWebSettings()
},
})
parentCmd.AddCommand(configCmd)
}
func generateWebSettings() error {
falseVal := false
noAdditional := jsonschema.SchemaOrBool{TypeBoolean: &falseVal}
reflector := jsonschema.Reflector{}
reflector.DefaultOptions = append(reflector.DefaultOptions,
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) {
if params.Value.Kind() == reflect.Struct {
params.Schema.AdditionalProperties = &noAdditional
}
return false, nil
}),
jsonschema.InterceptDefName(func(t reflect.Type, defaultDefName string) string {
return strings.TrimPrefix(defaultDefName, "Web")
}),
)
schema, err := reflector.Reflect(web.Settings{})
if err != nil {
return err
}
data, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return err
}
return os.WriteFile(webSettingsSchemaPath, append(data, '\n'), 0o600)
}

View File

@@ -17,6 +17,7 @@ func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
registerGenerateOpenAPI(generateCmd)
registerGenerateAuthz(generateCmd)
registerGenerateConfig(generateCmd)
parentCmd.AddCommand(generateCmd)
}

View File

@@ -182,6 +182,11 @@ alertmanager:
poll_interval: 1m
# The URL under which Alertmanager is externally reachable (for example, if Alertmanager is served via a reverse proxy). Used for generating relative and absolute links back to Alertmanager itself.
external_url: http://localhost:8080
# The list of globs from which SigNoz's alertmanager notification templates are loaded (e.g. the email.signoz.html layout).
# This mirrors the upstream alertmanager `templates` config option. The upstream default templates (default.tmpl, email.tmpl)
# are always loaded from the embedded alertmanager assets, so only SigNoz's own templates need to be listed here.
templates:
- /opt/signoz/conf/templates/alertmanager/*.gotmpl
# The global configuration for the alertmanager. All the exahustive fields can be found in the upstream: https://github.com/prometheus/alertmanager/blob/efa05feffd644ba4accb526e98a8c6545d26a783/config/config.go#L833
global:
# ResolveTimeout is the time after which an alert is declared resolved if it has not been updated.

View File

@@ -129,6 +129,8 @@ components:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
scope:
type: string
status:
$ref: '#/components/schemas/AlertmanagertypesMaintenanceStatus'
updatedAt:
@@ -272,6 +274,8 @@ components:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
scope:
type: string
required:
- name
- schedule
@@ -1540,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:
@@ -1843,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'
@@ -5686,12 +5705,6 @@ components:
type: string
rootServiceName:
type: string
serviceNameToTotalDurationMap:
additionalProperties:
minimum: 0
type: integer
nullable: true
type: object
spans:
items:
$ref: '#/components/schemas/SpantypesWaterfallSpan'

View File

@@ -0,0 +1,42 @@
{
"required": [
"posthog",
"appcues"
],
"additionalProperties": false,
"definitions": {
"Appcues": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"Posthog": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
}
},
"properties": {
"appcues": {
"$ref": "#/definitions/Appcues"
},
"posthog": {
"$ref": "#/definitions/Posthog"
}
},
"type": "object"
}

View File

@@ -89,6 +89,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
useDashboardV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseDashboardV2, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseDashboardV2.String()),
Active: useDashboardV2,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -94,6 +94,19 @@
}
})();
</script>
<script type="application/json" id="signoz-boot-settings">
[[.Settings]]
</script>
<script>
try {
var _el = document.getElementById('signoz-boot-settings');
window.signozBootData = {
settings: _el ? JSON.parse(_el.textContent) : null,
};
} catch (e) {
window.signozBootData = { settings: null };
}
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
@@ -135,7 +148,10 @@
</script>
<script>
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
if (APPCUES_APP_ID) {
var appcuesSettings =
((window.signozBootData || {}).settings || {}).appcues || {};
var appcuesEnabled = appcuesSettings.enabled !== false;
if (APPCUES_APP_ID && appcuesEnabled) {
(function (d, t) {
var a = d.createElement(t);
a.async = 1;

View File

@@ -47,10 +47,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid)/)',
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -24,7 +24,8 @@
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
"generate:config:web-settings": "json2ts ../docs/config/web-settings.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */'"
},
"engines": {
"node": ">=22.0.0",
@@ -49,7 +50,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.21",
"@signozhq/ui": "0.0.22",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -160,8 +161,8 @@
"@testing-library/user-event": "14.4.3",
"@types/color": "^3.0.3",
"@types/crypto-js": "4.2.2",
"@types/event-source-polyfill": "^1.0.0",
"@types/d3-hierarchy": "1.1.11",
"@types/event-source-polyfill": "^1.0.0",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@types/lodash-es": "^4.17.4",
@@ -187,6 +188,7 @@
"is-ci": "^3.0.1",
"jest-environment-jsdom": "29.7.0",
"jest-styled-components": "^7.2.0",
"json-schema-to-typescript": "^15.0.4",
"lint-staged": "^17.0.4",
"msw": "1.3.2",
"orval": "8.9.1",
@@ -241,4 +243,4 @@
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}
}

View File

@@ -77,8 +77,8 @@ importers:
specifier: 0.0.2
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@signozhq/ui':
specifier: 0.0.21
version: 0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
specifier: 0.0.22
version: 0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -449,6 +449,9 @@ importers:
jest-styled-components:
specifier: ^7.2.0
version: 7.2.0(styled-components@5.3.11(react-dom@18.2.0(react@18.2.0))(react-is@19.2.6)(react@18.2.0))
json-schema-to-typescript:
specifier: ^15.0.4
version: 15.0.4
lint-staged:
specifier: ^17.0.4
version: 17.0.4
@@ -457,7 +460,7 @@ importers:
version: 1.3.2(typescript@5.9.3)
orval:
specifier: 8.9.1
version: 8.9.1(typescript@5.9.3)
version: 8.9.1(prettier@3.8.3)(typescript@5.9.3)
oxfmt:
specifier: 0.47.0
version: 0.47.0
@@ -545,6 +548,10 @@ packages:
peerDependencies:
react: '>=16.9.0'
'@apidevtools/json-schema-ref-parser@11.9.3':
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
engines: {node: '>= 16'}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -1991,6 +1998,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@keyv/bigmap@1.3.1':
resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==}
engines: {node: '>= 18'}
@@ -3269,8 +3279,8 @@ packages:
peerDependencies:
react: ^18.2.0
'@signozhq/ui@0.0.21':
resolution: {integrity: sha512-uLM3Vqwxlk2USXbwtb3qRLpjZR9b9QSHFQq/jtcfYNMDmIE/sNjSj0nRkEhX4RqqRgsLRt2PVA33aeWxDOLO3g==}
'@signozhq/ui@0.0.22':
resolution: {integrity: sha512-CJDyA4H+uXG/U2/d7/nRMNY6WIW0YWc843mfzUQALjm+xOhbO4T+qt67THjV4s1wTMs1cZLkmScbMddf+hXLIQ==}
peerDependencies:
'@signozhq/icons': 0.3.0
react: ^18.2.0
@@ -6066,6 +6076,11 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-schema-to-typescript@15.0.4:
resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==}
engines: {node: '>=16.0.0'}
hasBin: true
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -7104,6 +7119,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -9044,6 +9064,12 @@ snapshots:
resize-observer-polyfill: 1.5.1
throttle-debounce: 5.0.0
'@apidevtools/json-schema-ref-parser@11.9.3':
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -10798,6 +10824,8 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.5
optional: true
'@jsdevtools/ono@7.1.3': {}
'@keyv/bigmap@1.3.1(keyv@5.6.0)':
dependencies:
hashery: 1.5.1
@@ -12013,7 +12041,7 @@ snapshots:
- react-dom
- tailwindcss
'@signozhq/ui@0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
dependencies:
'@chenglou/pretext': 0.0.5
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -15374,6 +15402,18 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
json-schema-to-typescript@15.0.4:
dependencies:
'@apidevtools/json-schema-ref-parser': 11.9.3
'@types/json-schema': 7.0.15
'@types/lodash': 4.17.24
is-glob: 4.0.3
js-yaml: 4.1.1
lodash: 4.18.1
minimist: 1.2.8
prettier: 3.8.3
tinyglobby: 0.2.15
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -16290,7 +16330,7 @@ snapshots:
strip-ansi: 6.0.1
wcwidth: 1.0.1
orval@8.9.1(typescript@5.9.3):
orval@8.9.1(prettier@3.8.3)(typescript@5.9.3):
dependencies:
'@commander-js/extra-typings': 14.0.0(commander@14.0.2)
'@orval/angular': 8.9.1(typescript@5.9.3)
@@ -16321,6 +16361,8 @@ snapshots:
typedoc: 0.28.19(typescript@5.9.3)
typedoc-plugin-coverage: 4.0.2(typedoc@0.28.19(typescript@5.9.3))
typedoc-plugin-markdown: 4.11.0(typedoc@0.28.19(typescript@5.9.3))
optionalDependencies:
prettier: 3.8.3
transitivePeerDependencies:
- '@faker-js/faker'
- supports-color
@@ -16581,6 +16623,8 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.8.3: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1

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

@@ -35,6 +35,7 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { extractDomain } from 'utils/app';
import { bootSettings } from 'utils/bootData';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
@@ -332,7 +333,7 @@ function App(): JSX.Element {
useEffect(() => {
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (process.env.POSTHOG_KEY) {
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
posthog.init(process.env.POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well

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

@@ -225,6 +225,10 @@ export interface AlertmanagertypesPlannedMaintenanceDTO {
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
/**
* @type string
*/
scope?: string;
status: AlertmanagertypesMaintenanceStatusDTO;
/**
* @type string
@@ -1714,6 +1718,10 @@ export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
/**
* @type string
*/
scope?: string;
}
export interface AlertmanagertypesPostableRoutePolicyDTO {
@@ -6743,15 +6751,6 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export type SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf =
{ [key: string]: number };
/**
* @nullable
*/
export type SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap =
SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf | null;
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -6940,10 +6939,6 @@ export interface SpantypesGettableWaterfallTraceDTO {
* @type string
*/
rootServiceName?: string;
/**
* @type object,null
*/
serviceNameToTotalDurationMap?: SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap;
/**
* @type array,null
*/

View File

@@ -51,6 +51,10 @@
}
}
}
.duration-input-slider {
padding: 12px 0px;
margin-top: 12px;
}
}
.divider {

View File

@@ -10,9 +10,6 @@ export const DEFAULT_AUTH0_APP_REDIRECTION_PATH = ROUTES.APPLICATION;
export const INVITE_MEMBERS_HASH = '#invite-team-members';
export const SIGNOZ_UPGRADE_PLAN_URL =
'https://upgrade.signoz.io/upgrade-from-app';
export const DASHBOARD_TIME_IN_DURATION = 'refreshInterval';
export const DEFAULT_ENTITY_VERSION = 'v3';

View File

@@ -11,4 +11,5 @@ export enum FeatureKeys {
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
}

View File

@@ -0,0 +1,64 @@
// Collapsed activity summary — one row that hides the underlying
// thinking + tool-call steps. Reuses the same quiet treatment as
// ThinkingStep / ToolCallStep so it sits flush in the assistant bubble.
.activityGroup {
width: 100%;
font-size: 12px;
}
.activityHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--l3-foreground);
user-select: none;
transition: color 0.12s ease;
width: 100%;
text-align: left;
&:hover {
color: var(--l1-foreground);
}
}
.sparkleIcon {
flex-shrink: 0;
color: var(--accent-primary);
&.iconPulsing {
animation: activityGroupPulse 1.4s ease-in-out infinite;
}
}
@keyframes activityGroupPulse {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.activitySummary {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 400;
}
.toggleChevron {
flex-shrink: 0;
color: inherit;
}
.activityBody {
display: flex;
flex-direction: column;
padding: 2px 0 4px;
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from 'react';
import cx from 'classnames';
import { ChevronDown, ChevronRight, Sparkles } from '@signozhq/icons';
import { formatTime } from 'utils/timeUtils';
import { StreamingToolCall } from '../../types';
import ThinkingStep, { ThinkingContent, thinkingLabel } from '../ThinkingStep';
import ToolCallStep, {
getToolDisplayLabel,
ToolCallContent,
} from '../ToolCallStep';
import styles from './ActivityGroup.module.scss';
export type ActivityItem =
| { id: string; kind: 'thinking'; content: string }
| { id: string; kind: 'tool'; toolCall: StreamingToolCall };
interface ActivityGroupProps {
items: ActivityItem[];
/**
* True only for the trailing activity group of an active stream — drives
* the live "Working…" label and the elapsed-time tick (which re-stamps on
* approval/clarification resume so wait time isn't counted).
*/
isLive?: boolean;
}
/**
* Single-item groups get a step-specific summary so the user doesn't see a
* pointless "Worked through 1 step". Multi-item groups roll up into the
* generic "Working… / Worked through N steps" treatment.
*/
function buildSummary(
items: ActivityItem[],
isLive: boolean,
elapsed: number,
): string {
if (items.length === 1) {
const [only] = items;
if (only.kind === 'thinking') {
return thinkingLabel(isLive);
}
return getToolDisplayLabel(only.toolCall);
}
const stepLabel = `${items.length} steps`;
if (!isLive) {
return `Worked through ${stepLabel}`;
}
// Suppress the elapsed token until ≥ 1s — the first tick fires after
// 1s anyway, and showing "0s" or "<1s" briefly adds noise.
return elapsed >= 1000
? `Working… · ${formatTime(elapsed / 1000)} · ${stepLabel}`
: `Working… · ${stepLabel}`;
}
/**
* Single collapsed summary row that hides a run of thinking + tool-call steps.
* Expands to show each underlying step inline. Used for every activity row
* (including single-item ones) so all "what the agent did" rows share a
* consistent ✨-led visual contract.
*/
export default function ActivityGroup({
items,
isLive = false,
}: ActivityGroupProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
// Captures the moment this live phase started. Re-stamped on every
// false→true transition so a stream that pauses on
// approval/clarification and resumes doesn't roll the user's wait time
// into the elapsed counter.
const startedAtRef = useRef<number>(Date.now());
const wasLiveRef = useRef<boolean>(isLive);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (isLive && !wasLiveRef.current) {
startedAtRef.current = Date.now();
setElapsed(0);
}
wasLiveRef.current = isLive;
if (!isLive) {
return undefined;
}
// Tick once per second — the display is integer-second precision, so
// faster ticks would just re-render the bubble for no visible change.
const id = window.setInterval(() => {
setElapsed(Date.now() - startedAtRef.current);
}, 1000);
return (): void => window.clearInterval(id);
}, [isLive]);
const summary = buildSummary(items, isLive, elapsed);
const isSingle = items.length === 1;
const toggle = (): void => setExpanded((v) => !v);
return (
<div className={styles.activityGroup}>
<div className={styles.activityHeader} onClick={toggle}>
<Sparkles
size={12}
className={cx(styles.sparkleIcon, { [styles.iconPulsing]: isLive })}
/>
<span className={styles.activitySummary}>{summary}</span>
{expanded ? (
<ChevronDown size={12} className={styles.toggleChevron} />
) : (
<ChevronRight size={12} className={styles.toggleChevron} />
)}
</div>
{expanded && (
<div className={styles.activityBody}>
{isSingle ? (
// Single-item: the outer chevron already provides disclosure,
// so render the underlying content directly instead of wrapping
// it in a second collapsible step row.
items[0].kind === 'thinking' ? (
<ThinkingContent content={items[0].content} />
) : (
<ToolCallContent toolCall={items[0].toolCall} />
)
) : (
items.map((item, i) => {
// A thinking step is live only while it's the trailing item
// in a trailing live group — once any later event (text or
// tool) arrives, the pass is done.
const isLastItem = i === items.length - 1;
return item.kind === 'thinking' ? (
<ThinkingStep
key={item.id}
content={item.content}
isLive={isLive && isLastItem}
/>
) : (
<ToolCallStep key={item.id} toolCall={item.toolCall} />
);
})
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default } from './ActivityGroup';
export type { ActivityItem } from './ActivityGroup';

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -9,11 +9,10 @@ import '../blocks';
import { useVariant } from '../../VariantContext';
import { Message, MessageBlock } from '../../types';
import ActionsSection from '../ActionsSection';
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
import { RichCodeBlock } from '../blocks';
import { MessageContext } from '../MessageContext';
import MessageFeedback from '../MessageFeedback';
import ThinkingStep from '../ThinkingStep';
import ToolCallStep from '../ToolCallStep';
import UserMessageActions from '../UserMessageActions';
import styles from './MessageBubble.module.scss';
@@ -40,38 +39,61 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
/** Renders a single MessageBlock by type. */
function renderBlock(block: MessageBlock, index: number): JSX.Element {
switch (block.type) {
case 'thinking':
return <ThinkingStep key={index} content={block.content} />;
case 'tool_call':
// Blocks in a persisted message are always complete — done is always true.
return (
<ToolCallStep
key={index}
toolCall={{
toolName: block.toolName,
input: block.toolInput,
result: block.result,
done: true,
displayText: block.displayText,
}}
/>
);
case 'text':
default:
return (
<ReactMarkdown
key={index}
className={styles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{block.content}
</ReactMarkdown>
);
type RenderGroup =
| { kind: 'text'; id: string; content: string }
| { kind: 'activity'; id: string; items: ActivityItem[] };
/**
* Partition message blocks into render groups so consecutive thinking and
* tool_call blocks collapse into a single ActivityGroup row. Text blocks
* stand alone, mirroring the streaming view.
*/
function groupBlocks(blocks: MessageBlock[]): RenderGroup[] {
const groups: RenderGroup[] = [];
blocks.forEach((block, i) => {
if (block.type === 'text') {
groups.push({ kind: 'text', id: `text-${i}`, content: block.content });
return;
}
const item: ActivityItem =
block.type === 'thinking'
? { id: `t-${i}`, kind: 'thinking', content: block.content }
: {
id: `c-${block.toolCallId}`,
kind: 'tool',
// Persisted blocks are always complete.
toolCall: {
toolName: block.toolName,
input: block.toolInput,
result: block.result,
done: true,
displayText: block.displayText,
},
};
const last = groups[groups.length - 1];
if (last?.kind === 'activity') {
last.items.push(item);
} else {
groups.push({ kind: 'activity', id: `a-${i}`, items: [item] });
}
});
return groups;
}
function renderGroup(group: RenderGroup): JSX.Element {
if (group.kind === 'text') {
return (
<ReactMarkdown
key={group.id}
className={styles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{group.content}
</ReactMarkdown>
);
}
return <ActivityGroup key={group.id} items={group.items} />;
}
interface MessageBubbleProps {
@@ -90,6 +112,14 @@ export default function MessageBubble({
const isUser = message.role === 'user';
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
// Recompute groups only when the blocks array identity changes — store
// updates that don't touch this message's blocks should not re-render the
// underlying ThinkingStep/ToolCallStep children.
const groups = useMemo(
() => (hasBlocks ? groupBlocks(message.blocks!) : []),
[hasBlocks, message.blocks],
);
const messageClass = cx(
styles.message,
isUser ? styles.user : styles.assistant,
@@ -128,8 +158,7 @@ export default function MessageBubble({
<p className={styles.text}>{message.content}</p>
) : hasBlocks ? (
<MessageContext.Provider value={{ messageId: message.id }}>
{/* eslint-disable-next-line react/no-array-index-key */}
{message.blocks!.map((block, i) => renderBlock(block, i))}
{groups.map((g) => renderGroup(g))}
</MessageContext.Provider>
) : (
<MessageContext.Provider value={{ messageId: message.id }}>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -10,11 +10,10 @@ import type {
import { useVariant } from '../../VariantContext';
import { StreamingEventItem } from '../../types';
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
import ApprovalCard from '../ApprovalCard';
import { RichCodeBlock } from '../blocks';
import ClarificationForm from '../ClarificationForm';
import ThinkingStep from '../ThinkingStep';
import ToolCallStep from '../ToolCallStep';
import messageStyles from '../MessageBubble/MessageBubble.module.scss';
import styles from './StreamingMessage.module.scss';
@@ -33,6 +32,59 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
type RenderGroup =
| { kind: 'text'; id: string; content: string }
| {
kind: 'activity';
id: string;
items: ActivityItem[];
isTrailing: boolean;
};
/**
* Partition the streaming event timeline into render groups: runs of
* consecutive thinking/tool events fold into a single activity group, text
* events stay standalone. The last group is flagged as trailing so the
* caller can drive a "live" indicator on it.
*
* Invariant relied on by the ActivityGroup elapsed-time timer: once a
* group exists at a given array index, later events only extend its
* `items` — they never shrink the array or re-key existing groups. That
* keeps each ActivityGroup React instance stable across re-renders so the
* timer's `wasLive` → `isLive` re-stamp captures the right transition.
* The id fields below piggyback on that invariant: each event's position in
* `events` is stable, so the derived id stays stable across re-renders.
*/
function groupStreamingEvents(events: StreamingEventItem[]): RenderGroup[] {
const groups: RenderGroup[] = [];
events.forEach((event, i) => {
if (event.kind === 'text') {
groups.push({ kind: 'text', id: `text-${i}`, content: event.content });
return;
}
const item: ActivityItem =
event.kind === 'thinking'
? { id: `t-${i}`, kind: 'thinking', content: event.content }
: { id: `c-${i}`, kind: 'tool', toolCall: event.toolCall };
const last = groups[groups.length - 1];
if (last?.kind === 'activity') {
last.items.push(item);
} else {
groups.push({
kind: 'activity',
id: `a-${i}`,
items: [item],
isTrailing: false,
});
}
});
const last = groups[groups.length - 1];
if (last?.kind === 'activity') {
last.isTrailing = true;
}
return groups;
}
/** Human-readable labels for execution status codes shown before any events arrive. */
const STATUS_LABEL: Record<string, string> = {
queued: 'Queued…',
@@ -79,6 +131,11 @@ export default function StreamingMessage({
[messageStyles.compact]: isCompact,
});
// Recompute groups only when the events array identity changes. The
// streaming reducer pushes new entries into the same array reference
// once per tick, so this naturally invalidates as events arrive.
const groups = useMemo(() => groupStreamingEvents(events), [events]);
return (
<div className={messageClass}>
<div className={messageStyles.bubble}>
@@ -88,27 +145,28 @@ export default function StreamingMessage({
)}
{isEmpty && !statusLabel && <TypingDots />}
{/* eslint-disable react/no-array-index-key */}
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
{events.map((event, i) => {
if (event.kind === 'tool') {
return <ToolCallStep key={i} toolCall={event.toolCall} />;
}
if (event.kind === 'thinking') {
return <ThinkingStep key={i} content={event.content} />;
{/* Runs of consecutive thinking + tool events collapse into a
single ActivityGroup; text events render inline between
them. The trailing group is "live" while streaming is
active and not blocked on the user. */}
{groups.map((group) => {
if (group.kind === 'text') {
return (
<ReactMarkdown
key={group.id}
className={messageStyles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{group.content}
</ReactMarkdown>
);
}
const groupIsLive = group.isTrailing && !isWaitingOnUser;
return (
<ReactMarkdown
key={i}
className={messageStyles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{event.content}
</ReactMarkdown>
<ActivityGroup key={group.id} items={group.items} isLive={groupIsLive} />
);
})}
{/* eslint-enable react/no-array-index-key */}
{/* While events are still streaming, append the typing dots so the
user has a clear "more is coming" signal. Hidden when the agent

View File

@@ -5,11 +5,31 @@ import styles from './ThinkingStep.module.scss';
interface ThinkingStepProps {
content: string;
/**
* When false, label reads "Thought for a few seconds" — intentionally
* vague because the API doesn't persist precise timing, so showing
* seconds would be inconsistent between fresh and reloaded threads.
*/
isLive?: boolean;
}
/** Body of a thinking step — extracted so ActivityGroup can render it directly. */
export function ThinkingContent({ content }: { content: string }): JSX.Element {
return (
<div className={styles.body}>
<p className={styles.content}>{content}</p>
</div>
);
}
export function thinkingLabel(isLive: boolean): string {
return isLive ? 'Thinking…' : 'Thought for a few seconds';
}
/** Collapsible thinking row — chevron + label, content in the expanded body. */
export default function ThinkingStep({
content,
isLive = false,
}: ThinkingStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
@@ -23,14 +43,10 @@ export default function ThinkingStep({
) : (
<ChevronRight size={12} className={styles.chevron} />
)}
<span className={styles.label}>Thinking</span>
<span className={styles.label}>{thinkingLabel(isLive)}</span>
</div>
{expanded && (
<div className={styles.body}>
<p className={styles.content}>{content}</p>
</div>
)}
{expanded && <ThinkingContent content={content} />}
</div>
);
}

View File

@@ -10,24 +10,58 @@ interface ToolCallStepProps {
toolCall: StreamingToolCall;
}
/**
* Server-supplied `displayText` is the human-friendly title the backend
* wants surfaced. Falls back to a derived label
* ("signoz_get_dashboard" → "Get Dashboard") when missing.
*/
export function getToolDisplayLabel(toolCall: StreamingToolCall): string {
const { toolName, displayText } = toolCall;
if (displayText && displayText.trim().length > 0) {
return displayText;
}
return toolName
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Body of a tool-call step — extracted so ActivityGroup can render it directly. */
export function ToolCallContent({
toolCall,
}: {
toolCall: StreamingToolCall;
}): JSX.Element {
const { toolName, input, result, done } = toolCall;
return (
<div className={styles.body}>
<div className={styles.section}>
<span className={styles.sectionLabel}>Tool</span>
<span className={styles.toolName}>{toolName}</span>
</div>
<div className={styles.section}>
<span className={styles.sectionLabel}>Input</span>
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
</div>
{done && result !== undefined && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Output</span>
<pre className={styles.json}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
);
}
/** Collapsible tool-call row — chevron + label, in/out detail in the body. */
export default function ToolCallStep({
toolCall,
}: ToolCallStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
const { toolName, input, result, done, displayText } = toolCall;
// Prefer the server-supplied `displayText` from `ToolCallEventDTO` —
// it's the human-friendly title the backend wants surfaced. Fall back
// to a derived label ("signoz_get_dashboard" → "Get Dashboard") when
// the field is empty / null / missing.
const label =
displayText && displayText.trim().length > 0
? displayText
: toolName
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
const { done } = toolCall;
const label = getToolDisplayLabel(toolCall);
const toggle = (): void => setExpanded((v) => !v);
@@ -44,26 +78,7 @@ export default function ToolCallStep({
<span className={styles.label}>{label}</span>
</div>
{expanded && (
<div className={styles.body}>
<div className={styles.section}>
<span className={styles.sectionLabel}>Tool</span>
<span className={styles.toolName}>{toolName}</span>
</div>
<div className={styles.section}>
<span className={styles.sectionLabel}>Input</span>
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
</div>
{done && result !== undefined && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Output</span>
<pre className={styles.json}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
{expanded && <ToolCallContent toolCall={toolCall} />}
</div>
);
}

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,7 +1,8 @@
import { SIGNOZ_UPGRADE_PLAN_URL } from 'constants/app';
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import {
googleChatTextDefaultValue,
googleChatTitleDefaultValue,
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
@@ -313,16 +314,6 @@ describe('Create Alert Channel (Normal User)', () => {
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
});
it.skip('Should check if the upgrade plan message is shown', () => {
expect(screen.getByText('Upgrade to a Paid Plan')).toBeInTheDocument();
expect(
screen.getByText(/This feature is available for paid plans only./),
).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'Click here' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', SIGNOZ_UPGRADE_PLAN_URL);
expect(screen.getByText(/to Upgrade/)).toBeInTheDocument();
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(
screen.getByRole('button', { name: 'button_save_channel' }),
@@ -343,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

@@ -91,7 +91,6 @@ function ChartPreview({
const renderQBChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={headline}
name=""
query={stagedQuery}
selectedInterval={globalSelectedInterval}
alertDef={alertDef}
@@ -107,7 +106,6 @@ function ChartPreview({
const renderPromAndChQueryChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={headline}
name="Chart Preview"
query={stagedQuery}
alertDef={alertDef}
selectedInterval={globalSelectedInterval}

View File

@@ -17,7 +17,6 @@ import { CreateAlertProvider } from '../../context';
import ChartPreview from '../ChartPreview/ChartPreview';
const REQUESTS_PER_SEC = 'requests/sec';
const CHART_PREVIEW_NAME = 'Chart Preview';
const QUERY_TYPE_TEST_ID = 'query-type';
const GRAPH_TYPE_TEST_ID = 'graph-type';
const CHART_PREVIEW_COMPONENT_TEST_ID = 'chart-preview-component';
@@ -34,7 +33,6 @@ jest.mock(
return (
<div data-testid={CHART_PREVIEW_COMPONENT_TEST_ID}>
<div data-testid="headline">{props.headline}</div>
<div data-testid="name">{props.name}</div>
<div data-testid={QUERY_TYPE_TEST_ID}>{props.query?.queryType}</div>
<div data-testid="selected-interval">
{props.selectedInterval?.startTime}
@@ -175,12 +173,6 @@ describe('ChartPreview', () => {
);
});
it('renders QueryBuilder chart preview with empty name when query type is QUERY_BUILDER', () => {
renderChartPreview();
expect(screen.getByTestId('name')).toHaveTextContent('');
});
it('renders QueryBuilder chart preview with correct props', () => {
renderChartPreview();
@@ -191,7 +183,6 @@ describe('ChartPreview', () => {
expect(screen.getByTestId(GRAPH_TYPE_TEST_ID)).toHaveTextContent(
PANEL_TYPES.TIME_SERIES,
);
expect(screen.getByTestId('name')).toHaveTextContent('');
expect(screen.getByTestId('headline')).toBeInTheDocument();
expect(screen.getByTestId('selected-interval')).toBeInTheDocument();
});
@@ -214,7 +205,6 @@ describe('ChartPreview', () => {
expect(
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
).toBeInTheDocument();
expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME);
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.PROM,
);
@@ -238,7 +228,6 @@ describe('ChartPreview', () => {
expect(
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
).toBeInTheDocument();
expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME);
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
EQueryType.CLICKHOUSE,
);

View File

@@ -17,10 +17,11 @@ import { getTimeRange } from 'utils/getTimeRange';
import BarChart from '../../charts/BarChart/BarChart';
import ChartManager from '../../components/ChartManager/ChartManager';
import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import { prepareBarPanelConfig } from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -99,7 +100,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareBarPanelData(queryResponse?.data?.payload);
return prepareChartData(queryResponse?.data?.payload);
}, [queryResponse?.data?.payload]);
const layoutChildren = useMemo(() => {

View File

@@ -11,21 +11,10 @@ import { get } from 'lodash-es';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { AlignedData } from 'uplot';
import { PanelMode } from '../types';
import { fillMissingXAxisTimestamps, getXAxisTimestamps } from '../utils';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
export function prepareBarPanelData(
apiResponse: MetricRangePayloadProps,
): AlignedData {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
}
export function prepareBarPanelConfig({
widget,
isDarkMode,

View File

@@ -17,10 +17,11 @@ import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import { prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const {

View File

@@ -6,7 +6,8 @@ import {
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PanelMode } from '../../types';
import { prepareChartData, prepareUPlotConfig } from '../utils';
import { prepareUPlotConfig } from '../utils';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',

View File

@@ -1,10 +1,6 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
fillMissingXAxisTimestamps,
getXAxisTimestamps,
} from 'container/DashboardContainer/visualization/panels/utils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
@@ -15,42 +11,15 @@ import {
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import get from 'lodash-es/get';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { PanelMode } from '../types';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
export const prepareChartData = (
apiResponse: MetricRangePayloadProps,
): uPlot.AlignedData => {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
};
function hasSingleVisiblePointForSeries(series: QueryData): boolean {
const rawValues = series.values ?? [];
let validPointCount = 0;
for (const [, rawValue] of rawValues) {
if (!isInvalidPlotValue(rawValue)) {
validPointCount += 1;
if (validPointCount > 1) {
return false;
}
}
}
return true;
}
export const prepareUPlotConfig = ({
widget,
isDarkMode,
@@ -107,7 +76,7 @@ export const prepareUPlotConfig = ({
}
apiResponse.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePointForSeries(series);
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query

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

@@ -0,0 +1,118 @@
import { useMemo } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import uPlot from 'uplot';
import {
AlertChartPanelType,
buildAlertChartConfig,
buildChartId,
} from './utils';
// Panel types that render through the UPlotConfigBuilder pipeline.
// To support a new modern-chart panel type, add an entry here and extend
// `AlertChartPanelType` / `buildAlertChartConfig` to handle its series setup.
const SUPPORTED_CHARTS: Record<
AlertChartPanelType,
typeof TimeSeries | typeof BarChart
> = {
[PANEL_TYPES.TIME_SERIES]: TimeSeries,
[PANEL_TYPES.BAR]: BarChart,
};
const isSupportedPanelType = (
panelType: PANEL_TYPES,
): panelType is AlertChartPanelType => panelType in SUPPORTED_CHARTS;
export interface ChartContentProps {
panelType: PANEL_TYPES;
alertId?: string;
query: Query;
apiResponse?: MetricRangePayloadProps;
data: uPlot.AlignedData;
thresholds: ThresholdProps[];
yAxisUnit: string;
legendPosition: LegendPosition;
isDarkMode: boolean;
timezone: Timezone;
width: number;
height: number;
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect: (start: number, end: number) => void;
}
export default function ChartContent({
panelType,
alertId,
query,
thresholds,
apiResponse,
data,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
width,
height,
legendPosition,
}: ChartContentProps): JSX.Element | null {
const supported = isSupportedPanelType(panelType);
const config = useMemo(
() =>
buildAlertChartConfig({
id: buildChartId(alertId),
panelType: panelType as AlertChartPanelType,
query,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
alertId,
panelType,
query,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
],
);
if (!supported) {
return null;
}
const Component = SUPPORTED_CHARTS[panelType];
return (
<Component
config={config}
data={data}
width={width}
height={height}
legendConfig={{ position: legendPosition }}
canPinTooltip
yAxisUnit={yAxisUnit}
timezone={timezone}
/>
);
}

View File

@@ -15,8 +15,6 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView';
import { INITIAL_CRITICAL_THRESHOLD } from 'container/CreateAlertV2/context/constants';
import { Threshold } from 'container/CreateAlertV2/context/types';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import GridPanelSwitch from 'container/GridPanelSwitch';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
@@ -32,8 +30,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
@@ -41,24 +38,27 @@ import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { AlertDef } from 'types/api/alerts/def';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getTimeRange } from 'utils/getTimeRange';
import { AlertDetectionTypes } from '..';
import ChartContent from './ChartContent';
import { ChartContainer } from './styles';
import { getThresholds } from './utils';
import './ChartPreview.styles.scss';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
// Height reserved for the `.chart-preview-header` strip rendered above the chart.
const CHART_PREVIEW_HEADER_HEIGHT = 48;
const CHART_PREVIEW_CONTAINER_PADDING = 16;
export interface ChartPreviewProps {
name: string;
query: Query | null;
graphType?: PANEL_TYPES;
selectedTime?: timePreferenceType;
@@ -77,7 +77,6 @@ export interface ChartPreviewProps {
// eslint-disable-next-line sonarjs/cognitive-complexity
function ChartPreview({
name,
query,
graphType = PANEL_TYPES.TIME_SERIES,
selectedTime = 'GLOBAL_TIME',
@@ -113,14 +112,6 @@ function ChartPreview({
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const { currentQuery } = useQueryBuilder();
const {
@@ -219,18 +210,6 @@ function ChartPreview({
setMaxTimeScale(endTime);
}, [maxTime, minTime, globalSelectedInterval, queryResponse, setQueryStatus]);
// Initialize graph visibility from localStorage
useEffect(() => {
if (queryResponse?.data?.payload?.data?.result) {
const { graphVisibilityStates: localStoredVisibilityState } =
getLocalStorageGraphVisibilityState({
apiResponse: queryResponse.data.payload.data.result,
name: 'alert-chart-preview',
});
setGraphVisibility(localStoredVisibilityState);
}
}, [queryResponse?.data?.payload?.data?.result]);
if (queryResponse.data && graphType === PANEL_TYPES.BAR) {
const sortedSeriesData = getSortedSeriesData(
queryResponse.data?.payload.data.result,
@@ -288,62 +267,17 @@ function ChartPreview({
return LegendPosition.RIGHT;
}, [queryResponse?.data?.payload?.data?.result?.length, showSideLegend]);
const options = useMemo(
() =>
getUPlotChartOptions({
id: 'alert_legend_widget',
yAxisUnit,
apiResponse: queryResponse?.data?.payload,
dimensions: {
height: containerDimensions?.height ? containerDimensions.height - 48 : 0,
width: containerDimensions?.width,
},
minTimeScale,
maxTimeScale,
isDarkMode,
onDragSelect,
thresholds: getThresholds(thresholds, t, optionName, yAxisUnit),
softMax: null,
softMin: null,
panelType: graphType,
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
currentQuery,
query: query || currentQuery,
graphsVisibilityStates: graphVisibility,
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
yAxisUnit,
queryResponse?.data?.payload,
containerDimensions,
minTimeScale,
maxTimeScale,
isDarkMode,
onDragSelect,
thresholds,
t,
optionName,
graphType,
timezone.value,
currentQuery,
query,
graphVisibility,
legendPosition,
],
const resolvedThresholds = useMemo(
() => getThresholds(thresholds, t, optionName, yAxisUnit),
[thresholds, t, optionName, yAxisUnit],
);
const chartData = getUPlotChartData(queryResponse?.data?.payload);
const chartData = useMemo(() => {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareChartData(queryResponse?.data?.payload);
}, [queryResponse?.data?.payload]);
const hasResultData = !!queryResponse?.data?.payload?.data?.result?.length;
@@ -361,6 +295,14 @@ function ChartPreview({
?.active || false;
const isWarning = !isEmpty(queryResponse.data?.warning);
const chartWidth = containerDimensions?.width
? containerDimensions.width - CHART_PREVIEW_CONTAINER_PADDING
: 0;
const chartHeight = containerDimensions?.height
? containerDimensions.height - CHART_PREVIEW_HEADER_HEIGHT
: 0;
return (
<div className="alert-chart-container" ref={graphRef}>
<ChartContainer>
@@ -384,16 +326,22 @@ function ChartPreview({
)}
{chartDataAvailable && !isAnomalyDetectionAlert && (
<GridPanelSwitch
options={options}
<ChartContent
panelType={graphType}
alertId={alertDef?.id}
query={query || currentQuery}
apiResponse={queryResponse.data?.payload}
data={chartData}
name={name || 'Chart Preview'}
panelData={
queryResponse.data?.payload?.data?.newResult?.data?.result || []
}
query={query || initialQueriesMap.metrics}
thresholds={resolvedThresholds}
yAxisUnit={yAxisUnit}
legendPosition={legendPosition}
isDarkMode={isDarkMode}
timezone={timezone}
width={chartWidth}
height={chartHeight}
minTimeScale={minTimeScale}
maxTimeScale={maxTimeScale}
onDragSelect={onDragSelect}
/>
)}

View File

@@ -1,6 +1,10 @@
import { Color } from '@signozhq/design-tokens';
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Threshold } from 'container/CreateAlertV2/context/types';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import {
BooleanFormats,
@@ -11,6 +15,20 @@ import {
TimeFormats,
} from 'container/NewWidget/RightContainer/types';
import { TFunction } from 'i18next';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
dataFormatConfig,
@@ -20,6 +38,8 @@ import {
timeUnitsConfig,
} from './config';
const CHART_ID_PREFIX = 'alert_legend_widget';
export function covertIntoDataFormats({
value,
sourceUnit,
@@ -142,3 +162,110 @@ export const getThresholds = (
});
return thresholdsToReturn;
};
export type AlertChartPanelType = PANEL_TYPES.TIME_SERIES | PANEL_TYPES.BAR;
export interface BuildAlertChartConfigParams {
id: string;
panelType: AlertChartPanelType;
query: Query;
thresholds: ThresholdProps[];
apiResponse?: MetricRangePayloadProps;
yAxisUnit?: string;
isDarkMode: boolean;
timezone: Timezone;
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect: (startTime: number, endTime: number) => void;
onClick?: OnClickPluginOpts['onClick'];
}
export const buildAlertChartConfig = ({
id,
panelType,
query,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
}: BuildAlertChartConfigParams): UPlotConfigBuilder => {
const stepIntervals: ExecStats['stepIntervals'] = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
);
const stepIntervalValues = Object.values(stepIntervals);
const minStepInterval = stepIntervalValues.length
? Math.min(...stepIntervalValues)
: undefined;
const builder = buildBaseConfig({
id,
panelType,
panelMode: PanelMode.DASHBOARD_VIEW,
thresholds,
apiResponse,
yAxisUnit,
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
stepInterval: minStepInterval,
onDragSelect,
onClick,
});
const seriesList = apiResponse?.data?.result;
if (!seriesList?.length) {
return builder;
}
const isBar = panelType === PANEL_TYPES.BAR;
seriesList.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = query ? getLegend(series, query, baseLabelName) : baseLabelName;
if (isBar) {
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping: {},
isDarkMode,
stepInterval: get(stepIntervals, series.queryName, undefined),
});
return;
}
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
builder.addSeries({
scaleKey: 'y',
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping: {},
spanGaps: true,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: hasSingleValidPoint,
pointSize: 5,
fillMode: FillMode.None,
isDarkMode,
metric: series.metric,
});
});
return builder;
};
export const buildChartId = (id?: string): string =>
id ? `${CHART_ID_PREFIX}_${id}` : CHART_ID_PREFIX;

View File

@@ -719,7 +719,6 @@ function FormAlertRules({
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name=""
query={stagedQuery}
selectedInterval={globalSelectedInterval}
alertDef={alertDef}
@@ -739,7 +738,6 @@ function FormAlertRules({
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name="Chart Preview"
query={stagedQuery}
alertDef={alertDef}
selectedInterval={globalSelectedInterval}

View File

@@ -263,7 +263,7 @@ function External(): JSX.Element {
timestamp: selectedTimeStamp,
domainName: selectedData?.address || '',
isError: true,
stepInterval,
stepInterval: 300,
safeNavigate,
})}
/>
@@ -306,7 +306,7 @@ function External(): JSX.Element {
timestamp: selectedTimeStamp,
domainName: selectedData?.address,
isError: false,
stepInterval,
stepInterval: 300,
safeNavigate,
})}
/>
@@ -352,7 +352,7 @@ function External(): JSX.Element {
timestamp: selectedTimeStamp,
domainName: selectedData?.address,
isError: false,
stepInterval,
stepInterval: 300,
safeNavigate,
})}
/>
@@ -395,7 +395,7 @@ function External(): JSX.Element {
timestamp: selectedTimeStamp,
domainName: selectedData?.address,
isError: false,
stepInterval,
stepInterval: 300,
safeNavigate,
})}
/>

View File

@@ -151,7 +151,7 @@ export function onViewAPIMonitoringPopupClick({
safeNavigate,
}: OnViewAPIMonitoringPopupClickProps): (e?: React.MouseEvent) => void {
return (e?: React.MouseEvent): void => {
const endTime = timestamp;
const endTime = timestamp + (stepInterval || 60);
const startTime = timestamp - (stepInterval || 60);
const filters = {
items: [

View File

@@ -295,37 +295,6 @@
.slider-container {
width: calc(100% - 16px);
.ant-slider .ant-slider-mark {
margin-top: 12px;
.ant-slider-mark-text {
color: var(--l3-foreground);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on,
'cpsp' on,
'case' on;
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 100%;
}
}
&.logs-slider-container {
.ant-slider .ant-slider-mark {
.ant-slider-mark-text {
&:last-child {
left: calc(100% - 8px) !important;
white-space: nowrap;
}
}
}
}
}
.do-later-container {

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Slider } from 'antd';
import { Slider } from '@signozhq/ui/slider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { ArrowRight, LoaderCircle, Minus } from '@signozhq/icons';
@@ -204,23 +204,23 @@ function OptimiseSignozNeeds({
<label className="question-slider" htmlFor="organisationName">
Logs / Day
</label>
<div className="slider-container logs-slider-container">
<div className="slider-container">
<div>
<Slider
min={0}
max={100}
value={sliderValues.logsPerDay}
marks={marks}
onChange={(value: number): void =>
handleSliderChange('logsPerDay', value)
onChange={(value): void =>
handleSliderChange('logsPerDay', value as number)
}
styles={{
track: {
background: '#4E74F8',
range: {
backgroundColor: '#4E74F8',
},
}}
tooltip={{
formatter: (): string => `${logsPerDayValue.toLocaleString()} GB`, // Show whole number
formatter: (): string => `${logsPerDayValue.toLocaleString()} GB`,
}}
/>
</div>
@@ -238,16 +238,16 @@ function OptimiseSignozNeeds({
max={100}
value={sliderValues.hostsPerDay}
marks={hostMarks}
onChange={(value: number): void =>
handleSliderChange('hostsPerDay', value)
onChange={(value): void =>
handleSliderChange('hostsPerDay', value as number)
}
styles={{
track: {
background: '#4E74F8',
range: {
backgroundColor: '#4E74F8',
},
}}
tooltip={{
formatter: (): string => `${hostsPerDayValue.toLocaleString()}`, // Show whole number
formatter: (): string => `${hostsPerDayValue.toLocaleString()}`,
}}
/>
</div>
@@ -265,16 +265,16 @@ function OptimiseSignozNeeds({
max={100}
value={sliderValues.services}
marks={serviceMarks}
onChange={(value: number): void =>
handleSliderChange('services', value)
onChange={(value): void =>
handleSliderChange('services', value as number)
}
styles={{
track: {
background: '#4E74F8',
range: {
backgroundColor: '#4E74F8',
},
}}
tooltip={{
formatter: (): string => `${servicesValue.toLocaleString()}`, // Show whole number
formatter: (): string => `${servicesValue.toLocaleString()}`,
}}
/>
</div>

View File

@@ -81,6 +81,20 @@
}
}
.alert-rule-scope {
margin-bottom: 12px;
.ant-radio-wrapper {
color: var(--l1-foreground);
}
}
.alert-rule-all-warning {
font-size: 12px;
font-weight: 400;
color: var(--l2-foreground);
}
.formItemWithBullet {
margin-bottom: 0;
}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Check } from '@signozhq/icons';
import { Check, Info } from '@signozhq/icons';
import {
Button,
DatePicker,
@@ -8,9 +8,11 @@ import {
FormInstance,
Input,
Modal,
Radio,
Select,
SelectProps,
Spin,
Tooltip,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
@@ -70,14 +72,18 @@ const TZ_OPTIONS: DefaultOptionType[] = ALL_TIME_ZONES.map(
}),
);
type AlertRuleScope = 'all' | 'specific';
interface PlannedDowntimeFormData {
name: string;
startTime: dayjs.Dayjs | null;
endTime: dayjs.Dayjs | null;
recurrence?: AlertmanagertypesRecurrenceDTO;
alertRuleScope: AlertRuleScope;
alertRules: DefaultOptionType[];
recurrenceSelect?: AlertmanagertypesRecurrenceDTO;
timezone?: string;
scope?: string;
}
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
@@ -127,6 +133,12 @@ export function PlannedDowntimeForm(
recurrenceOptions.doesNotRepeat.value,
);
const [alertRuleScope, setAlertRuleScope] = useState<AlertRuleScope>(
initialValues.id && (initialValues.alertIds || []).length === 0
? 'all'
: 'specific',
);
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
@@ -140,10 +152,14 @@ export function PlannedDowntimeForm(
const saveHandler = useCallback(
async (values: PlannedDowntimeFormData) => {
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
alertIds: values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
alertIds:
values.alertRuleScope === 'all'
? []
: (values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[]),
name: values.name,
scope: values.scope,
schedule: {
startTime: values.startTime?.format(),
endTime: values.endTime?.format(),
@@ -262,12 +278,13 @@ export function PlannedDowntimeForm(
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
const initialAlertIds = initialValues.alertIds || [];
return {
name: defaultTo(initialValues.name, ''),
alertRules: getAlertOptionsFromIds(
initialValues.alertIds || [],
alertOptions,
),
alertRuleScope:
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
recurrence: {
@@ -278,11 +295,13 @@ export function PlannedDowntimeForm(
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
} as AlertmanagertypesRecurrenceDTO,
timezone: schedule?.timezone as string,
scope: initialValues.scope || '',
};
}, [initialValues, alertOptions]);
useEffect(() => {
setSelectedTags(formattedInitialValues.alertRules);
setAlertRuleScope(formattedInitialValues.alertRuleScope);
form.setFieldsValue({ ...formattedInitialValues });
}, [form, formattedInitialValues, initialValues]);
@@ -311,7 +330,7 @@ export function PlannedDowntimeForm(
default:
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
}
}, [formData, recurrenceType, timezone]);
}, [formData, recurrenceType]);
const endTimeText = useMemo((): string => {
const endTime = formData.endTime;
@@ -322,7 +341,7 @@ export function PlannedDowntimeForm(
const formattedEndTime = endTime.format(TIME_FORMAT);
const formattedEndDate = endTime.format(DATE_FORMAT);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData, recurrenceType, timezone]);
}, [formData, recurrenceType]);
return (
<Modal
@@ -345,6 +364,7 @@ export function PlannedDowntimeForm(
onFinish={onFinish}
onValuesChange={(): void => {
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
setAlertRuleScope(form.getFieldValue('alertRuleScope') as AlertRuleScope);
handleFormData(form.getFieldsValue());
}}
autoComplete="off"
@@ -444,50 +464,107 @@ export function PlannedDowntimeForm(
<div className="scheduleTimeInfoText">{endTimeText}</div>
)}
<div>
<div className="alert-rule-form">
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
<Typography style={{ marginBottom: 8 }} className="alert-rule-info">
(Leave empty to silence all alerts)
</Typography>
</div>
<Form.Item noStyle shouldUpdate>
<AlertRuleTags
closable
selectedTags={selectedTags}
handleClose={handleClose}
/>
</Form.Item>
<Form.Item name={alertRuleFormName}>
<Select
placeholder="Search for alerts rules or groups..."
mode="multiple"
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleAlertRulesChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
}
notFoundContent={
isLoading ? (
<span>
<Spin size="small" /> Loading...
</span>
) : (
<span>No alert available.</span>
)
}
>
{alertOptions?.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
<Form.Item
name="alertRuleScope"
initialValue="specific"
className="alert-rule-scope"
>
<Radio.Group>
<Radio value="all">All alert rules</Radio>
<Radio value="specific">Specific alert rules</Radio>
</Radio.Group>
</Form.Item>
{alertRuleScope === 'specific' && (
<>
<Form.Item noStyle shouldUpdate>
<AlertRuleTags
closable
selectedTags={selectedTags}
handleClose={handleClose}
/>
</Form.Item>
<Form.Item
name={alertRuleFormName}
rules={[
{
validator: async (
_rule,
value: DefaultOptionType[] | undefined,
): Promise<void> => {
if (!value || value.length === 0) {
throw new Error(
'Select at least one alert rule, or choose "All alert rules" to silence everything.',
);
}
},
},
]}
>
<Select
placeholder="Search for alert rules or groups..."
mode="multiple"
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleAlertRulesChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
(option?.label as string)
?.toLowerCase()
?.includes(input.toLowerCase())
}
notFoundContent={
isLoading ? (
<span>
<Spin size="small" /> Loading...
</span>
) : (
<span>No alert available.</span>
)
}
>
{alertOptions?.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
</>
)}
</div>
<Form.Item
label={
<span>
Scope&nbsp;
<Tooltip
mouseLeaveDelay={0.3}
title={
<span>
Scope the planned downtime by alert labels.{' '}
<a
href="https://signoz.io/docs/alerts-management/planned-maintenance/#scoping-with-label-expressions"
target="_blank"
rel="noopener noreferrer"
>
Learn more
</a>
</span>
}
>
<Info size={13} />
</Tooltip>
</span>
}
name="scope"
>
<Input.TextArea
placeholder='e.g. env = "prod" AND region = "us-east-1"'
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<ModalButtonWrapper>
<Button

View File

@@ -204,7 +204,7 @@ export function CollapseListContent({
selectedTags={alertOptions}
/>
) : (
'-'
<Tag className="all-alerts-tag">All alert rules</Tag>
),
)}
</Flex>

View File

@@ -8,8 +8,7 @@ import {
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { Slider } from 'antd';
import type { SliderRangeProps } from 'antd/lib/slider';
import { Slider } from '@signozhq/ui/slider';
import getFilters from 'api/trace/getFilters';
import useDebouncedFn from 'hooks/useDebouncedFunction';
// eslint-disable-next-line no-restricted-imports
@@ -169,16 +168,15 @@ function Duration(): JSX.Element {
debouncedFunction(min, max);
};
const onRangeHandler: SliderRangeProps['onChange'] = ([min, max]) => {
const onRangeHandler = (value: number | number[]): void => {
const [min, max] = value as number[];
updatedUrl(min, max);
};
const TipComponent = useCallback((value: undefined | number) => {
if (value === undefined) {
return <div />;
}
return <div>{`${value?.toString()}ms`}</div>;
}, []);
const TipComponent = useCallback(
(value: number) => <div>{`${value.toString()}ms`}</div>,
[],
);
return (
<div>
@@ -210,7 +208,8 @@ function Duration(): JSX.Element {
max={Number(getMs(String(preLocalMaxDuration.current || 0)))}
range
tooltip={{ formatter: TipComponent }}
onChange={([min, max]): void => {
onChange={(value): void => {
const [min, max] = value as number[];
onRangeSliderHandler([String(min), String(max)]);
}}
onAfterChange={onRangeHandler}

View File

@@ -1,3 +1,9 @@
import {
fillMissingXAxisTimestamps,
getXAxisTimestamps,
} from 'container/DashboardContainer/visualization/panels/utils';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
/**
* Checks if a value is invalid for plotting
*
@@ -52,6 +58,28 @@ export function normalizePlotValue(
return value as number;
}
/**
* Returns true if at most one entry in `values` is a valid plot value.
*
* Used to decide whether a series should render as a single point (drawStyle:
* Points) vs a line — a continuous line with only one visible sample is
* invisible to the user.
*/
export function hasSingleVisiblePoint(
values: ReadonlyArray<readonly [unknown, unknown]> | undefined,
): boolean {
let validPointCount = 0;
for (const [, rawValue] of values ?? []) {
if (!isInvalidPlotValue(rawValue)) {
validPointCount += 1;
if (validPointCount > 1) {
return false;
}
}
}
return true;
}
export interface SeriesSpanGapsOption {
spanGaps?: boolean | number;
}
@@ -226,3 +254,21 @@ export function applySpanGapsToAlignedData(
return [newX, ...transformedSeries] as uPlot.AlignedData;
}
/** * Transforms raw API response into aligned data format expected by uPlot.
*
* The API response contains multiple series of time-value pairs, each with its
* own set of timestamps. uPlot requires a single shared x-axis (timestamps)
* and separate y-value arrays for each series, aligned by index. This function
* extracts the unique sorted timestamps across all series and fills in missing
* values with null to maintain alignment.
*/
export const prepareChartData = (
apiResponse: MetricRangePayloadProps,
): uPlot.AlignedData => {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
};

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

@@ -7,8 +7,8 @@ import {
useMemo,
useState,
} from 'react';
import { Input, Slider } from 'antd';
import type { SliderRangeProps } from 'antd/es/slider';
import { Input } from 'antd';
import { Slider } from '@signozhq/ui/slider';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import useDebouncedFn from 'hooks/useDebouncedFunction';
@@ -88,16 +88,15 @@ export function DurationSection(props: DurationProps): JSX.Element {
debouncedFunction(min, max);
};
const onRangeHandler: SliderRangeProps['onChange'] = ([min, max]) => {
const onRangeHandler = (value: number | number[]): void => {
const [min, max] = value as number[];
updateDurationFilter(min.toString(), max.toString());
};
const TipComponent = useCallback((value: undefined | number) => {
if (value === undefined) {
return <div />;
}
return <div>{`${value?.toString()}ms`}</div>;
}, []);
const TipComponent = useCallback(
(value: number) => <div>{`${value.toString()}ms`}</div>,
[],
);
return (
<div>
@@ -123,13 +122,14 @@ export function DurationSection(props: DurationProps): JSX.Element {
addonAfter="ms"
/>
</div>
<div>
<div className="duration-input-slider">
<Slider
min={0}
max={100000}
range
tooltip={{ formatter: TipComponent }}
onChange={([min, max]): void => {
onChange={(value): void => {
const [min, max] = value as number[];
onRangeSliderHandler([String(min), String(max)]);
}}
onAfterChange={onRangeHandler}

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

@@ -0,0 +1,12 @@
/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */
export interface WebSettings {
appcues: Appcues;
posthog: Posthog;
}
export interface Appcues {
enabled: boolean;
}
export interface Posthog {
enabled: boolean;
}

View File

@@ -1,5 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import { compose, Store } from 'redux';
import type { WebSettings } from 'types/generated/webSettings';
declare global {
interface Window {
@@ -7,6 +8,7 @@ declare global {
pylon: any;
Appcues: Record<string, any>;
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__: typeof compose;
signozBootData?: { settings: WebSettings | null };
}
}

View File

@@ -0,0 +1,74 @@
export {};
type BootData = typeof import('../bootData');
function loadModule(settings?: object | null): BootData {
(window as any).signozBootData =
settings !== undefined ? { settings } : undefined;
let mod!: BootData;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../bootData');
});
return mod;
}
afterEach(() => {
delete (window as any).signozBootData;
});
describe('when window.signozBootData is absent', () => {
it('defaults posthog and appcues to enabled', () => {
const { bootSettings } = loadModule();
expect(bootSettings.posthog.enabled).toBe(true);
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData.settings is null (injection failed)', () => {
it('defaults posthog and appcues to enabled', () => {
const { bootSettings } = loadModule(null);
expect(bootSettings.posthog.enabled).toBe(true);
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData.settings is populated', () => {
it('reads posthog enabled: true', () => {
const { bootSettings } = loadModule({ posthog: { enabled: true } });
expect(bootSettings.posthog.enabled).toBe(true);
});
it('reads posthog enabled: false', () => {
const { bootSettings } = loadModule({ posthog: { enabled: false } });
expect(bootSettings.posthog.enabled).toBe(false);
});
it('reads appcues enabled: true', () => {
const { bootSettings } = loadModule({ appcues: { enabled: true } });
expect(bootSettings.appcues.enabled).toBe(true);
});
it('reads appcues enabled: false', () => {
const { bootSettings } = loadModule({ appcues: { enabled: false } });
expect(bootSettings.appcues.enabled).toBe(false);
});
it('missing sub-namespace defaults to enabled', () => {
const { bootSettings } = loadModule({ posthog: { enabled: false } });
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData exists but settings is undefined', () => {
it('defaults posthog and appcues to enabled', () => {
(window as any).signozBootData = {};
let mod!: BootData;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../bootData');
});
expect(mod.bootSettings.posthog.enabled).toBe(true);
expect(mod.bootSettings.appcues.enabled).toBe(true);
});
});

View File

@@ -0,0 +1,11 @@
import type { WebSettings } from 'types/generated/webSettings';
const raw = window.signozBootData?.settings as
| Partial<WebSettings>
| null
| undefined;
export const bootSettings: Readonly<WebSettings> = {
posthog: { enabled: raw?.posthog?.enabled ?? true },
appcues: { enabled: raw?.appcues?.enabled ?? true },
};

View File

@@ -23,6 +23,20 @@ function devBasePathPlugin(basePath: string): Plugin {
};
}
function devBootDataPlugin(env: Record<string, string>): Plugin {
return {
name: 'dev-boot-data',
apply: 'serve',
transformIndexHtml(html): string {
const settings = {
posthog: { enabled: env.VITE_POSTHOG_ENABLED !== 'false' },
appcues: { enabled: env.VITE_APPCUES_ENABLED !== 'false' },
};
return html.replaceAll('[[.Settings]]', JSON.stringify(settings));
},
};
}
function rawMarkdownPlugin(): Plugin {
return {
name: 'raw-markdown',
@@ -47,6 +61,7 @@ export default defineConfig(({ mode }): UserConfig => {
tsconfigPaths(),
rawMarkdownPlugin(),
devBasePathPlugin(basePath),
devBootDataPlugin(env),
react(),
createHtmlPlugin({
inject: {

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

@@ -23,7 +23,13 @@ import (
"sync"
"time"
htmltemplate "html/template"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
@@ -34,20 +40,39 @@ import (
const (
Integration = "email"
// alertEmailLayoutTemplate is the name of the HTML layout template that
// wraps the rendered alert bodies. It is loaded into the notification
// template (n.tmpl) from the alertmanager templates config and lives at
// templates/alertmanager/email.gotmpl.
alertEmailLayoutTemplate = "email.signoz.html"
)
// Email implements a Notifier for email notifications.
type Email struct {
conf *config.EmailConfig
tmpl *template.Template
logger *slog.Logger
hostname string
conf *config.EmailConfig
tmpl *template.Template
logger *slog.Logger
hostname string
templater alertmanagertypes.Templater
}
// layoutData is the value passed to the email.signoz.html layout
// template. It embeds NotificationTemplateData so templates can reference
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalResolved`,
// `.NotificationTemplateData.ExternalURL`, etc. alongside the rendered
// Title and per-alert Bodies.
type layoutData struct {
alertmanagertypes.NotificationTemplateData
Title string
Bodies []htmltemplate.HTML
}
var errNoAuthUsernameConfigured = errors.NewInternalf(errors.CodeInternal, "no auth username configured")
// New returns a new Email notifier.
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
// New returns a new Email notifier. When the email.signoz.html layout is
// not defined in t, custom-body alerts fall back to plain <div>-wrapped HTML.
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) *Email {
if _, ok := c.Headers["Subject"]; !ok {
c.Headers["Subject"] = config.DefaultEmailSubject
}
@@ -63,7 +88,7 @@ func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
if err != nil {
h = "localhost.localdomain"
}
return &Email{conf: c, tmpl: t, logger: l, hostname: h}
return &Email{conf: c, tmpl: t, logger: l, hostname: h, templater: templater}
}
// auth resolves a string of authentication mechanisms.
@@ -199,9 +224,9 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
if ok, mech := c.Extension("AUTH"); ok {
auth, err := n.auth(mech)
if err != nil && err != errNoAuthUsernameConfigured {
if err != nil && !errors.Is(err, errNoAuthUsernameConfigured) {
return true, errors.WrapInternalf(err, errors.CodeInternal, "find auth mechanism")
} else if err == errNoAuthUsernameConfigured {
} else if errors.Is(err, errNoAuthUsernameConfigured) {
n.logger.DebugContext(ctx, "no auth username configured. Attempting to send email without authenticating")
}
if auth != nil {
@@ -245,6 +270,16 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
}
}
// Prepare the content for the email. subject, when non-empty, overrides
// the configured Subject header for this notification only. We deliberately
// do not mutate n.conf.Headers here: the config map is shared across
// concurrent notifications to the same receiver.
subject, htmlBody, err := n.prepareContent(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
// Send the email headers and body.
message, err := c.Data()
if err != nil {
@@ -262,6 +297,10 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
buffer := &bytes.Buffer{}
for header, t := range n.conf.Headers {
if header == "Subject" {
fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", subject))
continue
}
value, err := n.tmpl.ExecuteTextString(t, data)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute %q header template", header)
@@ -336,7 +375,7 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
}
}
if len(n.conf.HTML) > 0 {
if htmlBody != "" {
// Html template
// Preferred alternative placed last per section 5.1.4 of RFC 2046
// https://www.ietf.org/rfc/rfc2046.txt
@@ -347,12 +386,8 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for html template")
}
body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute html template")
}
qw := quotedprintable.NewWriter(w)
_, err = qw.Write([]byte(body))
_, err = qw.Write([]byte(htmlBody))
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "write HTML part")
}
@@ -381,6 +416,124 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
return false, nil
}
// prepareContent returns a subject override (empty when the default config
// Subject should be used) and the HTML body for the email. Callers must treat
// the subject as local state and never write it back to n.conf.Headers.
func (n *Email) prepareContent(ctx context.Context, alerts []*types.Alert) (string, string, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Headers["Subject"],
DefaultBodyTemplate: n.conf.HTML,
}, alerts)
if err != nil {
return "", "", err
}
subject := result.Title
if !result.IsDefaultBody {
// Custom-body path: render each expanded markdown body to HTML, then
// wrap the whole thing in the email.signoz.html layout (or fall
// back to plain <div> wrapping when the layout template is not loaded).
for i, body := range result.Body {
if body == "" {
continue
}
rendered, err := markdownrenderer.RenderHTML(body)
if err != nil {
return "", "", err
}
result.Body[i] = rendered
}
appendRelatedLinkButtons(alerts, result.Body)
html, err := n.renderLayout(result)
if err != nil {
n.logger.WarnContext(ctx, "custom email template rendering failed, falling back to plain <div> wrap", errors.Attr(err))
return subject, wrapBodiesAsDivs(result.Body), nil
}
return subject, html, nil
}
return subject, result.Body[0], nil
}
// renderLayout wraps result in the email.signoz.html HTML layout loaded
// into n.tmpl from the alertmanager templates config. Returns an error when the
// layout template is not defined (e.g. in tests where no templates are loaded)
// so prepareContent can fall back to plain <div> wrapping.
func (n *Email) renderLayout(result *alertmanagertypes.ExpandResult) (string, error) {
bodies := make([]htmltemplate.HTML, 0, len(result.Body))
for _, b := range result.Body {
bodies = append(bodies, htmltemplate.HTML(b))
}
data := layoutData{Title: result.Title, Bodies: bodies}
if result.NotificationData != nil {
data.NotificationTemplateData = *result.NotificationData
}
html, err := n.tmpl.ExecuteHTMLString(`{{ template "`+alertEmailLayoutTemplate+`" . }}`, data)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to render email layout")
}
return html, nil
}
// appendRelatedLinkButtons appends "View Related Logs/Traces" buttons to each
// per-alert body when the rule manager attached the corresponding annotation.
// bodies is positionally aligned with alerts (see alertmanagertemplate.Prepare);
// empty bodies are skipped so we never attach a button to an alert that produced
// no visible content.
func appendRelatedLinkButtons(alerts []*types.Alert, bodies []string) {
for i := range bodies {
if i >= len(alerts) || bodies[i] == "" {
continue
}
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
bodies[i] += htmlButton("View Related Logs", string(link))
}
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
bodies[i] += htmlButton("View Related Traces", string(link))
}
}
}
func wrapBodiesAsDivs(bodies []string) string {
var b strings.Builder
for _, part := range bodies {
if part == "" {
continue
}
b.WriteString("<div>")
b.WriteString(part)
b.WriteString("</div>")
}
return b.String()
}
func htmlButton(text, url string) string {
return fmt.Sprintf(`
<a href="%s" target="_blank" style="text-decoration: none;">
<button style="
padding: 6px 16px;
/* Default System Font */
font-family: sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
/* Light Theme & Dynamic Background (Solid) */
color: #111827;
background-color: #f9fafb;
/* Static Outline */
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
">
%s
</button>
</a>`, url, text)
}
type loginAuth struct {
username, password string
}

View File

@@ -8,6 +8,7 @@ import (
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
@@ -17,7 +18,10 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/emersion/go-smtp"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -42,6 +46,11 @@ const (
emailFrom = "alertmanager@example.com"
)
// testTemplater returns a Templater bound to tmpl with a discard logger.
func testTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
// email represents an email returned by the MailDev REST API.
// See https://github.com/djfarrelly/MailDev/blob/master/docs/rest.md.
type email struct {
@@ -162,7 +171,7 @@ func notifyEmailWithContext(ctx context.Context, t *testing.T, cfg *config.Email
return nil, false, err
}
email := New(cfg, tmpl, promslog.NewNopLogger())
email := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
retry, err := email.Notify(ctx, firingAlert)
if err != nil {
@@ -706,7 +715,7 @@ func TestEmailRejected(t *testing.T) {
tmpl, firingAlert, err := prepare(cfg)
require.NoError(t, err)
e := New(cfg, tmpl, promslog.NewNopLogger())
e := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
// Send the alert to mock SMTP server.
retry, err := e.Notify(context.Background(), firingAlert)
@@ -1030,6 +1039,135 @@ func TestEmailImplicitTLS(t *testing.T) {
}
}
func TestPrepareContent(t *testing.T) {
t.Run("default title template; custom body template", func(t *testing.T) {
tmpl, err := template.FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
bodyTpl := "line $labels.instance"
a1 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
model.LabelName("instance"): model.LabelValue("one"),
},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue(bodyTpl),
},
},
}
a2 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
model.LabelName("instance"): model.LabelValue("two"),
},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue(bodyTpl),
},
},
}
alerts := []*types.Alert{a1, a2}
cfg := &config.EmailConfig{Headers: map[string]string{"Subject": "subj"}}
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "subj", subject)
require.Equal(t, "<div><p>line one</p>\n</div><div><p>line two</p>\n</div>", htmlBody)
})
t.Run("custom title template; default body HTML template", func(t *testing.T) {
tmpl, err := template.FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationTitleTemplate): model.LabelValue("fixed from $alert.status"),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{firingAlert}
cfg := &config.EmailConfig{
Headers: map[string]string{},
HTML: "Status: {{ .Status }}",
}
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "Status: firing", htmlBody)
require.Equal(t, "fixed from firing", subject)
})
t.Run("default template without HTML", func(t *testing.T) {
cfg := &config.EmailConfig{Headers: map[string]string{"Subject": "the email subject"}}
tmpl, err := template.FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{firingAlert}
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "", htmlBody)
require.Equal(t, "the email subject", subject)
})
t.Run("custom title template; custom body template", func(t *testing.T) {
// Load the email.signoz.html layout into the notification template
// the same way the alertmanager server does via the templates config.
tmpl, err := template.FromGlobs([]string{"../../../../templates/alertmanager/*.gotmpl"})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
model.LabelName("instance"): model.LabelValue("two"),
},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationTitleTemplate): model.LabelValue("fixed from $alert.status"),
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue("line $labels.instance"),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{firingAlert}
cfg := &config.EmailConfig{
Headers: map[string]string{"Subject": "subject"},
HTML: "Well, what are you?",
}
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Contains(t, htmlBody, "<!DOCTYPE html>")
require.Contains(t, htmlBody, "<p>line two</p>")
require.NotContains(t, htmlBody, "Well, what are you?")
require.Equal(t, subject, "fixed from firing")
require.NotContains(t, subject, "subject")
})
}
func ptrTo(b bool) *bool {
return &b
}

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

@@ -15,7 +15,9 @@ import (
"slices"
"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/common/model"
@@ -44,6 +46,7 @@ type Notifier struct {
retrier *notify.Retrier
webhookURL *config.SecretURL
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
templater alertmanagertypes.Templater
}
// https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1#adaptivecarditemschema
@@ -52,7 +55,7 @@ type Content struct {
Type string `json:"type"`
Version string `json:"version"`
Body []Body `json:"body"`
Msteams Msteams `json:"msteams,omitempty"`
Msteams Msteams `json:"msteams,omitzero"`
Actions []Action `json:"actions"`
}
@@ -94,7 +97,7 @@ type teamsMessage struct {
}
// New returns a new notifier that uses the Microsoft Teams Power Platform connector.
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
@@ -109,6 +112,7 @@ func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *s
retrier: &notify.Retrier{},
webhookURL: c.WebhookURL,
postJSONFunc: notify.PostJSON,
templater: templater,
}
return n, nil
@@ -128,25 +132,11 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
return false, err
}
title := tmpl(n.conf.Title)
if err != nil {
return false, err
}
titleLink := tmpl(n.titleLink)
if err != nil {
return false, err
}
alerts := types.Alerts(as...)
color := colorGrey
switch alerts.Status() {
case model.AlertFiring:
color = colorRed
case model.AlertResolved:
color = colorGreen
}
var url string
if n.conf.WebhookURL != nil {
url = n.conf.WebhookURL.String()
@@ -158,6 +148,12 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
url = strings.TrimSpace(string(content))
}
bodyBlocks, err := n.prepareContent(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
// A message as referenced in https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1%2Cdotnet#request-body-schema
t := teamsMessage{
Type: "message",
@@ -169,17 +165,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
Type: "AdaptiveCard",
Version: "1.2",
Body: []Body{
{
Type: "TextBlock",
Text: title,
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Style: "heading",
Color: color,
},
},
Body: bodyBlocks,
Actions: []Action{
{
Type: "Action.OpenUrl",
@@ -195,20 +181,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
},
}
// add labels and annotations to the body of all alerts
for _, alert := range as {
t.Attachments[0].Content.Body = append(t.Attachments[0].Content.Body, Body{
Type: "TextBlock",
Text: "Alerts",
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Color: color,
})
t.Attachments[0].Content.Body = append(t.Attachments[0].Content.Body, n.createLabelsAndAnnotationsBody(alert)...)
}
var payload bytes.Buffer
if err = json.NewEncoder(&payload).Encode(t); err != nil {
return false, err
@@ -228,6 +200,75 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
return shouldRetry, err
}
// prepareContent builds the Adaptive Card body blocks for the notification.
// The first block is always the title; the remainder depends on whether the
// alerts carried a custom body template.
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) ([]Body, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Title,
DefaultBodyTemplate: n.conf.Text,
}, alerts)
if err != nil {
return nil, err
}
color := colorGrey
switch types.Alerts(alerts...).Status() {
case model.AlertFiring:
color = colorRed
case model.AlertResolved:
color = colorGreen
}
blocks := []Body{{
Type: "TextBlock",
Text: result.Title,
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Style: "heading",
Color: color,
}}
if result.IsDefaultBody {
for _, alert := range alerts {
blocks = append(blocks, Body{
Type: "TextBlock",
Text: "Alerts",
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Color: color,
})
blocks = append(blocks, n.createLabelsAndAnnotationsBody(alert)...)
}
return blocks, nil
}
// Custom body path: result.Body is positionally aligned with alerts;
// entries for alerts whose template rendered empty are kept as "" so we
// can skip them here without shifting the per-alert color index.
for i, body := range result.Body {
if body == "" || i >= len(alerts) {
continue
}
perAlertColor := colorRed
if alerts[i].Resolved() {
perAlertColor = colorGreen
}
blocks = append(blocks, Body{
Type: "TextBlock",
Text: body,
Wrap: true,
Color: perAlertColor,
})
}
return blocks, nil
}
func (*Notifier) createLabelsAndAnnotationsBody(alert *types.Alert) []Body {
bodies := []Body{}
bodies = append(bodies, Body{
@@ -258,7 +299,8 @@ func (*Notifier) createLabelsAndAnnotationsBody(alert *types.Alert) []Body {
annotationsFacts := []Fact{}
for k, v := range alert.Annotations {
if slices.Contains([]string{"summary", "related_logs", "related_traces"}, string(k)) {
if slices.Contains([]string{"summary", "related_logs", "related_traces"}, string(k)) ||
alertmanagertypes.IsPrivateAnnotation(string(k)) {
continue
}
annotationsFacts = append(annotationsFacts, Fact{Title: string(k), Value: string(v)})

View File

@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
@@ -15,6 +16,9 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -23,21 +27,28 @@ import (
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
// This is a test URL that has been modified to not be valid.
var testWebhookURL, _ = url.Parse("https://example.westeurope.logic.azure.com:443/workflows/xxx/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=xxx")
func TestMSTeamsV2Retry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -64,14 +75,16 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -153,7 +166,8 @@ func TestMSTeamsV2Templating(t *testing.T) {
t.Run(tc.title, func(t *testing.T) {
tc.cfg.WebhookURL = &config.SecretURL{URL: u}
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
pd, err := New(tc.cfg, test.CreateTmpl(t), tc.titleLink, promslog.NewNopLogger())
tmpl := test.CreateTmpl(t)
pd, err := New(tc.cfg, tmpl, tc.titleLink, promslog.NewNopLogger(), newTestTemplater(tmpl))
require.NoError(t, err)
ctx := context.Background()
@@ -186,20 +200,124 @@ func TestMSTeamsV2RedactedURL(t *testing.T) {
defer fn()
secret := "secret"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret)
}
func TestPrepareContent(t *testing.T) {
t.Run("default template - firing alerts", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
Title: "Alertname: {{ .CommonLabels.alertname }}",
},
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "test"},
// Custom body template
Annotations: model.LabelSet{
ruletypes.AnnotationBodyTemplate: "Firing alert: $alertname",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}
blocks, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.NotEmpty(t, blocks)
// First block should be the title with color (firing = red)
require.Equal(t, "Bolder", blocks[0].Weight)
require.Equal(t, colorRed, blocks[0].Color)
// verify title text
require.Equal(t, "Alertname: test", blocks[0].Text)
// verify body text
require.Equal(t, "Firing alert: test", blocks[1].Text)
})
t.Run("custom template - per-alert color", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "test1"},
Annotations: model.LabelSet{
"summary": "test",
ruletypes.AnnotationTitleTemplate: "Custom Title",
ruletypes.AnnotationBodyTemplate: "custom body $alertname",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "test2"},
Annotations: model.LabelSet{
"summary": "test",
ruletypes.AnnotationTitleTemplate: "Custom Title",
ruletypes.AnnotationBodyTemplate: "custom body $alertname",
},
StartsAt: time.Now().Add(-time.Hour),
EndsAt: time.Now().Add(-time.Minute),
},
},
}
blocks, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.NotEmpty(t, blocks)
// total 3 blocks: title and 2 body blocks
require.True(t, len(blocks) == 3)
// First block: title color is overall color of the alerts
require.Equal(t, colorRed, blocks[0].Color)
// verify title text
require.Equal(t, "Custom Title", blocks[0].Text)
// Body blocks should have per-alert color
require.Equal(t, colorRed, blocks[1].Color) // firing
require.Equal(t, colorGreen, blocks[2].Color) // resolved
})
}
func TestMSTeamsV2ReadingURLFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
@@ -209,14 +327,16 @@ func TestMSTeamsV2ReadingURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String() + "\n")
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)

View File

@@ -15,7 +15,10 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -34,25 +37,27 @@ const maxMessageLenRunes = 130
// Notifier implements a Notifier for OpsGenie notifications.
type Notifier struct {
conf *config.OpsGenieConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
conf *config.OpsGenieConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
// New returns a new OpsGenie notifier.
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
}, nil
}
@@ -123,6 +128,55 @@ func safeSplit(s, sep string) []string {
return b
}
// prepareContent expands alert templates and returns the OpsGenie-ready title
// (truncated to the 130-rune limit) and HTML description. Custom bodies are
// rendered to HTML and stitched together with <hr> dividers; default bodies
// are joined with newlines (OpsGenie's legacy plain-text description).
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (string, string, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Message,
DefaultBodyTemplate: n.conf.Description,
}, alerts)
if err != nil {
return "", "", err
}
var description string
if result.IsDefaultBody {
description = strings.Join(result.Body, "\n")
} else {
var b strings.Builder
first := true
for _, part := range result.Body {
if part == "" {
continue
}
rendered, renderErr := markdownrenderer.RenderHTML(part)
if renderErr != nil {
return "", "", renderErr
}
if !first {
b.WriteString("<hr>")
}
b.WriteString("<div>")
b.WriteString(rendered)
b.WriteString("</div>")
first = false
}
description = b.String()
}
title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes))
}
return title, description, nil
}
// Create requests for a list of alerts.
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
key, err := notify.ExtractGroupKey(ctx)
@@ -168,9 +222,10 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
}
requests = append(requests, req.WithContext(ctx))
default:
message, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes)
if truncated {
logger.WarnContext(ctx, "Truncated message", slog.Any("alert", key), slog.Int("max_runes", maxMessageLenRunes))
message, description, err := n.prepareContent(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return nil, false, err
}
createEndpointURL := n.conf.APIURL.Copy()
@@ -209,7 +264,7 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
msg := &opsGenieCreateMessage{
Alias: alias,
Message: message,
Description: tmpl(n.conf.Description),
Description: description,
Details: details,
Source: tmpl(n.conf.Source),
Responders: responders,

View File

@@ -8,12 +8,16 @@ import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -22,16 +26,23 @@ import (
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
func TestOpsGenieRetry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.OpsGenieConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -47,14 +58,16 @@ func TestOpsGenieRedactedURL(t *testing.T) {
defer fn()
key := "key"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.OpsGenieConfig{
APIURL: &config.URL{URL: u},
APIKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -72,14 +85,16 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
_, err = f.WriteString(key)
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.OpsGenieConfig{
APIURL: &config.URL{URL: u},
APIKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -202,7 +217,7 @@ func TestOpsGenie(t *testing.T) {
},
} {
t.Run(tc.title, func(t *testing.T) {
notifier, err := New(tc.cfg, tmpl, logger)
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl))
require.NoError(t, err)
ctx := context.Background()
@@ -278,7 +293,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
alert := &types.Alert{
Alert: model.Alert{
StartsAt: time.Now(),
@@ -321,7 +336,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
require.NoError(t, err)
requests, _, err := notifierWithUpdate.createRequests(ctx)
@@ -329,6 +344,99 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
}
func TestPrepareContent(t *testing.T) {
t.Run("default template", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
logger := promslog.NewNopLogger()
notifier := &Notifier{
conf: &config.OpsGenieConfig{
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
},
tmpl: tmpl,
logger: logger,
templater: newTestTemplater(tmpl),
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"Message": "Firing alert: test",
"Description": "Check runbook for more details",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{alert}
title, desc, prepErr := notifier.prepareContent(ctx, alerts)
require.NoError(t, prepErr)
require.Equal(t, "Firing alert: test", title)
require.Equal(t, "Check runbook for more details", desc)
})
t.Run("custom template", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
logger := promslog.NewNopLogger()
notifier := &Notifier{
conf: &config.OpsGenieConfig{
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
},
tmpl: tmpl,
logger: logger,
templater: newTestTemplater(tmpl),
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alert1 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"service": "payment",
"namespace": "potter-the-harry",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "High request throughput for $service",
ruletypes.AnnotationBodyTemplate: "Alert firing in NS: $labels.namespace",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alert2 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"service": "payment",
"namespace": "smart-the-rat",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "High request throughput for $service",
ruletypes.AnnotationBodyTemplate: "Alert firing in NS: $labels.namespace",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{alert1, alert2}
title, desc, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "High request throughput for payment", title)
// Each alert body wrapped in <div>, separated by <hr>
require.Equal(t, "<div><p>Alert firing in NS: potter-the-harry</p>\n</div><hr><div><p>Alert firing in NS: smart-the-rat</p>\n</div>", desc)
})
}
func readBody(t *testing.T, r *http.Request) string {
t.Helper()
body, err := io.ReadAll(r.Body)

View File

@@ -15,7 +15,9 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/alecthomas/units"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -40,21 +42,22 @@ const (
// Notifier implements a Notifier for PagerDuty notifications.
type Notifier struct {
conf *config.PagerdutyConfig
tmpl *template.Template
logger *slog.Logger
apiV1 string // for tests.
client *http.Client
retrier *notify.Retrier
conf *config.PagerdutyConfig
tmpl *template.Template
logger *slog.Logger
apiV1 string // for tests.
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
// New returns a new PagerDuty notifier.
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
n := &Notifier{conf: c, tmpl: t, logger: l, client: client}
n := &Notifier{conf: c, tmpl: t, logger: l, client: client, templater: templater}
if c.ServiceKey != "" || c.ServiceKeyFile != "" {
n.apiV1 = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
// Retrying can solve the issue on 403 (rate limiting) and 5xx response codes.
@@ -143,11 +146,12 @@ func (n *Notifier) notifyV1(
key notify.Key,
data *template.Data,
details map[string]any,
title string,
) (bool, error) {
var tmplErr error
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
description, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV1DescriptionLenRunes)
description, truncated := notify.TruncateInRunes(title, maxV1DescriptionLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated description", slog.Any("key", key), slog.Int("max_runes", maxV1DescriptionLenRunes))
}
@@ -203,6 +207,7 @@ func (n *Notifier) notifyV2(
key notify.Key,
data *template.Data,
details map[string]any,
title string,
) (bool, error) {
var tmplErr error
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
@@ -211,7 +216,7 @@ func (n *Notifier) notifyV2(
n.conf.Severity = "error"
}
summary, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV2SummaryLenRunes)
summary, truncated := notify.TruncateInRunes(title, maxV2SummaryLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated summary", slog.Any("key", key), slog.Int("max_runes", maxV2SummaryLenRunes))
}
@@ -294,6 +299,22 @@ func (n *Notifier) notifyV2(
return retry, err
}
// prepareTitle expands the notification title. PagerDuty has no body surface
// we care about — the description/summary field is what users see as the
// incident headline, so we feed the configured Description as the default
// title template and ignore any custom body_template entirely.
func (n *Notifier) prepareTitle(ctx context.Context, alerts []*types.Alert) (string, error) {
customTitle, _ := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
DefaultTitleTemplate: n.conf.Description,
}, alerts)
if err != nil {
return "", err
}
return result.Title, nil
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
key, err := notify.ExtractGroupKey(ctx)
@@ -302,6 +323,12 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
}
logger := n.logger.With(slog.Any("group_key", key))
title, err := n.prepareTitle(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
var (
alerts = types.Alerts(as...)
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
@@ -329,7 +356,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
if n.apiV1 != "" {
nf = n.notifyV1
}
retry, err := nf(ctx, eventType, key, data, details)
retry, err := nf(ctx, eventType, key, data, details, title)
if err != nil {
if ctx.Err() != nil {
err = errors.WrapInternalf(err, errors.CodeInternal, "failed to notify PagerDuty: %v", context.Cause(ctx))

View File

@@ -9,6 +9,7 @@ import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
@@ -17,7 +18,10 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -30,14 +34,20 @@ import (
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
func TestPagerDutyRetryV1(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -49,13 +59,15 @@ func TestPagerDutyRetryV1(t *testing.T) {
}
func TestPagerDutyRetryV2(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -71,13 +83,15 @@ func TestPagerDutyRedactedURLV1(t *testing.T) {
defer fn()
key := "01234567890123456789012345678901"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
notifier.apiV1 = u.String()
@@ -90,14 +104,16 @@ func TestPagerDutyRedactedURLV2(t *testing.T) {
defer fn()
key := "01234567890123456789012345678901"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
URL: &config.URL{URL: u},
RoutingKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -114,13 +130,15 @@ func TestPagerDutyV1ServiceKeyFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
ServiceKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
notifier.apiV1 = u.String()
@@ -138,14 +156,16 @@ func TestPagerDutyV2RoutingKeyFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
URL: &config.URL{URL: u},
RoutingKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -302,7 +322,8 @@ func TestPagerDutyTemplating(t *testing.T) {
t.Run(tc.title, func(t *testing.T) {
tc.cfg.URL = &config.URL{URL: u}
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
tmpl := test.CreateTmpl(t)
pd, err := New(tc.cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
require.NoError(t, err)
if pd.apiV1 != "" {
pd.apiV1 = u.String()
@@ -392,13 +413,15 @@ func TestEventSizeEnforcement(t *testing.T) {
Details: bigDetailsV1,
}
tmpl := test.CreateTmpl(t)
notifierV1, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -420,8 +443,9 @@ func TestEventSizeEnforcement(t *testing.T) {
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -536,7 +560,8 @@ func TestPagerDutyEmptySrcHref(t *testing.T) {
Links: links,
}
pagerDuty, err := New(&pagerDutyConfig, test.CreateTmpl(t), promslog.NewNopLogger())
pdTmpl := test.CreateTmpl(t)
pagerDuty, err := New(&pagerDutyConfig, pdTmpl, promslog.NewNopLogger(), newTestTemplater(pdTmpl))
require.NoError(t, err)
ctx := context.Background()
@@ -603,7 +628,8 @@ func TestPagerDutyTimeout(t *testing.T) {
Timeout: tt.timeout,
}
pd, err := New(&cfg, test.CreateTmpl(t), promslog.NewNopLogger())
tmpl := test.CreateTmpl(t)
pd, err := New(&cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
require.NoError(t, err)
ctx := context.Background()
@@ -881,3 +907,79 @@ func TestRenderDetails(t *testing.T) {
})
}
}
func TestPrepareContent(t *testing.T) {
prepareContext := func() context.Context {
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
ctx = notify.WithReceiverName(ctx, "test-receiver")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "HighCPU for Payment service"})
return ctx
}
t.Run("default template uses go text template config for title", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
Description: `{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})`,
},
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := prepareContext()
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "HighCPU for Payment service"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}
title, err := notifier.prepareTitle(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "HighCPU for Payment service (FIRING)", title)
})
t.Run("custom template uses $variable annotation for title", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := prepareContext()
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "HighCPU",
"service": "api-server",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "$rule.name on $service is in $alert.status state",
},
StartsAt: time.Now().Add(-time.Hour),
EndsAt: time.Now(),
},
},
}
title, err := notifier.prepareTitle(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "HighCPU on api-server is in resolved state", title)
})
}

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) ([]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
}
@@ -53,23 +55,30 @@ func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Templ
}
for i, c := range nc.WebhookConfigs {
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l) })
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l, templater) })
}
for i, c := range nc.EmailConfigs {
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil })
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return email.New(c, tmpl, l, templater), nil
})
}
for i, c := range nc.PagerdutyConfigs {
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l) })
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) })
}
for i, c := range nc.OpsGenieConfigs {
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l) })
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater) })
}
for i, c := range nc.SlackConfigs {
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l) })
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) })
}
for i, c := range nc.MSTeamsV2Configs {
add(msteamsv2.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l)
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)
})
}

View File

@@ -14,7 +14,11 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
@@ -25,6 +29,8 @@ import (
const (
Integration = "slack"
colorRed = "#FF0000"
colorGreen = "#00FF00"
)
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
@@ -32,17 +38,18 @@ const maxTitleLenRunes = 1024
// Notifier implements a Notifier for Slack notifications.
type Notifier struct {
conf *config.SlackConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
conf *config.SlackConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
}
// New returns a new Slack notification handler.
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
@@ -54,6 +61,7 @@ func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts .
logger: l,
client: client,
retrier: &notify.Retrier{},
templater: templater,
postJSONFunc: notify.PostJSON,
}, nil
}
@@ -81,9 +89,10 @@ type attachment struct {
Actions []config.SlackAction `json:"actions,omitempty"`
ImageURL string `json:"image_url,omitempty"`
ThumbURL string `json:"thumb_url,omitempty"`
Footer string `json:"footer"`
Footer string `json:"footer,omitempty"`
Color string `json:"color,omitempty"`
MrkdwnIn []string `json:"mrkdwn_in,omitempty"`
Blocks []any `json:"blocks,omitempty"`
}
// Notify implements the Notifier interface.
@@ -100,79 +109,15 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
tmplText = notify.TmplText(n.tmpl, data, &err)
)
var markdownIn []string
if len(n.conf.MrkdwnIn) == 0 {
markdownIn = []string{"fallback", "pretext", "text"}
} else {
markdownIn = n.conf.MrkdwnIn
attachments, err := n.prepareContent(ctx, as, tmplText)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes)
if truncated {
logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
}
att := &attachment{
Title: title,
TitleLink: tmplText(n.conf.TitleLink),
Pretext: tmplText(n.conf.Pretext),
Text: tmplText(n.conf.Text),
Fallback: tmplText(n.conf.Fallback),
CallbackID: tmplText(n.conf.CallbackID),
ImageURL: tmplText(n.conf.ImageURL),
ThumbURL: tmplText(n.conf.ThumbURL),
Footer: tmplText(n.conf.Footer),
Color: tmplText(n.conf.Color),
MrkdwnIn: markdownIn,
}
numFields := len(n.conf.Fields)
if numFields > 0 {
fields := make([]config.SlackField, numFields)
for index, field := range n.conf.Fields {
// Check if short was defined for the field otherwise fallback to the global setting
var short bool
if field.Short != nil {
short = *field.Short
} else {
short = n.conf.ShortFields
}
// Rebuild the field by executing any templates and setting the new value for short
fields[index] = config.SlackField{
Title: tmplText(field.Title),
Value: tmplText(field.Value),
Short: &short,
}
}
att.Fields = fields
}
numActions := len(n.conf.Actions)
if numActions > 0 {
actions := make([]config.SlackAction, numActions)
for index, action := range n.conf.Actions {
slackAction := config.SlackAction{
Type: tmplText(action.Type),
Text: tmplText(action.Text),
URL: tmplText(action.URL),
Style: tmplText(action.Style),
Name: tmplText(action.Name),
Value: tmplText(action.Value),
}
if action.ConfirmField != nil {
slackAction.ConfirmField = &config.SlackConfirmationField{
Title: tmplText(action.ConfirmField.Title),
Text: tmplText(action.ConfirmField.Text),
OkText: tmplText(action.ConfirmField.OkText),
DismissText: tmplText(action.ConfirmField.DismissText),
}
}
actions[index] = slackAction
}
att.Actions = actions
if len(attachments) > 0 {
n.addFieldsAndActions(&attachments[0], tmplText)
}
req := &request{
@@ -182,7 +127,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
IconURL: tmplText(n.conf.IconURL),
LinkNames: n.conf.LinkNames,
Text: tmplText(n.conf.MessageText),
Attachments: []attachment{*att},
Attachments: attachments,
}
if err != nil {
return false, err
@@ -238,6 +183,150 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
return retry, nil
}
// prepareContent expands alert templates and returns the Slack attachment(s)
// ready to send. When alerts carry a custom body template, one title-only
// attachment plus one body attachment per alert is returned so that each alert
// can get its own firing/resolved color and per-alert action buttons.
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert, tmplText func(string) string) ([]attachment, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Title,
DefaultBodyTemplate: n.conf.Text,
}, alerts)
if err != nil {
return nil, err
}
title, truncated := notify.TruncateInRunes(result.Title, maxTitleLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
}
if result.IsDefaultBody {
var markdownIn []string
if len(n.conf.MrkdwnIn) == 0 {
markdownIn = []string{"fallback", "pretext", "text"}
} else {
markdownIn = n.conf.MrkdwnIn
}
return []attachment{
{
Title: title,
TitleLink: tmplText(n.conf.TitleLink),
Pretext: tmplText(n.conf.Pretext),
Text: result.Body[0],
Fallback: tmplText(n.conf.Fallback),
CallbackID: tmplText(n.conf.CallbackID),
ImageURL: tmplText(n.conf.ImageURL),
ThumbURL: tmplText(n.conf.ThumbURL),
Footer: tmplText(n.conf.Footer),
Color: tmplText(n.conf.Color),
MrkdwnIn: markdownIn,
},
}, nil
}
// Custom template path: one title attachment + one attachment per
// non-empty alert body. result.Body is positionally aligned with alerts,
// so we index alerts[i] directly and skip empty entries.
attachments := make([]attachment, 0, 1+len(result.Body))
attachments = append(attachments, attachment{
Title: title,
TitleLink: tmplText(n.conf.TitleLink),
})
for i, body := range result.Body {
if body == "" || i >= len(alerts) {
continue
}
// Custom bodies are authored in markdown; render each non-empty body to
// Slack's mrkdwn flavour. Default bodies skip this because the Text
// template is already channel-ready.
rendered, renderErr := markdownrenderer.RenderSlackMrkdwn(body)
if renderErr != nil {
return nil, renderErr
}
color := colorRed
if alerts[i].Resolved() {
color = colorGreen
}
attachments = append(attachments, attachment{
Text: rendered,
Color: color,
MrkdwnIn: []string{"text"},
Actions: buildRelatedLinkActions(alerts[i]),
})
}
return attachments, nil
}
// buildRelatedLinkActions returns the "View Related Logs/Traces" action
// buttons for an alert, or nil when no related-link annotations are present.
func buildRelatedLinkActions(alert *types.Alert) []config.SlackAction {
var actions []config.SlackAction
if link := alert.Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Logs", URL: string(link)})
}
if link := alert.Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Traces", URL: string(link)})
}
return actions
}
// addFieldsAndActions populates fields and actions on the attachment from the Slack config.
func (n *Notifier) addFieldsAndActions(att *attachment, tmplText func(string) string) {
numFields := len(n.conf.Fields)
if numFields > 0 {
fields := make([]config.SlackField, numFields)
for index, field := range n.conf.Fields {
var short bool
if field.Short != nil {
short = *field.Short
} else {
short = n.conf.ShortFields
}
fields[index] = config.SlackField{
Title: tmplText(field.Title),
Value: tmplText(field.Value),
Short: &short,
}
}
att.Fields = fields
}
numActions := len(n.conf.Actions)
if numActions > 0 {
actions := make([]config.SlackAction, numActions)
for index, action := range n.conf.Actions {
slackAction := config.SlackAction{
Type: tmplText(action.Type),
Text: tmplText(action.Text),
URL: tmplText(action.URL),
Style: tmplText(action.Style),
Name: tmplText(action.Name),
Value: tmplText(action.Value),
}
if action.ConfirmField != nil {
slackAction.ConfirmField = &config.SlackConfirmationField{
Title: tmplText(action.ConfirmField.Title),
Text: tmplText(action.ConfirmField.Text),
OkText: tmplText(action.ConfirmField.OkText),
DismissText: tmplText(action.ConfirmField.DismissText),
}
}
actions[index] = slackAction
}
att.Actions = actions
}
}
// checkResponseError parses out the error message from Slack API response.
func checkResponseError(resp *http.Response) (bool, error) {
body, err := io.ReadAll(resp.Body)

View File

@@ -17,6 +17,9 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -29,13 +32,19 @@ import (
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
func TestSlackRetry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -49,13 +58,15 @@ func TestSlackRedactedURL(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
APIURL: &config.SecretURL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -71,13 +82,15 @@ func TestGettingSlackURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String())
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
APIURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -93,13 +106,15 @@ func TestTrimmingSlackURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String() + "\n\n")
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
APIURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -184,6 +199,7 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
apiurl, _ := url.Parse("https://slack.com/post.Message")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
NotifierConfig: config.NotifierConfig{},
@@ -191,8 +207,9 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
APIURL: &config.SecretURL{URL: apiurl},
Channel: "channelname",
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -242,6 +259,7 @@ func TestSlackTimeout(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
u, _ := url.Parse("https://slack.com/post.Message")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
NotifierConfig: config.NotifierConfig{},
@@ -250,8 +268,9 @@ func TestSlackTimeout(t *testing.T) {
Channel: "channelname",
Timeout: tt.timeout,
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
@@ -282,6 +301,225 @@ func TestSlackTimeout(t *testing.T) {
}
}
// setupTestContext creates a context with group key, receiver name, and group labels
// required by the notification processor.
func setupTestContext() context.Context {
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "test-group")
ctx = notify.WithReceiverName(ctx, "slack")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{
"alertname": "TestAlert",
"severity": "critical",
})
return ctx
}
func TestPrepareContent(t *testing.T) {
t.Run("default template uses go text template config for title and body", func(t *testing.T) {
// When alerts have no custom annotation templates (title_template / body_template),
tmpl := test.CreateTmpl(t)
templater := newTestTemplater(tmpl)
notifier := &Notifier{
conf: &config.SlackConfig{
Title: `{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})`,
Text: `{{ range .Alerts }}Alert: {{ .Labels.alertname }} - severity {{ .Labels.severity }}{{ end }}`,
Color: `{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}`,
TitleLink: "https://alertmanager.signoz.com",
},
tmpl: tmpl,
logger: slog.New(slog.DiscardHandler),
templater: templater,
}
ctx := setupTestContext()
alerts := []*types.Alert{
{Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
}},
}
// Build tmplText the same way Notify does
var err error
data := notify.GetTemplateData(ctx, tmpl, alerts, slog.New(slog.DiscardHandler))
tmplText := notify.TmplText(tmpl, data, &err)
atts, attErr := notifier.prepareContent(ctx, alerts, tmplText)
require.NoError(t, attErr)
require.NoError(t, err)
require.Len(t, atts, 1)
require.Equal(t, "HighCPU (FIRING)", atts[0].Title)
require.Equal(t, "Alert: HighCPU - severity critical", atts[0].Text)
// Color is templated — firing alert should be "danger"
require.Equal(t, "danger", atts[0].Color)
// No BlockKit blocks for default template
require.Nil(t, atts[0].Blocks)
// Default markdownIn when config has none
require.Equal(t, []string{"fallback", "pretext", "text"}, atts[0].MrkdwnIn)
})
t.Run("custom template produces 1+N attachments with per-alert color", func(t *testing.T) {
// When alerts carry custom $variable annotation templates (title_template / body_template)
tmpl := test.CreateTmpl(t)
templater := newTestTemplater(tmpl)
notifier := &Notifier{
conf: &config.SlackConfig{
Title: "default title fallback",
Text: "default text fallback",
TitleLink: "https://alertmanager.signoz.com",
},
tmpl: tmpl,
logger: slog.New(slog.DiscardHandler),
templater: templater,
}
tmplText := func(s string) string { return s }
bodyTemplate := `## $rule.name
**Service:** *$labels.service*
**Instance:** *$labels.instance*
**Region:** *$labels.region*
**Method:** *$labels.http_method*
---
| Metric | Value |
|--------|-------|
| **Current** | *$value* |
| **Threshold** | *$threshold.value* |
**Status:** $alert.status | **Severity:** $labels.severity`
titleTemplate := "[$alert.status] $rule.name — $labels.service"
ctx := setupTestContext()
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical", "service": "api-server", "instance": "i-0abc123", "region": "us-east-1", "http_method": "GET"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
"value": "100",
"threshold.value": "200",
},
},
}
resolvedAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical", "service": "api-server", "instance": "i-0abc123", "region": "us-east-1", "http_method": "GET"},
StartsAt: time.Now().Add(-2 * time.Hour),
EndsAt: time.Now().Add(-time.Hour),
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
"value": "50",
"threshold.value": "200",
},
},
}
atts, err := notifier.prepareContent(ctx, []*types.Alert{firingAlert, resolvedAlert}, tmplText)
require.NoError(t, err)
// 1 title attachment + 2 body attachments (one per alert)
require.Len(t, atts, 3)
// First attachment: title-only, no color, no blocks
require.Equal(t, "[firing] HighCPU — api-server", atts[0].Title)
require.Empty(t, atts[0].Color)
require.Nil(t, atts[0].Blocks)
require.Equal(t, "https://alertmanager.signoz.com", atts[0].TitleLink)
expectedFiringBody := "*HighCPU*\n\n" +
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
"---\n\n" +
"```\nMetric | Value\n----------|------\nCurrent | 100 \nThreshold | 200 \n```\n\n" +
"*Status:* firing | *Severity:* critical\n\n"
expectedResolvedBody := "*HighCPU*\n\n" +
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
"---\n\n" +
"```\nMetric | Value\n----------|------\nCurrent | 50 \nThreshold | 200 \n```\n\n" +
"*Status:* resolved | *Severity:* critical\n\n"
// Second attachment: firing alert body rendered as slack mrkdwn text, red color
require.Nil(t, atts[1].Blocks)
require.Equal(t, "#FF0000", atts[1].Color)
require.Equal(t, []string{"text"}, atts[1].MrkdwnIn)
require.Equal(t, expectedFiringBody, atts[1].Text)
// Third attachment: resolved alert body rendered as slack mrkdwn text, green color
require.Nil(t, atts[2].Blocks)
require.Equal(t, "#00FF00", atts[2].Color)
require.Equal(t, []string{"text"}, atts[2].MrkdwnIn)
require.Equal(t, expectedResolvedBody, atts[2].Text)
})
t.Run("default template with fields and actions", func(t *testing.T) {
// Verifies that addFieldsAndActions (called from Notify after prepareContent)
// correctly populates fields and actions on the attachment from config.
tmpl := test.CreateTmpl(t)
templater := newTestTemplater(tmpl)
short := true
notifier := &Notifier{
conf: &config.SlackConfig{
Title: `{{ .CommonLabels.alertname }}`,
Text: "alert text",
Color: "warning",
Fields: []*config.SlackField{
{Title: "Severity", Value: "critical", Short: &short},
{Title: "Service", Value: "api-server", Short: &short},
},
Actions: []*config.SlackAction{
{Type: "button", Text: "View Alert", URL: "https://alertmanager.signoz.com"},
},
TitleLink: "https://alertmanager.signoz.com",
},
tmpl: tmpl,
logger: slog.New(slog.DiscardHandler),
templater: templater,
}
tmplText := func(s string) string { return s }
ctx := setupTestContext()
alerts := []*types.Alert{
{Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "TestAlert"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
}},
}
atts, err := notifier.prepareContent(ctx, alerts, tmplText)
require.NoError(t, err)
require.Len(t, atts, 1)
// prepareContent does not populate fields/actions — that's done by
// addFieldsAndActions which is called from Notify.
require.Nil(t, atts[0].Fields)
require.Nil(t, atts[0].Actions)
// Simulate what Notify does after prepareContent
notifier.addFieldsAndActions(&atts[0], tmplText)
// Verify fields
require.Len(t, atts[0].Fields, 2)
require.Equal(t, "Severity", atts[0].Fields[0].Title)
require.Equal(t, "critical", atts[0].Fields[0].Value)
require.True(t, *atts[0].Fields[0].Short)
require.Equal(t, "Service", atts[0].Fields[1].Title)
require.Equal(t, "api-server", atts[0].Fields[1].Value)
// Verify actions
require.Len(t, atts[0].Actions, 1)
require.Equal(t, "button", atts[0].Actions[0].Type)
require.Equal(t, "View Alert", atts[0].Actions[0].Text)
require.Equal(t, "https://alertmanager.signoz.com", atts[0].Actions[0].URL)
})
}
func TestSlackMessageField(t *testing.T) {
// 1. Setup a fake Slack server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -329,7 +567,7 @@ func TestSlackMessageField(t *testing.T) {
tmpl.ExternalURL = u
logger := slog.New(slog.DiscardHandler)
notifier, err := New(conf, tmpl, logger)
notifier, err := New(conf, tmpl, logger, newTestTemplater(tmpl))
if err != nil {
t.Fatal(err)
}

View File

@@ -14,6 +14,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
@@ -28,15 +29,16 @@ const (
// Notifier implements a Notifier for generic webhooks.
type Notifier struct {
conf *config.WebhookConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
conf *config.WebhookConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
// New returns a new Webhook.
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
@@ -48,7 +50,8 @@ func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpO
client: client,
// Webhooks are assumed to respond with 2xx response codes on a successful
// request and 5xx response codes are assumed to be recoverable.
retrier: &notify.Retrier{},
retrier: &notify.Retrier{},
templater: templater,
}, nil
}

View File

@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
@@ -20,6 +21,7 @@ import (
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
@@ -27,13 +29,15 @@ import (
)
func TestWebhookRetry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL("http://example.com"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
alertmanagertemplate.New(tmpl, slog.Default()),
)
if err != nil {
require.NoError(t, err)
@@ -96,13 +100,16 @@ func TestWebhookRedactedURL(t *testing.T) {
defer fn()
secret := "secret"
tmpl := test.CreateTmpl(t)
templater := alertmanagertemplate.New(tmpl, slog.Default())
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL(u.String()),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
templater,
)
require.NoError(t, err)
@@ -118,13 +125,15 @@ func TestWebhookReadingURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String() + "\n")
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.WebhookConfig{
URLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
alertmanagertemplate.New(tmpl, slog.Default()),
)
require.NoError(t, err)
@@ -178,13 +187,15 @@ func TestWebhookURLTemplating(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
calledURL = "" // Reset for each test
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL(tc.url),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
tmpl,
promslog.NewNopLogger(),
alertmanagertemplate.New(tmpl, slog.Default()),
)
require.NoError(t, err)

View File

@@ -28,6 +28,13 @@ type Config struct {
// Configuration for the notification log.
NFLog NFLogConfig `mapstructure:"nflog"`
// Templates is the list of globs from which SigNoz's alertmanager notification
// templates are loaded (e.g. the email.signoz.html layout). This mirrors the
// upstream alertmanager `templates` config option (https://github.com/prometheus/alertmanager/blob/3b06b97af4d146e141af92885a185891eb79a5b0/config/config.go#L412).
// The upstream default templates (default.tmpl, email.tmpl) are always loaded
// from the embedded alertmanager assets, so only SigNoz's own templates are listed here.
Templates []string `mapstructure:"templates"`
}
type AlertsConfig struct {
@@ -100,5 +107,6 @@ func NewConfig() Config {
MaintenanceInterval: 15 * time.Minute,
Retention: 120 * time.Hour,
},
Templates: []string{"/root/templates/alertmanager/*.gotmpl"},
}
}

View File

@@ -42,7 +42,14 @@ func (m *MaintenanceMuter) Mutes(ctx context.Context, lset model.LabelSet) bool
}
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now) {
skip, err := mw.ShouldSkip(ruleID, now, lset)
if err != nil {
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
slog.String("maintenance_id", mw.ID.StringValue()),
slog.String("scope", mw.Scope),
slog.Any("error", err),
)
} else if skip {
return true
}
}
@@ -61,7 +68,14 @@ func (m *MaintenanceMuter) MutedBy(ctx context.Context, lset model.LabelSet) []s
var ids []string
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now) {
skip, err := mw.ShouldSkip(ruleID, now, lset)
if err != nil {
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
slog.String("maintenance_id", mw.ID.StringValue()),
slog.String("scope", mw.Scope),
slog.Any("error", err),
)
} else if skip {
ids = append(ids, mw.ID.String())
}
}

View File

@@ -2,6 +2,8 @@ package alertmanagerserver
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
@@ -10,6 +12,7 @@ import (
"github.com/prometheus/alertmanager/types"
"golang.org/x/sync/errgroup"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/dispatch"
"github.com/prometheus/alertmanager/featurecontrol"
"github.com/prometheus/alertmanager/inhibit"
@@ -23,8 +26,8 @@ import (
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
@@ -65,6 +68,7 @@ type Server struct {
muter *MaintenanceMuter
marker *types.MemMarker
tmpl *template.Template
templater alertmanagertypes.Templater
wg sync.WaitGroup
stopc chan struct{}
notificationManager nfmanager.NotificationManager
@@ -240,15 +244,25 @@ 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
server.tmpl, err = alertmanagertypes.FromGlobs(config.Templates)
// Load SigNoz's alertmanager notification templates from the configured
// globs. The upstream default templates (default.tmpl, email.tmpl) are
// always loaded from the embedded alertmanager assets inside FromGlobs, so
// only SigNoz's own templates (e.g. the email.signoz.html layout) are listed
// here. The upstream config.Templates field is not used: SigNoz never
// populates it (there is no per-org template configuration).
server.tmpl, err = alertmanagertypes.FromGlobs(server.srvConfig.Templates)
if err != nil {
return err
}
server.tmpl.ExternalURL = server.srvConfig.ExternalURL
server.templater = alertmanagertemplate.New(server.tmpl, server.logger)
// Build the routing tree and record which receivers are used.
routes := dispatch.NewRoute(config.Route, nil)
activeReceivers := make(map[string]struct{})
@@ -265,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)
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
}
@@ -340,9 +358,9 @@ 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())
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, testAlert.Labels, testAlert)
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)
}
func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmanagertypes.PostableAlert][]string, config *alertmanagertypes.NotificationConfig) error {
@@ -410,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))
@@ -420,11 +438,12 @@ func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmana
err = alertmanagertypes.TestReceiver(
gCtx,
receiver,
baseReceiver,
alertmanagernotify.NewReceiverIntegrations,
server.alertmanagerConfig,
server.tmpl,
server.logger,
server.templater,
group.groupLabels,
group.alerts...,
)

View File

@@ -41,7 +41,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Expression: `ruleId == "high-cpu-usage" && severity == "critical"`,
Expression: `ruleId = "high-cpu-usage" AND severity = "critical"`,
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Name: "high-cpu-usage",
Description: "High CPU critical alerts to webhook",
@@ -53,7 +53,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Expression: `ruleId == "high-cpu-usage" && severity == "warning"`,
Expression: `ruleId = "high-cpu-usage" AND severity = "warning"`,
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Name: "high-cpu-usage",
Description: "High CPU warning alerts to webhook",
@@ -87,18 +87,25 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
err = notificationManager.SetNotificationConfig(orgID, "high-cpu-usage", &notifConfig)
require.NoError(t, err)
mwID := valuer.GenerateUUID()
activeSchedule := &alertmanagertypes.Schedule{
Timezone: "UTC",
StartTime: time.Now().Add(-time.Hour),
EndTime: time.Now().Add(time.Hour),
}
// mwRuleIDAndScope: only critical high-cpu-usage alerts.
mwRuleIDAndScope := valuer.GenerateUUID()
// mwRuleIDOnly: all high-cpu-usage alerts regardless of severity.
mwRuleIDOnly := valuer.GenerateUUID()
// mwScopeOnly: all critical alerts regardless of rule ID.
mwScopeOnly := valuer.GenerateUUID()
maintenanceStore := alertmanagertypestest.NewMockMaintenanceStore(t)
maintenanceStore.On("ListPlannedMaintenance", mock.Anything, orgID).Return(
[]*alertmanagertypes.PlannedMaintenance{{
ID: mwID,
Schedule: &alertmanagertypes.Schedule{
Timezone: "UTC",
StartTime: time.Now().Add(-time.Hour),
EndTime: time.Now().Add(time.Hour),
},
RuleIDs: []string{"high-cpu-usage"},
}}, nil,
[]*alertmanagertypes.PlannedMaintenance{
{ID: mwRuleIDAndScope, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}, Scope: `severity = "critical"`},
{ID: mwRuleIDOnly, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}},
{ID: mwScopeOnly, Schedule: activeSchedule, Scope: `severity = "critical"`},
}, nil,
)
srvCfg := NewConfig()
@@ -249,18 +256,42 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-03\", ruleId=\"high-cpu-usage\"}", alertGroups[2].GroupKey)
})
t.Run("verify_muting", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
t.Run("verify_muting_ruleid_and_scope", func(t *testing.T) {
// Window with ruleID + scope mutes only alerts matching both.
for _, alert := range alerts {
if alert.Labels["ruleId"] == "high-cpu-usage" && alert.Labels["severity"] == "critical" {
require.Contains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
} else {
require.NotContains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
}
}
})
t.Run("verify_muting_ruleid_only", func(t *testing.T) {
// Window with ruleID but no scope mutes all severities for that rule.
for _, alert := range alerts {
if alert.Labels["ruleId"] == "high-cpu-usage" {
require.Equal(t, []string{mwID.String()}, alert.Status.MutedBy)
require.Contains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
} else {
require.Empty(t, alert.Status.MutedBy)
require.NotContains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
}
}
})
t.Run("verify_muting_scope_only", func(t *testing.T) {
// Window with scope but no ruleIDs mutes all critical alerts regardless of rule.
for _, alert := range alerts {
if alert.Labels["severity"] == "critical" {
require.Contains(t, alert.Status.MutedBy, mwScopeOnly.String())
} else {
require.NotContains(t, alert.Status.MutedBy, mwScopeOnly.String())
}
}
})

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

@@ -89,6 +89,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
Scope: maintenance.Scope,
}
maintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
@@ -123,7 +124,6 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
NewInsert().
Model(&maintenanceRules).
Exec(ctx)
if err != nil {
return err
}
@@ -141,6 +141,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
Description: storablePlannedMaintenance.Description,
Schedule: storablePlannedMaintenance.Schedule,
RuleIDs: maintenance.AlertIds,
Scope: maintenance.Scope,
CreatedAt: storablePlannedMaintenance.CreatedAt,
CreatedBy: storablePlannedMaintenance.CreatedBy,
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
@@ -189,6 +190,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
Scope: maintenance.Scope,
}
storablePlannedMaintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
@@ -224,7 +226,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
Where("planned_maintenance_id = ?", storablePlannedMaintenance.ID.StringValue()).
Exec(ctx)
if err != nil {
return err
}
@@ -241,7 +242,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
}
return nil
})
if err != nil {
return err

View File

@@ -15,13 +15,6 @@ import (
"github.com/prometheus/common/model"
)
// Templater expands user-authored title and body templates against a group
// of alerts and returns channel-ready strings along with the aggregate data
// a caller might reuse (e.g. to render an email layout around the body).
type Templater interface {
Expand(ctx context.Context, req alertmanagertypes.ExpandRequest, alerts []*types.Alert) (*alertmanagertypes.ExpandResult, error)
}
type templater struct {
tmpl *template.Template
logger *slog.Logger
@@ -29,7 +22,7 @@ type templater struct {
// New returns a Templater bound to the given Prometheus alertmanager
// template and logger.
func New(tmpl *template.Template, logger *slog.Logger) Templater {
func New(tmpl *template.Template, logger *slog.Logger) alertmanagertypes.Templater {
return &templater{tmpl: tmpl, logger: logger}
}
@@ -137,6 +130,9 @@ func (at *templater) expandTitle(
}
// expandBody expands the body template for each individual alert. Returns nil if the template is empty.
// Non-nil results are positionally aligned with ntd.Alerts: sb[i] corresponds to alerts[i], and
// entries for alerts whose template expands to empty are kept as "" so callers can index per-alert
// metadata (related links, firing/resolved color) by the same index.
func (at *templater) expandBody(
bodyTemplate string,
ntd *alertmanagertypes.NotificationTemplateData,
@@ -144,7 +140,7 @@ func (at *templater) expandBody(
if bodyTemplate == "" {
return nil, nil, nil
}
var sb []string
sb := make([]string, len(ntd.Alerts))
missingVars := make(map[string]bool)
for i := range ntd.Alerts {
processRes, err := preProcessTemplateAndData(bodyTemplate, &ntd.Alerts[i])
@@ -155,13 +151,10 @@ func (at *templater) expandBody(
if err != nil {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute custom body template: %s", err.Error())
}
// add unknown variables and templated text to the result
for k := range processRes.UnknownVars {
missingVars[k] = true
}
if strings.TrimSpace(part) != "" {
sb = append(sb, strings.TrimSpace(part))
}
sb[i] = strings.TrimSpace(part)
}
return sb, missingVars, nil
}
@@ -189,17 +182,20 @@ func (at *templater) buildNotificationTemplateData(
externalURL = at.tmpl.ExternalURL.String()
}
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
// Raw (including private `_*`) kv first so buildRuleInfo can read the
// private rule annotations. The filtered copies are what ends up
// on the template-visible surfaces.
rawCommonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
commonLabels := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
// aggregate labels and annotations from all alerts
labels := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
annotations := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
// Strip private annotations from surfaces visible to templates or
// notifications; the structured fields on AlertInfo/RuleInfo already hold
// anything a template needs from them.
commonAnnotations = alertmanagertypes.FilterPublicAnnotations(commonAnnotations)
// Strip private annotations from template-visible surfaces; the structured
// fields on AlertInfo/RuleInfo already hold anything a template needs from
// them.
commonAnnotations := alertmanagertypes.FilterPublicAnnotations(rawCommonAnnotations)
annotations = alertmanagertypes.FilterPublicAnnotations(annotations)
// build the alert data slice
@@ -233,7 +229,7 @@ func (at *templater) buildNotificationTemplateData(
TotalFiring: firing,
TotalResolved: resolved,
},
Rule: buildRuleInfo(commonLabels, commonAnnotations),
Rule: buildRuleInfo(commonLabels, rawCommonAnnotations),
GroupLabels: gl,
CommonLabels: commonLabels,
CommonAnnotations: commonAnnotations,

View File

@@ -19,7 +19,7 @@ import (
// testSetup returns an AlertTemplater and a context pre-populated with group key,
// receiver name, and group labels for use in tests.
func testSetup(t *testing.T) (Templater, context.Context) {
func testSetup(t *testing.T) (alertmanagertypes.Templater, context.Context) {
t.Helper()
tmpl := test.CreateTmpl(t)
ctx := context.Background()

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

@@ -3,7 +3,6 @@ package rulebasednotification
import (
"context"
"log/slog"
"strings"
"sync"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
@@ -235,65 +234,13 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
return matchedChannels, nil
}
// convertLabelSetToEnv converts a flat label set with dotted keys into a nested map structure for expr env.
// when both a leaf and a deeper nested path exist (e.g. "foo" and "foo.bar"),
// the nested structure takes precedence. That means we will replace an existing leaf at any
// intermediate path with a map so we can materialize the deeper structure.
// TODO(srikanthccv): we need a better solution to handle this, remove the following
// when we update the expr to support dotted keys.
// convertLabelSetToEnv delegates to alertmanagertypes.ConvertLabelSetToEnv and
// logs when a key is a prefix of another (e.g. "foo" alongside "foo.bar").
func (r *provider) convertLabelSetToEnv(ctx context.Context, labelSet model.LabelSet) map[string]interface{} {
env := make(map[string]interface{})
logForReview := false
for lk, lv := range labelSet {
key := strings.TrimSpace(string(lk))
value := string(lv)
if strings.Contains(key, ".") {
parts := strings.Split(key, ".")
current := env
for i, raw := range parts {
part := strings.TrimSpace(raw)
last := i == len(parts)-1
if last {
if _, isMap := current[part].(map[string]interface{}); isMap {
logForReview = true
// deeper structure already exists; do not overwrite.
break
}
current[part] = value
break
}
// ensure a map so we can keep descending.
if nextMap, ok := current[part].(map[string]interface{}); ok {
current = nextMap
continue
}
// if absent or a leaf, replace it with a map.
newMap := make(map[string]interface{})
current[part] = newMap
current = newMap
}
continue
}
// if a map already sits here (due to nested keys), keep the map (nested wins).
if _, isMap := env[key].(map[string]interface{}); isMap {
logForReview = true
continue
}
env[key] = value
}
if logForReview {
env, conflict := alertmanagertypes.ConvertLabelSetToEnv(labelSet)
if conflict {
r.settings.Logger().InfoContext(ctx, "found label set with conflicting prefix dotted keys", slog.Any("labels", labelSet))
}
return env
}

View File

@@ -925,72 +925,3 @@ func TestProvider_CreateRoutes(t *testing.T) {
})
}
}
func TestConvertLabelSetToEnv(t *testing.T) {
tests := []struct {
name string
labelSet model.LabelSet
expected map[string]interface{}
}{
{
name: "simple keys",
labelSet: model.LabelSet{
"key1": "value1",
"key2": "value2",
},
expected: map[string]interface{}{
"key1": "value1",
"key2": "value2",
},
},
{
name: "nested keys",
labelSet: model.LabelSet{
"foo.bar": "value1",
"foo.baz": "value2",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"bar": "value1",
"baz": "value2",
},
},
},
{
name: "conflict - nested structure wins",
labelSet: model.LabelSet{
"foo.bar.baz": "deep",
"foo.bar": "shallow",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"bar": map[string]interface{}{
"baz": "deep",
},
},
},
},
{
name: "conflict - leaf value vs nested",
labelSet: model.LabelSet{
"foo.bar": "value",
"foo": "should_be_ignored",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"bar": "value",
},
},
},
}
provider := &provider{
settings: factory.NewScopedProviderSettings(createTestProviderSettings(), "provider_test"),
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.convertLabelSetToEnv(context.Background(), tt.labelSet)
assert.Equal(t, tt.expected, result)
})
}
}

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()

Some files were not shown because too many files have changed in this diff Show More