mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-16 17:00:28 +01:00
Compare commits
42 Commits
base-path-
...
feat/alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c46c0e105a | ||
|
|
cc5a0b93ae | ||
|
|
c9f3e1ae26 | ||
|
|
41ded342a1 | ||
|
|
64ce90e418 | ||
|
|
2fcffb7cdc | ||
|
|
abc0d71c16 | ||
|
|
2e2dd4c42b | ||
|
|
0ce76a94d6 | ||
|
|
46ae74ced5 | ||
|
|
2d8c1b7c86 | ||
|
|
6602c8c523 | ||
|
|
c22dbcbf74 | ||
|
|
250bd9abeb | ||
|
|
f132dc28c3 | ||
|
|
9ffc1203da | ||
|
|
79518b6823 | ||
|
|
e6a9f49cec | ||
|
|
fd5fc40823 | ||
|
|
db2e2a4617 | ||
|
|
9368d3f393 | ||
|
|
0c97ba36d6 | ||
|
|
2e1bdbc2fd | ||
|
|
330737f779 | ||
|
|
f0c531ae2b | ||
|
|
54477ee786 | ||
|
|
d281f7b6a2 | ||
|
|
378dc350ef | ||
|
|
89c38ed9bc | ||
|
|
04c4869b12 | ||
|
|
388a1184ca | ||
|
|
03901b353b | ||
|
|
74441c74a8 | ||
|
|
93d332bef2 | ||
|
|
1e730cae8c | ||
|
|
01a09cf6d2 | ||
|
|
403dddab85 | ||
|
|
d07a833574 | ||
|
|
b39bec7245 | ||
|
|
6ff55c48be | ||
|
|
b15fa0f88f | ||
|
|
19fe4f860e |
@@ -75,7 +75,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
},
|
||||
signoz.NewEmailingProviderFactories(),
|
||||
signoz.NewCacheProviderFactories(),
|
||||
signoz.NewWebProviderFactories(config.Global),
|
||||
signoz.NewWebProviderFactories(),
|
||||
func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
|
||||
return signoz.NewSQLSchemaProviderFactories(sqlstore)
|
||||
},
|
||||
|
||||
@@ -96,7 +96,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
},
|
||||
signoz.NewEmailingProviderFactories(),
|
||||
signoz.NewCacheProviderFactories(),
|
||||
signoz.NewWebProviderFactories(config.Global),
|
||||
signoz.NewWebProviderFactories(),
|
||||
func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
|
||||
existingFactories := signoz.NewSQLSchemaProviderFactories(sqlstore)
|
||||
if err := existingFactories.Add(postgressqlschema.NewFactory(sqlstore)); err != nil {
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
##################### Global #####################
|
||||
global:
|
||||
# the url under which the signoz apiserver is externally reachable.
|
||||
# the path component (e.g. /signoz in https://example.com/signoz) is used
|
||||
# as the base path for all HTTP routes (both API and web frontend).
|
||||
external_url: <unset>
|
||||
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
|
||||
ingestion_url: <unset>
|
||||
@@ -52,8 +50,8 @@ pprof:
|
||||
web:
|
||||
# Whether to enable the web frontend
|
||||
enabled: true
|
||||
# The index file to use as the SPA entrypoint.
|
||||
index: index.html
|
||||
# The prefix to serve web on
|
||||
prefix: /
|
||||
# The directory containing the static build files.
|
||||
directory: /etc/signoz/web
|
||||
|
||||
|
||||
@@ -262,20 +262,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routePrefix := s.config.Global.ExternalPath()
|
||||
if routePrefix != "" {
|
||||
prefixed := http.StripPrefix(routePrefix, handler)
|
||||
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
|
||||
r.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
prefixed.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
Handler: handler,
|
||||
}, nil
|
||||
|
||||
5
frontend/.gitignore
vendored
5
frontend/.gitignore
vendored
@@ -28,7 +28,4 @@ e2e/test-plan/saved-views/
|
||||
e2e/test-plan/service-map/
|
||||
e2e/test-plan/services/
|
||||
e2e/test-plan/traces/
|
||||
e2e/test-plan/user-preferences/
|
||||
|
||||
# Generated by `vite build` — do not commit
|
||||
index.html.gotmpl
|
||||
e2e/test-plan/user-preferences/
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="[[.BasePath]]" />
|
||||
<meta
|
||||
http-equiv="Cache-Control"
|
||||
content="no-cache, no-store, must-revalidate, max-age: 0"
|
||||
@@ -60,7 +59,7 @@
|
||||
<meta data-react-helmet="true" name="docusaurus_locale" content="en" />
|
||||
<meta data-react-helmet="true" name="docusaurus_tag" content="default" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<link data-react-helmet="true" rel="shortcut icon" href="favicon.ico" />
|
||||
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body data-theme="default">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
@@ -114,7 +113,7 @@
|
||||
})(document, 'script');
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
<link rel="stylesheet" href="/css/uPlot.min.css" />
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
interceptorRejected,
|
||||
interceptorsRequestBasePath,
|
||||
interceptorsRequestResponse,
|
||||
interceptorsResponse,
|
||||
} from 'api';
|
||||
@@ -18,7 +17,6 @@ export const GeneratedAPIInstance = <T>(
|
||||
return generatedAPIAxiosInstance({ ...config }).then(({ data }) => data);
|
||||
};
|
||||
|
||||
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
generatedAPIAxiosInstance.interceptors.response.use(
|
||||
interceptorsResponse,
|
||||
|
||||
@@ -11,7 +11,6 @@ import axios, {
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { Events } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { getBasePath } from 'utils/getBasePath';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
|
||||
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
|
||||
@@ -68,28 +67,6 @@ export const interceptorsRequestResponse = (
|
||||
return value;
|
||||
};
|
||||
|
||||
// Prepends the runtime base path to outgoing requests so API calls work under
|
||||
// a URL prefix (e.g. /signoz/api/v1/…). No-op for root deployments and dev
|
||||
// (dev baseURL is a full http:// URL, not an absolute path).
|
||||
export const interceptorsRequestBasePath = (
|
||||
value: InternalAxiosRequestConfig,
|
||||
): InternalAxiosRequestConfig => {
|
||||
const basePath = getBasePath();
|
||||
if (basePath === '/') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value.baseURL?.startsWith('/')) {
|
||||
// Named instances: baseURL='/api/v1/' → '/signoz/api/v1/'
|
||||
value.baseURL = basePath + value.baseURL.slice(1);
|
||||
} else if (!value.baseURL && value.url?.startsWith('/')) {
|
||||
// Generated instance: baseURL is '' in prod, path is in url
|
||||
value.url = basePath + value.url.slice(1);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const interceptorRejected = async (
|
||||
value: AxiosResponse<any>,
|
||||
): Promise<AxiosResponse<any>> => {
|
||||
@@ -156,7 +133,6 @@ const instance = axios.create({
|
||||
});
|
||||
|
||||
instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
instance.interceptors.response.use(interceptorsResponse, interceptorRejected);
|
||||
|
||||
export const AxiosAlertManagerInstance = axios.create({
|
||||
@@ -171,7 +147,6 @@ ApiV2Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
|
||||
// axios V3
|
||||
export const ApiV3Instance = axios.create({
|
||||
@@ -183,7 +158,6 @@ ApiV3Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios V4
|
||||
@@ -196,7 +170,6 @@ ApiV4Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios V5
|
||||
@@ -209,7 +182,6 @@ ApiV5Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios Base
|
||||
@@ -222,7 +194,6 @@ LogEventAxiosInstance.interceptors.response.use(
|
||||
interceptorRejectedBase,
|
||||
);
|
||||
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
AxiosAlertManagerInstance.interceptors.response.use(
|
||||
@@ -230,7 +201,6 @@ AxiosAlertManagerInstance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
|
||||
export { apiV1 };
|
||||
export default instance;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { getBasePath } from 'utils/getBasePath';
|
||||
|
||||
export default createBrowserHistory({ basename: getBasePath() });
|
||||
export default createBrowserHistory();
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { Home, LifeBuoy } from 'lucide-react';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
|
||||
@@ -12,9 +11,8 @@ import './ErrorBoundaryFallback.styles.scss';
|
||||
|
||||
function ErrorBoundaryFallback(): JSX.Element {
|
||||
const handleReload = (): void => {
|
||||
// Use history.push so the navigation stays within the base path prefix
|
||||
// (window.location.href would strip any /signoz/ prefix).
|
||||
history.push(ROUTES.HOME);
|
||||
// Go to home page
|
||||
window.location.href = ROUTES.HOME;
|
||||
};
|
||||
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { getBasePath } from 'utils/getBasePath';
|
||||
|
||||
/**
|
||||
* Contract tests for getBasePath().
|
||||
*
|
||||
* These lock down the exact DOM-reading contract so that any future change to
|
||||
* the utility (or to how index.html injects the <base> tag) surfaces
|
||||
* immediately as a test failure.
|
||||
*/
|
||||
describe('getBasePath', () => {
|
||||
afterEach(() => {
|
||||
// Remove any <base> elements added during the test.
|
||||
document.head.querySelectorAll('base').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
it('returns the href from the <base> tag when present', () => {
|
||||
const base = document.createElement('base');
|
||||
base.setAttribute('href', '/signoz/');
|
||||
document.head.appendChild(base);
|
||||
|
||||
expect(getBasePath()).toBe('/signoz/');
|
||||
});
|
||||
|
||||
it('returns "/" when no <base> tag exists in the document', () => {
|
||||
expect(getBasePath()).toBe('/');
|
||||
});
|
||||
|
||||
it('returns "/" when the <base> tag has no href attribute', () => {
|
||||
const base = document.createElement('base');
|
||||
document.head.appendChild(base);
|
||||
|
||||
expect(getBasePath()).toBe('/');
|
||||
});
|
||||
|
||||
it('returns the href unchanged when it already has a trailing slash', () => {
|
||||
const base = document.createElement('base');
|
||||
base.setAttribute('href', '/my/nested/path/');
|
||||
document.head.appendChild(base);
|
||||
|
||||
expect(getBasePath()).toBe('/my/nested/path/');
|
||||
});
|
||||
|
||||
it('appends a trailing slash when the href is missing one', () => {
|
||||
const base = document.createElement('base');
|
||||
base.setAttribute('href', '/signoz');
|
||||
document.head.appendChild(base);
|
||||
|
||||
expect(getBasePath()).toBe('/signoz/');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Returns the base path for this SigNoz deployment by reading the
|
||||
* `<base href>` element injected into index.html by the Go backend at
|
||||
* serve time.
|
||||
*
|
||||
* Always returns a string ending with `/` (e.g. `/`, `/signoz/`).
|
||||
* Falls back to `/` when no `<base>` element is present so the app
|
||||
* behaves correctly in local Vite dev and unit-test environments.
|
||||
*
|
||||
* @internal — consume through `src/lib/history` and the axios interceptor;
|
||||
* do not read `<base>` directly anywhere else in the codebase.
|
||||
*/
|
||||
export function getBasePath(): string {
|
||||
const href = document.querySelector('base')?.getAttribute('href') ?? '/';
|
||||
// Trailing slash is required for relative asset resolution and API prefixing.
|
||||
return href.endsWith('/') ? href : `${href}/`;
|
||||
}
|
||||
@@ -10,18 +10,6 @@ import { createHtmlPlugin } from 'vite-plugin-html';
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
// In dev the Go backend is not involved, so replace the [[.BasePath]] placeholder
|
||||
// with "/" so relative assets resolve correctly from the Vite dev server.
|
||||
function devBasePathPlugin(): Plugin {
|
||||
return {
|
||||
name: 'dev-base-path',
|
||||
apply: 'serve',
|
||||
transformIndexHtml(html): string {
|
||||
return html.replace('[[.BasePath]]', '/');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rawMarkdownPlugin(): Plugin {
|
||||
return {
|
||||
name: 'raw-markdown',
|
||||
@@ -44,7 +32,6 @@ export default defineConfig(
|
||||
const plugins = [
|
||||
tsconfigPaths(),
|
||||
rawMarkdownPlugin(),
|
||||
devBasePathPlugin(),
|
||||
react(),
|
||||
createHtmlPlugin({
|
||||
inject: {
|
||||
@@ -137,7 +124,6 @@ export default defineConfig(
|
||||
'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN),
|
||||
'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL),
|
||||
},
|
||||
base: './',
|
||||
build: {
|
||||
sourcemap: true,
|
||||
outDir: 'build',
|
||||
|
||||
292
pkg/alertmanager/alertmanagertemplate/alertmanagertemplate.go
Normal file
292
pkg/alertmanager/alertmanagertemplate/alertmanagertemplate.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
// AlertManagerTemplater processes alert notification templates.
|
||||
type AlertManagerTemplater interface {
|
||||
// ProcessTemplates expands the title and body templates from input
|
||||
// against the provided alerts and returns the expanded templates.
|
||||
ProcessTemplates(ctx context.Context, input TemplateInput, alerts []*types.Alert) (*ExpandedTemplates, error)
|
||||
// BuildNotificationTemplateData builds the NotificationTemplateData from context and alerts.
|
||||
// This exposes the structured alert data that gets used in the notification templates.
|
||||
BuildNotificationTemplateData(ctx context.Context, alerts []*types.Alert) *NotificationTemplateData
|
||||
}
|
||||
|
||||
type alertManagerTemplater struct {
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(tmpl *template.Template, logger *slog.Logger) AlertManagerTemplater {
|
||||
return &alertManagerTemplater{tmpl: tmpl, logger: logger}
|
||||
}
|
||||
|
||||
// ProcessTemplates expands the title and body templates from input
|
||||
// against the provided alerts and returns the expanded templates.
|
||||
func (at *alertManagerTemplater) ProcessTemplates(
|
||||
ctx context.Context,
|
||||
input TemplateInput,
|
||||
alerts []*types.Alert,
|
||||
) (*ExpandedTemplates, error) {
|
||||
ntd := at.buildNotificationTemplateData(ctx, alerts)
|
||||
missingVars := make(map[string]bool)
|
||||
|
||||
title, titleMissingVars, err := at.expandTitle(input.TitleTemplate, ntd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// if title template results in empty string, use default template
|
||||
// this happens for rules where custom title annotation was not set
|
||||
if title == "" && input.DefaultTitleTemplate != "" {
|
||||
title, err = at.expandDefaultTemplate(ctx, input.DefaultTitleTemplate, alerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
mergeMissingVars(missingVars, titleMissingVars)
|
||||
}
|
||||
|
||||
// isDefaultTemplated tracks whether the body is templated using default templates
|
||||
isDefaultTemplated := false
|
||||
body, bodyMissingVars, err := at.expandBody(input.BodyTemplate, ntd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// if body template results in nil, use default template
|
||||
// this happens for rules where custom body annotation was not set
|
||||
if body == nil {
|
||||
isDefaultTemplated = true
|
||||
defaultBody, err := at.expandDefaultTemplate(ctx, input.DefaultBodyTemplate, alerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = []string{defaultBody} // default template combines all alerts message into a single body
|
||||
} else {
|
||||
mergeMissingVars(missingVars, bodyMissingVars)
|
||||
}
|
||||
|
||||
// convert the internal map to a sorted slice for returning missing variables
|
||||
missingVarsList := make([]string, 0, len(missingVars))
|
||||
for k := range missingVars {
|
||||
missingVarsList = append(missingVarsList, k)
|
||||
}
|
||||
sort.Strings(missingVarsList)
|
||||
|
||||
return &ExpandedTemplates{
|
||||
Title: title,
|
||||
Body: body,
|
||||
MissingVars: missingVarsList,
|
||||
IsDefaultTemplatedBody: isDefaultTemplated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildNotificationTemplateData builds the NotificationTemplateData from context and alerts.
|
||||
func (at *alertManagerTemplater) BuildNotificationTemplateData(
|
||||
ctx context.Context,
|
||||
alerts []*types.Alert,
|
||||
) *NotificationTemplateData {
|
||||
return at.buildNotificationTemplateData(ctx, alerts)
|
||||
}
|
||||
|
||||
// expandDefaultTemplate uses go-template to expand the default template.
|
||||
func (at *alertManagerTemplater) expandDefaultTemplate(
|
||||
ctx context.Context,
|
||||
tmplStr string,
|
||||
alerts []*types.Alert,
|
||||
) (string, error) {
|
||||
// if even the default template is empty, return empty string
|
||||
// this is possible if user added channel with blank template
|
||||
if tmplStr == "" {
|
||||
at.logger.WarnContext(ctx, "default template is empty")
|
||||
return "", nil
|
||||
}
|
||||
data := notify.GetTemplateData(ctx, at.tmpl, alerts, at.logger)
|
||||
result, err := at.tmpl.ExecuteTextString(tmplStr, data)
|
||||
if err != nil {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute default template: %s", err.Error())
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// mergeMissingVars adds all keys from src into dst.
|
||||
func mergeMissingVars(dst, src map[string]bool) {
|
||||
for k := range src {
|
||||
dst[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
// expandTitle expands the title template. Returns empty string if the template is empty.
|
||||
func (at *alertManagerTemplater) expandTitle(
|
||||
titleTemplate string,
|
||||
ntd *NotificationTemplateData,
|
||||
) (string, map[string]bool, error) {
|
||||
if titleTemplate == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
processRes, err := PreProcessTemplateAndData(titleTemplate, ntd)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
result, err := at.tmpl.ExecuteTextString(processRes.Template, processRes.Data)
|
||||
if err != nil {
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute custom title template: %s", err.Error())
|
||||
}
|
||||
return strings.TrimSpace(result), processRes.UnknownVars, nil
|
||||
}
|
||||
|
||||
// expandBody expands the body template for each individual alert. Returns nil if the template is empty.
|
||||
func (at *alertManagerTemplater) expandBody(
|
||||
bodyTemplate string,
|
||||
ntd *NotificationTemplateData,
|
||||
) ([]string, map[string]bool, error) {
|
||||
if bodyTemplate == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var sb []string
|
||||
missingVars := make(map[string]bool)
|
||||
for i := range ntd.Alerts {
|
||||
processRes, err := PreProcessTemplateAndData(bodyTemplate, &ntd.Alerts[i])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
part, err := at.tmpl.ExecuteTextString(processRes.Template, processRes.Data)
|
||||
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))
|
||||
}
|
||||
}
|
||||
return sb, missingVars, nil
|
||||
}
|
||||
|
||||
// buildNotificationTemplateData creates the NotificationTemplateData using
|
||||
// info from context and the raw alerts.
|
||||
func (at *alertManagerTemplater) buildNotificationTemplateData(
|
||||
ctx context.Context,
|
||||
alerts []*types.Alert,
|
||||
) *NotificationTemplateData {
|
||||
// extract the required data from the context
|
||||
receiver, ok := notify.ReceiverName(ctx)
|
||||
if !ok {
|
||||
at.logger.WarnContext(ctx, "missing receiver name in context")
|
||||
}
|
||||
|
||||
groupLabels, ok := notify.GroupLabels(ctx)
|
||||
if !ok {
|
||||
at.logger.WarnContext(ctx, "missing group labels in context")
|
||||
}
|
||||
|
||||
// extract the external URL from the template
|
||||
externalURL := ""
|
||||
if at.tmpl.ExternalURL != nil {
|
||||
externalURL = at.tmpl.ExternalURL.String()
|
||||
}
|
||||
|
||||
commonAnnotations := 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 })
|
||||
|
||||
// build the alert data slice
|
||||
alertDataSlice := make([]AlertData, 0, len(alerts))
|
||||
for _, a := range alerts {
|
||||
ad := buildAlertData(a, receiver)
|
||||
alertDataSlice = append(alertDataSlice, ad)
|
||||
}
|
||||
|
||||
// count the number of firing and resolved alerts
|
||||
var firing, resolved int
|
||||
for _, ad := range alertDataSlice {
|
||||
if ad.IsFiring {
|
||||
firing++
|
||||
} else if ad.IsResolved {
|
||||
resolved++
|
||||
}
|
||||
}
|
||||
|
||||
// extract the rule-level convenience fields from common labels
|
||||
alertName := commonLabels[ruletypes.LabelAlertName]
|
||||
ruleID := commonLabels[ruletypes.LabelRuleID]
|
||||
ruleLink := commonLabels[ruletypes.LabelRuleSource]
|
||||
|
||||
// build the group labels
|
||||
gl := make(template.KV, len(groupLabels))
|
||||
for k, v := range groupLabels {
|
||||
gl[string(k)] = string(v)
|
||||
}
|
||||
|
||||
// build the notification template data
|
||||
return &NotificationTemplateData{
|
||||
Receiver: receiver,
|
||||
Status: string(types.Alerts(alerts...).Status()),
|
||||
AlertName: alertName,
|
||||
RuleID: ruleID,
|
||||
RuleLink: ruleLink,
|
||||
TotalFiring: firing,
|
||||
TotalResolved: resolved,
|
||||
Alerts: alertDataSlice,
|
||||
GroupLabels: gl,
|
||||
CommonLabels: commonLabels,
|
||||
CommonAnnotations: commonAnnotations,
|
||||
ExternalURL: externalURL,
|
||||
Labels: labels,
|
||||
Annotations: annotations,
|
||||
}
|
||||
}
|
||||
|
||||
// buildAlertData converts a single *types.Alert into an AlertData.
|
||||
func buildAlertData(a *types.Alert, receiver string) AlertData {
|
||||
labels := make(template.KV, len(a.Labels))
|
||||
for k, v := range a.Labels {
|
||||
labels[string(k)] = string(v)
|
||||
}
|
||||
|
||||
annotations := make(template.KV, len(a.Annotations))
|
||||
for k, v := range a.Annotations {
|
||||
annotations[string(k)] = string(v)
|
||||
}
|
||||
|
||||
return AlertData{
|
||||
Receiver: receiver,
|
||||
Status: string(a.Status()),
|
||||
Labels: labels,
|
||||
Annotations: annotations,
|
||||
StartsAt: a.StartsAt,
|
||||
EndsAt: a.EndsAt,
|
||||
GeneratorURL: a.GeneratorURL,
|
||||
Fingerprint: a.Fingerprint().String(),
|
||||
AlertName: labels[ruletypes.LabelAlertName],
|
||||
RuleID: labels[ruletypes.LabelRuleID],
|
||||
RuleLink: labels[ruletypes.LabelRuleSource],
|
||||
Severity: labels[ruletypes.LabelSeverityName],
|
||||
LogLink: annotations[ruletypes.AnnotationRelatedLogs],
|
||||
TraceLink: annotations[ruletypes.AnnotationRelatedTraces],
|
||||
Value: annotations[ruletypes.AnnotationValue],
|
||||
Threshold: annotations[ruletypes.AnnotationThresholdValue],
|
||||
CompareOp: annotations[ruletypes.AnnotationCompareOp],
|
||||
MatchType: annotations[ruletypes.AnnotationMatchType],
|
||||
IsFiring: a.Status() == model.AlertFiring,
|
||||
IsResolved: a.Status() == model.AlertResolved,
|
||||
IsMissingData: labels[ruletypes.LabelNoData] == "true",
|
||||
IsRecovering: labels[ruletypes.LabelIsRecovering] == "true",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
// 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) (AlertManagerTemplater, context.Context) {
|
||||
t.Helper()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "test-group")
|
||||
ctx = notify.WithReceiverName(ctx, "slack")
|
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "TestAlert", "severity": "critical"})
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
return New(tmpl, logger), ctx
|
||||
}
|
||||
|
||||
func createAlert(labels, annotations map[string]string, isFiring bool) *types.Alert {
|
||||
ls := model.LabelSet{}
|
||||
for k, v := range labels {
|
||||
ls[model.LabelName(k)] = model.LabelValue(v)
|
||||
}
|
||||
ann := model.LabelSet{}
|
||||
for k, v := range annotations {
|
||||
ann[model.LabelName(k)] = model.LabelValue(v)
|
||||
}
|
||||
startsAt := time.Now()
|
||||
var endsAt time.Time
|
||||
if isFiring {
|
||||
endsAt = startsAt.Add(time.Hour)
|
||||
} else {
|
||||
startsAt = startsAt.Add(-2 * time.Hour)
|
||||
endsAt = startsAt.Add(-time.Hour)
|
||||
}
|
||||
return &types.Alert{Alert: model.Alert{Labels: ls, Annotations: ann, StartsAt: startsAt, EndsAt: endsAt}}
|
||||
}
|
||||
|
||||
func TestExpandTemplates(t *testing.T) {
|
||||
at, ctx := testSetup(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
alerts []*types.Alert
|
||||
input TemplateInput
|
||||
wantTitle string
|
||||
wantBody []string
|
||||
wantMissingVars []string
|
||||
errorContains string
|
||||
wantIsDefaultBody bool
|
||||
}{
|
||||
{
|
||||
// High request throughput on a service — service is a custom label.
|
||||
// $labels.service extracts the label value; $annotations.description pulls the annotation.
|
||||
name: "new template: high request throughput for a service",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{
|
||||
ruletypes.LabelAlertName: "HighRequestThroughput",
|
||||
ruletypes.LabelSeverityName: "warning",
|
||||
"service": "payment-service",
|
||||
},
|
||||
map[string]string{"description": "Request rate exceeded 10k/s"},
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: TemplateInput{
|
||||
TitleTemplate: "High request throughput for $service",
|
||||
BodyTemplate: `The service $service is getting high request. Please investigate.
|
||||
Severity: $severity
|
||||
Status: $status
|
||||
Service: $service
|
||||
Description: $description`,
|
||||
},
|
||||
wantTitle: "High request throughput for payment-service",
|
||||
wantBody: []string{`The service payment-service is getting high request. Please investigate.
|
||||
Severity: warning
|
||||
Status: firing
|
||||
Service: payment-service
|
||||
Description: Request rate exceeded 10k/s`},
|
||||
wantIsDefaultBody: false,
|
||||
},
|
||||
{
|
||||
// Disk usage alert using old Go template syntax throughout.
|
||||
// No custom templates — both title and body use the default fallback path.
|
||||
name: "old template: disk usage high on database host",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "DiskUsageHigh",
|
||||
ruletypes.LabelSeverityName: "critical",
|
||||
"instance": "db-primary-01",
|
||||
},
|
||||
map[string]string{
|
||||
"summary": "Disk usage high on database host",
|
||||
"description": "Disk usage is high on the database host",
|
||||
"related_logs": "https://logs.example.com/search?q=DiskUsageHigh",
|
||||
"related_traces": "https://traces.example.com/search?q=DiskUsageHigh",
|
||||
},
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: TemplateInput{
|
||||
DefaultTitleTemplate: `[{{ .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 -}}
|
||||
)
|
||||
{{- end }}`,
|
||||
DefaultBodyTemplate: `{{ range .Alerts -}}
|
||||
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
|
||||
|
||||
*Summary:* {{ .Annotations.summary }}
|
||||
*Description:* {{ .Annotations.description }}
|
||||
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
|
||||
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
|
||||
|
||||
*Details:*
|
||||
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
|
||||
{{ end }}
|
||||
{{ end }}`,
|
||||
},
|
||||
wantTitle: "[FIRING:1] DiskUsageHigh for (instance=\"db-primary-01\")",
|
||||
wantBody: []string{`*Alert:* DiskUsageHigh - critical
|
||||
|
||||
*Summary:* Disk usage high on database host
|
||||
*Description:* Disk usage is high on the database host
|
||||
*RelatedLogs:* View in <https://logs.example.com/search?q=DiskUsageHigh|logs explorer>
|
||||
*RelatedTraces:* View in <https://traces.example.com/search?q=DiskUsageHigh|traces explorer>
|
||||
|
||||
*Details:*
|
||||
• *alertname:* DiskUsageHigh
|
||||
• *instance:* db-primary-01
|
||||
• *severity:* critical
|
||||
|
||||
`},
|
||||
wantIsDefaultBody: true,
|
||||
},
|
||||
{
|
||||
// Pod crash loop on multiple pods — body is expanded once per alert
|
||||
// and joined with "\n\n", with the pod name pulled from labels.
|
||||
name: "new template: pod crash loop on multiple pods, body per-alert",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-1"}, nil, true),
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-2"}, nil, true),
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-3"}, nil, true),
|
||||
},
|
||||
input: TemplateInput{
|
||||
TitleTemplate: "$rule_name: $total_firing pods affected",
|
||||
BodyTemplate: "$labels.pod is crash looping",
|
||||
},
|
||||
wantTitle: "PodCrashLoop: 3 pods affected",
|
||||
wantBody: []string{"api-worker-1 is crash looping", "api-worker-2 is crash looping", "api-worker-3 is crash looping"},
|
||||
wantIsDefaultBody: false,
|
||||
},
|
||||
{
|
||||
// Incident partially resolved — one service still down, one recovered.
|
||||
// Title shows the aggregate counts; body shows per-service status.
|
||||
name: "new template: service degradation with mixed firing and resolved alerts",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "ServiceDown", "service": "auth-service"}, nil, true),
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "ServiceDown", "service": "payment-service"}, nil, false),
|
||||
},
|
||||
input: TemplateInput{
|
||||
TitleTemplate: "$total_firing firing, $total_resolved resolved",
|
||||
BodyTemplate: "$labels.service ($status)",
|
||||
},
|
||||
wantTitle: "1 firing, 1 resolved",
|
||||
wantBody: []string{"auth-service (firing)", "payment-service (resolved)"},
|
||||
wantIsDefaultBody: false,
|
||||
},
|
||||
{
|
||||
// $environment is not a known AlertData or NotificationTemplateData field,
|
||||
// so it lands in MissingVars and renders as "<no value>" in the output.
|
||||
name: "missing vars: unknown $environment variable in title",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, true),
|
||||
},
|
||||
input: TemplateInput{
|
||||
TitleTemplate: "[$environment] $rule_name",
|
||||
},
|
||||
wantTitle: "[<no value>] HighCPU",
|
||||
wantMissingVars: []string{"environment"},
|
||||
wantIsDefaultBody: true,
|
||||
},
|
||||
{
|
||||
// $runbook_url is not a known field — someone tried to embed a runbook link
|
||||
// directly as a variable instead of via annotations.
|
||||
name: "missing vars: unknown $runbook_url variable in body",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "PodOOMKilled", ruletypes.LabelSeverityName: "warning"}, nil, true),
|
||||
},
|
||||
input: TemplateInput{
|
||||
BodyTemplate: "$rule_name: see runbook at $runbook_url",
|
||||
},
|
||||
wantBody: []string{"PodOOMKilled: see runbook at <no value>"},
|
||||
wantMissingVars: []string{"runbook_url"},
|
||||
},
|
||||
{
|
||||
// Both title and body use unknown variables; MissingVars is the union of both.
|
||||
name: "missing vars: unknown variables in both title and body",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "HighMemory", ruletypes.LabelSeverityName: "critical"}, nil, true),
|
||||
},
|
||||
input: TemplateInput{
|
||||
TitleTemplate: "[$environment] $rule_name and [{{ $service }}]",
|
||||
BodyTemplate: "$rule_name: see runbook at $runbook_url",
|
||||
},
|
||||
wantTitle: "[<no value>] HighMemory and [<no value>]",
|
||||
wantBody: []string{"HighMemory: see runbook at <no value>"},
|
||||
wantMissingVars: []string{"environment", "runbook_url", "service"},
|
||||
},
|
||||
{
|
||||
// Custom title template that expands to only whitespace triggers the fallback,
|
||||
// so the default title template is used instead.
|
||||
name: "fallback: whitespace-only custom title falls back to default",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, false),
|
||||
},
|
||||
input: TemplateInput{
|
||||
TitleTemplate: " ",
|
||||
DefaultTitleTemplate: "{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})",
|
||||
DefaultBodyTemplate: "Runbook: https://runbook.example.com",
|
||||
},
|
||||
wantTitle: "HighCPU (RESOLVED)",
|
||||
wantBody: []string{"Runbook: https://runbook.example.com"},
|
||||
wantIsDefaultBody: true,
|
||||
},
|
||||
{
|
||||
name: "using non-existing function in template",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, true),
|
||||
},
|
||||
input: TemplateInput{
|
||||
TitleTemplate: "$rule_name ({{$severity | toUpperAndTrim}}) for $alertname",
|
||||
},
|
||||
errorContains: "function \"toUpperAndTrim\" not defined",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := at.ProcessTemplates(ctx, tc.input, tc.alerts)
|
||||
if tc.errorContains != "" {
|
||||
require.ErrorContains(t, err, tc.errorContains)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if tc.wantTitle != "" {
|
||||
require.Equal(t, tc.wantTitle, got.Title)
|
||||
}
|
||||
if tc.wantBody != nil {
|
||||
require.Equal(t, tc.wantBody, got.Body)
|
||||
}
|
||||
require.Equal(t, tc.wantIsDefaultBody, got.IsDefaultTemplatedBody)
|
||||
|
||||
if len(tc.wantMissingVars) == 0 {
|
||||
require.Empty(t, got.MissingVars)
|
||||
} else {
|
||||
sort.Strings(tc.wantMissingVars)
|
||||
require.Equal(t, tc.wantMissingVars, got.MissingVars)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
271
pkg/alertmanager/alertmanagertemplate/preprocessor.go
Normal file
271
pkg/alertmanager/alertmanagertemplate/preprocessor.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
// fieldMapping represents a mapping from a JSON tag name to its struct field name.
|
||||
type fieldMapping struct {
|
||||
VarName string // JSON tag name (e.g., "receiver", "rule_name")
|
||||
FieldName string // Struct field name (e.g., "Receiver", "AlertName")
|
||||
}
|
||||
|
||||
// extractFieldMappings uses reflection to extract field mappings from a struct.
|
||||
func extractFieldMappings(data any) []fieldMapping {
|
||||
val := reflect.ValueOf(data)
|
||||
// Handle pointer types
|
||||
if val.Kind() == reflect.Ptr {
|
||||
if val.IsNil() {
|
||||
return nil
|
||||
}
|
||||
val = val.Elem()
|
||||
}
|
||||
// return nil if the given data is not a struct
|
||||
if val.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
typ := val.Type()
|
||||
|
||||
var mappings []fieldMapping
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
// Skip unexported fields
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
// Get JSON tag name
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
// Extract the name part (before any comma options like omitempty)
|
||||
varName := strings.Split(jsonTag, ",")[0]
|
||||
if varName == "" {
|
||||
continue
|
||||
}
|
||||
varFieldName := field.Tag.Get("mapstructure")
|
||||
if varFieldName == "" {
|
||||
varFieldName = field.Name
|
||||
}
|
||||
// Skip complex types: slices and interfaces
|
||||
kind := field.Type.Kind()
|
||||
if kind == reflect.Slice || kind == reflect.Interface {
|
||||
continue
|
||||
}
|
||||
// For struct types, we skip all but with few exceptions like time.Time
|
||||
if kind == reflect.Struct {
|
||||
// Allow time.Time which is commonly used
|
||||
if field.Type.String() != "time.Time" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
mappings = append(mappings, fieldMapping{
|
||||
VarName: varName,
|
||||
FieldName: varFieldName,
|
||||
})
|
||||
}
|
||||
|
||||
return mappings
|
||||
}
|
||||
|
||||
// prepareVariableName prepares the variable name to be used in go-text-template
|
||||
// it replaces every unwanted character like dots, spaces, etc. with an underscore
|
||||
// for example, "http.request.method" becomes "http_request_method".
|
||||
func prepareVariableName(key string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(key))
|
||||
|
||||
for i, r := range key {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_': // valid variable name characters
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
if b.Len() == 0 {
|
||||
// leading digit — replace with underscore
|
||||
b.WriteByte('_')
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
default:
|
||||
// dots, hyphens, spaces, etc. → underscore
|
||||
b.WriteByte('_')
|
||||
}
|
||||
_ = i
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// extractNestedFieldsDefinitions adds the labels and annotations keys from the data struct to the template variable definitions
|
||||
// it takes the known data struct and extracts the labels and annotations maps and adds their keys to template variable definitions to be used in the template.
|
||||
func extractNestedFieldsDefinitions(data any) map[string]string {
|
||||
variables := make(map[string]string)
|
||||
|
||||
addLabelsAndAnnotations := func(labels, annotations map[string]string) {
|
||||
for k := range annotations {
|
||||
variables[prepareVariableName(k)] = fmt.Sprintf("index .annotations \"%s\"", k)
|
||||
}
|
||||
for k := range labels {
|
||||
variables[prepareVariableName(k)] = fmt.Sprintf("index .labels \"%s\"", k)
|
||||
}
|
||||
}
|
||||
|
||||
switch data := data.(type) {
|
||||
case *NotificationTemplateData:
|
||||
addLabelsAndAnnotations(data.Labels, data.Annotations)
|
||||
case *AlertData:
|
||||
addLabelsAndAnnotations(data.Labels, data.Annotations)
|
||||
default:
|
||||
return variables
|
||||
}
|
||||
|
||||
return variables
|
||||
}
|
||||
|
||||
// prepareDataForTemplating prepares the data for templating by adding the labels and annotations values to the resulting map
|
||||
// so they can be accessed directly from root level, the predefined values take precedence over the labels and annotations values
|
||||
// for example, if labels have a value called rule_name, which collides with the rule_name field in the data struct, the value from the data struct will take precedence.
|
||||
func prepareDataForTemplating(data any) (map[string]interface{}, error) {
|
||||
var result map[string]interface{}
|
||||
if err := mapstructure.Decode(data, &result); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to prepare data for templating")
|
||||
}
|
||||
|
||||
addLabelsAndAnnotationsValues := func(labels, annotations map[string]string) {
|
||||
for k, v := range labels {
|
||||
k = prepareVariableName(k)
|
||||
if _, ok := result[k]; !ok {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range annotations {
|
||||
k = prepareVariableName(k)
|
||||
if _, ok := result[k]; !ok {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch data := data.(type) {
|
||||
case *NotificationTemplateData:
|
||||
addLabelsAndAnnotationsValues(data.Labels, data.Annotations)
|
||||
case *AlertData:
|
||||
addLabelsAndAnnotationsValues(data.Labels, data.Annotations)
|
||||
default:
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// generateVariableDefinitions creates `{{ $varname := "" }}` declarations for each variable name.
|
||||
func generateVariableDefinitions(varNames map[string]string) string {
|
||||
if len(varNames) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
for name := range varNames {
|
||||
fmt.Fprintf(&sb, `{{ $%s := %s }}`, name, varNames[name])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildVariableDefinitions constructs the full variable definition preamble for a template.
|
||||
// containing all known and unknown variables, the reason to include unknown variables is to
|
||||
// populate them with "<no value>" in template so go-text-template don't throw errors
|
||||
// when these variables are used in the template.
|
||||
func buildVariableDefinitions(tmpl string, data any) (string, map[string]bool, error) {
|
||||
// Extract the initial fields from the data struct and add to the definitions
|
||||
mappings := extractFieldMappings(data)
|
||||
|
||||
// Add variables from struct root level fields to the definitions
|
||||
variables := make(map[string]string)
|
||||
for _, m := range mappings {
|
||||
variables[m.VarName] = fmt.Sprintf(".%s", m.FieldName)
|
||||
}
|
||||
|
||||
// Extract the nested fields definitions from the data struct, like labels, annotations, etc.
|
||||
// once extracted we add them to the variables map along with the field address
|
||||
nestedVariables := extractNestedFieldsDefinitions(data)
|
||||
for k, v := range nestedVariables {
|
||||
variables[k] = v
|
||||
}
|
||||
|
||||
// variables that are used throughout the template
|
||||
usedVars, err := ExtractUsedVariables(tmpl)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Compute unknown variables: used in template but not covered by a field mapping
|
||||
probableUnknownVars := make(map[string]bool)
|
||||
for name := range usedVars {
|
||||
_, ok := variables[name]
|
||||
if !ok {
|
||||
probableUnknownVars[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add missing variables to the definitions with "<no value>"
|
||||
// missingkey=zero is used to replace the missing value with "<no value>"
|
||||
// but it only works when getting map values like {{ .keyfrommap }} from map and in struct this breaks
|
||||
// with missing variable errors, we add missing variables in map so when directly variables
|
||||
// are accessed directly in template block like {{ $variable }} it's handled and doesn't throw errors.
|
||||
for name := range probableUnknownVars {
|
||||
variables[name] = `"<no value>"`
|
||||
}
|
||||
|
||||
return generateVariableDefinitions(variables), probableUnknownVars, nil
|
||||
}
|
||||
|
||||
type ProcessingResult struct {
|
||||
Template string
|
||||
Data map[string]interface{}
|
||||
// UnknownVars is the set of possible unknown variables exptracted using regex
|
||||
UnknownVars map[string]bool
|
||||
}
|
||||
|
||||
// PreProcessTemplateAndData prepares a template string and struct data for Go template execution.
|
||||
//
|
||||
// Input: "$receiver has $rule_name in $status state"
|
||||
// Output: "{{ $receiver := .Receiver }}...{{ $receiver }} has {{ $rule_name }} in {{ $status }} state"
|
||||
func PreProcessTemplateAndData(tmpl string, data any) (*ProcessingResult, error) {
|
||||
// Handle empty template
|
||||
unknownVars := make(map[string]bool)
|
||||
if tmpl == "" {
|
||||
result, err := prepareDataForTemplating(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProcessingResult{Data: result, UnknownVars: unknownVars}, nil
|
||||
}
|
||||
|
||||
// Build variable definitions: known struct fields + fallback empty-string declarations
|
||||
definitions, unknownVars, err := buildVariableDefinitions(tmpl, data)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to build template definitions")
|
||||
}
|
||||
|
||||
// Attach definitions prefix so WrapDollarVariables can parse the AST without "undefined variable" errors.
|
||||
finalTmpl := definitions + tmpl
|
||||
|
||||
// Call WrapDollarVariables to transform bare $variable references to go-text-template format
|
||||
// with {{ $variable }} syntax from $variable syntax
|
||||
wrappedTmpl, err := WrapDollarVariables(finalTmpl)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to prepare template for templating")
|
||||
}
|
||||
|
||||
// Convert struct to map using mapstructure to be used for template execution
|
||||
result, err := prepareDataForTemplating(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ProcessingResult{Template: wrappedTmpl, Data: result, UnknownVars: unknownVars}, nil
|
||||
}
|
||||
327
pkg/alertmanager/alertmanagertemplate/preprocessor_test.go
Normal file
327
pkg/alertmanager/alertmanagertemplate/preprocessor_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtractFieldMappings(t *testing.T) {
|
||||
// Struct with various field types to test extraction logic
|
||||
type TestStruct struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
ActiveUserCount int `json:"user_count" mapstructure:"active_user_count"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"` // time.Time allowed
|
||||
Items []string `json:"items"` // slice skipped
|
||||
unexported string // unexported skipped (no tag needed)
|
||||
NoTag string // no json tag skipped
|
||||
SkippedTag string `json:"-"` // json:"-" skipped
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
data any
|
||||
expected []fieldMapping
|
||||
}{
|
||||
{
|
||||
name: "struct with mixed field types",
|
||||
data: TestStruct{Name: "test", ActiveUserCount: 5, unexported: ""},
|
||||
expected: []fieldMapping{
|
||||
{VarName: "name", FieldName: "Name"},
|
||||
{VarName: "status", FieldName: "Status"},
|
||||
{VarName: "user_count", FieldName: "active_user_count"},
|
||||
{VarName: "is_active", FieldName: "IsActive"},
|
||||
{VarName: "created_at", FieldName: "CreatedAt"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil data",
|
||||
data: nil,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "non-struct type",
|
||||
data: "string",
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := extractFieldMappings(tc.data)
|
||||
require.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVariableDefinitions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
tmpl string
|
||||
data any
|
||||
expectedVars []string // substrings that must appear in result
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "empty template still returns struct field definitions",
|
||||
tmpl: "",
|
||||
data: &NotificationTemplateData{Receiver: "test"},
|
||||
expectedVars: []string{
|
||||
"{{ $receiver := .receiver }}",
|
||||
"{{ $status := .status }}",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mix of known and unknown vars",
|
||||
tmpl: "$rule_name: $custom_label",
|
||||
data: &AlertData{AlertName: "test", Status: "ok", Severity: "critical"},
|
||||
expectedVars: []string{
|
||||
"{{ $rule_name := .rule_name }}",
|
||||
"{{ $status := .status }}",
|
||||
"{{ $severity := .severity }}",
|
||||
`{{ $custom_label := "<no value>" }}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested fields definitions coming from NotificationTemplateData",
|
||||
tmpl: "$severity for $service",
|
||||
data: &NotificationTemplateData{Labels: template.KV{
|
||||
"severity": "critical",
|
||||
"service": "test",
|
||||
"cloud.region.instance": "ap-south-1",
|
||||
}},
|
||||
expectedVars: []string{
|
||||
"{{ $severity := index .labels \"severity\" }}",
|
||||
"{{ $service := index .labels \"service\" }}",
|
||||
"{{ $cloud_region_instance := index .labels \"cloud.region.instance\" }}",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested fields definitions coming from AlertData",
|
||||
tmpl: "$severity for $service",
|
||||
data: &AlertData{Labels: template.KV{
|
||||
"severity": "critical",
|
||||
"service": "test",
|
||||
}},
|
||||
expectedVars: []string{
|
||||
"{{ $severity := index .labels \"severity\" }}",
|
||||
"{{ $service := index .labels \"service\" }}",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid template syntax returns error",
|
||||
tmpl: "{{invalid",
|
||||
data: &NotificationTemplateData{},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, _, err := buildVariableDefinitions(tc.tmpl, tc.data)
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
if len(tc.expectedVars) == 0 {
|
||||
require.Empty(t, result)
|
||||
return
|
||||
}
|
||||
for _, expected := range tc.expectedVars {
|
||||
require.Contains(t, result, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreProcessTemplateAndData(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
tmpl string
|
||||
data any
|
||||
expectedTemplateContains []string
|
||||
expectedData map[string]any
|
||||
expectedUnknownVars map[string]bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "NotificationTemplateData with dollar variables and labels with dots and hyphens",
|
||||
tmpl: "[$status] $rule_name (ID: $rule_id) - Firing: $total_firing, Resolved: $total_resolved, Severity: $severity\nHTTP method is: $http_request_method\nRequest path is: $http_request_path",
|
||||
data: &NotificationTemplateData{
|
||||
Receiver: "pagerduty",
|
||||
Status: "firing",
|
||||
AlertName: "HighMemory",
|
||||
RuleID: "rule-123",
|
||||
Labels: template.KV{
|
||||
"severity": "critical",
|
||||
"http.request.method": "GET",
|
||||
"http-request-path": "/api/v1/metrics",
|
||||
},
|
||||
TotalFiring: 3,
|
||||
TotalResolved: 1,
|
||||
},
|
||||
expectedTemplateContains: []string{
|
||||
"{{$status := .status}}",
|
||||
"{{$rule_name := .rule_name}}",
|
||||
"{{$rule_id := .rule_id}}",
|
||||
"{{$total_firing := .total_firing}}",
|
||||
"{{$total_resolved := .total_resolved}}",
|
||||
"{{$severity := index .labels \"severity\"}}",
|
||||
"[{{ .status }}] {{ .rule_name }} (ID: {{ .rule_id }}) - Firing: {{ .total_firing }}, Resolved: {{ .total_resolved }}",
|
||||
"{{$http_request_method := index .labels \"http.request.method\"}}",
|
||||
"{{$http_request_path := index .labels \"http-request-path\"}}",
|
||||
},
|
||||
expectedData: map[string]any{
|
||||
"status": "firing",
|
||||
"rule_name": "HighMemory",
|
||||
"rule_id": "rule-123",
|
||||
"total_firing": 3,
|
||||
"total_resolved": 1,
|
||||
"severity": "critical",
|
||||
"http_request_method": "GET",
|
||||
"http_request_path": "/api/v1/metrics",
|
||||
},
|
||||
expectedUnknownVars: map[string]bool{},
|
||||
},
|
||||
{
|
||||
name: "AlertData with dollar variables",
|
||||
tmpl: "$rule_name: Value $value exceeded $threshold (Status: $status, Severity: $severity, Description: $description)",
|
||||
data: &AlertData{
|
||||
Receiver: "webhook",
|
||||
Status: "resolved",
|
||||
AlertName: "DiskFull",
|
||||
RuleID: "disk-001",
|
||||
Severity: "warning",
|
||||
Annotations: template.KV{
|
||||
"description": "Disk full and cannot be written to",
|
||||
},
|
||||
Value: "85%",
|
||||
Threshold: "80%",
|
||||
IsFiring: false,
|
||||
IsResolved: true,
|
||||
},
|
||||
expectedTemplateContains: []string{
|
||||
"{{$rule_name := .rule_name}}",
|
||||
"{{$value := .value}}",
|
||||
"{{$threshold := .threshold}}",
|
||||
"{{$status := .status}}",
|
||||
"{{$severity := .severity}}",
|
||||
"{{$description := index .annotations \"description\"}}",
|
||||
"{{ .rule_name }}: Value {{ .value }} exceeded {{ .threshold }} (Status: {{ .status }}, Severity: {{ .severity }}, Description: {{ .description }})",
|
||||
},
|
||||
expectedData: map[string]any{
|
||||
"status": "resolved",
|
||||
"rule_name": "DiskFull",
|
||||
"rule_id": "disk-001",
|
||||
"severity": "warning",
|
||||
"value": "85%",
|
||||
"threshold": "80%",
|
||||
"description": "Disk full and cannot be written to",
|
||||
},
|
||||
expectedUnknownVars: map[string]bool{},
|
||||
},
|
||||
{
|
||||
name: "mixed dollar and dot notation with both labels and annotations",
|
||||
tmpl: "Alert $rule_name has {{.total_firing}} firing alerts",
|
||||
data: &NotificationTemplateData{
|
||||
AlertName: "HighCPU",
|
||||
TotalFiring: 5,
|
||||
Labels: template.KV{
|
||||
"value": "<MASKED VALUE>",
|
||||
"cpu.number": "10",
|
||||
},
|
||||
Annotations: template.KV{
|
||||
"value": "85%",
|
||||
},
|
||||
},
|
||||
expectedTemplateContains: []string{
|
||||
"{{$rule_name := .rule_name}}",
|
||||
"{{$value := index .labels \"value\"}}",
|
||||
"Alert {{ .rule_name }} has {{.total_firing}} firing alerts",
|
||||
"{{$cpu_number := index .labels \"cpu.number\"}}",
|
||||
},
|
||||
expectedData: map[string]any{
|
||||
"rule_name": "HighCPU",
|
||||
"total_firing": 5,
|
||||
"value": "<MASKED VALUE>",
|
||||
"cpu_number": "10",
|
||||
},
|
||||
expectedUnknownVars: map[string]bool{},
|
||||
},
|
||||
{
|
||||
name: "empty template",
|
||||
tmpl: "",
|
||||
data: &NotificationTemplateData{Receiver: "slack"},
|
||||
},
|
||||
{
|
||||
name: "invalid template syntax",
|
||||
tmpl: "{{invalid",
|
||||
data: &NotificationTemplateData{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "unknown dollar var in text renders empty",
|
||||
tmpl: "alert $custom_note fired",
|
||||
data: &NotificationTemplateData{AlertName: "HighCPU"},
|
||||
expectedTemplateContains: []string{
|
||||
`{{$custom_note := "<no value>"}}`,
|
||||
"alert {{ .custom_note }} fired",
|
||||
},
|
||||
expectedUnknownVars: map[string]bool{"custom_note": true},
|
||||
},
|
||||
{
|
||||
name: "unknown dollar var in action block renders empty",
|
||||
tmpl: "alert {{ $custom_note }} fired",
|
||||
data: &NotificationTemplateData{AlertName: "HighCPU"},
|
||||
expectedTemplateContains: []string{
|
||||
`{{$custom_note := "<no value>"}}`,
|
||||
`alert {{$custom_note}} fired`,
|
||||
},
|
||||
expectedUnknownVars: map[string]bool{"custom_note": true},
|
||||
},
|
||||
{
|
||||
name: "mix of known and unknown vars",
|
||||
tmpl: "$rule_name: $custom_label",
|
||||
data: &NotificationTemplateData{AlertName: "HighCPU"},
|
||||
expectedTemplateContains: []string{
|
||||
"{{$rule_name := .rule_name}}",
|
||||
`{{$custom_label := "<no value>"}}`,
|
||||
"{{ .rule_name }}: {{ .custom_label }}",
|
||||
},
|
||||
expectedData: map[string]any{"rule_name": "HighCPU"},
|
||||
expectedUnknownVars: map[string]bool{"custom_label": true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := PreProcessTemplateAndData(tc.tmpl, tc.data)
|
||||
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if tc.tmpl == "" {
|
||||
require.Equal(t, "", result.Template)
|
||||
return
|
||||
}
|
||||
|
||||
for _, substr := range tc.expectedTemplateContains {
|
||||
require.Contains(t, result.Template, substr)
|
||||
}
|
||||
for k, v := range tc.expectedData {
|
||||
require.Equal(t, v, result.Data[k])
|
||||
}
|
||||
if tc.expectedUnknownVars != nil {
|
||||
require.Equal(t, tc.expectedUnknownVars, result.UnknownVars)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
90
pkg/alertmanager/alertmanagertemplate/types.go
Normal file
90
pkg/alertmanager/alertmanagertemplate/types.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
)
|
||||
|
||||
// TemplateInput carries the title/body templates
|
||||
// and their defaults to apply in case the custom templates
|
||||
// are result in empty strings.
|
||||
type TemplateInput struct {
|
||||
TitleTemplate string
|
||||
BodyTemplate string
|
||||
DefaultTitleTemplate string
|
||||
DefaultBodyTemplate string
|
||||
}
|
||||
|
||||
// ExpandedTemplates is the result of ExpandTemplates.
|
||||
type ExpandedTemplates struct {
|
||||
Title string
|
||||
// Body is notification array of body for each alert
|
||||
Body []string
|
||||
// IsDefaultTemplatedBody is true if the body templates are templated using
|
||||
// default templates, false when custom templates were used for templating.
|
||||
IsDefaultTemplatedBody bool
|
||||
MissingVars []string // union of unknown vars from title + body templates
|
||||
}
|
||||
|
||||
// AlertData holds per-alert data used when expanding body templates.
|
||||
type AlertData struct {
|
||||
Receiver string `json:"receiver" mapstructure:"receiver"`
|
||||
Status string `json:"status" mapstructure:"status"`
|
||||
Labels template.KV `json:"labels" mapstructure:"labels"`
|
||||
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
|
||||
StartsAt time.Time `json:"starts_at" mapstructure:"starts_at"`
|
||||
EndsAt time.Time `json:"ends_at" mapstructure:"ends_at"`
|
||||
GeneratorURL string `json:"generator_url" mapstructure:"generator_url"`
|
||||
Fingerprint string `json:"fingerprint" mapstructure:"fingerprint"`
|
||||
|
||||
// Convenience fields extracted from well-known labels/annotations.
|
||||
AlertName string `json:"rule_name" mapstructure:"rule_name"`
|
||||
RuleID string `json:"rule_id" mapstructure:"rule_id"`
|
||||
RuleLink string `json:"rule_link" mapstructure:"rule_link"`
|
||||
Severity string `json:"severity" mapstructure:"severity"`
|
||||
|
||||
// Alert internal data fields
|
||||
Value string `json:"value" mapstructure:"value"`
|
||||
Threshold string `json:"threshold" mapstructure:"threshold"`
|
||||
CompareOp string `json:"compare_op" mapstructure:"compare_op"`
|
||||
MatchType string `json:"match_type" mapstructure:"match_type"`
|
||||
|
||||
// Link annotations added by the rule evaluator.
|
||||
LogLink string `json:"log_link" mapstructure:"log_link"`
|
||||
TraceLink string `json:"trace_link" mapstructure:"trace_link"`
|
||||
|
||||
// Status booleans for easy conditional templating.
|
||||
IsFiring bool `json:"is_firing" mapstructure:"is_firing"`
|
||||
IsResolved bool `json:"is_resolved" mapstructure:"is_resolved"`
|
||||
IsMissingData bool `json:"is_missing_data" mapstructure:"is_missing_data"`
|
||||
IsRecovering bool `json:"is_recovering" mapstructure:"is_recovering"`
|
||||
}
|
||||
|
||||
// NotificationTemplateData is the top-level data struct provided to custom templates.
|
||||
type NotificationTemplateData struct {
|
||||
Receiver string `json:"receiver" mapstructure:"receiver"`
|
||||
Status string `json:"status" mapstructure:"status"`
|
||||
|
||||
// Convenience fields for title templates.
|
||||
AlertName string `json:"rule_name" mapstructure:"rule_name"`
|
||||
RuleID string `json:"rule_id" mapstructure:"rule_id"`
|
||||
RuleLink string `json:"rule_link" mapstructure:"rule_link"`
|
||||
TotalFiring int `json:"total_firing" mapstructure:"total_firing"`
|
||||
TotalResolved int `json:"total_resolved" mapstructure:"total_resolved"`
|
||||
|
||||
// Per-alert data, also available as filtered sub-slices.
|
||||
Alerts []AlertData `json:"-" mapstructure:"-"`
|
||||
|
||||
// Cross-alert aggregates, computed as intersection across all alerts.
|
||||
GroupLabels template.KV `json:"group_labels" mapstructure:"group_labels"`
|
||||
CommonLabels template.KV `json:"common_labels" mapstructure:"common_labels"`
|
||||
CommonAnnotations template.KV `json:"common_annotations" mapstructure:"common_annotations"`
|
||||
ExternalURL string `json:"external_url" mapstructure:"external_url"`
|
||||
// Labels and Annotations that are collection of labels
|
||||
// and annotations from all alerts, it includes only the common labels and annotations
|
||||
// and for non-common labels and annotations, it picks some first few labels/annotations
|
||||
// and joins them with ", " to avoid blank values in the template
|
||||
Labels template.KV `json:"labels" mapstructure:"labels"`
|
||||
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
|
||||
}
|
||||
230
pkg/alertmanager/alertmanagertemplate/utils.go
Normal file
230
pkg/alertmanager/alertmanagertemplate/utils.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template/parse"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
// maxAggregatedValues is the maximum number of unique values to include
|
||||
// when aggregating non-common label/annotation values across alerts.
|
||||
const maxAggregatedValues = 5
|
||||
|
||||
// bareVariableRegex matches bare $variable references including dotted paths like $service.name.
|
||||
var bareVariableRegex = regexp.MustCompile(`\$(\w+(?:\.\w+)*)`)
|
||||
|
||||
// bareVariableRegexFirstSeg matches only the base $variable name, stopping before any dotted path.
|
||||
// e.g. "$labels.severity" matches "$labels", "$name" matches "$name".
|
||||
var bareVariableRegexFirstSeg = regexp.MustCompile(`\$\w+`)
|
||||
|
||||
// ExtractTemplatesFromAnnotations computes the common annotations across all alerts
|
||||
// and returns the values for the title_template and body_template annotation keys as title and body templates.
|
||||
func ExtractTemplatesFromAnnotations(alerts []*types.Alert) (titleTemplate, bodyTemplate string) {
|
||||
if len(alerts) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
|
||||
return commonAnnotations[ruletypes.AnnotationTitleTemplate], commonAnnotations[ruletypes.AnnotationBodyTemplate]
|
||||
}
|
||||
|
||||
// WrapDollarVariables wraps bare $variable references in Go template syntax.
|
||||
// Example transformations:
|
||||
// - "$name is $status" -> "{{ $name }} is {{ $status }}"
|
||||
// - "$labels.severity" -> "{{ index .labels \"severity\" }}"
|
||||
// - "$labels.http.status" -> "{{ index .labels \"http.status\" }}"
|
||||
// - "$annotations.summary" -> "{{ index .annotations \"summary\" }}"
|
||||
// - "$service.name" -> "{{ index . \"service.name\" }}"
|
||||
// - "$name is {{ .Status }}" -> "{{ $name }} is {{ .Status }}"
|
||||
func WrapDollarVariables(src string) (string, error) {
|
||||
if src == "" {
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// Create a new parse.Tree directly
|
||||
tree := parse.New("template")
|
||||
tree.Mode = parse.SkipFuncCheck
|
||||
|
||||
// Parse the template
|
||||
_, err := tree.Parse(src, "{{", "}}", make(map[string]*parse.Tree), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Walk the AST and transform TextNodes
|
||||
walkAndWrapTextNodes(tree.Root)
|
||||
|
||||
// Return the reassembled template
|
||||
return tree.Root.String(), nil
|
||||
}
|
||||
|
||||
// walkAndWrapTextNodes recursively walks the parse tree trying to find a text node.
|
||||
// Once a text node is found, it wraps the bare $variable and changes it to index-based
|
||||
// element access from the data map, like '.key' or '.key.subkey'.
|
||||
func walkAndWrapTextNodes(node parse.Node) {
|
||||
if reflect.ValueOf(node).IsNil() {
|
||||
return
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
// `$name is {{.Status}}` is a list node with one text and one action node
|
||||
case *parse.ListNode:
|
||||
// Recurse into all child nodes
|
||||
if n.Nodes != nil {
|
||||
for _, child := range n.Nodes {
|
||||
walkAndWrapTextNodes(child)
|
||||
}
|
||||
}
|
||||
|
||||
// `$name is ` is a text node with plain text in root
|
||||
// we try to find the $name variable and wrap it with template block
|
||||
// like `{{ .name }}`, for labels and annotations we use the index to access the value
|
||||
// so `$labels.service` becomes `{{ index .labels "service" }}`
|
||||
case *parse.TextNode:
|
||||
// Transform $variable based on its pattern
|
||||
n.Text = bareVariableRegex.ReplaceAllFunc(n.Text, func(match []byte) []byte {
|
||||
// Extract variable name without the $
|
||||
varName := string(match[1:])
|
||||
|
||||
// Check if variable contains dots
|
||||
if strings.Contains(varName, ".") {
|
||||
// Check for reserved prefixes: labels.* or annotations.*
|
||||
if strings.HasPrefix(varName, "labels.") {
|
||||
key := strings.TrimPrefix(varName, "labels.")
|
||||
return []byte(fmt.Sprintf(`{{ index .labels "%s" }}`, key))
|
||||
}
|
||||
if strings.HasPrefix(varName, "annotations.") {
|
||||
key := strings.TrimPrefix(varName, "annotations.")
|
||||
return []byte(fmt.Sprintf(`{{ index .annotations "%s" }}`, key))
|
||||
}
|
||||
// Other dotted variables: index into root context
|
||||
return []byte(fmt.Sprintf(`{{ index . "%s" }}`, varName))
|
||||
}
|
||||
|
||||
// Simple variables: use dot notation to directly access the field
|
||||
// without raising any error due to missing variables
|
||||
return []byte(fmt.Sprintf("{{ .%s }}", varName))
|
||||
})
|
||||
|
||||
// `{{if pipeline}} T1 {{else}} T0 {{end}}` is a if node with T1 part of List and T0 part of ElseList
|
||||
case *parse.IfNode:
|
||||
// Recurse into both branches
|
||||
walkAndWrapTextNodes(n.List)
|
||||
walkAndWrapTextNodes(n.ElseList)
|
||||
|
||||
// `{{range pipeline}} T1 {{else}} T0 {{end}}` is a range node with T1 part of List and T0 part of ElseList
|
||||
case *parse.RangeNode:
|
||||
// Recurse into both branches
|
||||
walkAndWrapTextNodes(n.List)
|
||||
walkAndWrapTextNodes(n.ElseList)
|
||||
|
||||
// All other node types (ActionNode, PipeNode, VariableNode, etc.) are already
|
||||
// inside {{ }} action blocks and don't need transformation
|
||||
|
||||
// Support for `with` can be added later when we start supporting it in editor block
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractUsedVariables returns the set of all $variable referenced in template
|
||||
// — text nodes, action blocks, branch conditions, and loop declarations — regardless of scope.
|
||||
// After finding all variables, we find those that are not part of our alert data and handle them so the
|
||||
// text/template parser does not reject undefined $variables.
|
||||
func ExtractUsedVariables(src string) (map[string]bool, error) {
|
||||
if src == "" {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
|
||||
// Regex-scan raw template string to collect all $var base names.
|
||||
// bareVariableRegexFirstSeg stops before dots, so "$labels.severity" yields "$labels".
|
||||
used := make(map[string]bool)
|
||||
for _, m := range bareVariableRegexFirstSeg.FindAll([]byte(src), -1) {
|
||||
used[string(m[1:])] = true // strip leading "$"
|
||||
}
|
||||
|
||||
// Build a preamble that pre-declares every found variable.
|
||||
// This prevents "undefined variable" parse errors for $vars used in action
|
||||
// blocks while still letting genuine syntax errors propagate.
|
||||
var preamble strings.Builder
|
||||
for name := range used {
|
||||
fmt.Fprintf(&preamble, `{{$%s := ""}}`, name)
|
||||
}
|
||||
|
||||
// Validate template syntax.
|
||||
tree := parse.New("template")
|
||||
tree.Mode = parse.SkipFuncCheck
|
||||
if _, err := tree.Parse(preamble.String()+src, "{{", "}}", make(map[string]*parse.Tree), nil); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInternal, "failed to extract used variables")
|
||||
}
|
||||
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// aggregateKV aggregates key-value pairs (labels or annotations) from all alerts into a single template.KV.
|
||||
// The result is used to populate the labels and annotations in the notification template data.
|
||||
func aggregateKV(alerts []*types.Alert, extractFn func(*types.Alert) model.LabelSet) template.KV {
|
||||
// track unique values per key in order of first appearance
|
||||
valuesPerKey := make(map[string][]string)
|
||||
// track which values have been seen for deduplication
|
||||
seenValues := make(map[string]map[string]bool)
|
||||
|
||||
for _, alert := range alerts {
|
||||
kvPairs := extractFn(alert)
|
||||
for k, v := range kvPairs {
|
||||
key := string(k)
|
||||
value := string(v)
|
||||
|
||||
if seenValues[key] == nil {
|
||||
seenValues[key] = make(map[string]bool)
|
||||
}
|
||||
|
||||
// only add if not already seen and under the limit of maxAggregatedValues
|
||||
if !seenValues[key][value] && len(valuesPerKey[key]) < maxAggregatedValues {
|
||||
seenValues[key][value] = true
|
||||
valuesPerKey[key] = append(valuesPerKey[key], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// build the result by joining values
|
||||
result := make(template.KV, len(valuesPerKey))
|
||||
for key, values := range valuesPerKey {
|
||||
result[key] = strings.Join(values, ", ")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractCommonKV returns the intersection of key-value pairs across all alerts.
|
||||
// A key/value pair is included only if it appears identically on every alert.
|
||||
func extractCommonKV(alerts []*types.Alert, extractFn func(*types.Alert) model.LabelSet) template.KV {
|
||||
if len(alerts) == 0 {
|
||||
return template.KV{}
|
||||
}
|
||||
|
||||
common := make(template.KV, len(extractFn(alerts[0])))
|
||||
for k, v := range extractFn(alerts[0]) {
|
||||
common[string(k)] = string(v)
|
||||
}
|
||||
|
||||
for _, a := range alerts[1:] {
|
||||
kv := extractFn(a)
|
||||
for k := range common {
|
||||
if string(kv[model.LabelName(k)]) != common[k] {
|
||||
delete(common, k)
|
||||
}
|
||||
}
|
||||
if len(common) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return common
|
||||
}
|
||||
348
pkg/alertmanager/alertmanagertemplate/utils_test.go
Normal file
348
pkg/alertmanager/alertmanagertemplate/utils_test.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWrapBareVars(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "mixed variables with actions",
|
||||
input: "$name is {{.Status}}",
|
||||
expected: "{{ .name }} is {{.Status}}",
|
||||
},
|
||||
{
|
||||
name: "nested variables in range",
|
||||
input: `{{range .items}}
|
||||
$title
|
||||
{{end}}`,
|
||||
expected: `{{range .items}}
|
||||
{{ .title }}
|
||||
{{end}}`,
|
||||
},
|
||||
{
|
||||
name: "nested variables in if else",
|
||||
input: "{{if .ok}}$a{{else}}$b{{end}}",
|
||||
expected: "{{if .ok}}{{ .a }}{{else}}{{ .b }}{{end}}",
|
||||
},
|
||||
// Labels prefix: index into .labels map
|
||||
{
|
||||
name: "labels variables prefix simple",
|
||||
input: "$labels.service",
|
||||
expected: `{{ index .labels "service" }}`,
|
||||
},
|
||||
{
|
||||
name: "labels variables prefix nested with multiple dots",
|
||||
input: "$labels.http.status",
|
||||
expected: `{{ index .labels "http.status" }}`,
|
||||
},
|
||||
{
|
||||
name: "multiple labels variables simple and nested",
|
||||
input: "$labels.service and $labels.instance.id",
|
||||
expected: `{{ index .labels "service" }} and {{ index .labels "instance.id" }}`,
|
||||
},
|
||||
// Annotations prefix: index into .annotations map
|
||||
{
|
||||
name: "annotations variables prefix simple",
|
||||
input: "$annotations.summary",
|
||||
expected: `{{ index .annotations "summary" }}`,
|
||||
},
|
||||
{
|
||||
name: "annotations variables prefix nested with multiple dots",
|
||||
input: "$annotations.alert.url",
|
||||
expected: `{{ index .annotations "alert.url" }}`,
|
||||
},
|
||||
// Other dotted paths: index into root context
|
||||
{
|
||||
name: "other variables with multiple dots",
|
||||
input: "$service.name",
|
||||
expected: `{{ index . "service.name" }}`,
|
||||
},
|
||||
{
|
||||
name: "other variables with multiple dots nested",
|
||||
input: "$http.status.code",
|
||||
expected: `{{ index . "http.status.code" }}`,
|
||||
},
|
||||
// Hybrid: all types combined
|
||||
{
|
||||
name: "hybrid - all variables types",
|
||||
input: "Alert: $alert_name Labels: $labels.severity Annotations: $annotations.desc Service: $service.name Count: $error_count",
|
||||
expected: `Alert: {{ .alert_name }} Labels: {{ index .labels "severity" }} Annotations: {{ index .annotations "desc" }} Service: {{ index . "service.name" }} Count: {{ .error_count }}`,
|
||||
},
|
||||
{
|
||||
name: "already wrapped should not be changed",
|
||||
input: "{{$status := .status}}{{.name}} is {{$status | toUpper}}",
|
||||
expected: "{{$status := .status}}{{.name}} is {{$status | toUpper}}",
|
||||
},
|
||||
{
|
||||
name: "no variables should not be changed",
|
||||
input: "Hello world",
|
||||
expected: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "deeply nested",
|
||||
input: "{{range .items}}{{if .ok}}$deep{{end}}{{end}}",
|
||||
expected: "{{range .items}}{{if .ok}}{{ .deep }}{{end}}{{end}}",
|
||||
},
|
||||
{
|
||||
name: "complex example",
|
||||
input: `Hello $name, your score is $score.
|
||||
{{if .isAdmin}}
|
||||
Welcome back $name, you have {{.unreadCount}} messages.
|
||||
{{end}}`,
|
||||
expected: `Hello {{ .name }}, your score is {{ .score }}.
|
||||
{{if .isAdmin}}
|
||||
Welcome back {{ .name }}, you have {{.unreadCount}} messages.
|
||||
{{end}}`,
|
||||
},
|
||||
{
|
||||
name: "with custom function",
|
||||
input: "$name triggered at {{urlescape .url}}",
|
||||
expected: "{{ .name }} triggered at {{urlescape .url}}",
|
||||
},
|
||||
{
|
||||
name: "invalid template",
|
||||
input: "{{invalid",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := WrapDollarVariables(tc.input)
|
||||
|
||||
if tc.expectError {
|
||||
require.Error(t, err, "should error on invalid template syntax")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUsedVariables(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected map[string]bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "simple usage in text",
|
||||
input: "$name is $status",
|
||||
expected: map[string]bool{"name": true, "status": true},
|
||||
},
|
||||
{
|
||||
name: "declared in action block",
|
||||
input: "{{ $name := .name }}",
|
||||
expected: map[string]bool{"name": true},
|
||||
},
|
||||
{
|
||||
name: "range loop vars",
|
||||
input: "{{ range $i, $v := .items }}{{ end }}",
|
||||
expected: map[string]bool{"i": true, "v": true},
|
||||
},
|
||||
{
|
||||
name: "mixed text and action",
|
||||
input: "$x and {{ $y }}",
|
||||
expected: map[string]bool{"x": true, "y": true},
|
||||
},
|
||||
{
|
||||
name: "dotted path in text extracts base only",
|
||||
input: "$labels.severity",
|
||||
expected: map[string]bool{"labels": true},
|
||||
},
|
||||
{
|
||||
name: "nested if else",
|
||||
input: "{{ if .ok }}{{ $a }}{{ else }}{{ $b }}{{ end }}",
|
||||
expected: map[string]bool{"a": true, "b": true},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: map[string]bool{},
|
||||
},
|
||||
{
|
||||
name: "no variables",
|
||||
input: "Hello world",
|
||||
expected: map[string]bool{},
|
||||
},
|
||||
{
|
||||
name: "invalid template returns error",
|
||||
input: "{{invalid",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := ExtractUsedVariables(tc.input)
|
||||
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateKV(t *testing.T) {
|
||||
extractLabels := func(a *types.Alert) model.LabelSet { return a.Labels }
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
alerts []*types.Alert
|
||||
extractFn func(*types.Alert) model.LabelSet
|
||||
expected template.KV
|
||||
}{
|
||||
{
|
||||
name: "empty alerts slice",
|
||||
alerts: []*types.Alert{},
|
||||
extractFn: extractLabels,
|
||||
expected: template.KV{},
|
||||
},
|
||||
{
|
||||
name: "single alert",
|
||||
alerts: []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"env": "production",
|
||||
"service": "backend",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
extractFn: extractLabels,
|
||||
expected: template.KV{
|
||||
"env": "production",
|
||||
"service": "backend",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "varying values with duplicates deduped",
|
||||
alerts: []*types.Alert{
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "backend"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "api"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "frontend"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "api"}}},
|
||||
},
|
||||
extractFn: extractLabels,
|
||||
expected: template.KV{
|
||||
"env": "production",
|
||||
"service": "backend, api, frontend",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "more than 5 unique values truncates to 5",
|
||||
alerts: []*types.Alert{
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc1"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc2"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc3"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc4"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc5"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc6"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc7"}}},
|
||||
},
|
||||
extractFn: extractLabels,
|
||||
expected: template.KV{
|
||||
"service": "svc1, svc2, svc3, svc4, svc5",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := aggregateKV(tc.alerts, tc.extractFn)
|
||||
require.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCommonKV(t *testing.T) {
|
||||
extractLabels := func(a *types.Alert) model.LabelSet { return a.Labels }
|
||||
extractAnnotations := func(a *types.Alert) model.LabelSet { return a.Annotations }
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
alerts []*types.Alert
|
||||
extractFn func(*types.Alert) model.LabelSet
|
||||
expected template.KV
|
||||
}{
|
||||
{
|
||||
name: "empty alerts slice",
|
||||
alerts: []*types.Alert{},
|
||||
extractFn: extractLabels,
|
||||
expected: template.KV{},
|
||||
},
|
||||
{
|
||||
name: "single alert returns all labels",
|
||||
alerts: []*types.Alert{
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "api"}}},
|
||||
},
|
||||
extractFn: extractLabels,
|
||||
expected: template.KV{"env": "prod", "service": "api"},
|
||||
},
|
||||
{
|
||||
name: "multiple alerts with fully common labels",
|
||||
alerts: []*types.Alert{
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "region": "us-east"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "region": "us-east"}}},
|
||||
},
|
||||
extractFn: extractLabels,
|
||||
expected: template.KV{"env": "prod", "region": "us-east"},
|
||||
},
|
||||
{
|
||||
name: "multiple alerts with partially common labels",
|
||||
alerts: []*types.Alert{
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "api"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "worker"}}},
|
||||
},
|
||||
extractFn: extractLabels,
|
||||
expected: template.KV{"env": "prod"},
|
||||
},
|
||||
{
|
||||
name: "multiple alerts with no common labels",
|
||||
alerts: []*types.Alert{
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"service": "api"}}},
|
||||
{Alert: model.Alert{Labels: model.LabelSet{"service": "worker"}}},
|
||||
},
|
||||
extractFn: extractLabels,
|
||||
expected: template.KV{},
|
||||
},
|
||||
{
|
||||
name: "annotations extract common annotations",
|
||||
alerts: []*types.Alert{
|
||||
{Alert: model.Alert{Annotations: model.LabelSet{"summary": "high cpu", "runbook": "http://x"}}},
|
||||
{Alert: model.Alert{Annotations: model.LabelSet{"summary": "high cpu", "runbook": "http://y"}}},
|
||||
},
|
||||
extractFn: extractAnnotations,
|
||||
expected: template.KV{"summary": "high cpu"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := extractCommonKV(tc.alerts, tc.extractFn)
|
||||
require.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package global
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
|
||||
@@ -39,34 +37,5 @@ func newConfig() factory.Config {
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.ExternalURL != nil {
|
||||
if c.ExternalURL.Path != "" && c.ExternalURL.Path != "/" {
|
||||
if !strings.HasPrefix(c.ExternalURL.Path, "/") {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidGlobalConfig, "global::external_url path must start with '/', got %q", c.ExternalURL.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) ExternalPath() string {
|
||||
if c.ExternalURL == nil || c.ExternalURL.Path == "" || c.ExternalURL.Path == "/" {
|
||||
return ""
|
||||
}
|
||||
|
||||
p := path.Clean("/" + c.ExternalURL.Path)
|
||||
if p == "/" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (c Config) ExternalPathTrailing() string {
|
||||
if p := c.ExternalPath(); p != "" {
|
||||
return p + "/"
|
||||
}
|
||||
|
||||
return "/"
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExternalPath(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
config Config
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "NilURL",
|
||||
config: Config{ExternalURL: nil},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "EmptyPath",
|
||||
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: ""}},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "RootPath",
|
||||
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/"}},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "SingleSegment",
|
||||
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
|
||||
expected: "/signoz",
|
||||
},
|
||||
{
|
||||
name: "TrailingSlash",
|
||||
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz/"}},
|
||||
expected: "/signoz",
|
||||
},
|
||||
{
|
||||
name: "MultiSegment",
|
||||
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/a/b/c"}},
|
||||
expected: "/a/b/c",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.config.ExternalPath())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalPathTrailing(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
config Config
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "NilURL",
|
||||
config: Config{ExternalURL: nil},
|
||||
expected: "/",
|
||||
},
|
||||
{
|
||||
name: "EmptyPath",
|
||||
config: Config{ExternalURL: &url.URL{Path: ""}},
|
||||
expected: "/",
|
||||
},
|
||||
{
|
||||
name: "RootPath",
|
||||
config: Config{ExternalURL: &url.URL{Path: "/"}},
|
||||
expected: "/",
|
||||
},
|
||||
{
|
||||
name: "SingleSegment",
|
||||
config: Config{ExternalURL: &url.URL{Path: "/signoz"}},
|
||||
expected: "/signoz/",
|
||||
},
|
||||
{
|
||||
name: "MultiSegment",
|
||||
config: Config{ExternalURL: &url.URL{Path: "/a/b/c"}},
|
||||
expected: "/a/b/c/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, tc.config.ExternalPathTrailing())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
config Config
|
||||
fail bool
|
||||
}{
|
||||
{
|
||||
name: "NilURL",
|
||||
config: Config{ExternalURL: nil},
|
||||
fail: false,
|
||||
},
|
||||
{
|
||||
name: "EmptyPath",
|
||||
config: Config{ExternalURL: &url.URL{Path: ""}},
|
||||
fail: false,
|
||||
},
|
||||
{
|
||||
name: "RootPath",
|
||||
config: Config{ExternalURL: &url.URL{Path: "/"}},
|
||||
fail: false,
|
||||
},
|
||||
{
|
||||
name: "ValidPath",
|
||||
config: Config{ExternalURL: &url.URL{Path: "/signoz"}},
|
||||
fail: false,
|
||||
},
|
||||
{
|
||||
name: "NoLeadingSlash",
|
||||
config: Config{ExternalURL: &url.URL{Path: "signoz"}},
|
||||
fail: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.config.Validate()
|
||||
if tc.fail {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -587,6 +587,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
router.HandleFunc("/api/v1/query_filter/analyze", am.ViewAccess(aH.QueryParserAPI.AnalyzeQueryFilter)).Methods(http.MethodPost)
|
||||
}
|
||||
|
||||
|
||||
func Intersection(a, b []int) (c []int) {
|
||||
m := make(map[int]bool)
|
||||
|
||||
|
||||
@@ -244,20 +244,6 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routePrefix := s.config.Global.ExternalPath()
|
||||
if routePrefix != "" {
|
||||
prefixed := http.StripPrefix(routePrefix, handler)
|
||||
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
|
||||
r.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
prefixed.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
Handler: handler,
|
||||
}, nil
|
||||
|
||||
@@ -88,9 +88,9 @@ func NewCacheProviderFactories() factory.NamedMap[factory.ProviderFactory[cache.
|
||||
)
|
||||
}
|
||||
|
||||
func NewWebProviderFactories(globalConfig global.Config) factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]] {
|
||||
func NewWebProviderFactories() factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
routerweb.NewFactory(globalConfig),
|
||||
routerweb.NewFactory(),
|
||||
noopweb.NewFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
@@ -35,7 +34,7 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
})
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
NewWebProviderFactories(global.Config{})
|
||||
NewWebProviderFactories()
|
||||
})
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
|
||||
@@ -11,19 +11,22 @@ import (
|
||||
alertmanagertemplate "github.com/prometheus/alertmanager/template"
|
||||
)
|
||||
|
||||
func AdditionalFuncMap() tmpltext.FuncMap {
|
||||
return tmpltext.FuncMap{
|
||||
// urlescape escapes the string for use in a URL query parameter.
|
||||
// It returns tmplhtml.HTML to prevent the template engine from escaping the already escaped string.
|
||||
// url.QueryEscape escapes spaces as "+", and html/template escapes "+" as "+" if tmplhtml.HTML is not used.
|
||||
"urlescape": func(value string) tmplhtml.HTML {
|
||||
return tmplhtml.HTML(url.QueryEscape(value))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// customTemplateOption returns an Option that adds custom functions to the template.
|
||||
func customTemplateOption() alertmanagertemplate.Option {
|
||||
return func(text *tmpltext.Template, html *tmplhtml.Template) {
|
||||
funcs := tmpltext.FuncMap{
|
||||
// urlescape escapes the string for use in a URL query parameter.
|
||||
// It returns tmplhtml.HTML to prevent the template engine from escaping the already escaped string.
|
||||
// url.QueryEscape escapes spaces as "+", and html/template escapes "+" as "+" if tmplhtml.HTML is not used.
|
||||
"urlescape": func(value string) tmplhtml.HTML {
|
||||
return tmplhtml.HTML(url.QueryEscape(value))
|
||||
},
|
||||
}
|
||||
text.Funcs(funcs)
|
||||
html.Funcs(funcs)
|
||||
text.Funcs(AdditionalFuncMap())
|
||||
html.Funcs(AdditionalFuncMap())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,4 +9,20 @@ const (
|
||||
LabelSeverityName = "severity"
|
||||
LabelLastSeen = "lastSeen"
|
||||
LabelRuleID = "ruleId"
|
||||
LabelRuleSource = "ruleSource"
|
||||
LabelNoData = "nodata"
|
||||
LabelTestAlert = "testalert"
|
||||
LabelAlertName = "alertname"
|
||||
LabelIsRecovering = "is_recovering"
|
||||
)
|
||||
|
||||
const (
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationTitleTemplate = "title_template"
|
||||
AnnotationBodyTemplate = "body_template"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
)
|
||||
|
||||
@@ -8,11 +8,10 @@ import (
|
||||
type Config struct {
|
||||
// Whether the web package is enabled.
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
|
||||
// The name of the index file to serve.
|
||||
Index string `mapstructure:"index"`
|
||||
|
||||
// The directory from which to serve the web files.
|
||||
// The prefix to serve the files from
|
||||
Prefix string `mapstructure:"prefix"`
|
||||
// The directory containing the static build files. The root of this directory should
|
||||
// have an index.html file.
|
||||
Directory string `mapstructure:"directory"`
|
||||
}
|
||||
|
||||
@@ -23,7 +22,7 @@ func NewConfigFactory() factory.ConfigFactory {
|
||||
func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Enabled: true,
|
||||
Index: "index.html",
|
||||
Prefix: "/",
|
||||
Directory: "/etc/signoz/web",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewWithEnvProvider(t *testing.T) {
|
||||
t.Setenv("SIGNOZ_WEB_PREFIX", "/web")
|
||||
t.Setenv("SIGNOZ_WEB_ENABLED", "false")
|
||||
|
||||
conf, err := config.New(
|
||||
@@ -36,7 +37,7 @@ func TestNewWithEnvProvider(t *testing.T) {
|
||||
|
||||
expected := &Config{
|
||||
Enabled: false,
|
||||
Index: def.Index,
|
||||
Prefix: "/web",
|
||||
Directory: def.Directory,
|
||||
}
|
||||
|
||||
|
||||
@@ -8,55 +8,56 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const (
|
||||
indexFileName string = "index.html"
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
config web.Config
|
||||
indexContents []byte
|
||||
fileHandler http.Handler
|
||||
config web.Config
|
||||
}
|
||||
|
||||
func NewFactory(globalConfig global.Config) factory.ProviderFactory[web.Web, web.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("router"), func(ctx context.Context, settings factory.ProviderSettings, config web.Config) (web.Web, error) {
|
||||
return New(ctx, settings, config, globalConfig)
|
||||
})
|
||||
func NewFactory() factory.ProviderFactory[web.Web, web.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("router"), New)
|
||||
}
|
||||
|
||||
func New(ctx context.Context, settings factory.ProviderSettings, config web.Config, globalConfig global.Config) (web.Web, error) {
|
||||
func New(ctx context.Context, settings factory.ProviderSettings, config web.Config) (web.Web, error) {
|
||||
fi, err := os.Stat(config.Directory)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot access web directory")
|
||||
}
|
||||
|
||||
if !fi.IsDir() {
|
||||
ok := fi.IsDir()
|
||||
if !ok {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "web directory is not a directory")
|
||||
}
|
||||
|
||||
indexPath := filepath.Join(config.Directory, config.Index)
|
||||
raw, err := os.ReadFile(indexPath)
|
||||
fi, err = os.Stat(filepath.Join(config.Directory, indexFileName))
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot read %q in web directory", config.Index)
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot access %q in web directory", indexFileName)
|
||||
}
|
||||
|
||||
logger := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/web/routerweb").Logger()
|
||||
indexContents := web.NewIndex(ctx, logger, config.Index, raw, web.TemplateData{BaseHref: globalConfig.ExternalPathTrailing()})
|
||||
if os.IsNotExist(err) || fi.IsDir() {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "%q does not exist", indexFileName)
|
||||
}
|
||||
|
||||
return &provider{
|
||||
config: config,
|
||||
indexContents: indexContents,
|
||||
fileHandler: http.FileServer(http.Dir(config.Directory)),
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
cache := middleware.NewCache(0)
|
||||
err := router.PathPrefix("/").
|
||||
err := router.PathPrefix(provider.config.Prefix).
|
||||
Handler(
|
||||
cache.Wrap(http.HandlerFunc(provider.ServeHTTP)),
|
||||
http.StripPrefix(
|
||||
provider.config.Prefix,
|
||||
cache.Wrap(http.HandlerFunc(provider.ServeHTTP)),
|
||||
),
|
||||
).GetError()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "unable to add web to router")
|
||||
@@ -74,7 +75,7 @@ func (provider *provider) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
if err != nil {
|
||||
// if the file doesn't exist, serve index.html
|
||||
if os.IsNotExist(err) {
|
||||
provider.serveIndex(rw)
|
||||
http.ServeFile(rw, req, filepath.Join(provider.config.Directory, indexFileName))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,15 +87,10 @@ func (provider *provider) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
if fi.IsDir() {
|
||||
// path is a directory, serve index.html
|
||||
provider.serveIndex(rw)
|
||||
http.ServeFile(rw, req, filepath.Join(provider.config.Directory, indexFileName))
|
||||
return
|
||||
}
|
||||
|
||||
// otherwise, use http.FileServer to serve the static file
|
||||
provider.fileHandler.ServeHTTP(rw, req)
|
||||
}
|
||||
|
||||
func (provider *provider) serveIndex(rw http.ResponseWriter) {
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = rw.Write(provider.indexContents)
|
||||
http.FileServer(http.Dir(provider.config.Directory)).ServeHTTP(rw, req)
|
||||
}
|
||||
|
||||
@@ -5,113 +5,45 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func startServer(t *testing.T, config web.Config, globalConfig global.Config) string {
|
||||
t.Helper()
|
||||
func TestServeHttpWithoutPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
fi, err := os.Open(filepath.Join("testdata", indexFileName))
|
||||
require.NoError(t, err)
|
||||
|
||||
web, err := New(context.Background(), factorytest.NewSettings(), config, globalConfig)
|
||||
expected, err := io.ReadAll(fi)
|
||||
require.NoError(t, err)
|
||||
|
||||
web, err := New(context.Background(), factorytest.NewSettings(), web.Config{Prefix: "/", Directory: filepath.Join("testdata")})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := mux.NewRouter()
|
||||
require.NoError(t, web.AddToRouter(router))
|
||||
err = web.AddToRouter(router)
|
||||
require.NoError(t, err)
|
||||
|
||||
listener, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
server := &http.Server{Handler: router}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(func() { _ = server.Close() })
|
||||
|
||||
return "http://" + listener.Addr().String()
|
||||
}
|
||||
|
||||
func httpGet(t *testing.T, url string) string {
|
||||
t.Helper()
|
||||
|
||||
res, err := http.DefaultClient.Get(url)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func TestServeTemplatedIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
path string
|
||||
globalConfig global.Config
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "RootBaseHrefAtRoot",
|
||||
path: "/",
|
||||
globalConfig: global.Config{},
|
||||
expected: `<html><head><base href="/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "RootBaseHrefAtNonExistentPath",
|
||||
path: "/does-not-exist",
|
||||
globalConfig: global.Config{},
|
||||
expected: `<html><head><base href="/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "RootBaseHrefAtDirectory",
|
||||
path: "/assets",
|
||||
globalConfig: global.Config{},
|
||||
expected: `<html><head><base href="/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "SubPathBaseHrefAtRoot",
|
||||
path: "/",
|
||||
globalConfig: global.Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
|
||||
expected: `<html><head><base href="/signoz/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "SubPathBaseHrefAtNonExistentPath",
|
||||
path: "/does-not-exist",
|
||||
globalConfig: global.Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
|
||||
expected: `<html><head><base href="/signoz/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "SubPathBaseHrefAtDirectory",
|
||||
path: "/assets",
|
||||
globalConfig: global.Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
|
||||
expected: `<html><head><base href="/signoz/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
},
|
||||
server := &http.Server{
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
base := startServer(t, web.Config{Index: "valid_template.html", Directory: "testdata"}, testCase.globalConfig)
|
||||
|
||||
assert.Equal(t, testCase.expected, strings.TrimSuffix(httpGet(t, base+testCase.path), "\n"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeNoTemplateIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected, err := os.ReadFile(filepath.Join("testdata", "no_template.html"))
|
||||
require.NoError(t, err)
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
defer func() {
|
||||
_ = server.Close()
|
||||
}()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -122,7 +54,11 @@ func TestServeNoTemplateIndex(t *testing.T) {
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
name: "NonExistentPath",
|
||||
name: "Index",
|
||||
path: "/" + indexFileName,
|
||||
},
|
||||
{
|
||||
name: "DoesNotExist",
|
||||
path: "/does-not-exist",
|
||||
},
|
||||
{
|
||||
@@ -131,55 +67,104 @@ func TestServeNoTemplateIndex(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
base := startServer(t, web.Config{Index: "no_template.html", Directory: "testdata"}, global.Config{})
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res, err := http.DefaultClient.Get("http://" + listener.Addr().String() + tc.path)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, string(expected), httpGet(t, base+testCase.path))
|
||||
defer func() {
|
||||
_ = res.Body.Close()
|
||||
}()
|
||||
|
||||
actual, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expected, actual)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestServeInvalidTemplateIndex(t *testing.T) {
|
||||
func TestServeHttpWithPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected, err := os.ReadFile(filepath.Join("testdata", "invalid_template.html"))
|
||||
fi, err := os.Open(filepath.Join("testdata", indexFileName))
|
||||
require.NoError(t, err)
|
||||
|
||||
expected, err := io.ReadAll(fi)
|
||||
require.NoError(t, err)
|
||||
|
||||
web, err := New(context.Background(), factorytest.NewSettings(), web.Config{Prefix: "/web", Directory: filepath.Join("testdata")})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := mux.NewRouter()
|
||||
err = web.AddToRouter(router)
|
||||
require.NoError(t, err)
|
||||
|
||||
listener, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
server := &http.Server{
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
defer func() {
|
||||
_ = server.Close()
|
||||
}()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
path string
|
||||
name string
|
||||
path string
|
||||
found bool
|
||||
}{
|
||||
{
|
||||
name: "Root",
|
||||
path: "/",
|
||||
name: "Root",
|
||||
path: "/web",
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "NonExistentPath",
|
||||
path: "/does-not-exist",
|
||||
name: "Index",
|
||||
path: "/web/" + indexFileName,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "Directory",
|
||||
path: "/assets",
|
||||
name: "FileDoesNotExist",
|
||||
path: "/web/does-not-exist",
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "Directory",
|
||||
path: "/web/assets",
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "DoesNotExist",
|
||||
path: "/does-not-exist",
|
||||
found: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
base := startServer(t, web.Config{Index: "invalid_template.html", Directory: "testdata"}, global.Config{ExternalURL: &url.URL{Path: "/signoz"}})
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res, err := http.DefaultClient.Get("http://" + listener.Addr().String() + tc.path)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
_ = res.Body.Close()
|
||||
}()
|
||||
|
||||
if tc.found {
|
||||
actual, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expected, actual)
|
||||
} else {
|
||||
assert.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
}
|
||||
|
||||
assert.Equal(t, string(expected), httpGet(t, base+testCase.path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStaticFilesUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected, err := os.ReadFile(filepath.Join("testdata", "assets", "style.css"))
|
||||
require.NoError(t, err)
|
||||
|
||||
base := startServer(t, web.Config{Index: "valid_template.html", Directory: "testdata"}, global.Config{ExternalURL: &url.URL{Path: "/signoz"}})
|
||||
|
||||
assert.Equal(t, string(expected), httpGet(t, base+"/assets/style.css"))
|
||||
|
||||
}
|
||||
|
||||
3
pkg/web/routerweb/testdata/assets/index.css
vendored
Normal file
3
pkg/web/routerweb/testdata/assets/index.css
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
#root {
|
||||
background-color: red;
|
||||
}
|
||||
1
pkg/web/routerweb/testdata/assets/style.css
vendored
1
pkg/web/routerweb/testdata/assets/style.css
vendored
@@ -1 +0,0 @@
|
||||
body { color: red; }
|
||||
1
pkg/web/routerweb/testdata/index.html
vendored
Normal file
1
pkg/web/routerweb/testdata/index.html
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<h1>Welcome to test data!!!</h1>
|
||||
@@ -1 +0,0 @@
|
||||
<html><head><base href="[[." /></head><body>Bad template</body></html>
|
||||
1
pkg/web/routerweb/testdata/no_template.html
vendored
1
pkg/web/routerweb/testdata/no_template.html
vendored
@@ -1 +0,0 @@
|
||||
<html><head></head><body>No template here</body></html>
|
||||
@@ -1 +0,0 @@
|
||||
<html><head><base href="[[.BaseHref]]" /></head><body>Welcome to test data!!!</body></html>
|
||||
@@ -1,42 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"text/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// Field names map to the HTML attributes they populate in the template:
|
||||
// - BaseHref → <base href="[[.BaseHref]]" />
|
||||
type TemplateData struct {
|
||||
BaseHref string
|
||||
}
|
||||
|
||||
// If the template cannot be parsed or executed, the raw bytes are
|
||||
// returned unchanged and the error is logged.
|
||||
func NewIndex(ctx context.Context, logger *slog.Logger, name string, raw []byte, data TemplateData) []byte {
|
||||
result, err := NewIndexE(name, raw, data)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "cannot render index template, serving raw file", slog.String("name", name), errors.Attr(err))
|
||||
return raw
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func NewIndexE(name string, raw []byte, data TemplateData) ([]byte, error) {
|
||||
tmpl, err := template.New(name).Delims("[[", "]]").Parse(string(raw))
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot parse %q as template", name)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot execute template for %q", name)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user