Compare commits

..

14 Commits

Author SHA1 Message Date
Vinícius Lourenço
735e9b1ee6 fix(window.open): missing few places 2026-04-21 11:24:05 -03:00
SagarRajput-7
06e521ad97 Merge branch 'base-path-config-setup-1' into base-path-task-phase-2 2026-04-21 11:04:29 +05:30
SagarRajput-7
a9dcad863b Merge branch 'main' into base-path-config-setup-1 2026-04-21 11:04:19 +05:30
SagarRajput-7
72db77b068 feat(base-path): migrate remaining pattern for window.location.origin + path 2026-04-21 10:33:24 +05:30
SagarRajput-7
4f84f07494 feat(base-path): replace window.open with openInNewTab for internal paths 2026-04-21 10:28:57 +05:30
SagarRajput-7
a9e09ee349 feat: code refactor around feedbacks 2026-04-21 10:25:02 +05:30
SagarRajput-7
0a9bb6ba0b feat: applied suggested patch changes 2026-04-21 10:24:51 +05:30
SagarRajput-7
6664a0fae3 feat: code refactor around feedbacks 2026-04-21 10:24:38 +05:30
SagarRajput-7
040dcb9c9b feat: updated base path utils and fixed navigation and translations 2026-04-21 10:24:23 +05:30
SagarRajput-7
4a39453826 feat: updated the html template 2026-04-21 10:24:12 +05:30
SagarRajput-7
ac4db09ec6 feat: removed plugin and serving the index.html only as the template 2026-04-21 10:24:01 +05:30
SagarRajput-7
a691e6a775 feat: refactor the interceptor and added gotmpl into gitignore 2026-04-21 10:23:51 +05:30
SagarRajput-7
d270a3807b feat: changed output path to dir level 2026-04-21 10:23:42 +05:30
SagarRajput-7
c2b553d26c feat: base path config setup and plugin for gotmpl generation at build time 2026-04-21 10:23:33 +05:30
58 changed files with 596 additions and 4130 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -66,6 +66,8 @@ module.exports = {
rules: {
// Asset migration — base-path safety
'rulesdir/no-unsupported-asset-pattern': 'error',
// Base-path safety — window.open and origin-concat patterns; upgrade to error coming PR
'rulesdir/no-raw-absolute-path': 'warn',
// Code quality rules
'prefer-const': 'error', // Enforces const for variables never reassigned

View File

@@ -0,0 +1,153 @@
'use strict';
/**
* ESLint rule: no-raw-absolute-path
*
* Catches patterns that break at runtime when the app is served from a
* sub-path (e.g. /signoz/):
*
* 1. window.open(path, '_blank')
* → use openInNewTab(path) which calls withBasePath internally
*
* 2. window.location.origin + path / `${window.location.origin}${path}`
* → use getAbsoluteUrl(path)
*
* 3. frontendBaseUrl: window.location.origin (bare origin usage)
* → use getBaseUrl() to include the base path
*
* 4. window.location.href = path
* → use withBasePath(path) or navigate() for internal navigation
*
* External URLs (first arg starts with "http") are explicitly allowed.
*/
function isOriginAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'origin' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isHrefAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'href' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isExternalUrl(node) {
if (node.type === 'Literal' && typeof node.value === 'string') {
return node.value.startsWith('http://') || node.value.startsWith('https://');
}
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) {
const raw = node.quasis[0].value.raw;
return raw.startsWith('http://') || raw.startsWith('https://');
}
return false;
}
// window.open(withBasePath(x)) and window.open(getAbsoluteUrl(x)) are already safe.
function isSafeHelperCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'withBasePath' || node.callee.name === 'getAbsoluteUrl')
);
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow raw window.open and origin-concatenation patterns that miss the runtime base path',
category: 'Base Path Safety',
},
schema: [],
messages: {
windowOpen:
'Use openInNewTab(path) instead of window.open(path, "_blank") — openInNewTab prepends the base path automatically.',
originConcat:
'Use getAbsoluteUrl(path) instead of window.location.origin + path — getAbsoluteUrl prepends the base path automatically.',
originDirect:
'Use getBaseUrl() instead of window.location.origin — getBaseUrl includes the base path.',
hrefAssign:
'Use withBasePath(path) or navigate() instead of window.location.href = path — ensures the base path is included.',
},
},
create(context) {
return {
// window.open(path, ...) — allow only external first-arg URLs
CallExpression(node) {
const { callee, arguments: args } = node;
if (
callee.type !== 'MemberExpression' ||
callee.object.type !== 'Identifier' ||
callee.object.name !== 'window' ||
callee.property.name !== 'open'
)
return;
if (args.length < 1) return;
if (isExternalUrl(args[0])) return;
if (isSafeHelperCall(args[0])) return;
context.report({ node, messageId: 'windowOpen' });
},
// window.location.origin + path
BinaryExpression(node) {
if (node.operator !== '+') return;
if (isOriginAccess(node.left) || isOriginAccess(node.right)) {
context.report({ node, messageId: 'originConcat' });
}
},
// `${window.location.origin}${path}`
TemplateLiteral(node) {
if (node.expressions.some(isOriginAccess)) {
context.report({ node, messageId: 'originConcat' });
}
},
// window.location.origin used directly (not in concatenation)
// Catches: frontendBaseUrl: window.location.origin
MemberExpression(node) {
if (!isOriginAccess(node)) return;
const parent = node.parent;
// Skip if parent is BinaryExpression with + (handled by BinaryExpression visitor)
if (parent.type === 'BinaryExpression' && parent.operator === '+') return;
// Skip if inside TemplateLiteral (handled by TemplateLiteral visitor)
if (parent.type === 'TemplateLiteral') return;
context.report({ node, messageId: 'originDirect' });
},
// window.location.href = path
AssignmentExpression(node) {
if (node.operator !== '=') return;
if (!isHrefAccess(node.left)) return;
// Allow external URLs
if (isExternalUrl(node.right)) return;
// Allow safe helper calls
if (isSafeHelperCall(node.right)) return;
context.report({ node, messageId: 'hrefAssign' });
},
};
},
};

View File

@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="[[.BaseHref]]" />
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate, max-age: 0"
@@ -59,7 +60,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">
<script>
@@ -136,7 +137,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>

View File

@@ -2,6 +2,7 @@ import { initReactI18next } from 'react-i18next';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getBasePath } from 'utils/basePath';
import cacheBursting from '../../i18n-translations-hash.json';
@@ -24,7 +25,7 @@ i18n
const ns = namespace[0];
const pathkey = `/${language}/${ns}`;
const hash = cacheBursting[pathkey as keyof typeof cacheBursting] || '';
return `/locales/${language}/${namespace}.json?h=${hash}`;
return `${getBasePath()}locales/${language}/${namespace}.json?h=${hash}`;
},
},
react: {

View File

@@ -4774,7 +4774,7 @@ export interface RuletypesPostableRuleDTO {
* @type string
*/
alert: string;
alertType: RuletypesAlertTypeDTO;
alertType?: RuletypesAlertTypeDTO;
/**
* @type object
*/
@@ -4899,7 +4899,7 @@ export interface RuletypesRuleDTO {
* @type string
*/
alert: string;
alertType: RuletypesAlertTypeDTO;
alertType?: RuletypesAlertTypeDTO;
/**
* @type object
*/
@@ -4984,8 +4984,8 @@ export interface RuletypesRuleConditionDTO {
*/
algorithm?: string;
compositeQuery: RuletypesAlertCompositeQueryDTO;
matchType?: RuletypesMatchTypeDTO;
op?: RuletypesCompareOperatorDTO;
matchType: RuletypesMatchTypeDTO;
op: RuletypesCompareOperatorDTO;
/**
* @type boolean
*/

View File

@@ -1,5 +1,6 @@
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
@@ -17,6 +18,7 @@ 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,

View File

@@ -11,6 +11,7 @@ import axios, {
import { ENVIRONMENT } from 'constants/env';
import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getBasePath } from 'utils/basePath';
import { eventEmitter } from 'utils/getEventEmitter';
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
@@ -67,6 +68,39 @@ export const interceptorsRequestResponse = (
return value;
};
// Strips the leading '/' from path and joins with base — idempotent if already prefixed.
// e.g. prependBase('/signoz/', '/api/v1/') → '/signoz/api/v1/'
function prependBase(base: string, path: string): string {
return path.startsWith(base) ? path : base + path.slice(1);
}
// 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('/')) {
// Production relative baseURL: '/api/v1/' → '/signoz/api/v1/'
value.baseURL = prependBase(basePath, value.baseURL);
} else if (value.baseURL?.startsWith('http')) {
// Dev absolute baseURL (VITE_FRONTEND_API_ENDPOINT): 'https://host/api/v1/' → 'https://host/signoz/api/v1/'
const url = new URL(value.baseURL);
url.pathname = prependBase(basePath, url.pathname);
value.baseURL = url.toString();
} else if (!value.baseURL && value.url?.startsWith('/')) {
// Orval-generated client (empty baseURL, path in url): '/api/signoz/v1/rules' → '/signoz/api/signoz/v1/rules'
value.url = prependBase(basePath, value.url);
}
return value;
};
export const interceptorRejected = async (
value: AxiosResponse<any>,
): Promise<AxiosResponse<any>> => {
@@ -133,6 +167,7 @@ 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({
@@ -147,6 +182,7 @@ ApiV2Instance.interceptors.response.use(
interceptorRejected,
);
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
// axios V3
export const ApiV3Instance = axios.create({
@@ -158,6 +194,7 @@ ApiV3Instance.interceptors.response.use(
interceptorRejected,
);
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V4
@@ -170,6 +207,7 @@ ApiV4Instance.interceptors.response.use(
interceptorRejected,
);
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V5
@@ -182,6 +220,7 @@ ApiV5Instance.interceptors.response.use(
interceptorRejected,
);
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios Base
@@ -194,6 +233,7 @@ LogEventAxiosInstance.interceptors.response.use(
interceptorRejectedBase,
);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
//
AxiosAlertManagerInstance.interceptors.response.use(
@@ -201,6 +241,7 @@ AxiosAlertManagerInstance.interceptors.response.use(
interceptorRejected,
);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
export { apiV1 };
export default instance;

View File

@@ -12,6 +12,8 @@ import { AppState } from 'store/reducers';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
export interface NavigateToExplorerProps {
filters: TagFilterItem[];
@@ -133,7 +135,11 @@ export function useNavigateToExplorer(): (
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
window.open(newExplorerPath, sameTab ? '_self' : '_blank');
if (sameTab) {
window.location.href = withBasePath(newExplorerPath);
} else {
openInNewTab(newExplorerPath);
}
},
[
prepareQuery,

View File

@@ -13,6 +13,7 @@ import GetMinMax from 'lib/getMinMax';
import { Check, Info, Link2 } from 'lucide-react';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
const routesToBeSharedWithTime = [
ROUTES.LOGS_EXPLORER,
@@ -80,17 +81,13 @@ function ShareURLModal(): JSX.Element {
urlQuery.delete(QueryParams.relativeTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
}
}

View File

@@ -9,6 +9,7 @@ import {
} from 'container/ApiMonitoring/utils';
import { UnfoldVertical } from 'lucide-react';
import { SuccessResponse } from 'types/api';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
@@ -94,20 +95,14 @@ function DependentServices({
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
const url = new URL(
`/services/${
record.serviceData.serviceName &&
record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: ''
}`,
window.location.origin,
);
const serviceName =
record.serviceData.serviceName && record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: '';
const urlQuery = new URLSearchParams();
urlQuery.set(QueryParams.startTime, timeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, timeRange.endTime.toString());
url.search = urlQuery.toString();
window.open(url.toString(), '_blank');
openInNewTab(`/services/${serviceName}?${urlQuery.toString()}`);
},
className: 'clickable-row',
})}

View File

@@ -14,6 +14,7 @@ import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { ROUTING_POLICIES_ROUTE } from './constants';
import { RoutingPolicyBannerProps } from './types';
@@ -387,7 +388,7 @@ export function NotificationChannelsNotFoundContent({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
}}
>
here.

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
@@ -30,7 +30,6 @@ export default function ChartWrapper({
onDestroy = noop,
children,
layoutChildren,
yAxisUnit,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
@@ -63,13 +62,6 @@ export default function ChartWrapper({
[customTooltip],
);
const syncMetadata = useMemo(
() => ({
yAxisUnit,
}),
[yAxisUnit],
);
return (
<PlotContextProvider>
<ChartLayout
@@ -107,7 +99,6 @@ export default function ChartWrapper({
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
syncMetadata={syncMetadata}
render={renderTooltipCallback}
pinnedTooltipElement={pinnedTooltipElement}
/>

View File

@@ -24,12 +24,13 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
}
const tooltipProps: HistogramTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (

View File

@@ -12,7 +12,10 @@ interface BaseChartProps {
height: number;
showTooltip?: boolean;
showLegend?: boolean;
timezone?: Timezone;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
@@ -29,31 +32,18 @@ interface UPlotBasedChartProps {
layoutChildren?: React.ReactNode;
}
interface UPlotChartDataProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}
export interface TimeSeriesChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
timezone?: Timezone;
}
UPlotBasedChartProps {}
export interface HistogramChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
UPlotBasedChartProps {
isQueriesMerged?: boolean;
}
export interface BarChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
isStackedBarChart?: boolean;
timezone?: Timezone;
}
export type ChartProps =

View File

@@ -123,13 +123,13 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
}}
plotRef={onPlotRef}
onDestroy={onPlotDestroy}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
>
<ContextMenu

View File

@@ -3,6 +3,8 @@ import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
@@ -27,6 +29,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const config = useMemo(() => {
return prepareHistogramPanelConfig({
@@ -89,9 +92,11 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
onDestroy={(): void => {
uPlotRef.current = null;
}}
isQueriesMerged={widget.mergeAllActiveQueries}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
isQueriesMerged={widget.mergeAllActiveQueries}
syncMode={DashboardCursorSync.Crosshair}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -48,8 +48,8 @@ jest.mock(
{JSON.stringify({
legendPosition: props.legendConfig?.position,
isQueriesMerged: props.isQueriesMerged,
yAxisUnit: props?.yAxisUnit,
decimalPrecision: props?.decimalPrecision,
yAxisUnit: props.yAxisUnit,
decimalPrecision: props.decimalPrecision,
})}
</div>
{props.layoutChildren}

View File

@@ -112,9 +112,9 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
timezone={timezone}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -15,6 +15,7 @@ import { AlertDef, Labels } from 'types/api/alerts/def';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
import { popupContainer } from 'utils/selectPopupContainer';
import ChannelSelect from './ChannelSelect';
@@ -87,7 +88,7 @@ function BasicInfo({
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
ruleId: isNewRule ? 0 : alertDef?.id,
});
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasLoggedEvent = useRef(false);

View File

@@ -10,6 +10,7 @@ import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { Dashboard } from 'types/api/dashboard/getAll';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import dialsUrl from '@/assets/Icons/dials.svg';
@@ -114,7 +115,7 @@ export default function Dashboards({
dashboardName: dashboard.data.title,
});
if (event.metaKey || event.ctrlKey) {
window.open(getLink(), '_blank');
openInNewTab(getLink());
} else {
safeNavigate(getLink());
}

View File

@@ -51,6 +51,7 @@ import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuidv4 } from 'uuid';
import { filterDuplicateFilters } from '../commonUtils';
@@ -569,10 +570,7 @@ function K8sBaseDetails<T>({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
const compositeQuery = {
...initialQueryState,
@@ -591,10 +589,7 @@ function K8sBaseDetails<T>({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}
};

View File

@@ -4,6 +4,7 @@ import {
CloudintegrationtypesServiceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { openInNewTab } from 'utils/navigation';
import './ServiceDashboards.styles.scss';
@@ -44,7 +45,7 @@ function ServiceDashboards({
return;
}
if (event.metaKey || event.ctrlKey) {
window.open(dashboardUrl, '_blank', 'noopener,noreferrer');
openInNewTab(dashboardUrl);
return;
}
safeNavigate(dashboardUrl);
@@ -54,7 +55,7 @@ function ServiceDashboards({
return;
}
if (event.button === 1) {
window.open(dashboardUrl, '_blank', 'noopener,noreferrer');
openInNewTab(dashboardUrl);
}
}}
onKeyDown={(event): void => {

View File

@@ -83,6 +83,8 @@ import {
} from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import dashboardsUrl from '@/assets/Icons/dashboards.svg';
@@ -457,7 +459,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
window.open(getLink(), '_blank');
openInNewTab(getLink());
}}
>
Open in New Tab
@@ -469,7 +471,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(`${window.location.origin}${getLink()}`);
setCopy(getAbsoluteUrl(getLink()));
}}
>
Copy Link

View File

@@ -1,6 +1,7 @@
import { LockFilled } from '@ant-design/icons';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
import { Data } from '../DashboardsList';
import { TableLinkText } from './styles';
@@ -12,7 +13,7 @@ function Name(name: Data['name'], data: Data): JSX.Element {
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
if (event.metaKey || event.ctrlKey) {
window.open(getLink(), '_blank');
openInNewTab(getLink());
} else {
history.push(getLink());
}

View File

@@ -17,6 +17,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { useContextLogData } from './useContextLogData';
@@ -116,7 +117,7 @@ function ContextLogRenderer({
);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(link, '_blank', 'noopener,noreferrer');
openInNewTab(link);
},
[query, urlQuery],
);

View File

@@ -34,6 +34,7 @@ import { SET_DETAILED_LOG_DATA } from 'types/actions/logs';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { openInNewTab } from 'utils/navigation';
import { ActionItemProps } from './ActionItem';
import FieldRenderer from './FieldRenderer';
@@ -191,7 +192,7 @@ function TableView({
if (event.ctrlKey || event.metaKey) {
// open the trace in new tab
window.open(route, '_blank');
openInNewTab(route);
} else {
history.push(route);
}

View File

@@ -34,6 +34,7 @@ import ROUTES from 'constants/routes';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDragColumns from 'hooks/useDragColumns';
import { getAbsoluteUrl } from 'utils/basePath';
import { infinityDefaultStyles } from '../InfinityTableView/config';
import { TanStackTableStyled } from '../InfinityTableView/styles';
@@ -239,7 +240,7 @@ const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });

View File

@@ -1,5 +1,6 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { openInNewTab as openInNewTabUtil } from 'utils/navigation';
import { TopOperationList } from './TopOperationsTable';
import { NavigateToTraceProps } from './types';
@@ -37,7 +38,7 @@ export const navigateToTrace = ({
}=${JSONCompositeQuery}`;
if (openInNewTab) {
window.open(newTraceExplorerPath, '_blank');
openInNewTabUtil(newTraceExplorerPath);
} else {
safeNavigate(newTraceExplorerPath);
}

View File

@@ -9,6 +9,7 @@ import {
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { Bell, Grid } from 'lucide-react';
import { openInNewTab } from 'utils/navigation';
import { pluralize } from 'utils/pluralize';
import { DashboardsAndAlertsPopoverProps } from './types';
@@ -67,9 +68,8 @@ function DashboardsAndAlertsPopover({
<Typography.Link
key={alert.alertId}
onClick={(): void => {
window.open(
openInNewTab(
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=${alert.alertId}`,
'_blank',
);
}}
className="dashboards-popover-content-item"
@@ -90,11 +90,10 @@ function DashboardsAndAlertsPopover({
<Typography.Link
key={dashboard.dashboardId}
onClick={(): void => {
window.open(
openInNewTab(
generatePath(ROUTES.DASHBOARD, {
dashboardId: dashboard.dashboardId,
}),
'_blank',
);
}}
className="dashboards-popover-content-item"

View File

@@ -14,6 +14,7 @@ import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { openInNewTab } from 'utils/navigation';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
@@ -115,7 +116,7 @@ const useBaseAggregateOptions = ({
key={id}
icon={<LinkOutlined />}
onClick={(): void => {
window.open(url, '_blank');
openInNewTab(url);
onClose?.();
}}
>

View File

@@ -14,6 +14,7 @@ import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';
import { Check, Loader, X } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE } from './constants';
import {
@@ -76,7 +77,7 @@ function RoutingPolicyDetails({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
}}
>
here.

View File

@@ -28,6 +28,7 @@ import {
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuid } from 'uuid';
import noDataUrl from '@/assets/Icons/no-data.svg';
@@ -143,7 +144,7 @@ function SpanLogs({
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
window.open(url, '_blank');
openInNewTab(url);
},
[
isLogSpanRelated,

View File

@@ -17,6 +17,7 @@ import { BarChart2, Compass, X } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
@@ -157,13 +158,7 @@ function SpanRelatedSignals({
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
`${window.location.origin}${
ROUTES.LOGS_EXPLORER
}?${searchParams.toString()}`,
'_blank',
'noopener,noreferrer',
);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
const emptyStateConfig = useMemo(

View File

@@ -31,6 +31,7 @@ import {
UPDATE_SPANS_AGGREGATE_PAGE_SIZE,
} from 'types/actions/trace';
import { TraceReducer } from 'types/reducer/trace';
import { openInNewTab } from 'utils/navigation';
import { v4 } from 'uuid';
dayjs.extend(duration);
@@ -214,7 +215,7 @@ function TraceTable(): JSX.Element {
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
window.open(getLink(record), '_blank');
openInNewTab(getLink(record));
} else {
history.push(getLink(record));
}

View File

@@ -28,6 +28,7 @@ import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { openInNewTab } from 'utils/navigation';
import './TracesTableComponent.styles.scss';
@@ -86,7 +87,7 @@ function TracesTableComponent({
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
window.open(getTraceLink(record), '_blank');
openInNewTab(getTraceLink(record));
} else {
history.push(getTraceLink(record));
}

View File

@@ -17,6 +17,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
import { HIGHLIGHTED_DELAY } from './configs';
import { UseCopyLogLink } from './types';
@@ -60,7 +61,7 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
urlQuery.set(QueryParams.startTime, minTime?.toString() || '');
urlQuery.set(QueryParams.endTime, maxTime?.toString() || '');
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);

View File

@@ -22,6 +22,7 @@ import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
import { openInNewTab } from 'utils/navigation';
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const queryRangeMutation = useMutation(getSubstituteVars);
@@ -92,7 +93,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const url = `${ROUTES.ALERTS_NEW}?${params.toString()}`;
window.open(url, '_blank', 'noreferrer');
openInNewTab(url);
},
onError: () => {
notifications.error({

View File

@@ -4,6 +4,7 @@ import { useCopyToClipboard } from 'react-use';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { Span } from 'types/api/trace/getTraceV2';
import { getAbsoluteUrl } from 'utils/basePath';
export const useCopySpanLink = (
span?: Span,
@@ -28,7 +29,7 @@ export const useCopySpanLink = (
urlQuery.set('spanId', span?.spanId);
}
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
notifications.success({

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
replace?: boolean;
@@ -130,7 +131,7 @@ export const useSafeNavigate = (
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
window.open(withBasePath(targetPath), '_blank');
return;
}

View File

@@ -1,3 +1,4 @@
import { createBrowserHistory } from 'history';
import { getBasePath } from 'utils/basePath';
export default createBrowserHistory();
export default createBrowserHistory({ basename: getBasePath() });

View File

@@ -62,10 +62,10 @@ export interface TooltipRenderArgs {
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
timezone?: Timezone;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];
timezone?: Timezone;
}
export interface TimeSeriesTooltipProps

View File

@@ -4,7 +4,6 @@ import cx from 'classnames';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import uPlot from 'uplot';
import { syncCursorRegistry } from './syncCursorRegistry';
import {
createInitialControllerState,
createSetCursorHandler,
@@ -41,7 +40,6 @@ export default function TooltipPlugin({
maxHeight = 600,
syncMode = DashboardCursorSync.None,
syncKey = '_tooltip_sync_global_',
syncMetadata,
pinnedTooltipElement,
canPinTooltip = false,
}: TooltipPluginProps): JSX.Element | null {
@@ -102,29 +100,7 @@ export default function TooltipPlugin({
// crosshair / tooltip can follow the dashboard-wide cursor.
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
config.setCursor({
sync: { key: syncKey, scales: ['x', 'y'] },
});
// Show the horizontal crosshair only when the receiving panel shares
// the same y-axis unit as the source panel. When this panel is the
// source (cursor.event != null) the line is always shown and this
// panel's metadata is written to the registry so receivers can read it.
config.addHook('setCursor', (u: uPlot): void => {
const yCursorEl = u.root.querySelector<HTMLElement>('.u-cursor-y');
if (!yCursorEl) {
return;
}
if (u.cursor.event != null) {
// This panel is the source — publish metadata and always show line.
syncCursorRegistry.setMetadata(syncKey, syncMetadata);
yCursorEl.style.display = '';
} else {
// This panel is receiving sync — show only if units match.
const sourceMeta = syncCursorRegistry.getMetadata(syncKey);
yCursorEl.style.display =
sourceMeta?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
}
sync: { key: syncKey, scales: ['x', null] },
});
}

View File

@@ -1,24 +0,0 @@
import type { TooltipSyncMetadata } from './types';
/**
* Module-level registry that tracks the metadata of the panel currently
* acting as the cursor source (the one being hovered) per sync group.
*
* uPlot fires the source panel's setCursor hook before broadcasting to
* receivers, so the registry is always populated before receivers read it.
*
* Receivers use this to make decisions such as:
* - Whether to show the horizontal crosshair line (matching yAxisUnit)
* - Future: what to render inside the tooltip (matching groupBy, etc.)
*/
const metadataBySyncKey = new Map<string, TooltipSyncMetadata | undefined>();
export const syncCursorRegistry = {
setMetadata(syncKey: string, metadata: TooltipSyncMetadata | undefined): void {
metadataBySyncKey.set(syncKey, metadata);
},
getMetadata(syncKey: string): TooltipSyncMetadata | undefined {
return metadataBySyncKey.get(syncKey);
},
};

View File

@@ -34,16 +34,11 @@ export interface TooltipLayoutInfo {
height: number;
}
export interface TooltipSyncMetadata {
yAxisUnit?: string;
}
export interface TooltipPluginProps {
config: UPlotConfigBuilder;
canPinTooltip?: boolean;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncMetadata?: TooltipSyncMetadata;
render: (args: TooltipRenderArgs) => ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
maxWidth?: number;

View File

@@ -516,7 +516,7 @@ describe('TooltipPlugin', () => {
);
expect(setCursorSpy).toHaveBeenCalledWith({
sync: { key: 'dashboard-sync', scales: ['x', 'y'] },
sync: { key: 'dashboard-sync', scales: ['x', null] },
});
});

View File

@@ -4,6 +4,7 @@ import ROUTES from 'constants/routes';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Home, LifeBuoy } from 'lucide-react';
import { withBasePath } from 'utils/basePath';
import cloudUrl from '@/assets/Images/cloud.svg';
@@ -11,8 +12,8 @@ import './ErrorBoundaryFallback.styles.scss';
function ErrorBoundaryFallback(): JSX.Element {
const handleReload = (): void => {
// Go to home page
window.location.href = ROUTES.HOME;
// Hard reload resets Sentry.ErrorBoundary state; withBasePath preserves any /signoz/ prefix.
window.location.href = withBasePath(ROUTES.HOME);
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();

View File

@@ -19,6 +19,7 @@ import {
} from 'pages/MessagingQueues/MessagingQueuesUtils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { openInNewTab } from 'utils/navigation';
import {
convertToMilliseconds,
@@ -93,7 +94,7 @@ export function getColumns(
key={item}
className="traceid-text"
onClick={(): void => {
window.open(`${ROUTES.TRACE}/${item}`, '_blank');
openInNewTab(`${ROUTES.TRACE}/${item}`);
logEvent(`MQ Kafka: Drop Rate - traceid navigation`, {
item,
});
@@ -123,7 +124,7 @@ export function getColumns(
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
window.open(`/services/${encodeURIComponent(text)}`, '_blank');
openInNewTab(`/services/${encodeURIComponent(text)}`);
}}
>
{text}

View File

@@ -0,0 +1,118 @@
/**
* basePath is memoized at module init, so each describe block isolates the
* module with a fresh DOM state using jest.isolateModules + require.
*/
type BasePath = typeof import('../basePath');
function loadModule(href?: string): BasePath {
if (href !== undefined) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.appendChild(base);
}
let mod!: BasePath;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
mod = require('../basePath');
});
return mod;
}
afterEach(() => {
document.head.querySelectorAll('base').forEach((el) => el.remove());
});
describe('at basePath="/"', () => {
let m: BasePath;
beforeEach(() => {
m = loadModule('/');
});
it('getBasePath returns "/"', () => {
expect(m.getBasePath()).toBe('/');
});
it('withBasePath is a no-op for any internal path', () => {
expect(m.withBasePath('/logs')).toBe('/logs');
expect(m.withBasePath('/logs/explorer')).toBe('/logs/explorer');
});
it('withBasePath passes through external URLs', () => {
expect(m.withBasePath('https://example.com/foo')).toBe(
'https://example.com/foo',
);
});
it('getAbsoluteUrl returns origin + path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(`${window.location.origin}/logs`);
});
it('getBaseUrl returns bare origin', () => {
expect(m.getBaseUrl()).toBe(window.location.origin);
});
});
describe('at basePath="/signoz/"', () => {
let m: BasePath;
beforeEach(() => {
m = loadModule('/signoz/');
});
it('getBasePath returns "/signoz/"', () => {
expect(m.getBasePath()).toBe('/signoz/');
});
it('withBasePath prepends the prefix', () => {
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
expect(m.withBasePath('/logs/explorer')).toBe('/signoz/logs/explorer');
});
it('withBasePath is idempotent — safe to call twice', () => {
expect(m.withBasePath('/signoz/logs')).toBe('/signoz/logs');
});
it('withBasePath is idempotent when path equals the prefix without trailing slash', () => {
expect(m.withBasePath('/signoz')).toBe('/signoz');
});
it('withBasePath passes through external URLs', () => {
expect(m.withBasePath('https://example.com/foo')).toBe(
'https://example.com/foo',
);
});
it('getAbsoluteUrl returns origin + prefixed path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(
`${window.location.origin}/signoz/logs`,
);
});
it('getBaseUrl returns origin + prefix without trailing slash', () => {
expect(m.getBaseUrl()).toBe(`${window.location.origin}/signoz`);
});
});
describe('no <base> tag', () => {
it('getBasePath falls back to "/"', () => {
const m = loadModule();
expect(m.getBasePath()).toBe('/');
});
});
describe('href without trailing slash', () => {
it('normalises to trailing slash', () => {
const m = loadModule('/signoz');
expect(m.getBasePath()).toBe('/signoz/');
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
});
});
describe('nested prefix "/a/b/prefix/"', () => {
it('withBasePath handles arbitrary depth', () => {
const m = loadModule('/a/b/prefix/');
expect(m.withBasePath('/logs')).toBe('/a/b/prefix/logs');
expect(m.withBasePath('/a/b/prefix/logs')).toBe('/a/b/prefix/logs');
});
});

View File

@@ -1,15 +1,27 @@
import { isModifierKeyPressed } from '../app';
import { openInNewTab } from '../navigation';
type NavigationModule = typeof import('../navigation');
function loadNavigationModule(href?: string): NavigationModule {
if (href !== undefined) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.appendChild(base);
}
let mod!: NavigationModule;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
mod = require('../navigation');
});
return mod;
}
describe('navigation utilities', () => {
const originalWindowOpen = window.open;
beforeEach(() => {
window.open = jest.fn();
});
afterEach(() => {
window.open = originalWindowOpen;
document.head.querySelectorAll('base').forEach((el) => el.remove());
});
describe('isModifierKeyPressed', () => {
@@ -56,25 +68,59 @@ describe('navigation utilities', () => {
});
describe('openInNewTab', () => {
it('calls window.open with the given path and _blank target', () => {
openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
describe('at basePath="/"', () => {
let m: NavigationModule;
beforeEach(() => {
window.open = jest.fn();
m = loadNavigationModule('/');
});
it('passes internal path through unchanged', () => {
m.openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
});
it('passes through external URLs unchanged', () => {
m.openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('handles paths with query strings', () => {
m.openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
expect(window.open).toHaveBeenCalledWith(
'/alerts?tab=AlertRules&relativeTime=30m',
'_blank',
);
});
});
it('handles full URLs', () => {
openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
describe('at basePath="/signoz/"', () => {
let m: NavigationModule;
beforeEach(() => {
window.open = jest.fn();
m = loadNavigationModule('/signoz/');
});
it('handles paths with query strings', () => {
openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
expect(window.open).toHaveBeenCalledWith(
'/alerts?tab=AlertRules&relativeTime=30m',
'_blank',
);
it('prepends base path to internal paths', () => {
m.openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
});
it('passes through external URLs unchanged', () => {
m.openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('is idempotent — does not double-prefix an already-prefixed path', () => {
m.openInNewTab('/signoz/dashboard');
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
});
});
});
});

View File

@@ -0,0 +1,50 @@
// Read once at module init — avoids a DOM query on every axios request.
const _basePath: string = ((): string => {
const href = document.querySelector('base')?.getAttribute('href') ?? '/';
return href.endsWith('/') ? href : `${href}/`;
})();
/** Returns the runtime base path — always trailing-slashed. e.g. "/" or "/signoz/" */
export function getBasePath(): string {
return _basePath;
}
/**
* Prepends the base path to an internal absolute path.
* Idempotent and safe to call on any value.
*
* withBasePath('/logs') → '/signoz/logs'
* withBasePath('/signoz/logs') → '/signoz/logs' (already prefixed)
* withBasePath('https://x.com') → 'https://x.com' (external, passthrough)
*/
export function withBasePath(path: string): string {
if (!path.startsWith('/')) {
return path;
}
if (_basePath === '/') {
return path;
}
if (path.startsWith(_basePath) || path === _basePath.slice(0, -1)) {
return path;
}
return _basePath + path.slice(1);
}
/**
* Full absolute URL — for copy-to-clipboard and window.open calls.
* getAbsoluteUrl(ROUTES.LOGS_EXPLORER) → 'https://host/signoz/logs/logs-explorer'
*/
export function getAbsoluteUrl(path: string): string {
return window.location.origin + withBasePath(path);
}
/**
* Origin + base path without trailing slash — for sending to the backend
* as frontendBaseUrl in invite / password-reset email flows.
* getBaseUrl() → 'https://host/signoz'
*/
export function getBaseUrl(): string {
return (
window.location.origin + (_basePath === '/' ? '' : _basePath.slice(0, -1))
);
}

View File

@@ -1,6 +1,5 @@
/**
* Opens the given path in a new browser tab.
*/
import { withBasePath } from 'utils/basePath';
export const openInNewTab = (path: string): void => {
window.open(path, '_blank');
window.open(withBasePath(path), '_blank');
};

View File

@@ -10,6 +10,18 @@ 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 [[.BaseHref]] 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.replaceAll('[[.BaseHref]]', '/');
},
};
}
function rawMarkdownPlugin(): Plugin {
return {
name: 'raw-markdown',
@@ -32,6 +44,7 @@ export default defineConfig(
const plugins = [
tsconfigPaths(),
rawMarkdownPlugin(),
devBasePathPlugin(),
react(),
createHtmlPlugin({
inject: {
@@ -124,6 +137,7 @@ 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',

View File

@@ -44,7 +44,6 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -55,28 +54,27 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
@@ -88,7 +86,6 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -105,7 +102,6 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
Description: "This endpoint fires a test notification for the given rule definition",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.GettableTestRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -160,27 +156,27 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(ruletypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(ruletypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -1,733 +0,0 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// postableRuleExamples returns example payloads attached to every rule-write
// endpoint. They cover each alert type, rule type, and composite-query shape.
func postableRuleExamples() []handler.OpenAPIExample {
rolling := func(evalWindow, frequency string) map[string]any {
return map[string]any{
"kind": "rolling",
"spec": map[string]any{"evalWindow": evalWindow, "frequency": frequency},
}
}
renotify := func(interval string, states ...string) map[string]any {
s := make([]any, 0, len(states))
for _, v := range states {
s = append(s, v)
}
return map[string]any{
"enabled": true,
"interval": interval,
"alertStates": s,
}
}
return []handler.OpenAPIExample{
{
Name: "metric_threshold_single",
Summary: "Metric threshold single builder query",
Description: "Fires when a pod consumes more than 80% of its requested CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.",
Value: map[string]any{
"alert": "Pod CPU above 80% of request",
"alertType": "METRIC_BASED_ALERT",
"description": "CPU usage for api-service pods exceeds 80% of the requested CPU",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "percentunit",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"stepInterval": 60,
"aggregations": []any{map[string]any{"metricName": "k8s.pod.cpu_request_utilization", "timeAggregation": "avg", "spaceAggregation": "max"}},
"filter": map[string]any{"expression": "k8s.deployment.name = 'api-service'"},
"groupBy": []any{
map[string]any{"name": "k8s.pod.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
},
"legend": "{{k8s.pod.name}} ({{deployment.environment}})",
},
},
},
},
"selectedQueryName": "A",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "all_the_times",
"target": 0.8,
"channels": []any{"slack-platform", "pagerduty-oncall"},
},
},
},
},
"evaluation": rolling("15m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"k8s.pod.name", "deployment.environment"},
"renotify": renotify("4h", "firing"),
},
"labels": map[string]any{"severity": "critical", "team": "platform"},
"annotations": map[string]any{
"description": "Pod {{$k8s.pod.name}} CPU is at {{$value}} of request in {{$deployment.environment}}.",
"summary": "Pod CPU above {{$threshold}} of request",
},
},
},
{
Name: "metric_threshold_formula",
Summary: "Metric threshold multi-query formula",
Description: "Computes disk utilization as (1 - available/capacity) * 100 by combining two disabled base queries with a builder_formula. The formula emits 0100, so compositeQuery.unit is set to \"percent\" and the target is a bare number.",
Value: map[string]any{
"alert": "PersistentVolume above 80% utilization",
"alertType": "METRIC_BASED_ALERT",
"description": "Disk utilization for a persistent volume is above 80%",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "percent",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"metricName": "k8s.volume.available", "timeAggregation": "max", "spaceAggregation": "max"}},
"filter": map[string]any{"expression": "k8s.volume.type = 'persistentVolumeClaim'"},
"groupBy": []any{
map[string]any{"name": "k8s.persistentvolumeclaim.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "k8s.namespace.name", "fieldContext": "resource", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "metrics",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"metricName": "k8s.volume.capacity", "timeAggregation": "max", "spaceAggregation": "max"}},
"filter": map[string]any{"expression": "k8s.volume.type = 'persistentVolumeClaim'"},
"groupBy": []any{
map[string]any{"name": "k8s.persistentvolumeclaim.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "k8s.namespace.name", "fieldContext": "resource", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "F1",
"expression": "(1 - A/B) * 100",
"legend": "{{k8s.persistentvolumeclaim.name}} ({{k8s.namespace.name}})",
},
},
},
},
"selectedQueryName": "F1",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "at_least_once",
"target": 80,
"channels": []any{"slack-storage"},
},
},
},
},
"evaluation": rolling("30m", "5m"),
"notificationSettings": map[string]any{
"groupBy": []any{"k8s.namespace.name", "k8s.persistentvolumeclaim.name"},
"renotify": renotify("2h", "firing"),
},
"labels": map[string]any{"severity": "critical"},
"annotations": map[string]any{
"description": "Volume {{$k8s.persistentvolumeclaim.name}} in {{$k8s.namespace.name}} is {{$value}}% full.",
"summary": "Disk utilization above {{$threshold}}%",
},
},
},
{
Name: "metric_promql",
Summary: "Metric threshold PromQL rule",
Description: "PromQL expression instead of the builder. Dotted OTEL resource attributes are quoted (\"deployment.environment\"). Useful for queries that combine series with group_right or other Prom operators.",
Value: map[string]any{
"alert": "Kafka consumer group lag above 1000",
"alertType": "METRIC_BASED_ALERT",
"description": "Consumer group lag computed via PromQL",
"ruleType": "promql_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "promql",
"panelType": "graph",
"queries": []any{
map[string]any{
"type": "promql",
"spec": map[string]any{
"name": "A",
"query": "(max by(topic, partition, \"deployment.environment\")(kafka_log_end_offset) - on(topic, partition, \"deployment.environment\") group_right max by(group, topic, partition, \"deployment.environment\")(kafka_consumer_committed_offset)) > 0",
"legend": "{{topic}}/{{partition}} ({{group}})",
},
},
},
},
"selectedQueryName": "A",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "all_the_times",
"target": 1000,
"channels": []any{"slack-data-platform", "pagerduty-data"},
},
},
},
},
"evaluation": rolling("10m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"group", "topic"},
"renotify": renotify("1h", "firing"),
},
"labels": map[string]any{"severity": "critical"},
"annotations": map[string]any{
"description": "Consumer group {{$group}} is {{$value}} messages behind on {{$topic}}/{{$partition}}.",
"summary": "Kafka consumer lag high",
},
},
},
{
Name: "metric_anomaly",
Summary: "Metric anomaly rule (v1 only)",
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
Value: map[string]any{
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"evalWindow": "24h",
"frequency": "3h",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"stepInterval": 21600,
"aggregations": []any{map[string]any{"metricName": "otelcol_receiver_accepted_spans", "timeAggregation": "rate", "spaceAggregation": "sum"}},
"filter": map[string]any{"expression": "tenant_tier = 'premium'"},
"groupBy": []any{map[string]any{"name": "tenant_id", "fieldContext": "attribute", "fieldDataType": "string"}},
"functions": []any{
map[string]any{
"name": "anomaly",
"args": []any{map[string]any{"name": "z_score_threshold", "value": 2}},
},
},
"legend": "{{tenant_id}}",
},
},
},
},
"op": "below",
"matchType": "all_the_times",
"target": 2,
"algorithm": "standard",
"seasonality": "daily",
"selectedQueryName": "A",
"requireMinPoints": true,
"requiredNumPoints": 3,
},
"labels": map[string]any{"severity": "warning"},
"preferredChannels": []any{"slack-ingestion"},
"annotations": map[string]any{
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
"summary": "Span ingestion anomaly",
},
},
},
{
Name: "logs_threshold",
Summary: "Logs threshold count() over filter",
Description: "Counts matching log records (ERROR severity + body contains) over a rolling window. Fires at least once per evaluation when the count exceeds zero.",
Value: map[string]any{
"alert": "Payments service panic logs",
"alertType": "LOGS_BASED_ALERT",
"description": "Any panic log line emitted by the payments service",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"stepInterval": 60,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name = 'payments-api' AND severity_text = 'ERROR' AND body CONTAINS 'panic'"},
"groupBy": []any{
map[string]any{"name": "k8s.pod.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
},
"legend": "{{k8s.pod.name}} ({{deployment.environment}})",
},
},
},
},
"selectedQueryName": "A",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "at_least_once",
"target": 0,
"channels": []any{"slack-payments", "pagerduty-payments"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"k8s.pod.name", "deployment.environment"},
"renotify": renotify("15m", "firing"),
},
"labels": map[string]any{"severity": "critical", "team": "payments"},
"annotations": map[string]any{
"description": "{{$k8s.pod.name}} emitted {{$value}} panic log(s) in {{$deployment.environment}}.",
"summary": "Payments service panic",
},
},
},
{
Name: "logs_error_rate_formula",
Summary: "Logs error rate error count / total count × 100",
Description: "Two disabled log count queries (A = errors, B = total) combined via a builder_formula into a percentage. Classic service-level error-rate alert pattern for log-based signals.",
Value: map[string]any{
"alert": "Payments-api error log rate above 1%",
"alertType": "LOGS_BASED_ALERT",
"description": "Error log ratio as a percentage of total logs for payments-api",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "percent",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name = 'payments-api' AND severity_text IN ['ERROR', 'FATAL']"},
"groupBy": []any{map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"}},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "logs",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name = 'payments-api'"},
"groupBy": []any{map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"}},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "F1",
"expression": "(A / B) * 100",
"legend": "{{deployment.environment}}",
},
},
},
},
"selectedQueryName": "F1",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "at_least_once",
"target": 1,
"channels": []any{"slack-payments"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"deployment.environment"},
"renotify": renotify("30m", "firing"),
},
"labels": map[string]any{"severity": "critical", "team": "payments"},
"annotations": map[string]any{
"description": "Error log rate in {{$deployment.environment}} is {{$value}}%",
"summary": "Payments-api error rate above {{$threshold}}%",
},
},
},
{
Name: "traces_threshold_latency",
Summary: "Traces threshold p99 latency (ns → s conversion)",
Description: "Builder query against the traces signal with p99(duration_nano). The series unit is ns (compositeQuery.unit), the target is in seconds (threshold.targetUnit) SigNoz converts before comparing. Canonical shape when series and target live in different units.",
Value: map[string]any{
"alert": "Search API p99 latency above 5s",
"alertType": "TRACES_BASED_ALERT",
"description": "p99 duration of the search endpoint exceeds 5s",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "ns",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"stepInterval": 60,
"aggregations": []any{map[string]any{"expression": "p99(duration_nano)"}},
"filter": map[string]any{"expression": "service.name = 'search-api' AND name = 'GET /api/v1/search'"},
"groupBy": []any{
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "http.route", "fieldContext": "attribute", "fieldDataType": "string"},
},
"legend": "{{service.name}} {{http.route}}",
},
},
},
},
"selectedQueryName": "A",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "warning",
"op": "above",
"matchType": "at_least_once",
"target": 5,
"targetUnit": "s",
"channels": []any{"slack-search"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"service.name", "http.route"},
"renotify": renotify("30m", "firing"),
},
"labels": map[string]any{"severity": "warning", "team": "search"},
"annotations": map[string]any{
"description": "p99 latency for {{$service.name}} on {{$http.route}} crossed {{$threshold}}s.",
"summary": "Search-api latency degraded",
},
},
},
{
Name: "traces_error_rate_formula",
Summary: "Traces error rate error spans / total spans × 100",
Description: "Two disabled trace count queries (A = error spans where hasError=true, B = total spans) combined via a builder_formula into a percentage. Mirrors the common request-error-rate dashboard shape.",
Value: map[string]any{
"alert": "Search-api error rate above 5%",
"alertType": "TRACES_BASED_ALERT",
"description": "Request error rate for search-api, grouped by route",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "percent",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name = 'search-api' AND hasError = true"},
"groupBy": []any{
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "http.route", "fieldContext": "attribute", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "traces",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name = 'search-api'"},
"groupBy": []any{
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "http.route", "fieldContext": "attribute", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "F1",
"expression": "(A / B) * 100",
"legend": "{{service.name}} {{http.route}}",
},
},
},
},
"selectedQueryName": "F1",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "at_least_once",
"target": 5,
"channels": []any{"slack-search", "pagerduty-search"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"service.name", "http.route"},
"renotify": renotify("15m", "firing"),
},
"labels": map[string]any{"severity": "critical", "team": "search"},
"annotations": map[string]any{
"description": "Error rate on {{$service.name}} {{$http.route}} is {{$value}}%",
"summary": "Search-api error rate above {{$threshold}}%",
},
},
},
{
Name: "tiered_thresholds",
Summary: "Tiered thresholds with per-tier channels",
Description: "Two tiers (warning and critical) in a single rule, each with its own target, op, matchType, and channels so warnings and pages route to different receivers. `alertOnAbsent` + `absentFor` fires a no-data alert when the query returns no series for 15 consecutive evaluations.",
Value: map[string]any{
"alert": "Kafka consumer lag warn / critical",
"alertType": "METRIC_BASED_ALERT",
"description": "Warn at lag ≥ 50 and page at ≥ 200, tiered via thresholds.spec.",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"metricName": "kafka_log_end_offset", "timeAggregation": "max", "spaceAggregation": "max"}},
"filter": map[string]any{"expression": "topic != '__consumer_offsets'"},
"groupBy": []any{
map[string]any{"name": "topic", "fieldContext": "attribute", "fieldDataType": "string"},
map[string]any{"name": "partition", "fieldContext": "attribute", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "metrics",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"metricName": "kafka_consumer_committed_offset", "timeAggregation": "max", "spaceAggregation": "max"}},
"filter": map[string]any{"expression": "topic != '__consumer_offsets'"},
"groupBy": []any{
map[string]any{"name": "topic", "fieldContext": "attribute", "fieldDataType": "string"},
map[string]any{"name": "partition", "fieldContext": "attribute", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "F1",
"expression": "A - B",
"legend": "{{topic}}/{{partition}}",
},
},
},
},
"alertOnAbsent": true,
"absentFor": 15,
"selectedQueryName": "F1",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "warning",
"op": "above",
"matchType": "all_the_times",
"target": 50,
"channels": []any{"slack-kafka-info"},
},
map[string]any{
"name": "critical",
"op": "above",
"matchType": "all_the_times",
"target": 200,
"channels": []any{"slack-kafka-alerts", "pagerduty-kafka"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"topic"},
"renotify": renotify("15m", "firing"),
},
"labels": map[string]any{"team": "data-platform"},
"annotations": map[string]any{
"description": "Consumer lag for {{$topic}} partition {{$partition}} is {{$value}}.",
"summary": "Kafka consumer lag",
},
},
},
{
Name: "notification_settings",
Summary: "Full notification settings (grouping, nodata renotify, grace period)",
Description: "Demonstrates the full notificationSettings surface: `groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay` gives newly-appearing series a grace period before firing, `renotify` re-alerts every 30m while firing OR while the alert is in nodata (missing data is treated as actionable), and `usePolicy: false` means channels come from the threshold entries rather than global routing policies. Set `usePolicy: true` to skip per-threshold channels and route via the org-level notification policy instead.",
Value: map[string]any{
"alert": "API 5xx error rate above 1%",
"alertType": "TRACES_BASED_ALERT",
"description": "Noise-controlled 5xx error rate alert with renotify on gaps",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
"panelType": "graph",
"unit": "percent",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name CONTAINS 'api' AND http.status_code >= 500"},
"groupBy": []any{
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "traces",
"stepInterval": 60,
"disabled": true,
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "service.name CONTAINS 'api'"},
"groupBy": []any{
map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "deployment.environment", "fieldContext": "resource", "fieldDataType": "string"},
},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "F1",
"expression": "(A / B) * 100",
"legend": "{{service.name}} ({{deployment.environment}})",
},
},
},
},
"selectedQueryName": "F1",
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "critical",
"op": "above",
"matchType": "at_least_once",
"target": 1,
"channels": []any{"slack-api-alerts", "pagerduty-oncall"},
},
},
},
},
"evaluation": rolling("5m", "1m"),
"notificationSettings": map[string]any{
"groupBy": []any{"service.name", "deployment.environment"},
"newGroupEvalDelay": "2m",
"usePolicy": false,
"renotify": renotify("30m", "firing", "nodata"),
},
"labels": map[string]any{"team": "platform"},
"annotations": map[string]any{
"description": "{{$service.name}} 5xx rate in {{$deployment.environment}} is {{$value}}%.",
"summary": "API service error rate elevated",
},
},
},
}
}

View File

@@ -1,32 +0,0 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
// TestPostableRuleExamplesValidate verifies every example payload returned by
// postableRuleExamples() round-trips through PostableRule.UnmarshalJSON and
// passes Validate(). If an example drifts from the runtime contract this
// breaks loudly so the spec doesn't ship invalid payloads to users.
func TestPostableRuleExamplesValidate(t *testing.T) {
for _, example := range postableRuleExamples() {
t.Run(example.Name, func(t *testing.T) {
raw, err := json.Marshal(example.Value)
if err != nil {
t.Fatalf("marshal example: %v", err)
}
var rule ruletypes.PostableRule
if err := json.Unmarshal(raw, &rule); err != nil {
t.Fatalf("unmarshal: %v\npayload: %s", err, raw)
}
if err := rule.Validate(); err != nil {
t.Fatalf("Validate: %v\npayload: %s", err, raw)
}
})
}
}

View File

@@ -114,11 +114,11 @@ type AlertCompositeQuery struct {
type RuleCondition struct {
CompositeQuery *AlertCompositeQuery `json:"compositeQuery" required:"true"`
CompareOperator CompareOperator `json:"op,omitzero"`
CompareOperator CompareOperator `json:"op" required:"true"`
Target *float64 `json:"target,omitempty"`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
AbsentFor uint64 `json:"absentFor,omitempty"`
MatchType MatchType `json:"matchType,omitzero"`
MatchType MatchType `json:"matchType" required:"true"`
TargetUnit string `json:"targetUnit,omitempty"`
Algorithm string `json:"algorithm,omitempty"`
Seasonality Seasonality `json:"seasonality,omitzero"`

View File

@@ -50,13 +50,13 @@ const (
// PostableRule is used to create alerting rule from HTTP api.
type PostableRule struct {
AlertName string `json:"alert" required:"true"`
AlertType AlertType `json:"alertType" required:"true"`
AlertType AlertType `json:"alertType,omitempty"`
Description string `json:"description,omitempty"`
RuleType RuleType `json:"ruleType" required:"true"`
RuleType RuleType `json:"ruleType,omitzero" required:"true"`
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero"`
Frequency valuer.TextDuration `json:"frequency,omitzero"`
RuleCondition *RuleCondition `json:"condition" required:"true"`
RuleCondition *RuleCondition `json:"condition,omitempty" required:"true"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
@@ -67,9 +67,9 @@ type PostableRule struct {
PreferredChannels []string `json:"preferredChannels,omitempty"`
Version string `json:"version"`
Version string `json:"version,omitempty"`
Evaluation *EvaluationEnvelope `json:"evaluation,omitempty"`
Evaluation *EvaluationEnvelope `yaml:"evaluation,omitempty" json:"evaluation,omitempty"`
SchemaVersion string `json:"schemaVersion,omitempty"`
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`