Compare commits

..

6 Commits

Author SHA1 Message Date
srikanthccv
7e1947800b chore: temp commit 2026-02-08 11:03:17 +05:30
Abhi kumar
3562de8fbb fix: added fix for tooltip sizing (#10205)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-05 22:46:17 +05:30
Abhi kumar
ef80fb39fd feat: added new time-series graph (#10201)
* feat: added new time-series graph

* chore: updated types for charts
2026-02-05 11:29:21 +00:00
Abhi kumar
594d4dc737 feat: added changes to compute legend items width for virtualization (#10196)
* feat: added changes to compute legend items width for virtualization

* feat: added support for single row in legends
2026-02-05 16:27:43 +05:30
Abhishek Kumar Singh
01415b58be chore: made group_wait and group_interval configuration dynamic for alert manager (#10198) 2026-02-05 09:56:27 +00:00
Ashwin Bhatkal
f7728c9019 chore: update variables store with derived values (#10194)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove redundant sort

* chore: use sorted variables array

* chore: update variables store with derived values

* chore: fix types

* chore: resolve cursor comments
2026-02-05 14:09:46 +05:30
105 changed files with 7892 additions and 2381 deletions

View File

@@ -26,6 +26,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
}
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule
tr, err := baserules.NewThresholdRule(

View File

@@ -22,7 +22,7 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { PenLine, Trash2 } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { TVariableMode } from './types';

View File

@@ -1,8 +1,11 @@
import { memo, useEffect, useState } from 'react';
import { memo, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Row } from 'antd';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import {
useDashboardVariables,
useDashboardVariablesSelector,
} from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
@@ -12,13 +15,7 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import DynamicVariableSelection from './DynamicVariableSelection';
import {
buildDependencies,
buildDependencyGraph,
buildParentDependencyGraph,
IDependencyData,
onUpdateVariableNode,
} from './util';
import { onUpdateVariableNode } from './util';
import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
@@ -35,11 +32,11 @@ function DashboardVariableSelection(): JSX.Element | null {
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables();
const [variablesTableData, setVariablesTableData] = useState<any>([]);
const [dependencyData, setDependencyData] = useState<IDependencyData | null>(
null,
const sortedVariablesArray = useDashboardVariablesSelector(
(state) => state.sortedVariablesArray,
);
const dependencyData = useDashboardVariablesSelector(
(state) => state.dependencyData,
);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
@@ -47,24 +44,6 @@ function DashboardVariableSelection(): JSX.Element | null {
);
useEffect(() => {
const tableRowData = [];
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of Object.entries(dashboardVariables)) {
const { id } = value;
tableRowData.push({
key,
name: key,
...dashboardVariables[key],
id,
});
}
tableRowData.sort((a, b) => a.order - b.order);
setVariablesTableData(tableRowData);
// Initialize variables with default values if not in URL
initializeDefaultVariables(
dashboardVariables,
@@ -73,30 +52,6 @@ function DashboardVariableSelection(): JSX.Element | null {
);
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
useEffect(() => {
if (variablesTableData.length > 0) {
const depGrp = buildDependencies(variablesTableData);
const { order, graph, hasCycle, cycleNodes } = buildDependencyGraph(depGrp);
const parentDependencyGraph = buildParentDependencyGraph(graph);
// cleanup order to only include variables that are of type 'QUERY'
const cleanedOrder = order.filter((variable) => {
const variableData = variablesTableData.find(
(v: IDashboardVariable) => v.name === variable,
);
return variableData?.type === 'QUERY';
});
setDependencyData({
order: cleanedOrder,
graph,
parentDependencyGraph,
hasCycle,
cycleNodes,
});
}
}, [dashboardVariables, variablesTableData]);
// this handles the case where the dependency order changes i.e. variable list updated via creation or deletion etc. and we need to refetch the variables
// also trigger when the global time changes
useEffect(
@@ -186,45 +141,30 @@ function DashboardVariableSelection(): JSX.Element | null {
}
};
if (!dashboardVariables) {
return null;
}
const orderBasedSortedVariables = variablesTableData.sort(
(a: { order: number }, b: { order: number }) => a.order - b.order,
);
return (
<Row style={{ display: 'flex', gap: '12px' }}>
{orderBasedSortedVariables &&
Array.isArray(orderBasedSortedVariables) &&
orderBasedSortedVariables.length > 0 &&
orderBasedSortedVariables.map((variable) =>
variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={`${variable.name}${variable.id}${variable.order}`}
existingVariables={dashboardVariables}
variableData={{
name: variable.name,
...variable,
}}
onValueUpdate={onValueUpdate}
/>
) : (
<VariableItem
key={`${variable.name}${variable.id}}${variable.order}`}
existingVariables={dashboardVariables}
variableData={{
name: variable.name,
...variable,
}}
onValueUpdate={onValueUpdate}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
),
)}
{sortedVariablesArray.map((variable) => {
const key = `${variable.name}${variable.id}${variable.order}`;
return variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={key}
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
/>
) : (
<VariableItem
key={key}
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
);
})}
</Row>
);
}

View File

@@ -16,6 +16,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
@@ -24,7 +25,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
import { SelectItemStyle } from './styles';
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
import { areArraysEqual, checkAPIInvocation } from './util';
import './DashboardVariableSelection.styles.scss';

View File

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { v4 as uuidv4 } from 'uuid';

View File

@@ -1,6 +1,9 @@
import { OptionData } from 'components/NewSelect/types';
import { isEmpty, isNull } from 'lodash-es';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import {
IDashboardVariables,
IDependencyData,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export function areArraysEqual(
@@ -97,14 +100,6 @@ export const buildDependencies = (
return graph;
};
export interface IDependencyData {
order: string[];
graph: VariableGraph;
parentDependencyGraph: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
export const buildParentDependencyGraph = (
graph: VariableGraph,
): VariableGraph => {

View File

@@ -0,0 +1,104 @@
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import Tooltip from 'lib/uPlotV2/components/Tooltip/Tooltip';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import _noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { ChartProps } from '../types';
const TOOLTIP_WIDTH_PADDING = 60;
const TOOLTIP_MIN_WIDTH = 200;
export default function TimeSeries({
legendConfig = { position: LegendPosition.BOTTOM },
config,
data,
width: containerWidth,
height: containerHeight,
disableTooltip = false,
canPinTooltip = false,
timezone,
yAxisUnit,
decimalPrecision,
syncMode,
syncKey,
onDestroy = _noop,
children,
layoutChildren,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
return (
<Legend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
/>
);
},
[config, legendConfig.position],
);
return (
<PlotContextProvider>
<ChartLayout
config={config}
containerWidth={containerWidth}
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={data}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;
onDestroy(plot);
}}
data-testid={testId}
>
{children}
{!disableTooltip && (
<TooltipPlugin
config={config}
canPinTooltip={canPinTooltip}
syncMode={syncMode}
maxWidth={Math.max(
TOOLTIP_MIN_WIDTH,
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
render={(props: TooltipRenderArgs): React.ReactNode => (
<Tooltip
{...props}
timezone={timezone}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
/>
)}
/>
)}
</UPlotChart>
)}
</ChartLayout>
</PlotContextProvider>
);
}

View File

@@ -0,0 +1,29 @@
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
interface BaseChartProps {
width: number;
height: number;
disableTooltip?: boolean;
timezone: string;
syncMode?: DashboardCursorSync;
syncKey?: string;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}
interface TimeSeriesChartProps extends BaseChartProps {
config: UPlotConfigBuilder;
legendConfig: LegendConfig;
data: uPlot.AlignedData;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;
layoutChildren?: React.ReactNode;
'data-testid'?: string;
}
export type ChartProps = TimeSeriesChartProps;

View File

@@ -5,25 +5,32 @@ export interface ChartDimensions {
height: number;
legendWidth: number;
legendHeight: number;
legendsPerSet: number;
averageLegendWidth: number;
}
const AVG_CHAR_WIDTH = 8;
const LEGEND_WIDTH_PERCENTILE = 0.85;
const DEFAULT_AVG_LABEL_LENGTH = 15;
const LEGEND_GAP = 16;
const BASE_LEGEND_WIDTH = 16;
const LEGEND_PADDING = 12;
const LEGEND_LINE_HEIGHT = 36;
const LEGEND_LINE_HEIGHT = 28;
/**
* Average text width from series labels (for legendsPerSet).
* Calculates the average width of the legend items based on the labels of the series.
* @param legends - The labels of the series.
* @returns The average width of the legend items.
*/
export function calculateAverageLegendWidth(legends: string[]): number {
if (legends.length === 0) {
return DEFAULT_AVG_LABEL_LENGTH;
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
}
const averageLabelLength =
legends.reduce((sum, l) => sum + l.length, 0) / legends.length;
return averageLabelLength * AVG_CHAR_WIDTH;
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
const percentileLength = lengths[Math.max(0, index)];
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
}
/**
@@ -64,7 +71,7 @@ export function calculateChartDimensions({
height: 0,
legendWidth: 0,
legendHeight: 0,
legendsPerSet: 0,
averageLegendWidth: 0,
};
}
@@ -85,13 +92,15 @@ export function calculateChartDimensions({
legendWidth: rightLegendWidth,
legendHeight: containerHeight,
// Single vertical list on the right.
legendsPerSet: 1,
averageLegendWidth: rightLegendWidth,
};
}
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
const legendItemWidth = Math.min(approxLegendItemWidth, 400);
const legendItemWidth = Math.ceil(
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
);
const legendItemsPerRow = Math.max(
1,
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
@@ -114,17 +123,11 @@ export function calculateChartDimensions({
maxAllowedLegendHeight,
);
// How many legend items per row in the Legend component.
const legendsPerSet = Math.ceil(
(containerWidth + LEGEND_GAP) /
(Math.min(MAX_LEGEND_WIDTH, approxLegendItemWidth) + LEGEND_GAP),
);
return {
width: containerWidth,
height: Math.max(0, containerHeight - bottomLegendHeight),
legendWidth: containerWidth,
legendHeight: bottomLegendHeight,
legendsPerSet,
averageLegendWidth: legendItemWidth,
};
}

View File

@@ -11,6 +11,7 @@ export interface ChartLayoutProps {
children: (props: {
chartWidth: number;
chartHeight: number;
averageLegendWidth: number;
}) => React.ReactNode;
layoutChildren?: React.ReactNode;
containerWidth: number;
@@ -56,6 +57,7 @@ export default function ChartLayout({
{children({
chartWidth: chartDimensions.width,
chartHeight: chartDimensions.height,
averageLegendWidth: chartDimensions.averageLegendWidth,
})}
</div>
<div
@@ -65,7 +67,7 @@ export default function ChartLayout({
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.legendsPerSet)}
{legendComponent(chartDimensions.averageLegendWidth)}
</div>
</div>
{layoutChildren}

View File

@@ -4,7 +4,7 @@ import { ToggleGraphProps } from 'components/Graph/types';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';

View File

@@ -12,7 +12,7 @@ import {
initialQueriesMap,
initialQueryBuilderFormValues,
} from 'constants/queryBuilder';
import { IUseDashboardVariablesReturn } from 'hooks/dashboard/useDashboardVariables';
import { IUseDashboardVariablesReturn } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { renderHook } from '@testing-library/react';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import useGetResolvedText from '../useGetResolvedText';

View File

@@ -1,21 +1,40 @@
import { useSyncExternalStore } from 'react';
import { useCallback, useRef, useSyncExternalStore } from 'react';
import { dashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
dashboardVariablesStore,
IDashboardVariables,
} from '../../providers/Dashboard/store/dashboardVariablesStore';
IDashboardVariablesStoreState,
IUseDashboardVariablesReturn,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
export interface IUseDashboardVariablesReturn {
dashboardVariables: IDashboardVariables;
}
/**
* Generic selector hook for dashboard variables store
* Allows granular subscriptions to any part of the store state
*
* @example
* ! Select top-level field
* const variables = useDashboardVariablesSelector(s => s.variables);
*
* ! Select specific variable
* const fooVar = useDashboardVariablesSelector(s => s.variables['foo']);
*
* ! Select derived value
* const hasVariables = useDashboardVariablesSelector(s => Object.keys(s.variables).length > 0);
*/
export const useDashboardVariablesSelector = <T>(
selector: (state: IDashboardVariablesStoreState) => T,
): T => {
const selectorRef = useRef(selector);
selectorRef.current = selector;
export const useDashboardVariables = (): IUseDashboardVariablesReturn => {
const dashboardVariables = useSyncExternalStore(
dashboardVariablesStore.subscribe,
dashboardVariablesStore.getSnapshot,
const getSnapshot = useCallback(
() => selectorRef.current(dashboardVariablesStore.getSnapshot()),
[],
);
return {
dashboardVariables,
};
return useSyncExternalStore(dashboardVariablesStore.subscribe, getSnapshot);
};
export const useDashboardVariables = (): IUseDashboardVariablesReturn => {
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
return { dashboardVariables };
};

View File

@@ -1,5 +1,5 @@
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import store from 'store';
export const getDashboardVariables = (

View File

@@ -1,7 +1,18 @@
.legend-search-container {
flex-shrink: 0;
width: 100%;
padding-right: 8px;
.legend-search-input {
font-size: 12px;
}
}
.legend-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
height: 100%;
width: 100%;
@@ -17,6 +28,38 @@
height: 100%;
width: 100%;
.virtuoso-grid-list {
min-width: 0;
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
row-gap: 4px;
column-gap: 12px;
}
.virtuoso-grid-item {
min-width: 0;
}
&.legend-virtuoso-container-right {
.virtuoso-grid-list {
grid-template-columns: 1fr;
}
}
&.legend-virtuoso-container-single-row {
.virtuoso-grid-list {
grid-template-columns: repeat(
auto-fit,
minmax(var(--legend-average-width, 240px), max-content)
);
justify-content: center;
}
}
&::-webkit-scrollbar {
width: 0.3rem;
}
@@ -58,9 +101,15 @@
align-items: center;
gap: 6px;
padding: 4px 8px;
max-width: 100%;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
&.legend-item-right {
width: 100%;
}
&.legend-item-off {
opacity: 0.3;
text-decoration: line-through;

View File

@@ -1,23 +1,21 @@
import { useCallback, useMemo, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Tooltip as AntdTooltip } from 'antd';
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input, Tooltip as AntdTooltip } from 'antd';
import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { LegendPosition } from 'types/api/dashboard/getAll';
import { LegendProps } from '../types';
import { LegendPosition, LegendProps } from '../types';
import { useLegendActions } from './useLegendActions';
import './Legend.styles.scss';
export const MAX_LEGEND_WIDTH = 320;
const LEGENDS_PER_SET_DEFAULT = 5;
export const MAX_LEGEND_WIDTH = 240;
export default function Legend({
position = LegendPosition.BOTTOM,
config,
legendsPerSet = LEGENDS_PER_SET_DEFAULT,
averageLegendWidth = MAX_LEGEND_WIDTH,
}: LegendProps): JSX.Element {
const {
legendItemsMap,
@@ -33,54 +31,54 @@ export default function Legend({
focusedSeriesIndex,
});
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
// Chunk legend items into rows of LEGENDS_PER_ROW items each
const legendRows = useMemo(() => {
const legendItems = Object.values(legendItemsMap);
const legendItems = useMemo(() => Object.values(legendItemsMap), [
legendItemsMap,
]);
return legendItems.reduce((acc: LegendItem[][], curr, i) => {
if (i % legendsPerSet === 0) {
acc.push([]);
}
acc[acc.length - 1].push(curr);
return acc;
}, [] as LegendItem[][]);
}, [legendItemsMap, legendsPerSet]);
const isSingleRow = useMemo(() => {
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
return false;
}
const containerWidth = legendContainerRef.current.clientWidth;
const renderLegendRow = useCallback(
(rowIndex: number, row: LegendItem[]): JSX.Element => (
<div
key={rowIndex}
className={cx(
'legend-row',
`legend-row-${position.toLowerCase()}`,
legendRows.length === 1 && position === LegendPosition.BOTTOM
? 'legend-single-row'
: '',
)}
>
{row.map((item) => (
<AntdTooltip key={item.seriesIndex} title={item.label}>
<div
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
style={{ maxWidth: `min(${MAX_LEGEND_WIDTH}px, 100%)` }}
>
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</AntdTooltip>
))}
</div>
const totalLegendWidth = legendItems.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, legendContainerRef, legendItems.length, position]);
const visibleLegendItems = useMemo(() => {
if (position !== LegendPosition.RIGHT || !legendSearchQuery.trim()) {
return legendItems;
}
const query = legendSearchQuery.trim().toLowerCase();
return legendItems.filter((item) =>
item.label?.toLowerCase().includes(query),
);
}, [position, legendSearchQuery, legendItems]);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => (
<AntdTooltip key={item.seriesIndex} title={item.label}>
<div
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</AntdTooltip>
),
[focusedSeriesIndex, position, legendRows],
[focusedSeriesIndex, position],
);
return (
@@ -90,11 +88,29 @@ export default function Legend({
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
style={{
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
}}
>
<Virtuoso
className="legend-virtuoso-container"
data={legendRows}
itemContent={(index, row): JSX.Element => renderLegendRow(index, row)}
{position === LegendPosition.RIGHT && (
<div className="legend-search-container">
<Input
allowClear
placeholder="Search..."
value={legendSearchQuery}
onChange={(e): void => setLegendSearchQuery(e.target.value)}
className="legend-search-input"
/>
</div>
)}
<VirtuosoGrid
className={cx(
'legend-virtuoso-container',
`legend-virtuoso-container-${position.toLowerCase()}`,
{ 'legend-virtuoso-container-single-row': isSingleRow },
)}
data={visibleLegendItems}
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
/>
</div>
);

View File

@@ -5,7 +5,7 @@
-webkit-font-smoothing: antialiased;
color: var(--bg-vanilla-100);
border-radius: 6px;
padding: 1rem 1rem 0.5rem 1rem;
padding: 1rem 0.5rem 0.5rem 1rem;
border: 1px solid var(--bg-ink-100);
display: flex;
flex-direction: column;
@@ -15,6 +15,12 @@
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border: 1px solid var(--bg-vanilla-300);
.uplot-tooltip-list {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}
}
.uplot-tooltip-header {
@@ -22,18 +28,18 @@
font-weight: 500;
}
.uplot-tooltip-list-container {
height: 100%;
.uplot-tooltip-list {
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
}
.uplot-tooltip-list {
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
}

View File

@@ -75,7 +75,7 @@ export interface LegendConfig {
export interface LegendProps {
position?: LegendPosition;
config: UPlotConfigBuilder;
legendsPerSet?: number;
averageLegendWidth?: number;
}
export interface TooltipContentItem {

View File

@@ -45,8 +45,11 @@ import APIError from 'types/api/error';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import { useDashboardVariables } from '../../hooks/dashboard/useDashboardVariables';
import { setDashboardVariablesStore } from './store/dashboardVariablesStore';
import { useDashboardVariablesSelector } from '../../hooks/dashboard/useDashboardVariables';
import {
setDashboardVariablesStore,
updateDashboardVariablesStore,
} from './store/dashboardVariables/dashboardVariablesStore';
import {
DashboardSortOrder,
IDashboardContext,
@@ -198,14 +201,23 @@ export function DashboardProvider({
: isDashboardWidgetPage?.params.dashboardId) || '';
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard>();
const dashboardVariables = useDashboardVariables();
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
const savedDashboardId = useDashboardVariablesSelector((s) => s.dashboardId);
useEffect(() => {
const existingVariables = dashboardVariables;
const updatedVariables = selectedDashboard?.data.variables || {};
if (!isEqual(existingVariables, updatedVariables)) {
setDashboardVariablesStore(updatedVariables);
if (savedDashboardId !== dashboardId) {
setDashboardVariablesStore({
dashboardId,
variables: updatedVariables,
});
} else if (!isEqual(existingVariables, updatedVariables)) {
updateDashboardVariablesStore({
dashboardId,
variables: updatedVariables,
});
}
}, [selectedDashboard]);

View File

@@ -0,0 +1,57 @@
import createStore from '../store';
import { IDashboardVariablesStoreState } from './dashboardVariablesStoreTypes';
import {
computeDerivedValues,
updateDerivedValues,
} from './dashboardVariablesStoreUtils';
const initialState: IDashboardVariablesStoreState = {
dashboardId: '',
variables: {},
sortedVariablesArray: [],
dependencyData: null,
};
export const dashboardVariablesStore = createStore<IDashboardVariablesStoreState>(
initialState,
);
/**
* Set dashboard variables (replaces all variables)
*/
export function setDashboardVariablesStore({
dashboardId,
variables,
}: {
dashboardId: string;
variables: IDashboardVariablesStoreState['variables'];
}): void {
dashboardVariablesStore.set(() => {
return {
dashboardId,
variables,
...computeDerivedValues(variables),
} as IDashboardVariablesStoreState;
});
}
/**
* Update specific dashboard variables (merges with existing)
*/
export function updateDashboardVariablesStore({
dashboardId,
variables,
}: {
dashboardId: string;
variables: IDashboardVariablesStoreState['variables'];
}): void {
dashboardVariablesStore.update((draft) => {
if (draft.dashboardId !== dashboardId) {
// If dashboardId doesn't match, we replace the entire state
draft.dashboardId = dashboardId;
}
draft.variables = variables;
updateDerivedValues(draft);
});
}

View File

@@ -0,0 +1,31 @@
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export type VariableGraph = Record<string, string[]>;
export interface IDependencyData {
order: string[];
graph: VariableGraph;
parentDependencyGraph: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
export type IDashboardVariables = Record<string, IDashboardVariable>;
export interface IDashboardVariablesStoreState {
// dashboard id
dashboardId: string;
// Raw variables keyed by id/name
variables: IDashboardVariables;
// Derived: sorted array of variables by order
sortedVariablesArray: IDashboardVariable[];
// Derived: dependency data for QUERY variables
dependencyData: IDependencyData | null;
}
export interface IUseDashboardVariablesReturn {
dashboardVariables: IDashboardVariablesStoreState['variables'];
}

View File

@@ -0,0 +1,89 @@
import {
buildDependencies,
buildDependencyGraph,
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import {
IDashboardVariables,
IDashboardVariablesStoreState,
IDependencyData,
} from './dashboardVariablesStoreTypes';
/**
* Build a sorted array of variables by their order property
*/
export function buildSortedVariablesArray(
variables: IDashboardVariables,
): IDashboardVariable[] {
const sortedVariablesArray: IDashboardVariable[] = [];
Object.values(variables).forEach((value) => {
sortedVariablesArray.push({ ...value });
});
sortedVariablesArray.sort((a, b) => a.order - b.order);
return sortedVariablesArray;
}
/**
* Build dependency data from sorted variables array
* This includes the dependency graph, topological order, and cycle detection
*/
export function buildDependencyData(
sortedVariablesArray: IDashboardVariable[],
): IDependencyData | null {
if (sortedVariablesArray.length === 0) {
return null;
}
const dependencies = buildDependencies(sortedVariablesArray);
const {
order,
graph,
parentDependencyGraph,
hasCycle,
cycleNodes,
} = buildDependencyGraph(dependencies);
// Filter order to only include QUERY type variables
const queryVariableOrder = order.filter((variable: string) => {
const variableData = sortedVariablesArray.find((v) => v.name === variable);
return variableData?.type === 'QUERY';
});
return {
order: queryVariableOrder,
graph,
parentDependencyGraph,
hasCycle,
cycleNodes,
};
}
/**
* Compute derived values from variables
* This is a composition of buildSortedVariablesArray and buildDependencyData
*/
export function computeDerivedValues(
variables: IDashboardVariablesStoreState['variables'],
): Pick<
IDashboardVariablesStoreState,
'sortedVariablesArray' | 'dependencyData'
> {
const sortedVariablesArray = buildSortedVariablesArray(variables);
const dependencyData = buildDependencyData(sortedVariablesArray);
return { sortedVariablesArray, dependencyData };
}
/**
* Update derived values in the store state (for use with immer)
*/
export function updateDerivedValues(
draft: IDashboardVariablesStoreState,
): void {
draft.sortedVariablesArray = buildSortedVariablesArray(draft.variables);
draft.dependencyData = buildDependencyData(draft.sortedVariablesArray);
}

View File

@@ -1,14 +0,0 @@
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import createStore from './store';
// export type IDashboardVariables = DashboardData['variables'];
export type IDashboardVariables = Record<string, IDashboardVariable>;
export const dashboardVariablesStore = createStore<IDashboardVariables>({});
export function setDashboardVariablesStore(
variables: Partial<IDashboardVariables>,
): void {
dashboardVariablesStore.set(() => ({ ...variables }));
}

View File

@@ -74,6 +74,21 @@ type Alertmanager interface {
CreateInhibitRules(ctx context.Context, orgID valuer.UUID, rules []amConfig.InhibitRule) error
DeleteAllInhibitRulesByRuleId(ctx context.Context, orgID valuer.UUID, ruleId string) error
// Planned Maintenance CRUD
GetAllPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.GettablePlannedMaintenance, error)
GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.GettablePlannedMaintenance, error)
CreatePlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance) (valuer.UUID, error)
EditPlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance, id valuer.UUID) error
DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error
// Rule State History
RecordRuleStateHistory(ctx context.Context, orgID string, entries []alertmanagertypes.RuleStateHistory) error
GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]alertmanagertypes.RuleStateHistory, error)
GetRuleStateHistoryTimeline(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error)
GetRuleStateHistoryTopContributors(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error)
GetOverallStateTransitions(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error)
GetRuleStats(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStats, error)
// Collects stats for the organization.
statsreporter.StatsCollector
}

View File

@@ -569,8 +569,8 @@ func (d *Dispatcher) getOrCreateRoute(receiver string) *dispatch.Route {
route := &dispatch.Route{
RouteOpts: dispatch.RouteOpts{
Receiver: receiver,
GroupWait: 30 * time.Second,
GroupInterval: 5 * time.Minute,
GroupWait: d.route.RouteOpts.GroupWait,
GroupInterval: d.route.RouteOpts.GroupInterval,
GroupByAll: false,
},
Matchers: labels.Matchers{{

View File

@@ -1183,8 +1183,8 @@ func TestDispatcherRaceOnFirstAlertNotDeliveredWhenGroupWaitIsZero(t *testing.T)
route:
group_by: ['alertname']
group_wait: 1h
group_interval: 1h
group_wait: 30s
group_interval: 5m
receiver: 'slack'`
conf, err := config.Load(confData)
if err != nil {
@@ -1308,3 +1308,95 @@ func TestDispatcher_DoMaintenance(t *testing.T) {
require.False(t, isMuted)
require.Empty(t, mutedBy)
}
func TestDispatcher_GetOrCreateRoute(t *testing.T) {
testCases := []struct {
name string
confData string
expectedReceiver string
expectedGroupWait time.Duration
expectedGroupInterval time.Duration
expectedGroupByAll bool
expectedMatchersLen int
expectedMatcherName string
expectedMatcherValue string
}{
{
name: "create route for slack receiver",
confData: `receivers:
- name: 'slack'
- name: 'email'
- name: 'pagerduty'
route:
group_by: ['alertname']
group_wait: 1m
group_interval: 1m
receiver: 'slack'`,
expectedReceiver: "slack",
expectedGroupWait: 1 * time.Minute,
expectedGroupInterval: 1 * time.Minute,
expectedGroupByAll: false,
expectedMatchersLen: 1,
expectedMatcherName: "__receiver__",
expectedMatcherValue: "slack",
},
{
name: "no group_wait and group_interval use default values",
confData: `receivers:
- name: 'slack'
route:
group_by: ['alertname']
receiver: 'slack'`,
expectedReceiver: "slack",
expectedGroupWait: 30 * time.Second,
expectedGroupInterval: 5 * time.Minute,
expectedGroupByAll: false,
expectedMatchersLen: 1,
expectedMatcherName: "__receiver__",
expectedMatcherValue: "slack",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
conf, err := config.Load(tc.confData)
if err != nil {
t.Fatal(err)
}
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
route := dispatch.NewRoute(conf.Route, nil)
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, nil, logger, nil)
if err != nil {
t.Fatal(err)
}
defer alerts.Close()
timeout := func(d time.Duration) time.Duration { return time.Duration(0) }
recorder := &recordStage{alerts: make(map[string]map[model.Fingerprint]*alertmanagertypes.Alert)}
metrics := NewDispatcherMetrics(false, prometheus.NewRegistry())
store := nfroutingstoretest.NewMockSQLRouteStore()
store.MatchExpectationsInOrder(false)
nfManager, err := rulebasednotification.New(context.Background(), providerSettings, nfmanager.Config{}, store)
if err != nil {
t.Fatal(err)
}
d := NewDispatcher(alerts, route, recorder, marker, timeout, nil, logger, metrics, nfManager, "test-org")
// setup the dispatcher for tests
d.receiverRoutes = map[string]*dispatch.Route{}
newRoute := d.getOrCreateRoute(tc.expectedReceiver)
require.Equal(t, tc.expectedReceiver, newRoute.RouteOpts.Receiver)
require.Equal(t, tc.expectedGroupWait, newRoute.RouteOpts.GroupWait)
require.Equal(t, tc.expectedGroupInterval, newRoute.RouteOpts.GroupInterval)
require.Equal(t, tc.expectedGroupByAll, newRoute.RouteOpts.GroupByAll)
require.Equal(t, tc.expectedMatchersLen, len(newRoute.Matchers))
require.Equal(t, tc.expectedMatcherName, newRoute.Matchers[0].Name)
require.Equal(t, tc.expectedMatcherValue, newRoute.Matchers[0].Value)
})
}
}

View File

@@ -68,20 +68,30 @@ type Server struct {
wg sync.WaitGroup
stopc chan struct{}
notificationManager nfmanager.NotificationManager
// maintenanceExprMuter is an optional muter for expression-based maintenance scoping
maintenanceExprMuter types.Muter
// muteStageMetrics are created once and reused across SetConfig calls
muteStageMetrics *notify.Metrics
// signozRegisterer is used for metrics in the pipeline
signozRegisterer prometheus.Registerer
}
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore, nfManager nfmanager.NotificationManager) (*Server, error) {
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore, nfManager nfmanager.NotificationManager, maintenanceExprMuter types.Muter) (*Server, error) {
server := &Server{
logger: logger.With("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver"),
registry: registry,
srvConfig: srvConfig,
orgID: orgID,
stateStore: stateStore,
stopc: make(chan struct{}),
notificationManager: nfManager,
logger: logger.With("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver"),
registry: registry,
srvConfig: srvConfig,
orgID: orgID,
stateStore: stateStore,
stopc: make(chan struct{}),
notificationManager: nfManager,
maintenanceExprMuter: maintenanceExprMuter,
}
signozRegisterer := prometheus.WrapRegistererWithPrefix("signoz_", registry)
signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, signozRegisterer)
server.signozRegisterer = prometheus.WrapRegistererWithPrefix("signoz_", registry)
server.signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, server.signozRegisterer)
signozRegisterer := server.signozRegisterer
// initialize marker
server.marker = alertmanagertypes.NewMarker(signozRegisterer)
@@ -198,6 +208,11 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
server.pipelineBuilder = notify.NewPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
server.dispatcherMetrics = NewDispatcherMetrics(false, signozRegisterer)
if server.maintenanceExprMuter != nil {
muteRegisterer := prometheus.WrapRegistererWithPrefix("maintenance_mute_", signozRegisterer)
server.muteStageMetrics = notify.NewMetrics(muteRegisterer, featurecontrol.NoopFlags{})
}
return server, nil
}
@@ -205,6 +220,9 @@ func (server *Server) GetAlerts(ctx context.Context, params alertmanagertypes.Ge
return alertmanagertypes.NewGettableAlertsFromAlertProvider(server.alerts, server.alertmanagerConfig, server.marker.Status, func(labels model.LabelSet) {
server.inhibitor.Mutes(labels)
server.silencer.Mutes(labels)
if server.maintenanceExprMuter != nil {
server.maintenanceExprMuter.Mutes(labels)
}
}, params)
}
@@ -293,6 +311,14 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
pipelinePeer,
)
// Inject expression-based maintenance muter into the pipeline
if server.maintenanceExprMuter != nil {
ms := notify.NewMuteStage(server.maintenanceExprMuter, server.muteStageMetrics)
for name, stage := range pipeline {
pipeline[name] = notify.MultiStage{ms, stage}
}
}
timeoutFunc := func(d time.Duration) time.Duration {
if d < notify.MinTimeout {
d = notify.MinTimeout

View File

@@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"net/http"
"sync"
"testing"
"time"
@@ -22,9 +23,35 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testMuter implements types.Muter for testing maintenance expression muting.
type testMuter struct {
mu sync.RWMutex
muteFunc func(model.LabelSet) bool
calls []model.LabelSet
}
func (m *testMuter) Mutes(labels model.LabelSet) bool {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = append(m.calls, labels)
if m.muteFunc != nil {
return m.muteFunc(labels)
}
return false
}
func (m *testMuter) getCalls() []model.LabelSet {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]model.LabelSet, len(m.calls))
copy(result, m.calls)
return result
}
func TestEndToEndAlertManagerFlow(t *testing.T) {
ctx := context.Background()
providerSettings := instrumentationtest.New().ToProviderSettings()
@@ -90,7 +117,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager)
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager, nil)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)
@@ -221,3 +248,257 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-03\", ruleId=\"high-cpu-usage\"}", alertGroups[2].GroupKey)
})
}
// TestEndToEndMaintenanceMuting verifies that the maintenance expression muter
// integrates correctly with the alertmanager server pipeline:
// 1. MuteStage is injected into the notification pipeline when a muter is provided
// 2. Alerts remain visible in GetAlerts during maintenance (muting suppresses
// notifications, not alert visibility)
// 3. The muter is called during GetAlerts for status resolution
func TestEndToEndMaintenanceMuting(t *testing.T) {
ctx := context.Background()
providerSettings := instrumentationtest.New().ToProviderSettings()
store := nfroutingstoretest.NewMockSQLRouteStore()
store.MatchExpectationsInOrder(false)
notificationManager, err := rulebasednotification.New(ctx, providerSettings, nfmanager.Config{}, store)
require.NoError(t, err)
orgID := "test-org-maintenance"
// Create a muter that mutes alerts with severity == "critical"
muter := &testMuter{
muteFunc: func(labels model.LabelSet) bool {
return string(labels["severity"]) == "critical"
},
}
srvCfg := NewConfig()
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
// Create server WITH the maintenance muter
server, err := New(ctx, logger, registry, srvCfg, orgID, stateStore, notificationManager, muter)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)
err = server.SetConfig(ctx, amConfig)
require.NoError(t, err)
// Put a mix of alerts: 2 critical (should be muted) and 1 warning (should not)
now := time.Now()
testAlerts := []*alertmanagertypes.PostableAlert{
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "disk-usage",
"severity": "critical",
"env": "prod",
"alertname": "DiskUsageHigh",
},
},
Annotations: map[string]string{"summary": "Disk usage critical"},
StartsAt: strfmt.DateTime(now.Add(-5 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "disk-usage",
"severity": "warning",
"env": "prod",
"alertname": "DiskUsageHigh",
},
},
Annotations: map[string]string{"summary": "Disk usage warning"},
StartsAt: strfmt.DateTime(now.Add(-3 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "memory-usage",
"severity": "critical",
"env": "staging",
"alertname": "MemoryUsageHigh",
},
},
Annotations: map[string]string{"summary": "Memory usage critical"},
StartsAt: strfmt.DateTime(now.Add(-2 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
}
err = server.PutAlerts(ctx, testAlerts)
require.NoError(t, err)
time.Sleep(2 * time.Second)
t.Run("alerts_visible_during_maintenance", func(t *testing.T) {
// Maintenance muting suppresses notifications, NOT alert visibility.
// All 3 alerts should still be returned by GetAlerts.
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
require.Len(t, alerts, 3, "All alerts should be visible during maintenance")
// Verify labels are intact
severities := map[string]int{}
for _, alert := range alerts {
severities[alert.Alert.Labels["severity"]]++
}
assert.Equal(t, 2, severities["critical"])
assert.Equal(t, 1, severities["warning"])
})
t.Run("muter_called_during_get_alerts", func(t *testing.T) {
// The muter should have been called for each alert during GetAlerts.
calls := muter.getCalls()
assert.GreaterOrEqual(t, len(calls), 3, "Muter should be called for each alert")
})
t.Run("muter_correctly_identifies_targets", func(t *testing.T) {
// Verify the muter returns correct results for different label sets
assert.True(t, muter.Mutes(model.LabelSet{"severity": "critical", "env": "prod"}),
"Should mute critical alerts")
assert.False(t, muter.Mutes(model.LabelSet{"severity": "warning", "env": "prod"}),
"Should not mute warning alerts")
assert.True(t, muter.Mutes(model.LabelSet{"severity": "critical", "env": "staging"}),
"Should mute critical regardless of env")
})
}
// TestEndToEndMaintenanceCatchAll verifies that a catch-all muter (always returns true)
// mutes all alerts while keeping them visible.
func TestEndToEndMaintenanceCatchAll(t *testing.T) {
ctx := context.Background()
providerSettings := instrumentationtest.New().ToProviderSettings()
store := nfroutingstoretest.NewMockSQLRouteStore()
store.MatchExpectationsInOrder(false)
notificationManager, err := rulebasednotification.New(ctx, providerSettings, nfmanager.Config{}, store)
require.NoError(t, err)
orgID := "test-org-catchall"
// Catch-all muter: mutes everything
muter := &testMuter{
muteFunc: func(labels model.LabelSet) bool {
return true
},
}
srvCfg := NewConfig()
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
server, err := New(ctx, logger, registry, srvCfg, orgID, stateStore, notificationManager, muter)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)
err = server.SetConfig(ctx, amConfig)
require.NoError(t, err)
now := time.Now()
testAlerts := []*alertmanagertypes.PostableAlert{
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "rule-1", "alertname": "Alert1", "env": "prod",
},
},
StartsAt: strfmt.DateTime(now.Add(-1 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "rule-2", "alertname": "Alert2", "env": "staging",
},
},
StartsAt: strfmt.DateTime(now.Add(-1 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
}
err = server.PutAlerts(ctx, testAlerts)
require.NoError(t, err)
time.Sleep(2 * time.Second)
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
assert.Len(t, alerts, 2, "All alerts should remain visible even when catch-all muter is active")
// Verify the muter was called for each alert
calls := muter.getCalls()
assert.GreaterOrEqual(t, len(calls), 2, "Muter should be called for each alert")
}
// TestEndToEndNoMuter verifies the server works correctly without a muter (nil),
// matching the existing behavior where no maintenance muting is configured.
func TestEndToEndNoMuter(t *testing.T) {
ctx := context.Background()
providerSettings := instrumentationtest.New().ToProviderSettings()
store := nfroutingstoretest.NewMockSQLRouteStore()
store.MatchExpectationsInOrder(false)
notificationManager, err := rulebasednotification.New(ctx, providerSettings, nfmanager.Config{}, store)
require.NoError(t, err)
orgID := "test-org-nomuter"
srvCfg := NewConfig()
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
// Create server WITHOUT a muter (nil)
server, err := New(ctx, logger, registry, srvCfg, orgID, stateStore, notificationManager, nil)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)
err = server.SetConfig(ctx, amConfig)
require.NoError(t, err)
now := time.Now()
testAlerts := []*alertmanagertypes.PostableAlert{
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "rule-1", "alertname": "Alert1", "severity": "critical",
},
},
StartsAt: strfmt.DateTime(now.Add(-1 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
}
err = server.PutAlerts(ctx, testAlerts)
require.NoError(t, err)
time.Sleep(2 * time.Second)
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
assert.Len(t, alerts, 1, "Alert should be returned when no muter is configured")
assert.Equal(t, "critical", alerts[0].Alert.Labels["severity"])
}

View File

@@ -25,7 +25,7 @@ import (
func TestServerSetConfigAndStop(t *testing.T) {
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, nil)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -37,7 +37,7 @@ func TestServerSetConfigAndStop(t *testing.T) {
func TestServerTestReceiverTypeWebhook(t *testing.T) {
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, nil)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -85,7 +85,7 @@ func TestServerPutAlerts(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, nil)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -133,7 +133,7 @@ func TestServerTestAlert(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, nil)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -238,7 +238,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, nil)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")

View File

@@ -0,0 +1,531 @@
package clickhousealertmanagerstore
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
signozHistoryDBName = "signoz_analytics"
ruleStateHistoryTableName = "distributed_rule_state_history_v2"
maxPointsInTimeSeries = 300
)
type stateHistoryStore struct {
conn clickhouse.Conn
}
func NewStateHistoryStore(conn clickhouse.Conn) alertmanagertypes.StateHistoryStore {
return &stateHistoryStore{conn: conn}
}
func (s *stateHistoryStore) WriteRuleStateHistory(ctx context.Context, entries []alertmanagertypes.RuleStateHistory) error {
if len(entries) == 0 {
return nil
}
statement, err := s.conn.PrepareBatch(ctx, fmt.Sprintf(
"INSERT INTO %s.%s (org_id, rule_id, rule_name, overall_state, overall_state_changed, state, state_changed, unix_milli, labels, fingerprint, value) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
signozHistoryDBName, ruleStateHistoryTableName))
if err != nil {
return err
}
defer statement.Abort()
for _, h := range entries {
if err := statement.Append(
h.OrgID,
h.RuleID, h.RuleName,
h.OverallState, h.OverallStateChanged,
h.State, h.StateChanged,
h.UnixMilli, h.Labels,
h.Fingerprint, h.Value,
); err != nil {
return err
}
}
return statement.Send()
}
func (s *stateHistoryStore) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]alertmanagertypes.RuleStateHistory, error) {
query := fmt.Sprintf(
"SELECT org_id, rule_id, rule_name, overall_state, overall_state_changed, state, state_changed, unix_milli, labels, fingerprint, value FROM %s.%s WHERE rule_id = '%s' AND state_changed = true ORDER BY unix_milli DESC LIMIT 1 BY fingerprint",
signozHistoryDBName, ruleStateHistoryTableName, ruleID)
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var results []alertmanagertypes.RuleStateHistory
for rows.Next() {
var h alertmanagertypes.RuleStateHistory
if err := rows.Scan(
&h.OrgID,
&h.RuleID, &h.RuleName,
&h.OverallState, &h.OverallStateChanged,
&h.State, &h.StateChanged,
&h.UnixMilli, &h.Labels,
&h.Fingerprint, &h.Value,
); err != nil {
return nil, err
}
results = append(results, h)
}
return results, rows.Err()
}
func (s *stateHistoryStore) GetRuleStateHistoryTimeline(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) (*alertmanagertypes.RuleStateTimeline, error) {
var conditions []string
conditions = append(conditions, fmt.Sprintf("org_id = '%s'", orgID))
conditions = append(conditions, fmt.Sprintf("rule_id = '%s'", ruleID))
conditions = append(conditions, fmt.Sprintf("unix_milli >= %d AND unix_milli < %d", params.Start, params.End))
if params.State.StringValue() != "" {
conditions = append(conditions, fmt.Sprintf("state = '%s'", params.State.StringValue()))
}
whereClause := strings.Join(conditions, " AND ")
// Main query — paginated results.
query := fmt.Sprintf(
"SELECT org_id, rule_id, rule_name, overall_state, overall_state_changed, state, state_changed, unix_milli, labels, fingerprint, value FROM %s.%s WHERE %s ORDER BY unix_milli %s LIMIT %d OFFSET %d",
signozHistoryDBName, ruleStateHistoryTableName, whereClause, params.Order.StringValue(), params.Limit, params.Offset)
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var items []alertmanagertypes.RuleStateHistory
for rows.Next() {
var h alertmanagertypes.RuleStateHistory
if err := rows.Scan(
&h.OrgID,
&h.RuleID, &h.RuleName,
&h.OverallState, &h.OverallStateChanged,
&h.State, &h.StateChanged,
&h.UnixMilli, &h.Labels,
&h.Fingerprint, &h.Value,
); err != nil {
return nil, err
}
items = append(items, h)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Count query.
var total uint64
countQuery := fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE %s",
signozHistoryDBName, ruleStateHistoryTableName, whereClause)
if err := s.conn.QueryRow(ctx, countQuery).Scan(&total); err != nil {
return nil, err
}
// Labels query — distinct labels for the rule.
labelsQuery := fmt.Sprintf("SELECT DISTINCT labels FROM %s.%s WHERE org_id = '%s' AND rule_id = '%s'",
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID)
labelRows, err := s.conn.Query(ctx, labelsQuery)
if err != nil {
return nil, err
}
defer labelRows.Close()
labelsMap := make(map[string][]string)
for labelRows.Next() {
var rawLabel string
if err := labelRows.Scan(&rawLabel); err != nil {
return nil, err
}
label := map[string]string{}
if err := json.Unmarshal([]byte(rawLabel), &label); err != nil {
continue
}
for k, v := range label {
labelsMap[k] = append(labelsMap[k], v)
}
}
if items == nil {
items = []alertmanagertypes.RuleStateHistory{}
}
return &alertmanagertypes.RuleStateTimeline{
Items: items,
Total: total,
Labels: labelsMap,
}, nil
}
func (s *stateHistoryStore) GetRuleStateHistoryTopContributors(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) ([]alertmanagertypes.RuleStateHistoryContributor, error) {
query := fmt.Sprintf(`SELECT
fingerprint,
any(labels) as labels,
count(*) as count
FROM %s.%s
WHERE org_id = '%s' AND rule_id = '%s' AND (state_changed = true) AND (state = 'firing') AND unix_milli >= %d AND unix_milli <= %d
GROUP BY fingerprint
HAVING labels != '{}'
ORDER BY count DESC`,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End)
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var contributors []alertmanagertypes.RuleStateHistoryContributor
for rows.Next() {
var c alertmanagertypes.RuleStateHistoryContributor
if err := rows.Scan(&c.Fingerprint, &c.Labels, &c.Count); err != nil {
return nil, err
}
contributors = append(contributors, c)
}
if contributors == nil {
contributors = []alertmanagertypes.RuleStateHistoryContributor{}
}
return contributors, rows.Err()
}
func (s *stateHistoryStore) GetOverallStateTransitions(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) ([]alertmanagertypes.RuleStateTransition, error) {
tmpl := `WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = 'firing'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = 'inactive'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT *
FROM matched_events
ORDER BY firing_time ASC;`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End)
type transition struct {
RuleID string `ch:"rule_id"`
State string `ch:"state"`
FiringTime int64 `ch:"firing_time"`
ResolutionTime int64 `ch:"resolution_time"`
}
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var transitions []transition
for rows.Next() {
var t transition
if err := rows.Scan(&t.RuleID, &t.State, &t.FiringTime, &t.ResolutionTime); err != nil {
return nil, err
}
transitions = append(transitions, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
var stateItems []alertmanagertypes.RuleStateTransition
for idx, item := range transitions {
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertState{String: valuer.NewString(item.State)},
Start: item.FiringTime,
End: item.ResolutionTime,
})
if idx < len(transitions)-1 {
nextStart := transitions[idx+1].FiringTime
if nextStart > item.ResolutionTime {
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertStateInactive,
Start: item.ResolutionTime,
End: nextStart,
})
}
}
}
// Fetch the most recent state to fill in edges.
var lastStateStr string
stateQuery := fmt.Sprintf(
"SELECT state FROM %s.%s WHERE org_id = '%s' AND rule_id = '%s' AND unix_milli <= %d ORDER BY unix_milli DESC LIMIT 1",
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.End)
if err := s.conn.QueryRow(ctx, stateQuery).Scan(&lastStateStr); err != nil {
lastStateStr = "inactive"
}
if len(transitions) == 0 {
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertState{String: valuer.NewString(lastStateStr)},
Start: params.Start,
End: params.End,
})
} else {
if lastStateStr == "inactive" {
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertStateInactive,
Start: transitions[len(transitions)-1].ResolutionTime,
End: params.End,
})
} else {
// Find the most recent firing event.
var firingTime int64
firingQuery := fmt.Sprintf(
"SELECT unix_milli FROM %s.%s WHERE org_id = '%s' AND rule_id = '%s' AND overall_state_changed = true AND overall_state = 'firing' AND unix_milli <= %d ORDER BY unix_milli DESC LIMIT 1",
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.End)
if err := s.conn.QueryRow(ctx, firingQuery).Scan(&firingTime); err != nil {
firingTime = transitions[len(transitions)-1].ResolutionTime
}
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertStateInactive,
Start: transitions[len(transitions)-1].ResolutionTime,
End: firingTime,
})
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertStateFiring,
Start: firingTime,
End: params.End,
})
}
}
return stateItems, nil
}
func (s *stateHistoryStore) GetTotalTriggers(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) (uint64, error) {
query := fmt.Sprintf(
"SELECT count(*) FROM %s.%s WHERE org_id = '%s' AND rule_id = '%s' AND (state_changed = true) AND (state = 'firing') AND unix_milli >= %d AND unix_milli <= %d",
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End)
var total uint64
if err := s.conn.QueryRow(ctx, query).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
func (s *stateHistoryStore) GetTriggersByInterval(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) (*alertmanagertypes.Series, error) {
step := minAllowedStepInterval(params.Start, params.End)
query := fmt.Sprintf(
"SELECT count(*), toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), INTERVAL %d SECOND) as ts FROM %s.%s WHERE org_id = '%s' AND rule_id = '%s' AND (state_changed = true) AND (state = 'firing') AND unix_milli >= %d AND unix_milli <= %d GROUP BY ts ORDER BY ts ASC",
step, signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End)
return s.queryTimeSeries(ctx, query)
}
func (s *stateHistoryStore) GetAvgResolutionTime(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) (float64, error) {
tmpl := `
WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = 'firing'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = 'inactive'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT AVG(resolution_time - firing_time) / 1000 AS avg_resolution_time
FROM matched_events;`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End)
var avgResolutionTime float64
if err := s.conn.QueryRow(ctx, query).Scan(&avgResolutionTime); err != nil {
return 0, err
}
return avgResolutionTime, nil
}
func (s *stateHistoryStore) GetAvgResolutionTimeByInterval(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) (*alertmanagertypes.Series, error) {
step := minAllowedStepInterval(params.Start, params.End)
tmpl := `
WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = 'firing'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = 'inactive'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT toStartOfInterval(toDateTime(firing_time / 1000), INTERVAL %d SECOND) AS ts, AVG(resolution_time - firing_time) / 1000 AS avg_resolution_time
FROM matched_events
GROUP BY ts
ORDER BY ts ASC;`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End, step)
return s.queryTimeSeries(ctx, query)
}
func (s *stateHistoryStore) queryTimeSeries(ctx context.Context, query string) (*alertmanagertypes.Series, error) {
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
series := &alertmanagertypes.Series{
Labels: map[string]string{},
}
for rows.Next() {
var value float64
var ts interface{}
if err := rows.Scan(&value, &ts); err != nil {
return nil, err
}
// The timestamp may come back as time.Time from ClickHouse.
var timestamp int64
switch v := ts.(type) {
case int64:
timestamp = v
default:
// Try time.Time
if t, ok := ts.(interface{ UnixMilli() int64 }); ok {
timestamp = t.UnixMilli()
}
}
series.Points = append(series.Points, alertmanagertypes.Point{
Timestamp: timestamp,
Value: value,
})
}
if len(series.Points) == 0 {
return nil, nil
}
return series, rows.Err()
}
func minAllowedStepInterval(start, end int64) int64 {
step := (end - start) / maxPointsInTimeSeries / 1000
if step < 60 {
return 60
}
return step - step%60
}

View File

@@ -0,0 +1,165 @@
package sqlalertmanagerstore
import (
"context"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type maintenance struct {
sqlstore sqlstore.SQLStore
}
func NewMaintenanceStore(store sqlstore.SQLStore) alertmanagertypes.MaintenanceStore {
return &maintenance{sqlstore: store}
}
func (r *maintenance) GetAllPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.GettablePlannedMaintenance, error) {
storables := make([]*alertmanagertypes.StorablePlannedMaintenance, 0)
err := r.sqlstore.
BunDB().
NewSelect().
Model(&storables).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, err
}
result := make([]*alertmanagertypes.GettablePlannedMaintenance, 0, len(storables))
for _, s := range storables {
result = append(result, alertmanagertypes.ConvertStorableToGettable(s))
}
return result, nil
}
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.GettablePlannedMaintenance, error) {
storable := new(alertmanagertypes.StorablePlannedMaintenance)
err := r.sqlstore.
BunDB().
NewSelect().
Model(storable).
Where("id = ?", id.StringValue()).
Scan(ctx)
if err != nil {
return nil, err
}
return alertmanagertypes.ConvertStorableToGettable(storable), nil
}
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance) (valuer.UUID, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return valuer.UUID{}, err
}
var ruleIDsStr string
if len(maintenance.RuleIDs) > 0 {
data, err := json.Marshal(maintenance.RuleIDs)
if err != nil {
return valuer.UUID{}, err
}
ruleIDsStr = string(data)
}
storable := alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: claims.Email,
UpdatedBy: claims.Email,
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
RuleIDs: ruleIDsStr,
Expression: maintenance.Expression,
OrgID: claims.OrgID,
}
_, err = r.sqlstore.
BunDB().
NewInsert().
Model(&storable).
Exec(ctx)
if err != nil {
return valuer.UUID{}, err
}
return storable.ID, nil
}
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
_, err := r.sqlstore.
BunDB().
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
return err
}
return nil
}
func (r *maintenance) EditPlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance, id valuer.UUID) error {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return err
}
var ruleIDsStr string
if len(maintenance.RuleIDs) > 0 {
data, err := json.Marshal(maintenance.RuleIDs)
if err != nil {
return err
}
ruleIDsStr = string(data)
}
storable := alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: id,
},
TimeAuditable: types.TimeAuditable{
CreatedAt: maintenance.CreatedAt,
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: maintenance.CreatedBy,
UpdatedBy: claims.Email,
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
RuleIDs: ruleIDsStr,
Expression: maintenance.Expression,
OrgID: claims.OrgID,
}
_, err = r.sqlstore.
BunDB().
NewUpdate().
Model(&storable).
Where("id = ?", storable.ID.StringValue()).
Exec(ctx)
if err != nil {
return err
}
return nil
}

View File

@@ -1845,3 +1845,216 @@ func (_c *MockAlertmanager_UpdateRoutePolicyByID_Call) RunAndReturn(run func(ctx
_c.Call.Return(run)
return _c
}
// GetAllPlannedMaintenance provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetAllPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.GettablePlannedMaintenance, error) {
ret := _mock.Called(ctx, orgID)
if len(ret) == 0 {
panic("no return value specified for GetAllPlannedMaintenance")
}
var r0 []*alertmanagertypes.GettablePlannedMaintenance
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]*alertmanagertypes.GettablePlannedMaintenance, error)); ok {
return returnFunc(ctx, orgID)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*alertmanagertypes.GettablePlannedMaintenance)
}
r1 = ret.Error(1)
return r0, r1
}
// GetPlannedMaintenanceByID provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.GettablePlannedMaintenance, error) {
ret := _mock.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for GetPlannedMaintenanceByID")
}
var r0 *alertmanagertypes.GettablePlannedMaintenance
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) (*alertmanagertypes.GettablePlannedMaintenance, error)); ok {
return returnFunc(ctx, id)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.GettablePlannedMaintenance)
}
r1 = ret.Error(1)
return r0, r1
}
// CreatePlannedMaintenance provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) CreatePlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance) (valuer.UUID, error) {
ret := _mock.Called(ctx, maintenance)
if len(ret) == 0 {
panic("no return value specified for CreatePlannedMaintenance")
}
var r0 valuer.UUID
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, alertmanagertypes.GettablePlannedMaintenance) (valuer.UUID, error)); ok {
return returnFunc(ctx, maintenance)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, alertmanagertypes.GettablePlannedMaintenance) valuer.UUID); ok {
r0 = returnFunc(ctx, maintenance)
} else {
r0 = ret.Get(0).(valuer.UUID)
}
r1 = ret.Error(1)
return r0, r1
}
// EditPlannedMaintenance provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) EditPlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance, id valuer.UUID) error {
ret := _mock.Called(ctx, maintenance, id)
if len(ret) == 0 {
panic("no return value specified for EditPlannedMaintenance")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, alertmanagertypes.GettablePlannedMaintenance, valuer.UUID) error); ok {
r0 = returnFunc(ctx, maintenance, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeletePlannedMaintenance provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
ret := _mock.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for DeletePlannedMaintenance")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) error); ok {
r0 = returnFunc(ctx, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// RecordRuleStateHistory provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) RecordRuleStateHistory(ctx context.Context, orgID string, entries []alertmanagertypes.RuleStateHistory) error {
ret := _mock.Called(ctx, orgID, entries)
if len(ret) == 0 {
panic("no return value specified for RecordRuleStateHistory")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, []alertmanagertypes.RuleStateHistory) error); ok {
r0 = returnFunc(ctx, orgID, entries)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetLastSavedRuleStateHistory provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]alertmanagertypes.RuleStateHistory, error) {
ret := _mock.Called(ctx, ruleID)
if len(ret) == 0 {
panic("no return value specified for GetLastSavedRuleStateHistory")
}
var r0 []alertmanagertypes.RuleStateHistory
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]alertmanagertypes.RuleStateHistory, error)); ok {
return returnFunc(ctx, ruleID)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).([]alertmanagertypes.RuleStateHistory)
}
r1 = ret.Error(1)
return r0, r1
}
// GetRuleStateHistoryTimeline provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetRuleStateHistoryTimeline(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error) {
ret := _mock.Called(ctx, orgID, ruleID, params)
if len(ret) == 0 {
panic("no return value specified for GetRuleStateHistoryTimeline")
}
var r0 *alertmanagertypes.RuleStateTimeline
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error)); ok {
return returnFunc(ctx, orgID, ruleID, params)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.RuleStateTimeline)
}
r1 = ret.Error(1)
return r0, r1
}
// GetRuleStateHistoryTopContributors provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetRuleStateHistoryTopContributors(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error) {
ret := _mock.Called(ctx, orgID, ruleID, params)
if len(ret) == 0 {
panic("no return value specified for GetRuleStateHistoryTopContributors")
}
var r0 []alertmanagertypes.RuleStateHistoryContributor
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error)); ok {
return returnFunc(ctx, orgID, ruleID, params)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).([]alertmanagertypes.RuleStateHistoryContributor)
}
r1 = ret.Error(1)
return r0, r1
}
// GetOverallStateTransitions provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetOverallStateTransitions(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error) {
ret := _mock.Called(ctx, orgID, ruleID, params)
if len(ret) == 0 {
panic("no return value specified for GetOverallStateTransitions")
}
var r0 []alertmanagertypes.RuleStateTransition
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error)); ok {
return returnFunc(ctx, orgID, ruleID, params)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).([]alertmanagertypes.RuleStateTransition)
}
r1 = ret.Error(1)
return r0, r1
}
// GetRuleStats provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetRuleStats(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStats, error) {
ret := _mock.Called(ctx, orgID, ruleID, params)
if len(ret) == 0 {
panic("no return value specified for GetRuleStats")
}
var r0 *alertmanagertypes.RuleStats
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStats, error)); ok {
return returnFunc(ctx, orgID, ruleID, params)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.RuleStats)
}
r1 = ret.Error(1)
return r0, r1
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -399,3 +400,312 @@ func (api *API) UpdateRoutePolicy(rw http.ResponseWriter, req *http.Request) {
}
render.Success(rw, http.StatusOK, result)
}
func (api *API) ListDowntimeSchedules(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
schedules, err := api.alertmanager.GetAllPlannedMaintenance(ctx, claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
if q := req.URL.Query().Get("active"); q != "" {
active, _ := strconv.ParseBool(q)
filtered := make([]*alertmanagertypes.GettablePlannedMaintenance, 0)
for _, schedule := range schedules {
now := time.Now().In(time.FixedZone(schedule.Schedule.Timezone, 0))
if schedule.IsActive(now) == active {
filtered = append(filtered, schedule)
}
}
schedules = filtered
}
if q := req.URL.Query().Get("recurring"); q != "" {
recurring, _ := strconv.ParseBool(q)
filtered := make([]*alertmanagertypes.GettablePlannedMaintenance, 0)
for _, schedule := range schedules {
if schedule.IsRecurring() == recurring {
filtered = append(filtered, schedule)
}
}
schedules = filtered
}
render.Success(rw, http.StatusOK, schedules)
}
func (api *API) GetDowntimeSchedule(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
vars := mux.Vars(req)
idString, ok := vars["id"]
if !ok {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required in path"))
return
}
id, err := valuer.NewUUID(idString)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
schedule, err := api.alertmanager.GetPlannedMaintenanceByID(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, schedule)
}
func (api *API) CreateDowntimeSchedule(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
body, err := io.ReadAll(req.Body)
if err != nil {
render.Error(rw, err)
return
}
defer req.Body.Close() //nolint:errcheck
var schedule alertmanagertypes.GettablePlannedMaintenance
if err := json.Unmarshal(body, &schedule); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := schedule.Validate(); err != nil {
render.Error(rw, err)
return
}
_, err = api.alertmanager.CreatePlannedMaintenance(ctx, schedule)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, nil)
}
func (api *API) EditDowntimeSchedule(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
vars := mux.Vars(req)
idString, ok := vars["id"]
if !ok {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required in path"))
return
}
id, err := valuer.NewUUID(idString)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
body, err := io.ReadAll(req.Body)
if err != nil {
render.Error(rw, err)
return
}
defer req.Body.Close() //nolint:errcheck
var schedule alertmanagertypes.GettablePlannedMaintenance
if err := json.Unmarshal(body, &schedule); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := schedule.Validate(); err != nil {
render.Error(rw, err)
return
}
err = api.alertmanager.EditPlannedMaintenance(ctx, schedule, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, nil)
}
func (api *API) DeleteDowntimeSchedule(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
vars := mux.Vars(req)
idString, ok := vars["id"]
if !ok {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required in path"))
return
}
id, err := valuer.NewUUID(idString)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
err = api.alertmanager.DeletePlannedMaintenance(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (api *API) GetRuleStateHistoryTimeline(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
ruleID := mux.Vars(req)["id"]
if ruleID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule ID is required"))
return
}
var params alertmanagertypes.QueryRuleStateHistory
if err := json.NewDecoder(req.Body).Decode(&params); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := api.alertmanager.GetRuleStateHistoryTimeline(ctx, claims.OrgID, ruleID, &params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
func (api *API) GetRuleStats(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
ruleID := mux.Vars(req)["id"]
if ruleID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule ID is required"))
return
}
var params alertmanagertypes.QueryRuleStateHistory
if err := json.NewDecoder(req.Body).Decode(&params); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := api.alertmanager.GetRuleStats(ctx, claims.OrgID, ruleID, &params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
func (api *API) GetRuleStateHistoryTopContributors(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
ruleID := mux.Vars(req)["id"]
if ruleID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule ID is required"))
return
}
var params alertmanagertypes.QueryRuleStateHistory
if err := json.NewDecoder(req.Body).Decode(&params); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := api.alertmanager.GetRuleStateHistoryTopContributors(ctx, claims.OrgID, ruleID, &params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
func (api *API) GetOverallStateTransitions(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
ruleID := mux.Vars(req)["id"]
if ruleID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule ID is required"))
return
}
var params alertmanagertypes.QueryRuleStateHistory
if err := json.NewDecoder(req.Body).Decode(&params); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := api.alertmanager.GetOverallStateTransitions(ctx, claims.OrgID, ruleID, &params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

1123
pkg/alertmanager/api_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,15 @@ package alertmanager
import (
"context"
"encoding/json"
"math"
"sync"
"time"
"github.com/prometheus/alertmanager/featurecontrol"
"github.com/prometheus/alertmanager/matcher/compat"
amtypes "github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
@@ -38,6 +43,15 @@ type Service struct {
serversMtx sync.RWMutex
notificationManager nfmanager.NotificationManager
// maintenanceExprMuter is an optional muter for expression-based maintenance scoping
maintenanceExprMuter amtypes.Muter
// stateHistoryStore writes rule state history to persistent storage (e.g. ClickHouse)
stateHistoryStore alertmanagertypes.StateHistoryStore
// stateTracker tracks alert state transitions for v2 state history recording
stateTracker *stateTracker
}
func New(
@@ -48,16 +62,21 @@ func New(
configStore alertmanagertypes.ConfigStore,
orgGetter organization.Getter,
nfManager nfmanager.NotificationManager,
maintenanceExprMuter amtypes.Muter,
stateHistoryStore alertmanagertypes.StateHistoryStore,
) *Service {
service := &Service{
config: config,
stateStore: stateStore,
configStore: configStore,
orgGetter: orgGetter,
settings: settings,
servers: make(map[string]*alertmanagerserver.Server),
serversMtx: sync.RWMutex{},
notificationManager: nfManager,
config: config,
stateStore: stateStore,
configStore: configStore,
orgGetter: orgGetter,
settings: settings,
servers: make(map[string]*alertmanagerserver.Server),
serversMtx: sync.RWMutex{},
notificationManager: nfManager,
maintenanceExprMuter: maintenanceExprMuter,
stateHistoryStore: stateHistoryStore,
stateTracker: newStateTracker(),
}
return service
@@ -131,7 +150,21 @@ func (service *Service) PutAlerts(ctx context.Context, orgID string, alerts aler
return err
}
return server.PutAlerts(ctx, alerts)
// Convert to typed alerts for state tracking (same conversion the server does).
now := time.Now()
typedAlerts, _ := alertmanagertypes.NewAlertsFromPostableAlerts(
alerts, time.Duration(service.config.Global.ResolveTimeout), now,
)
// Delegate to server for notification pipeline.
if err := server.PutAlerts(ctx, alerts); err != nil {
return err
}
// Record state history from the incoming alerts.
service.recordStateHistoryFromAlerts(ctx, orgID, typedAlerts, now)
return nil
}
func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) error {
@@ -176,7 +209,7 @@ func (service *Service) newServer(ctx context.Context, orgID string) (*alertmana
return nil, err
}
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore, service.notificationManager)
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore, service.notificationManager, service.maintenanceExprMuter)
if err != nil {
return nil, err
}
@@ -255,6 +288,205 @@ func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConf
}
// RecordRuleStateHistory applies maintenance muting logic and writes state history entries.
// For each entry with State=="firing", if the maintenance muter matches the entry's labels,
// the state is changed to "muted" before writing.
func (service *Service) RecordRuleStateHistory(ctx context.Context, orgID string, entries []alertmanagertypes.RuleStateHistory) error {
if service.stateHistoryStore == nil {
return nil
}
for i := range entries {
entries[i].OrgID = orgID
}
if service.maintenanceExprMuter != nil {
for i := range entries {
if entries[i].State != "firing" {
continue
}
lbls := labelsFromJSON(entries[i].Labels)
if lbls == nil {
continue
}
// Add ruleId to the label set for muter matching.
lbls["ruleId"] = model.LabelValue(entries[i].RuleID)
if service.maintenanceExprMuter.Mutes(lbls) {
entries[i].State = "muted"
}
}
}
return service.stateHistoryStore.WriteRuleStateHistory(ctx, entries)
}
func (service *Service) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]alertmanagertypes.RuleStateHistory, error) {
if service.stateHistoryStore == nil {
return nil, nil
}
return service.stateHistoryStore.GetLastSavedRuleStateHistory(ctx, ruleID)
}
func (service *Service) GetRuleStateHistoryTimeline(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error) {
if service.stateHistoryStore == nil {
return &alertmanagertypes.RuleStateTimeline{Items: []alertmanagertypes.RuleStateHistory{}}, nil
}
return service.stateHistoryStore.GetRuleStateHistoryTimeline(ctx, orgID, ruleID, params)
}
func (service *Service) GetRuleStateHistoryTopContributors(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error) {
if service.stateHistoryStore == nil {
return []alertmanagertypes.RuleStateHistoryContributor{}, nil
}
return service.stateHistoryStore.GetRuleStateHistoryTopContributors(ctx, orgID, ruleID, params)
}
func (service *Service) GetOverallStateTransitions(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error) {
if service.stateHistoryStore == nil {
return []alertmanagertypes.RuleStateTransition{}, nil
}
return service.stateHistoryStore.GetOverallStateTransitions(ctx, orgID, ruleID, params)
}
func (service *Service) GetRuleStats(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStats, error) {
if service.stateHistoryStore == nil {
return &alertmanagertypes.RuleStats{}, nil
}
store := service.stateHistoryStore
// Current period stats.
totalCurrentTriggers, err := store.GetTotalTriggers(ctx, orgID, ruleID, params)
if err != nil {
return nil, err
}
currentTriggersSeries, err := store.GetTriggersByInterval(ctx, orgID, ruleID, params)
if err != nil {
return nil, err
}
currentAvgResolutionTime, err := store.GetAvgResolutionTime(ctx, orgID, ruleID, params)
if err != nil {
return nil, err
}
currentAvgResolutionTimeSeries, err := store.GetAvgResolutionTimeByInterval(ctx, orgID, ruleID, params)
if err != nil {
return nil, err
}
// Past period stats — shift time window backward.
pastParams := *params
duration := params.End - params.Start
if duration >= 86400000 {
days := int64(math.Ceil(float64(duration) / 86400000))
pastParams.Start -= days * 86400000
pastParams.End -= days * 86400000
} else {
pastParams.Start -= 86400000
pastParams.End -= 86400000
}
totalPastTriggers, err := store.GetTotalTriggers(ctx, orgID, ruleID, &pastParams)
if err != nil {
return nil, err
}
pastTriggersSeries, err := store.GetTriggersByInterval(ctx, orgID, ruleID, &pastParams)
if err != nil {
return nil, err
}
pastAvgResolutionTime, err := store.GetAvgResolutionTime(ctx, orgID, ruleID, &pastParams)
if err != nil {
return nil, err
}
pastAvgResolutionTimeSeries, err := store.GetAvgResolutionTimeByInterval(ctx, orgID, ruleID, &pastParams)
if err != nil {
return nil, err
}
if math.IsNaN(currentAvgResolutionTime) || math.IsInf(currentAvgResolutionTime, 0) {
currentAvgResolutionTime = 0
}
if math.IsNaN(pastAvgResolutionTime) || math.IsInf(pastAvgResolutionTime, 0) {
pastAvgResolutionTime = 0
}
return &alertmanagertypes.RuleStats{
TotalCurrentTriggers: totalCurrentTriggers,
TotalPastTriggers: totalPastTriggers,
CurrentTriggersSeries: currentTriggersSeries,
PastTriggersSeries: pastTriggersSeries,
CurrentAvgResolutionTime: currentAvgResolutionTime,
PastAvgResolutionTime: pastAvgResolutionTime,
CurrentAvgResolutionTimeSeries: currentAvgResolutionTimeSeries,
PastAvgResolutionTimeSeries: pastAvgResolutionTimeSeries,
}, nil
}
// recordStateHistoryFromAlerts detects state transitions from incoming alerts
// and records them via RecordRuleStateHistory (which applies maintenance muting).
func (service *Service) recordStateHistoryFromAlerts(ctx context.Context, orgID string, alerts []*amtypes.Alert, now time.Time) {
if service.stateHistoryStore == nil {
return
}
entries := service.stateTracker.processAlerts(orgID, alerts, now)
if len(entries) == 0 {
return
}
if err := service.RecordRuleStateHistory(ctx, orgID, entries); err != nil {
service.settings.Logger().ErrorContext(ctx, "failed to record state history", "error", err)
}
}
// StartStateHistorySweep starts a background goroutine that periodically checks
// for stale firing alerts and records them as resolved. Call this once after creating the service.
func (service *Service) StartStateHistorySweep(ctx context.Context) {
if service.stateHistoryStore == nil {
return
}
staleTimeout := 2 * time.Duration(service.config.Global.ResolveTimeout)
if staleTimeout == 0 {
staleTimeout = 10 * time.Minute
}
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
now := time.Now()
entriesByOrg := service.stateTracker.sweepStale(staleTimeout, now)
for orgID, orgEntries := range entriesByOrg {
if err := service.RecordRuleStateHistory(ctx, orgID, orgEntries); err != nil {
service.settings.Logger().ErrorContext(ctx, "failed to record stale state history", "org_id", orgID, "error", err)
}
}
}
}
}()
}
// labelsFromJSON parses a JSON string of labels into a model.LabelSet.
func labelsFromJSON(labelsJSON string) model.LabelSet {
if labelsJSON == "" {
return nil
}
var m map[string]string
if err := json.Unmarshal([]byte(labelsJSON), &m); err != nil {
return nil
}
ls := make(model.LabelSet, len(m))
for k, v := range m {
ls[model.LabelName(k)] = model.LabelValue(v)
}
return ls
}
// getServer returns the server for the given orgID. It should be called with the lock held.
func (service *Service) getServer(orgID string) (*alertmanagerserver.Server, error) {
server, ok := service.servers[orgID]

View File

@@ -0,0 +1,426 @@
package alertmanager
import (
"context"
"math"
"sync"
"testing"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testMuter implements amtypes.Muter for testing.
type testMuter struct {
mu sync.Mutex
muteFunc func(model.LabelSet) bool
calls []model.LabelSet
}
func (m *testMuter) Mutes(labels model.LabelSet) bool {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = append(m.calls, labels)
if m.muteFunc != nil {
return m.muteFunc(labels)
}
return false
}
func (m *testMuter) getCalls() []model.LabelSet {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]model.LabelSet, len(m.calls))
copy(result, m.calls)
return result
}
// fakeStateHistoryStore captures calls for assertion.
type fakeStateHistoryStore struct {
written []alertmanagertypes.RuleStateHistory
lastErr error
getResult []alertmanagertypes.RuleStateHistory
getErr error
// Stats method returns
totalTriggers uint64
totalTriggersErr error
triggersSeries *alertmanagertypes.Series
triggersSeriesErr error
avgResolutionTime float64
avgResolutionTimeErr error
avgResTimeSeries *alertmanagertypes.Series
avgResTimeSeriesErr error
// Captures params passed to stats methods
statsCalls []*alertmanagertypes.QueryRuleStateHistory
}
func (w *fakeStateHistoryStore) WriteRuleStateHistory(_ context.Context, entries []alertmanagertypes.RuleStateHistory) error {
w.written = append(w.written, entries...)
return w.lastErr
}
func (w *fakeStateHistoryStore) GetLastSavedRuleStateHistory(_ context.Context, _ string) ([]alertmanagertypes.RuleStateHistory, error) {
return w.getResult, w.getErr
}
func (w *fakeStateHistoryStore) GetRuleStateHistoryTimeline(_ context.Context, _ string, _ string, _ *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error) {
return nil, nil
}
func (w *fakeStateHistoryStore) GetRuleStateHistoryTopContributors(_ context.Context, _ string, _ string, _ *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error) {
return nil, nil
}
func (w *fakeStateHistoryStore) GetOverallStateTransitions(_ context.Context, _ string, _ string, _ *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error) {
return nil, nil
}
func (w *fakeStateHistoryStore) GetTotalTriggers(_ context.Context, _ string, _ string, params *alertmanagertypes.QueryRuleStateHistory) (uint64, error) {
w.statsCalls = append(w.statsCalls, params)
return w.totalTriggers, w.totalTriggersErr
}
func (w *fakeStateHistoryStore) GetTriggersByInterval(_ context.Context, _ string, _ string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.Series, error) {
w.statsCalls = append(w.statsCalls, params)
return w.triggersSeries, w.triggersSeriesErr
}
func (w *fakeStateHistoryStore) GetAvgResolutionTime(_ context.Context, _ string, _ string, params *alertmanagertypes.QueryRuleStateHistory) (float64, error) {
w.statsCalls = append(w.statsCalls, params)
return w.avgResolutionTime, w.avgResolutionTimeErr
}
func (w *fakeStateHistoryStore) GetAvgResolutionTimeByInterval(_ context.Context, _ string, _ string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.Series, error) {
w.statsCalls = append(w.statsCalls, params)
return w.avgResTimeSeries, w.avgResTimeSeriesErr
}
func TestLabelsFromJSON(t *testing.T) {
tests := []struct {
name string
input string
want model.LabelSet
}{
{
name: "empty string",
input: "",
want: nil,
},
{
name: "invalid json",
input: "not json",
want: nil,
},
{
name: "valid labels",
input: `{"env":"prod","severity":"critical"}`,
want: model.LabelSet{
"env": "prod",
"severity": "critical",
},
},
{
name: "empty object",
input: `{}`,
want: model.LabelSet{},
},
{
name: "single label",
input: `{"alertname":"HighCPU"}`,
want: model.LabelSet{"alertname": "HighCPU"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := labelsFromJSON(tc.input)
assert.Equal(t, tc.want, got)
})
}
}
func TestRecordRuleStateHistory(t *testing.T) {
ctx := context.Background()
t.Run("nil writer returns nil", func(t *testing.T) {
svc := &Service{stateHistoryStore: nil}
err := svc.RecordRuleStateHistory(ctx, "org-1", []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing"},
})
require.NoError(t, err)
})
t.Run("no muter writes entries unchanged", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: nil,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Labels: `{"env":"prod"}`},
{RuleID: "r2", State: "normal", Labels: `{"env":"staging"}`},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 2)
assert.Equal(t, "firing", writer.written[0].State)
assert.Equal(t, "normal", writer.written[1].State)
})
t.Run("muter changes firing to muted when matched", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(ls model.LabelSet) bool {
return ls["env"] == "prod"
},
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Labels: `{"env":"prod"}`},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 1)
assert.Equal(t, "muted", writer.written[0].State)
})
t.Run("muter does not change firing when not matched", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(ls model.LabelSet) bool {
return ls["env"] == "prod"
},
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Labels: `{"env":"staging"}`},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 1)
assert.Equal(t, "firing", writer.written[0].State)
})
t.Run("muter only affects firing entries", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(model.LabelSet) bool { return true }, // mute everything
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "normal", Labels: `{"env":"prod"}`},
{RuleID: "r2", State: "no_data", Labels: `{"env":"prod"}`},
{RuleID: "r3", State: "firing", Labels: `{"env":"prod"}`},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 3)
assert.Equal(t, "normal", writer.written[0].State, "normal should not be muted")
assert.Equal(t, "no_data", writer.written[1].State, "no_data should not be muted")
assert.Equal(t, "muted", writer.written[2].State, "firing should become muted")
})
t.Run("ruleId is injected into labels for muter evaluation", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(ls model.LabelSet) bool {
return ls["ruleId"] == "target-rule"
},
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "target-rule", State: "firing", Labels: `{"env":"prod"}`},
{RuleID: "other-rule", State: "firing", Labels: `{"env":"prod"}`},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 2)
assert.Equal(t, "muted", writer.written[0].State, "target-rule should be muted")
assert.Equal(t, "firing", writer.written[1].State, "other-rule should not be muted")
// Verify the muter received labels with ruleId injected
calls := muter.getCalls()
require.Len(t, calls, 2)
assert.Equal(t, model.LabelValue("target-rule"), calls[0]["ruleId"])
assert.Equal(t, model.LabelValue("other-rule"), calls[1]["ruleId"])
})
t.Run("invalid labels JSON skips muting check", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(model.LabelSet) bool { return true },
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Labels: "not-json"},
{RuleID: "r2", State: "firing", Labels: ""},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 2)
// Both should stay firing because labels couldn't be parsed
assert.Equal(t, "firing", writer.written[0].State)
assert.Equal(t, "firing", writer.written[1].State)
// Muter should not have been called
assert.Empty(t, muter.getCalls())
})
t.Run("mixed entries with selective muting", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(ls model.LabelSet) bool {
return ls["severity"] == "warning"
},
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Labels: `{"severity":"critical"}`, Fingerprint: 1},
{RuleID: "r2", State: "firing", Labels: `{"severity":"warning"}`, Fingerprint: 2},
{RuleID: "r3", State: "normal", Labels: `{"severity":"warning"}`, Fingerprint: 3},
{RuleID: "r4", State: "firing", Labels: `{"severity":"warning"}`, Fingerprint: 4},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 4)
assert.Equal(t, "firing", writer.written[0].State, "critical firing stays firing")
assert.Equal(t, "muted", writer.written[1].State, "warning firing becomes muted")
assert.Equal(t, "normal", writer.written[2].State, "normal is never muted")
assert.Equal(t, "muted", writer.written[3].State, "warning firing becomes muted")
})
}
func TestGetLastSavedRuleStateHistory(t *testing.T) {
ctx := context.Background()
t.Run("nil writer returns nil", func(t *testing.T) {
svc := &Service{stateHistoryStore: nil}
result, err := svc.GetLastSavedRuleStateHistory(ctx, "r1")
require.NoError(t, err)
assert.Nil(t, result)
})
t.Run("delegates to writer", func(t *testing.T) {
expected := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Fingerprint: 123},
}
writer := &fakeStateHistoryStore{getResult: expected}
svc := &Service{stateHistoryStore: writer}
result, err := svc.GetLastSavedRuleStateHistory(ctx, "r1")
require.NoError(t, err)
assert.Equal(t, expected, result)
})
}
func TestGetRuleStats(t *testing.T) {
ctx := context.Background()
t.Run("aggregates current and past period stats", func(t *testing.T) {
currentSeries := &alertmanagertypes.Series{Points: []alertmanagertypes.Point{{Timestamp: 1000, Value: 5}}}
currentResSeries := &alertmanagertypes.Series{Points: []alertmanagertypes.Point{{Timestamp: 1000, Value: 120}}}
store := &fakeStateHistoryStore{
totalTriggers: 10,
triggersSeries: currentSeries,
avgResolutionTime: 300.5,
avgResTimeSeries: currentResSeries,
}
svc := &Service{stateHistoryStore: store}
params := &alertmanagertypes.QueryRuleStateHistory{
Start: 1000,
End: 90000000, // ~1 day
}
result, err := svc.GetRuleStats(ctx, "org-1", "rule-1", params)
require.NoError(t, err)
assert.Equal(t, uint64(10), result.TotalCurrentTriggers)
assert.Equal(t, uint64(10), result.TotalPastTriggers)
assert.Equal(t, currentSeries, result.CurrentTriggersSeries)
assert.Equal(t, 300.5, result.CurrentAvgResolutionTime)
})
t.Run("period shifting for duration >= 1 day", func(t *testing.T) {
store := &fakeStateHistoryStore{}
svc := &Service{stateHistoryStore: store}
// 2-day window: Start=0, End=172800000 (2 days in millis)
params := &alertmanagertypes.QueryRuleStateHistory{
Start: 0,
End: 172800000,
}
_, err := svc.GetRuleStats(ctx, "org-1", "rule-1", params)
require.NoError(t, err)
// First 4 calls are current period, next 4 are past period.
// For 2 days: ceil(172800000/86400000) = 2, shift = 2*86400000 = 172800000
require.GreaterOrEqual(t, len(store.statsCalls), 8)
pastParams := store.statsCalls[4] // first past period call
assert.Equal(t, int64(-172800000), pastParams.Start)
assert.Equal(t, int64(0), pastParams.End)
})
t.Run("period shifting for duration < 1 day", func(t *testing.T) {
store := &fakeStateHistoryStore{}
svc := &Service{stateHistoryStore: store}
// 1-hour window
params := &alertmanagertypes.QueryRuleStateHistory{
Start: 100000000,
End: 103600000, // 3600000ms = 1 hour
}
_, err := svc.GetRuleStats(ctx, "org-1", "rule-1", params)
require.NoError(t, err)
// For < 1 day: shift by exactly 1 day (86400000ms)
require.GreaterOrEqual(t, len(store.statsCalls), 8)
pastParams := store.statsCalls[4]
assert.Equal(t, int64(100000000-86400000), pastParams.Start)
assert.Equal(t, int64(103600000-86400000), pastParams.End)
})
t.Run("NaN and Inf avg resolution times are zeroed", func(t *testing.T) {
for _, val := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
store := &fakeStateHistoryStore{
avgResolutionTime: val,
}
svc := &Service{stateHistoryStore: store}
result, err := svc.GetRuleStats(ctx, "org-1", "rule-1", &alertmanagertypes.QueryRuleStateHistory{
Start: 0, End: 100000000,
})
require.NoError(t, err)
assert.Equal(t, float64(0), result.CurrentAvgResolutionTime)
assert.Equal(t, float64(0), result.PastAvgResolutionTime)
}
})
}

View File

@@ -0,0 +1,150 @@
package signozalertmanager
import (
"log/slog"
"strings"
"sync"
"github.com/expr-lang/expr"
"github.com/prometheus/common/model"
)
// convertLabelSetToEnv converts a flat label set with dotted keys into a nested map
// structure for expr-lang evaluation. When both a leaf and a deeper nested path exist
// (e.g. "foo" and "foo.bar"), the nested structure takes precedence.
func convertLabelSetToEnv(labelSet model.LabelSet) map[string]interface{} {
env := make(map[string]interface{})
for lk, lv := range labelSet {
key := strings.TrimSpace(string(lk))
value := string(lv)
if strings.Contains(key, ".") {
parts := strings.Split(key, ".")
current := env
for i, raw := range parts {
part := strings.TrimSpace(raw)
last := i == len(parts)-1
if last {
if _, isMap := current[part].(map[string]interface{}); isMap {
break
}
current[part] = value
break
}
if nextMap, ok := current[part].(map[string]interface{}); ok {
current = nextMap
continue
}
newMap := make(map[string]interface{})
current[part] = newMap
current = newMap
}
continue
}
if _, isMap := env[key].(map[string]interface{}); isMap {
continue
}
env[key] = value
}
return env
}
// evaluateExpr compiles and runs an expr-lang expression against the given label set.
func evaluateExpr(expression string, labelSet model.LabelSet) (bool, error) {
env := convertLabelSetToEnv(labelSet)
program, err := expr.Compile(expression, expr.Env(env))
if err != nil {
return false, err
}
output, err := expr.Run(program, env)
if err != nil {
return false, err
}
if boolVal, ok := output.(bool); ok {
return boolVal, nil
}
return false, nil
}
// activeMaintenanceExpr holds an active maintenance's scoping criteria.
// Muting logic: (ruleIDs match OR ruleIDs empty) AND (expression match OR expression empty).
type activeMaintenanceExpr struct {
ruleIDs []string
expression string
}
// MaintenanceExprMuter implements types.Muter for expression-based maintenance scoping.
// It evaluates expr-lang expressions against alert labels to determine if an alert
// should be muted (suppressed) during a maintenance window.
type MaintenanceExprMuter struct {
mu sync.RWMutex
expressions []activeMaintenanceExpr
logger *slog.Logger
}
// NewMaintenanceExprMuter creates a new MaintenanceExprMuter.
func NewMaintenanceExprMuter(logger *slog.Logger) *MaintenanceExprMuter {
return &MaintenanceExprMuter{
logger: logger,
}
}
// Mutes returns true if the given label set matches any active maintenance entry.
// Each entry uses AND logic: (ruleIDs match OR empty) AND (expression match OR empty).
// Empty ruleIDs means all rules are in scope. Empty expression means all labels match.
func (m *MaintenanceExprMuter) Mutes(labels model.LabelSet) bool {
m.mu.RLock()
defer m.mu.RUnlock()
for _, ae := range m.expressions {
// Check rule scope: empty ruleIDs means all rules match.
ruleMatch := len(ae.ruleIDs) == 0
if !ruleMatch {
alertRuleID := string(labels["ruleId"])
for _, rid := range ae.ruleIDs {
if rid == alertRuleID {
ruleMatch = true
break
}
}
}
if !ruleMatch {
continue
}
// Check expression scope: empty expression means all labels match.
if ae.expression == "" {
return true
}
matched, err := evaluateExpr(ae.expression, labels)
if err != nil {
m.logger.Error("failed to evaluate maintenance expression",
"expression", ae.expression,
"error", err)
continue
}
if matched {
return true
}
}
return false
}
// SetActiveExpressions updates the list of active maintenance expressions.
func (m *MaintenanceExprMuter) SetActiveExpressions(exprs []activeMaintenanceExpr) {
m.mu.Lock()
defer m.mu.Unlock()
m.expressions = exprs
}

View File

@@ -10,12 +10,14 @@ import (
amConfig "github.com/prometheus/alertmanager/config"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/clickhousealertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -23,25 +25,34 @@ import (
)
type provider struct {
service *alertmanager.Service
config alertmanager.Config
settings factory.ScopedProviderSettings
configStore alertmanagertypes.ConfigStore
stateStore alertmanagertypes.StateStore
notificationManager nfmanager.NotificationManager
stopC chan struct{}
service *alertmanager.Service
config alertmanager.Config
settings factory.ScopedProviderSettings
configStore alertmanagertypes.ConfigStore
stateStore alertmanagertypes.StateStore
notificationManager nfmanager.NotificationManager
maintenanceStore alertmanagertypes.MaintenanceStore
maintenanceExprMuter *MaintenanceExprMuter
orgGetter organization.Getter
stopC chan struct{}
}
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager, telemetryStore telemetrystore.TelemetryStore) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, settings factory.ProviderSettings, config alertmanager.Config) (alertmanager.Alertmanager, error) {
return New(ctx, settings, config, sqlstore, orgGetter, notificationManager)
return New(ctx, settings, config, sqlstore, orgGetter, notificationManager, telemetryStore)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) (*provider, error) {
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager, telemetryStore telemetrystore.TelemetryStore) (*provider, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager")
configStore := sqlalertmanagerstore.NewConfigStore(sqlstore)
stateStore := sqlalertmanagerstore.NewStateStore(sqlstore)
maintenanceExprMuter := NewMaintenanceExprMuter(settings.Logger())
var stateHistoryStore alertmanagertypes.StateHistoryStore
if telemetryStore != nil {
stateHistoryStore = clickhousealertmanagerstore.NewStateHistoryStore(telemetryStore.ClickhouseDB())
}
p := &provider{
service: alertmanager.New(
@@ -52,13 +63,18 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
configStore,
orgGetter,
notificationManager,
maintenanceExprMuter,
stateHistoryStore,
),
settings: settings,
config: config,
configStore: configStore,
stateStore: stateStore,
notificationManager: notificationManager,
stopC: make(chan struct{}),
settings: settings,
config: config,
configStore: configStore,
stateStore: stateStore,
notificationManager: notificationManager,
maintenanceStore: sqlalertmanagerstore.NewMaintenanceStore(sqlstore),
maintenanceExprMuter: maintenanceExprMuter,
orgGetter: orgGetter,
stopC: make(chan struct{}),
}
return p, nil
@@ -70,16 +86,28 @@ func (provider *provider) Start(ctx context.Context) error {
return err
}
ticker := time.NewTicker(provider.config.Signoz.PollInterval)
defer ticker.Stop()
// Initial maintenance sync before entering the ticker loop.
provider.syncMaintenance(ctx, provider.maintenanceExprMuter)
// Start background sweep for stale alerts in state history tracking.
provider.service.StartStateHistorySweep(ctx)
serverTicker := time.NewTicker(provider.config.Signoz.PollInterval)
defer serverTicker.Stop()
maintenanceTicker := time.NewTicker(maintenanceSyncInterval)
defer maintenanceTicker.Stop()
for {
select {
case <-provider.stopC:
return nil
case <-ticker.C:
case <-serverTicker.C:
if err := provider.service.SyncServers(ctx); err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to sync alertmanager servers", "error", err)
}
case <-maintenanceTicker.C:
provider.syncMaintenance(ctx, provider.maintenanceExprMuter)
}
}
}
@@ -561,3 +589,89 @@ func (provider *provider) DeleteAllInhibitRulesByRuleId(ctx context.Context, org
return provider.configStore.Set(ctx, config)
}
func (provider *provider) GetAllPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.GettablePlannedMaintenance, error) {
return provider.maintenanceStore.GetAllPlannedMaintenance(ctx, orgID)
}
func (provider *provider) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.GettablePlannedMaintenance, error) {
return provider.maintenanceStore.GetPlannedMaintenanceByID(ctx, id)
}
func (provider *provider) CreatePlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance) (valuer.UUID, error) {
return provider.maintenanceStore.CreatePlannedMaintenance(ctx, maintenance)
}
func (provider *provider) EditPlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance, id valuer.UUID) error {
return provider.maintenanceStore.EditPlannedMaintenance(ctx, maintenance, id)
}
func (provider *provider) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
return provider.maintenanceStore.DeletePlannedMaintenance(ctx, id)
}
func (provider *provider) RecordRuleStateHistory(ctx context.Context, orgID string, entries []alertmanagertypes.RuleStateHistory) error {
return provider.service.RecordRuleStateHistory(ctx, orgID, entries)
}
func (provider *provider) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]alertmanagertypes.RuleStateHistory, error) {
return provider.service.GetLastSavedRuleStateHistory(ctx, ruleID)
}
func (provider *provider) GetRuleStateHistoryTimeline(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error) {
return provider.service.GetRuleStateHistoryTimeline(ctx, orgID, ruleID, params)
}
func (provider *provider) GetRuleStateHistoryTopContributors(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error) {
return provider.service.GetRuleStateHistoryTopContributors(ctx, orgID, ruleID, params)
}
func (provider *provider) GetOverallStateTransitions(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error) {
return provider.service.GetOverallStateTransitions(ctx, orgID, ruleID, params)
}
func (provider *provider) GetRuleStats(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStats, error) {
return provider.service.GetRuleStats(ctx, orgID, ruleID, params)
}
const (
maintenanceSyncInterval = 30 * time.Second
)
// syncMaintenance checks planned maintenance windows and updates the given
// MaintenanceExprMuter with active maintenance entries. The muter is injected
// into the notification pipeline as a MuteStage, suppressing notifications
// while allowing rules to continue evaluating (preserving state history).
func (provider *provider) syncMaintenance(ctx context.Context, muter *MaintenanceExprMuter) {
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to list orgs for maintenance sync", "error", err)
return
}
now := time.Now()
var activeExprs []activeMaintenanceExpr
for _, org := range orgs {
orgID := org.ID.StringValue()
maintenanceList, err := provider.maintenanceStore.GetAllPlannedMaintenance(ctx, orgID)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to get planned maintenance for sync", "orgID", orgID, "error", err)
continue
}
for _, maint := range maintenanceList {
_, active := maint.CurrentWindowEndTime(now)
if !active {
continue
}
activeExprs = append(activeExprs, activeMaintenanceExpr{
ruleIDs: maint.RuleIDs,
expression: maint.Expression,
})
}
}
muter.SetActiveExpressions(activeExprs)
}

View File

@@ -0,0 +1,216 @@
package signozalertmanager
import (
"log/slog"
"testing"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
)
func TestMaintenanceExprMuter(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
tests := []struct {
name string
exprs []activeMaintenanceExpr
labels model.LabelSet
want bool
}{
// --- no maintenance ---
{
name: "no expressions - not muted",
exprs: nil,
labels: model.LabelSet{"env": "prod"},
want: false,
},
// --- expression only (ruleIDs empty = all rules) ---
{
name: "expression only - matching",
exprs: []activeMaintenanceExpr{
{expression: `env == "prod"`},
},
labels: model.LabelSet{"env": "prod"},
want: true,
},
{
name: "expression only - non-matching",
exprs: []activeMaintenanceExpr{
{expression: `env == "prod"`},
},
labels: model.LabelSet{"env": "staging"},
want: false,
},
{
name: "expression only - matches regardless of ruleId label",
exprs: []activeMaintenanceExpr{
{expression: `env == "prod"`},
},
labels: model.LabelSet{"env": "prod", "ruleId": "any-rule"},
want: true,
},
// --- ruleIDs only (expression empty = all labels) ---
{
name: "ruleIDs only - matching rule",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1", "rule-2"}},
},
labels: model.LabelSet{"ruleId": "rule-1", "env": "prod"},
want: true,
},
{
name: "ruleIDs only - non-matching rule",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1", "rule-2"}},
},
labels: model.LabelSet{"ruleId": "rule-3", "env": "prod"},
want: false,
},
{
name: "ruleIDs only - no ruleId label on alert",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}},
},
labels: model.LabelSet{"env": "prod"},
want: false,
},
// --- ruleIDs AND expression ---
{
name: "ruleIDs AND expression - both match",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}, expression: `severity == "critical"`},
},
labels: model.LabelSet{"ruleId": "rule-1", "severity": "critical"},
want: true,
},
{
name: "ruleIDs AND expression - rule matches, expression does not",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}, expression: `severity == "critical"`},
},
labels: model.LabelSet{"ruleId": "rule-1", "severity": "warning"},
want: false,
},
{
name: "ruleIDs AND expression - expression matches, rule does not",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}, expression: `severity == "critical"`},
},
labels: model.LabelSet{"ruleId": "rule-999", "severity": "critical"},
want: false,
},
{
name: "ruleIDs AND expression - neither matches",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}, expression: `severity == "critical"`},
},
labels: model.LabelSet{"ruleId": "rule-999", "severity": "warning"},
want: false,
},
// --- catch-all (both empty) ---
{
name: "catch-all - empty ruleIDs and empty expression mutes everything",
exprs: []activeMaintenanceExpr{
{},
},
labels: model.LabelSet{"ruleId": "any-rule", "env": "anything"},
want: true,
},
// --- multiple expressions ---
{
name: "multiple entries - first matches",
exprs: []activeMaintenanceExpr{
{expression: `env == "prod"`},
{expression: `env == "staging"`},
},
labels: model.LabelSet{"env": "prod"},
want: true,
},
{
name: "multiple entries - second matches",
exprs: []activeMaintenanceExpr{
{expression: `env == "staging"`},
{expression: `env == "prod"`},
},
labels: model.LabelSet{"env": "prod"},
want: true,
},
{
name: "multiple entries - none match",
exprs: []activeMaintenanceExpr{
{expression: `env == "staging"`},
{expression: `env == "dev"`},
},
labels: model.LabelSet{"env": "prod"},
want: false,
},
{
name: "multiple entries - ruleIDs entry matches, expression entry does not",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}},
{expression: `env == "staging"`},
},
labels: model.LabelSet{"ruleId": "rule-1", "env": "prod"},
want: true,
},
// --- complex expressions ---
{
name: "complex expression with AND",
exprs: []activeMaintenanceExpr{
{expression: `severity == "critical" && env == "prod"`},
},
labels: model.LabelSet{"severity": "critical", "env": "prod"},
want: true,
},
{
name: "complex expression with AND - partial match",
exprs: []activeMaintenanceExpr{
{expression: `severity == "critical" && env == "prod"`},
},
labels: model.LabelSet{"severity": "warning", "env": "prod"},
want: false,
},
{
name: "expression with OR logic",
exprs: []activeMaintenanceExpr{
{expression: `env == "prod" || env == "staging"`},
},
labels: model.LabelSet{"env": "staging"},
want: true,
},
{
name: "expression with nested label (dotted key)",
exprs: []activeMaintenanceExpr{
{expression: `labels.env == "prod"`},
},
labels: model.LabelSet{"labels.env": "prod"},
want: true,
},
// --- ruleId as expression (user can also match ruleId via expression) ---
{
name: "expression matching specific ruleId label",
exprs: []activeMaintenanceExpr{
{expression: `ruleId == "rule-1"`},
},
labels: model.LabelSet{"ruleId": "rule-1", "env": "prod"},
want: true,
},
{
name: "expression matching specific ruleId label - non-matching",
exprs: []activeMaintenanceExpr{
{expression: `ruleId == "rule-1"`},
},
labels: model.LabelSet{"ruleId": "rule-3", "env": "prod"},
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
muter := NewMaintenanceExprMuter(logger)
muter.SetActiveExpressions(tc.exprs)
got := muter.Mutes(tc.labels)
assert.Equal(t, tc.want, got)
})
}
}

View File

@@ -0,0 +1,263 @@
package alertmanager
import (
"encoding/json"
"strconv"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
// trackedAlert represents the last known state of a single alert series.
type trackedAlert struct {
state string // "firing" or "inactive"
labels string // JSON labels
ruleName string
value float64
lastSeen time.Time
}
// ruleOverallState tracks the overall state of a rule across all its alert series.
type ruleOverallState struct {
state string // "firing" or "inactive"
}
// stateTracker maintains per-org, per-rule, per-fingerprint alert state
// to detect state transitions when PutAlerts is called.
type stateTracker struct {
mu sync.Mutex
alerts map[string]map[string]map[uint64]*trackedAlert // orgID → ruleID → fingerprint → state
overallState map[string]map[string]*ruleOverallState // orgID → ruleID → overall state
}
func newStateTracker() *stateTracker {
return &stateTracker{
alerts: make(map[string]map[string]map[uint64]*trackedAlert),
overallState: make(map[string]map[string]*ruleOverallState),
}
}
// processAlerts detects state transitions from incoming alerts and returns
// RuleStateHistory entries for transitions only.
func (t *stateTracker) processAlerts(orgID string, alerts []*types.Alert, now time.Time) []alertmanagertypes.RuleStateHistory {
t.mu.Lock()
defer t.mu.Unlock()
if _, ok := t.alerts[orgID]; !ok {
t.alerts[orgID] = make(map[string]map[uint64]*trackedAlert)
}
if _, ok := t.overallState[orgID]; !ok {
t.overallState[orgID] = make(map[string]*ruleOverallState)
}
var entries []alertmanagertypes.RuleStateHistory
// Track which rules were affected in this batch for overall_state computation.
affectedRules := make(map[string]bool)
for _, alert := range alerts {
ruleID := string(alert.Labels[model.LabelName("ruleId")])
if ruleID == "" {
continue
}
fp := uint64(alert.Fingerprint())
ruleName := string(alert.Labels[model.LabelName("alertname")])
labelsJSON := labelsToJSON(alert.Labels)
value := valueFromAnnotations(alert.Annotations)
var newState string
if !alert.EndsAt.IsZero() && !alert.EndsAt.After(now) {
newState = "inactive"
} else {
newState = "firing"
}
if _, ok := t.alerts[orgID][ruleID]; !ok {
t.alerts[orgID][ruleID] = make(map[uint64]*trackedAlert)
}
tracked, exists := t.alerts[orgID][ruleID][fp]
if !exists {
// First time seeing this alert.
t.alerts[orgID][ruleID][fp] = &trackedAlert{
state: newState,
labels: labelsJSON,
ruleName: ruleName,
value: value,
lastSeen: now,
}
if newState == "firing" {
// New firing alert — record transition.
entries = append(entries, alertmanagertypes.RuleStateHistory{
OrgID: orgID,
RuleID: ruleID,
RuleName: ruleName,
State: "firing",
StateChanged: true,
UnixMilli: now.UnixMilli(),
Labels: labelsJSON,
Fingerprint: fp,
Value: value,
})
affectedRules[ruleID] = true
}
// Not found + resolved: no-op (we didn't track it firing).
continue
}
// Alert exists in tracker — check for transition.
tracked.lastSeen = now
tracked.value = value
tracked.labels = labelsJSON
if tracked.state != newState {
// State transition detected.
tracked.state = newState
entries = append(entries, alertmanagertypes.RuleStateHistory{
OrgID: orgID,
RuleID: ruleID,
RuleName: ruleName,
State: newState,
StateChanged: true,
UnixMilli: now.UnixMilli(),
Labels: labelsJSON,
Fingerprint: fp,
Value: value,
})
affectedRules[ruleID] = true
}
// Same state — no transition, nothing to record.
}
// Compute overall_state for affected rules and set on entries.
for ruleID := range affectedRules {
currentOverall := t.computeOverallState(orgID, ruleID)
prevOverall, hasPrev := t.overallState[orgID][ruleID]
overallChanged := !hasPrev || prevOverall.state != currentOverall
if !hasPrev {
t.overallState[orgID][ruleID] = &ruleOverallState{state: currentOverall}
} else {
prevOverall.state = currentOverall
}
// Set overall_state on all entries for this rule.
for i := range entries {
if entries[i].RuleID == ruleID {
entries[i].OverallState = currentOverall
entries[i].OverallStateChanged = overallChanged
}
}
}
return entries
}
// computeOverallState returns "firing" if any tracked alert for the rule is firing.
func (t *stateTracker) computeOverallState(orgID, ruleID string) string {
ruleAlerts, ok := t.alerts[orgID][ruleID]
if !ok {
return "inactive"
}
for _, a := range ruleAlerts {
if a.state == "firing" {
return "firing"
}
}
return "inactive"
}
// sweepStale finds alerts that haven't been updated within staleTimeout and
// records them as resolved. Returns transition entries grouped by orgID.
func (t *stateTracker) sweepStale(staleTimeout time.Duration, now time.Time) map[string][]alertmanagertypes.RuleStateHistory {
t.mu.Lock()
defer t.mu.Unlock()
result := make(map[string][]alertmanagertypes.RuleStateHistory)
affectedRules := make(map[string]map[string]bool) // orgID → ruleID → true
for orgID, rules := range t.alerts {
for ruleID, fingerprints := range rules {
for fp, tracked := range fingerprints {
if tracked.state != "firing" {
continue
}
if now.Sub(tracked.lastSeen) <= staleTimeout {
continue
}
// Stale firing alert — mark as resolved.
tracked.state = "inactive"
result[orgID] = append(result[orgID], alertmanagertypes.RuleStateHistory{
OrgID: orgID,
RuleID: ruleID,
RuleName: tracked.ruleName,
State: "inactive",
StateChanged: true,
UnixMilli: now.UnixMilli(),
Labels: tracked.labels,
Fingerprint: fp,
Value: tracked.value,
})
if affectedRules[orgID] == nil {
affectedRules[orgID] = make(map[string]bool)
}
affectedRules[orgID][ruleID] = true
}
}
}
// Compute overall_state for affected rules.
for orgID, rules := range affectedRules {
for ruleID := range rules {
currentOverall := t.computeOverallState(orgID, ruleID)
prevOverall, hasPrev := t.overallState[orgID][ruleID]
overallChanged := !hasPrev || prevOverall.state != currentOverall
if hasPrev {
prevOverall.state = currentOverall
}
for i := range result[orgID] {
if result[orgID][i].RuleID == ruleID {
result[orgID][i].OverallState = currentOverall
result[orgID][i].OverallStateChanged = overallChanged
}
}
}
}
return result
}
// labelsToJSON converts a model.LabelSet to a JSON string.
func labelsToJSON(ls model.LabelSet) string {
m := make(map[string]string, len(ls))
for k, v := range ls {
m[string(k)] = string(v)
}
b, err := json.Marshal(m)
if err != nil {
return "{}"
}
return string(b)
}
// valueFromAnnotations extracts the metric value from alert annotations.
func valueFromAnnotations(annotations model.LabelSet) float64 {
valStr := string(annotations[model.LabelName("value")])
if valStr == "" {
return 0
}
v, err := strconv.ParseFloat(valStr, 64)
if err != nil {
return 0
}
return v
}

View File

@@ -0,0 +1,328 @@
package alertmanager
import (
"testing"
"time"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func makeAlert(ruleID, alertname string, firing bool, now time.Time, extraLabels map[string]string) *types.Alert {
labels := model.LabelSet{
"ruleId": model.LabelValue(ruleID),
"alertname": model.LabelValue(alertname),
}
for k, v := range extraLabels {
labels[model.LabelName(k)] = model.LabelValue(v)
}
alert := &types.Alert{
Alert: model.Alert{
Labels: labels,
Annotations: model.LabelSet{"value": "42.5"},
StartsAt: now.Add(-1 * time.Minute),
},
UpdatedAt: now,
}
if firing {
alert.EndsAt = now.Add(5 * time.Minute) // future = firing
} else {
alert.EndsAt = now.Add(-10 * time.Second) // past = resolved
}
return alert
}
func TestProcessAlerts_NewFiringAlert(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
alerts := []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}
entries := tracker.processAlerts("org-1", alerts, now)
require.Len(t, entries, 1)
assert.Equal(t, "firing", entries[0].State)
assert.Equal(t, "rule-1", entries[0].RuleID)
assert.Equal(t, "HighCPU", entries[0].RuleName)
assert.Equal(t, "org-1", entries[0].OrgID)
assert.Equal(t, true, entries[0].StateChanged)
assert.Equal(t, 42.5, entries[0].Value)
assert.Equal(t, now.UnixMilli(), entries[0].UnixMilli)
assert.Equal(t, "firing", entries[0].OverallState)
assert.Equal(t, true, entries[0].OverallStateChanged)
}
func TestProcessAlerts_StillFiringNoTransition(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
alerts := []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}
// First call: new firing.
entries := tracker.processAlerts("org-1", alerts, now)
require.Len(t, entries, 1)
// Second call: still firing — no transition.
entries = tracker.processAlerts("org-1", alerts, now.Add(1*time.Minute))
assert.Empty(t, entries)
}
func TestProcessAlerts_FiringThenResolved(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// First: fire the alert.
firingAlerts := []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}
entries := tracker.processAlerts("org-1", firingAlerts, now)
require.Len(t, entries, 1)
assert.Equal(t, "firing", entries[0].State)
// Second: resolve the alert.
resolvedAlerts := []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now.Add(5*time.Minute), map[string]string{"host": "server-1"}),
}
entries = tracker.processAlerts("org-1", resolvedAlerts, now.Add(5*time.Minute))
require.Len(t, entries, 1)
assert.Equal(t, "inactive", entries[0].State)
assert.Equal(t, "rule-1", entries[0].RuleID)
assert.Equal(t, "inactive", entries[0].OverallState)
assert.Equal(t, true, entries[0].OverallStateChanged)
}
func TestProcessAlerts_ResolvedWithoutPriorFiring(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// A resolved alert arriving without prior tracking should produce no entry.
alerts := []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now, map[string]string{"host": "server-1"}),
}
entries := tracker.processAlerts("org-1", alerts, now)
assert.Empty(t, entries)
}
func TestProcessAlerts_ReFiring(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Fire.
entries := tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}, now)
require.Len(t, entries, 1)
assert.Equal(t, "firing", entries[0].State)
// Resolve.
entries = tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now.Add(5*time.Minute), map[string]string{"host": "server-1"}),
}, now.Add(5*time.Minute))
require.Len(t, entries, 1)
assert.Equal(t, "inactive", entries[0].State)
// Re-fire.
entries = tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now.Add(10*time.Minute), map[string]string{"host": "server-1"}),
}, now.Add(10*time.Minute))
require.Len(t, entries, 1)
assert.Equal(t, "firing", entries[0].State)
assert.Equal(t, "firing", entries[0].OverallState)
assert.Equal(t, true, entries[0].OverallStateChanged)
}
func TestProcessAlerts_OverallStateComputation(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Fire two series for the same rule.
entries := tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-2"}),
}, now)
require.Len(t, entries, 2)
assert.Equal(t, "firing", entries[0].OverallState)
assert.Equal(t, "firing", entries[1].OverallState)
// Resolve only one series — overall should still be "firing".
entries = tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now.Add(5*time.Minute), map[string]string{"host": "server-1"}),
}, now.Add(5*time.Minute))
require.Len(t, entries, 1)
assert.Equal(t, "inactive", entries[0].State)
assert.Equal(t, "firing", entries[0].OverallState)
assert.Equal(t, false, entries[0].OverallStateChanged) // still firing overall
// Resolve the second series — overall should transition to "inactive".
entries = tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now.Add(6*time.Minute), map[string]string{"host": "server-2"}),
}, now.Add(6*time.Minute))
require.Len(t, entries, 1)
assert.Equal(t, "inactive", entries[0].State)
assert.Equal(t, "inactive", entries[0].OverallState)
assert.Equal(t, true, entries[0].OverallStateChanged) // transitioned to inactive
}
func TestProcessAlerts_MultipleRulesIndependent(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
entries := tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
makeAlert("rule-2", "HighMem", true, now, map[string]string{"host": "server-1"}),
}, now)
require.Len(t, entries, 2)
// Each rule has its own overall state.
assert.Equal(t, "rule-1", entries[0].RuleID)
assert.Equal(t, "rule-2", entries[1].RuleID)
assert.Equal(t, "firing", entries[0].OverallState)
assert.Equal(t, "firing", entries[1].OverallState)
}
func TestProcessAlerts_AlertWithoutRuleIDSkipped(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
alert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "NoRuleID"},
StartsAt: now.Add(-1 * time.Minute),
EndsAt: now.Add(5 * time.Minute),
},
UpdatedAt: now,
}
entries := tracker.processAlerts("org-1", []*types.Alert{alert}, now)
assert.Empty(t, entries)
}
func TestProcessAlerts_MultipleOrgs(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Org 1 fires.
entries1 := tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, nil),
}, now)
require.Len(t, entries1, 1)
assert.Equal(t, "org-1", entries1[0].OrgID)
// Org 2 fires same rule ID — independent tracking.
entries2 := tracker.processAlerts("org-2", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, nil),
}, now)
require.Len(t, entries2, 1)
assert.Equal(t, "org-2", entries2[0].OrgID)
}
func TestSweepStale_FiringAlertBecomesInactive(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Fire an alert.
tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}, now)
// Sweep with staleTimeout = 5 minutes, 10 minutes later.
result := tracker.sweepStale(5*time.Minute, now.Add(10*time.Minute))
require.Len(t, result["org-1"], 1)
assert.Equal(t, "inactive", result["org-1"][0].State)
assert.Equal(t, "rule-1", result["org-1"][0].RuleID)
assert.Equal(t, "inactive", result["org-1"][0].OverallState)
assert.Equal(t, true, result["org-1"][0].OverallStateChanged)
}
func TestSweepStale_RecentAlertNotSwept(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Fire an alert.
tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}, now)
// Sweep with staleTimeout = 10 minutes, only 2 minutes later.
result := tracker.sweepStale(10*time.Minute, now.Add(2*time.Minute))
assert.Empty(t, result)
}
func TestSweepStale_InactiveAlertNotSwept(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Fire then resolve.
tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, nil),
}, now)
tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now.Add(1*time.Minute), nil),
}, now.Add(1*time.Minute))
// Sweep much later — should produce nothing since alert is already inactive.
result := tracker.sweepStale(5*time.Minute, now.Add(30*time.Minute))
assert.Empty(t, result)
}
func TestLabelsToJSON(t *testing.T) {
ls := model.LabelSet{
"alertname": "HighCPU",
"env": "prod",
}
result := labelsToJSON(ls)
// Parse back and verify.
parsed := labelsFromJSON(result)
require.NotNil(t, parsed)
assert.Equal(t, model.LabelValue("HighCPU"), parsed["alertname"])
assert.Equal(t, model.LabelValue("prod"), parsed["env"])
}
func TestValueFromAnnotations(t *testing.T) {
tests := []struct {
name string
annotations model.LabelSet
want float64
}{
{
name: "valid float",
annotations: model.LabelSet{"value": "42.5"},
want: 42.5,
},
{
name: "empty value",
annotations: model.LabelSet{},
want: 0,
},
{
name: "invalid value",
annotations: model.LabelSet{"value": "not-a-number"},
want: 0,
},
{
name: "scientific notation",
annotations: model.LabelSet{"value": "1.5E+02"},
want: 150,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := valueFromAnnotations(tc.annotations)
assert.Equal(t, tc.want, got)
})
}
}

View File

@@ -44,7 +44,6 @@ func NewAPI(
telemetrylogs.LogResourceKeysTblName,
telemetrymetadata.DBName,
telemetrymetadata.AttributesMetadataLocalTableName,
telemetrymetadata.ColumnEvolutionMetadataTableName,
)
return &API{

View File

@@ -610,18 +610,18 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
}
opts := querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: m.logger,
FieldMapper: m.fieldMapper,
ConditionBuilder: m.condBuilder,
FullTextColumn: &telemetrytypes.TelemetryFieldKey{
Name: "labels"},
FieldKeys: keys,
StartNs: querybuilder.ToNanoSecs(uint64(startMillis)),
EndNs: querybuilder.ToNanoSecs(uint64(endMillis)),
}
whereClause, err := querybuilder.PrepareWhereClause(expression, opts)
startNs := querybuilder.ToNanoSecs(uint64(startMillis))
endNs := querybuilder.ToNanoSecs(uint64(endMillis))
whereClause, err := querybuilder.PrepareWhereClause(expression, opts, startNs, endNs)
if err != nil {
return nil, err
}

View File

@@ -66,7 +66,6 @@ func newProvider(
telemetrylogs.LogResourceKeysTblName,
telemetrymetadata.DBName,
telemetrymetadata.AttributesMetadataLocalTableName,
telemetrymetadata.ColumnEvolutionMetadataTableName,
)
// Create trace statement builder

View File

@@ -26,6 +26,8 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/http/render"
@@ -492,18 +494,109 @@ func (aH *APIHandler) Respond(w http.ResponseWriter, data interface{}) {
func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/channels", am.ViewAccess(aH.AlertmanagerAPI.ListChannels)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/channels/{id}", am.ViewAccess(aH.AlertmanagerAPI.GetChannelByID)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/channels/{id}", am.AdminAccess(aH.AlertmanagerAPI.UpdateChannelByID)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/channels/{id}", am.AdminAccess(aH.AlertmanagerAPI.DeleteChannelByID)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/channels", am.EditAccess(aH.AlertmanagerAPI.CreateChannel)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/testChannel", am.EditAccess(aH.AlertmanagerAPI.TestReceiver)).Methods(http.MethodPost)
router.Handle("/api/v1/channels", handler.New(am.ViewAccess(aH.AlertmanagerAPI.ListChannels), handler.OpenAPIDef{
ID: "ListChannels",
Tags: []string{"channels"},
Summary: "List notification channels",
Description: "Returns all notification channels for the organization.",
Response: make([]*alertmanagertypes.Channel, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
})).Methods(http.MethodGet)
router.Handle("/api/v1/channels/{id}", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetChannelByID), handler.OpenAPIDef{
ID: "GetChannelByID",
Tags: []string{"channels"},
Summary: "Get a notification channel",
Description: "Returns a single notification channel by ID.",
Response: new(alertmanagertypes.Channel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodGet)
router.Handle("/api/v1/channels/{id}", handler.New(am.AdminAccess(aH.AlertmanagerAPI.UpdateChannelByID), handler.OpenAPIDef{
ID: "UpdateChannelByID",
Tags: []string{"channels"},
Summary: "Update a notification channel",
Description: "Updates a notification channel by ID.",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodPut)
router.Handle("/api/v1/channels/{id}", handler.New(am.AdminAccess(aH.AlertmanagerAPI.DeleteChannelByID), handler.OpenAPIDef{
ID: "DeleteChannelByID",
Tags: []string{"channels"},
Summary: "Delete a notification channel",
Description: "Deletes a notification channel by ID.",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodDelete)
router.Handle("/api/v1/channels", handler.New(am.EditAccess(aH.AlertmanagerAPI.CreateChannel), handler.OpenAPIDef{
ID: "CreateChannel",
Tags: []string{"channels"},
Summary: "Create a notification channel",
Description: "Creates a new notification channel.",
Response: new(alertmanagertypes.Channel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v1/testChannel", handler.New(am.EditAccess(aH.AlertmanagerAPI.TestReceiver), handler.OpenAPIDef{
ID: "TestReceiver",
Tags: []string{"channels"},
Summary: "Test a notification channel",
Description: "Sends a test alert to a receiver configuration.",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.HandleFunc("/api/v1/route_policies", am.ViewAccess(aH.AlertmanagerAPI.GetAllRoutePolicies)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/route_policies/{id}", am.ViewAccess(aH.AlertmanagerAPI.GetRoutePolicyByID)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/route_policies", am.AdminAccess(aH.AlertmanagerAPI.CreateRoutePolicy)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/route_policies/{id}", am.AdminAccess(aH.AlertmanagerAPI.DeleteRoutePolicyByID)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/route_policies/{id}", am.AdminAccess(aH.AlertmanagerAPI.UpdateRoutePolicy)).Methods(http.MethodPut)
router.Handle("/api/v1/route_policies", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetAllRoutePolicies), handler.OpenAPIDef{
ID: "GetAllRoutePolicies",
Tags: []string{"route_policies"},
Summary: "List route policies",
Description: "Returns all notification route policies.",
Response: make([]*alertmanagertypes.GettableRoutePolicy, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
})).Methods(http.MethodGet)
router.Handle("/api/v1/route_policies/{id}", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetRoutePolicyByID), handler.OpenAPIDef{
ID: "GetRoutePolicyByID",
Tags: []string{"route_policies"},
Summary: "Get a route policy",
Description: "Returns a single notification route policy by ID.",
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodGet)
router.Handle("/api/v1/route_policies", handler.New(am.AdminAccess(aH.AlertmanagerAPI.CreateRoutePolicy), handler.OpenAPIDef{
ID: "CreateRoutePolicy",
Tags: []string{"route_policies"},
Summary: "Create a route policy",
Description: "Creates a new notification route policy.",
Request: new(alertmanagertypes.PostableRoutePolicy),
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v1/route_policies/{id}", handler.New(am.AdminAccess(aH.AlertmanagerAPI.DeleteRoutePolicyByID), handler.OpenAPIDef{
ID: "DeleteRoutePolicyByID",
Tags: []string{"route_policies"},
Summary: "Delete a route policy",
Description: "Deletes a notification route policy by ID.",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodDelete)
router.Handle("/api/v1/route_policies/{id}", handler.New(am.AdminAccess(aH.AlertmanagerAPI.UpdateRoutePolicy), handler.OpenAPIDef{
ID: "UpdateRoutePolicy",
Tags: []string{"route_policies"},
Summary: "Update a route policy",
Description: "Updates a notification route policy by ID.",
Request: new(alertmanagertypes.PostableRoutePolicy),
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodPut)
router.HandleFunc("/api/v1/alerts", am.ViewAccess(aH.AlertmanagerAPI.GetAlerts)).Methods(http.MethodGet)
@@ -525,6 +618,103 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/downtime_schedules/{id}", am.EditAccess(aH.editDowntimeSchedule)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/downtime_schedules/{id}", am.EditAccess(aH.deleteDowntimeSchedule)).Methods(http.MethodDelete)
// V2 downtime schedules (alertmanager-based)
router.Handle("/api/v2/downtime_schedules", handler.New(am.ViewAccess(aH.AlertmanagerAPI.ListDowntimeSchedules), handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtime_schedules"},
Summary: "List downtime schedules",
Description: "Returns all planned maintenance schedules for the organization. Supports filtering by active and recurring query parameters.",
Response: make([]*alertmanagertypes.GettablePlannedMaintenance, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
})).Methods(http.MethodGet)
router.Handle("/api/v2/downtime_schedules/{id}", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetDowntimeSchedule), handler.OpenAPIDef{
ID: "GetDowntimeSchedule",
Tags: []string{"downtime_schedules"},
Summary: "Get a downtime schedule",
Description: "Returns a single planned maintenance schedule by ID.",
Response: new(alertmanagertypes.GettablePlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodGet)
router.Handle("/api/v2/downtime_schedules", handler.New(am.EditAccess(aH.AlertmanagerAPI.CreateDowntimeSchedule), handler.OpenAPIDef{
ID: "CreateDowntimeSchedule",
Tags: []string{"downtime_schedules"},
Summary: "Create a downtime schedule",
Description: "Creates a new planned maintenance schedule.",
Request: new(alertmanagertypes.GettablePlannedMaintenance),
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v2/downtime_schedules/{id}", handler.New(am.EditAccess(aH.AlertmanagerAPI.EditDowntimeSchedule), handler.OpenAPIDef{
ID: "EditDowntimeSchedule",
Tags: []string{"downtime_schedules"},
Summary: "Update a downtime schedule",
Description: "Updates an existing planned maintenance schedule by ID.",
Request: new(alertmanagertypes.GettablePlannedMaintenance),
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodPut)
router.Handle("/api/v2/downtime_schedules/{id}", handler.New(am.EditAccess(aH.AlertmanagerAPI.DeleteDowntimeSchedule), handler.OpenAPIDef{
ID: "DeleteDowntimeSchedule",
Tags: []string{"downtime_schedules"},
Summary: "Delete a downtime schedule",
Description: "Deletes a planned maintenance schedule by ID.",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodDelete)
// V2 rule state history (alertmanager-based)
router.Handle("/api/v2/rules/{id}/history/timeline", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetRuleStateHistoryTimeline), handler.OpenAPIDef{
ID: "GetRuleStateHistoryTimeline",
Tags: []string{"rule_state_history"},
Summary: "Get rule state history timeline",
Description: "Returns paginated state history entries for a rule within a time range, with optional state filter and distinct label keys for filter UI.",
Request: new(alertmanagertypes.QueryRuleStateHistory),
Response: new(alertmanagertypes.RuleStateTimeline),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v2/rules/{id}/history/stats", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetRuleStats), handler.OpenAPIDef{
ID: "GetRuleStats",
Tags: []string{"rule_state_history"},
Summary: "Get rule trigger and resolution statistics",
Description: "Returns trigger counts and average resolution times for a rule, comparing the current time period against a previous period of equal length.",
Request: new(alertmanagertypes.QueryRuleStateHistory),
Response: new(alertmanagertypes.RuleStats),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v2/rules/{id}/history/top_contributors", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetRuleStateHistoryTopContributors), handler.OpenAPIDef{
ID: "GetRuleStateHistoryTopContributors",
Tags: []string{"rule_state_history"},
Summary: "Get top contributing alert series",
Description: "Returns alert series (by fingerprint) that transitioned to firing most frequently for a rule within a time range, ranked by count.",
Request: new(alertmanagertypes.QueryRuleStateHistory),
Response: make([]alertmanagertypes.RuleStateHistoryContributor, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v2/rules/{id}/history/overall_status", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetOverallStateTransitions), handler.OpenAPIDef{
ID: "GetOverallStateTransitions",
Tags: []string{"rule_state_history"},
Summary: "Get overall state transition timeline",
Description: "Returns a timeline of contiguous firing and inactive periods for a rule within a time range, with gap-filling between transitions.",
Request: new(alertmanagertypes.QueryRuleStateHistory),
Response: make([]alertmanagertypes.RuleStateTransition, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.HandleFunc("/api/v1/dashboards", am.ViewAccess(aH.List)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/dashboards", am.EditAccess(aH.Signoz.Handlers.Dashboard.Create)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/dashboards/{id}", am.ViewAccess(aH.Get)).Methods(http.MethodGet)

View File

@@ -478,15 +478,14 @@ func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, prevState, curren
}
}
if len(revisedItemsToAdd) > 0 && r.reader != nil {
if len(revisedItemsToAdd) > 0 {
zap.L().Debug("writing rule state history", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd))
entries := make([]model.RuleStateHistory, 0, len(revisedItemsToAdd))
for _, item := range revisedItemsToAdd {
entries = append(entries, item)
}
err := r.reader.AddRuleStateHistory(ctx, entries)
if err != nil {
if err := r.reader.AddRuleStateHistory(ctx, entries); err != nil {
zap.L().Error("error while inserting rule state history", zap.Error(err), zap.Any("itemsToAdd", itemsToAdd))
}
}

View File

@@ -48,8 +48,6 @@ func NewAggExprRewriter(
// and the args if the parametric aggregation function is used.
func (r *aggExprRewriter) Rewrite(
ctx context.Context,
startNs uint64,
endNs uint64,
expr string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
@@ -76,12 +74,7 @@ func (r *aggExprRewriter) Rewrite(
return "", nil, errors.NewInternalf(errors.CodeInternal, "no SELECT items for %q", expr)
}
visitor := newExprVisitor(
ctx,
startNs,
endNs,
r.logger,
keys,
visitor := newExprVisitor(r.logger, keys,
r.fullTextColumn,
r.fieldMapper,
r.conditionBuilder,
@@ -101,8 +94,6 @@ func (r *aggExprRewriter) Rewrite(
// RewriteMulti rewrites a slice of expressions.
func (r *aggExprRewriter) RewriteMulti(
ctx context.Context,
startNs uint64,
endNs uint64,
exprs []string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
@@ -111,7 +102,7 @@ func (r *aggExprRewriter) RewriteMulti(
var errs []error
var chArgsList [][]any
for i, e := range exprs {
w, chArgs, err := r.Rewrite(ctx, startNs, endNs, e, rateInterval, keys)
w, chArgs, err := r.Rewrite(ctx, e, rateInterval, keys)
if err != nil {
errs = append(errs, err)
out[i] = e
@@ -128,9 +119,6 @@ func (r *aggExprRewriter) RewriteMulti(
// exprVisitor walks FunctionExpr nodes and applies the mappers.
type exprVisitor struct {
ctx context.Context
startNs uint64
endNs uint64
chparser.DefaultASTVisitor
logger *slog.Logger
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
@@ -144,9 +132,6 @@ type exprVisitor struct {
}
func newExprVisitor(
ctx context.Context,
startNs uint64,
endNs uint64,
logger *slog.Logger,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
fullTextColumn *telemetrytypes.TelemetryFieldKey,
@@ -155,9 +140,6 @@ func newExprVisitor(
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
) *exprVisitor {
return &exprVisitor{
ctx: ctx,
startNs: startNs,
endNs: endNs,
logger: logger,
fieldKeys: fieldKeys,
fullTextColumn: fullTextColumn,
@@ -204,16 +186,13 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
whereClause, err := PrepareWhereClause(
origPred,
FilterExprVisitorOpts{
Context: v.ctx,
Logger: v.logger,
FieldKeys: v.fieldKeys,
FieldMapper: v.fieldMapper,
ConditionBuilder: v.conditionBuilder,
FullTextColumn: v.fullTextColumn,
JsonKeyToKey: v.jsonKeyToKey,
StartNs: v.startNs,
EndNs: v.endNs,
},
}, 0, 0,
)
if err != nil {
return err
@@ -233,7 +212,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
for i := 0; i < len(args)-1; i++ {
origVal := args[i].String()
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(origVal)
expr, exprArgs, err := CollisionHandledFinalExpr(v.ctx, v.startNs, v.endNs, &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey)
expr, exprArgs, err := CollisionHandledFinalExpr(context.Background(), &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey)
if err != nil {
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to get table field name for %q", origVal)
}
@@ -251,7 +230,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
for i, arg := range args {
orig := arg.String()
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(orig)
expr, exprArgs, err := CollisionHandledFinalExpr(v.ctx, v.startNs, v.endNs, &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey)
expr, exprArgs, err := CollisionHandledFinalExpr(context.Background(), &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey)
if err != nil {
return err
}

View File

@@ -153,7 +153,6 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
key.Indexes = intrinsicOrCalculatedField.Indexes
key.Materialized = intrinsicOrCalculatedField.Materialized
key.JSONPlan = intrinsicOrCalculatedField.JSONPlan
key.Evolutions = intrinsicOrCalculatedField.Evolutions
return actions
}
@@ -206,8 +205,7 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
key.Indexes = matchingKey.Indexes
key.Materialized = matchingKey.Materialized
key.JSONPlan = matchingKey.JSONPlan
key.Evolutions = matchingKey.Evolutions
return actions
} else {
// multiple matching keys, set materialized only if all the keys are materialized

View File

@@ -19,8 +19,6 @@ import (
func CollisionHandledFinalExpr(
ctx context.Context,
startNs uint64,
endNs uint64,
field *telemetrytypes.TelemetryFieldKey,
fm qbtypes.FieldMapper,
cb qbtypes.ConditionBuilder,
@@ -46,7 +44,7 @@ func CollisionHandledFinalExpr(
addCondition := func(key *telemetrytypes.TelemetryFieldKey) error {
sb := sqlbuilder.NewSelectBuilder()
condition, err := cb.ConditionFor(ctx, startNs, endNs, key, qbtypes.FilterOperatorExists, nil, sb)
condition, err := cb.ConditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb, 0, 0)
if err != nil {
return err
}
@@ -59,7 +57,7 @@ func CollisionHandledFinalExpr(
return nil
}
colName, fieldForErr := fm.FieldFor(ctx, startNs, endNs, field)
colName, fieldForErr := fm.FieldFor(ctx, field)
if errors.Is(fieldForErr, qbtypes.ErrColumnNotFound) {
// the key didn't have the right context to be added to the query
// we try to use the context we know of
@@ -94,7 +92,7 @@ func CollisionHandledFinalExpr(
if err != nil {
return "", nil, err
}
colName, _ = fm.FieldFor(ctx, startNs, endNs, key)
colName, _ = fm.FieldFor(ctx, key)
colName, _ = DataTypeCollisionHandledFieldName(key, dummyValue, colName, qbtypes.FilterOperatorUnknown)
stmts = append(stmts, colName)
}

View File

@@ -44,12 +44,12 @@ func keyIndexFilter(key *telemetrytypes.TelemetryFieldKey) any {
func (b *defaultConditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
op qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
_ uint64,
_ uint64,
) (string, error) {
if key.FieldContext != telemetrytypes.FieldContextResource {
@@ -60,17 +60,15 @@ func (b *defaultConditionBuilder) ConditionFor(
// as we store resource values as string
formattedValue := querybuilder.FormatValueForContains(value)
columns, err := b.fm.ColumnFor(ctx, startNs, endNs, key)
column, err := b.fm.ColumnFor(ctx, key)
if err != nil {
return "", err
}
// resource evolution on main table doesn't affect this as we not changing the resource column in the resource fingerprint table.
column := columns[0]
keyIdxFilter := sb.Like(column.Name, keyIndexFilter(key))
valueForIndexFilter := valueForIndexFilter(op, key, value)
fieldName, err := b.fm.FieldFor(ctx, startNs, endNs, key)
fieldName, err := b.fm.FieldFor(ctx, key)
if err != nil {
return "", err
}

View File

@@ -206,7 +206,7 @@ func TestConditionBuilder(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(context.Background(), 0, 0, tc.key, tc.op, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(context.Background(), tc.key, tc.op, tc.value, sb, 0, 0)
sb.Where(cond)
if tc.expectedErr != nil {

View File

@@ -27,48 +27,44 @@ func NewFieldMapper() *defaultFieldMapper {
func (m *defaultFieldMapper) getColumn(
_ context.Context,
_, _ uint64,
key *telemetrytypes.TelemetryFieldKey,
) ([]*schema.Column, error) {
) (*schema.Column, error) {
if key.FieldContext == telemetrytypes.FieldContextResource {
return []*schema.Column{resourceColumns["labels"]}, nil
return resourceColumns["labels"], nil
}
if col, ok := resourceColumns[key.Name]; ok {
return []*schema.Column{col}, nil
return col, nil
}
return nil, qbtypes.ErrColumnNotFound
}
func (m *defaultFieldMapper) ColumnFor(
ctx context.Context,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,
) ([]*schema.Column, error) {
return m.getColumn(ctx, tsStart, tsEnd, key)
) (*schema.Column, error) {
return m.getColumn(ctx, key)
}
func (m *defaultFieldMapper) FieldFor(
ctx context.Context,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,
) (string, error) {
columns, err := m.getColumn(ctx, tsStart, tsEnd, key)
column, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
if key.FieldContext == telemetrytypes.FieldContextResource {
return fmt.Sprintf("simpleJSONExtractString(%s, '%s')", columns[0].Name, key.Name), nil
return fmt.Sprintf("simpleJSONExtractString(%s, '%s')", column.Name, key.Name), nil
}
return columns[0].Name, nil
return column.Name, nil
}
func (m *defaultFieldMapper) ColumnExpressionFor(
ctx context.Context,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,
_ map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
colName, err := m.FieldFor(ctx, tsStart, tsEnd, key)
colName, err := m.FieldFor(ctx, key)
if err != nil {
return "", err
}

View File

@@ -148,7 +148,7 @@ func (b *resourceFilterStatementBuilder[T]) Build(
// addConditions adds both filter and time conditions to the query
func (b *resourceFilterStatementBuilder[T]) addConditions(
ctx context.Context,
_ context.Context,
sb *sqlbuilder.SelectBuilder,
start, end uint64,
query qbtypes.QueryBuilderQuery[T],
@@ -160,7 +160,6 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
// warnings would be encountered as part of the main condition already
filterWhereClause, err := querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fieldMapper,
ConditionBuilder: b.conditionBuilder,
@@ -172,9 +171,7 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
// there is no need for "key" not found error for resource filtering
IgnoreNotFoundKeys: true,
Variables: variables,
StartNs: start,
EndNs: end,
})
}, start, end)
if err != nil {
return err

View File

@@ -23,7 +23,6 @@ const stringMatchingOperatorDocURL = "https://signoz.io/docs/userguide/operators
// filterExpressionVisitor implements the FilterQueryVisitor interface
// to convert the parsed filter expressions into ClickHouse WHERE clause
type filterExpressionVisitor struct {
context context.Context
logger *slog.Logger
fieldMapper qbtypes.FieldMapper
conditionBuilder qbtypes.ConditionBuilder
@@ -47,7 +46,6 @@ type filterExpressionVisitor struct {
}
type FilterExprVisitorOpts struct {
Context context.Context
Logger *slog.Logger
FieldMapper qbtypes.FieldMapper
ConditionBuilder qbtypes.ConditionBuilder
@@ -67,7 +65,6 @@ type FilterExprVisitorOpts struct {
// newFilterExpressionVisitor creates a new filterExpressionVisitor
func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVisitor {
return &filterExpressionVisitor{
context: opts.Context,
logger: opts.Logger,
fieldMapper: opts.FieldMapper,
conditionBuilder: opts.ConditionBuilder,
@@ -93,7 +90,7 @@ type PreparedWhereClause struct {
}
// PrepareWhereClause generates a ClickHouse compatible WHERE clause from the filter query
func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*PreparedWhereClause, error) {
func PrepareWhereClause(query string, opts FilterExprVisitorOpts, startNs uint64, endNs uint64) (*PreparedWhereClause, error) {
// Setup the ANTLR parsing pipeline
input := antlr.NewInputStream(query)
@@ -127,6 +124,8 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*PreparedWher
}
tokens.Reset()
opts.StartNs = startNs
opts.EndNs = endNs
visitor := newFilterExpressionVisitor(opts)
// Handle syntax errors
@@ -318,7 +317,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
// create a full text search condition on the body field
keyText := keyCtx.GetText()
cond, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(keyText), v.builder)
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(keyText), v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
@@ -338,7 +337,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
v.errors = append(v.errors, fmt.Sprintf("unsupported value type: %s", valCtx.GetText()))
return ""
}
cond, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
@@ -382,7 +381,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
}
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, nil, v.builder)
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, nil, v.builder, v.startNs, v.endNs)
if err != nil {
return ""
}
@@ -454,7 +453,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
}
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, values, v.builder)
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, values, v.builder, v.startNs, v.endNs)
if err != nil {
return ""
}
@@ -486,7 +485,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, []any{value1, value2}, v.builder)
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, []any{value1, value2}, v.builder, v.startNs, v.endNs)
if err != nil {
return ""
}
@@ -571,7 +570,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, value, v.builder)
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, value, v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build condition: %s", err.Error()))
return ""
@@ -650,7 +649,7 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
v.errors = append(v.errors, "full text search is not supported")
return ""
}
cond, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
@@ -735,13 +734,13 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
if key.FieldContext == telemetrytypes.FieldContextBody {
var err error
if BodyJSONQueryEnabled {
fieldName, err = v.fieldMapper.FieldFor(v.context, v.startNs, v.endNs, key)
fieldName, err = v.fieldMapper.FieldFor(context.Background(), key)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to get field name for key %s: %s", key.Name, err.Error()))
return ""
}
} else {
fieldName, _ = v.jsonKeyToKey(v.context, key, qbtypes.FilterOperatorUnknown, value)
fieldName, _ = v.jsonKeyToKey(context.Background(), key, qbtypes.FilterOperatorUnknown, value)
}
} else {
// TODO(add docs for json body search)
@@ -856,7 +855,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
// 1. either user meant key ( this is already handled above in fieldKeysForName )
// 2. or user meant `attribute.key` we look up in the map for all possible field keys with name 'attribute.key'
// Note:
// Note:
// If user only wants to search `attribute.key`, then they have to use `attribute.attribute.key`
// If user only wants to search `key`, then they have to use `key`
// If user wants to search both, they can use `attribute.key` and we will resolve the ambiguity

View File

@@ -1,7 +1,6 @@
package querybuilder
import (
"context"
"log/slog"
"strings"
"testing"
@@ -55,12 +54,11 @@ func TestPrepareWhereClause_EmptyVariableList(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := FilterExprVisitorOpts{
Context: context.Background(),
FieldKeys: keys,
Variables: tt.variables,
}
_, err := PrepareWhereClause(tt.expr, opts)
_, err := PrepareWhereClause(tt.expr, opts, 0, 0)
if tt.expectError {
if err == nil {
@@ -469,7 +467,7 @@ func TestVisitKey(t *testing.T) {
expectedWarnings: nil,
expectedMainWrnURL: "",
},
{
{
name: "only attribute.custom_field is selected",
keyText: "attribute.attribute.custom_field",
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{

View File

@@ -34,7 +34,7 @@ func TestNewHandlers(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, nil)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)
emailing := emailingtest.New()

View File

@@ -34,7 +34,7 @@ func TestNewModules(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, nil)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)
emailing := emailingtest.New()

View File

@@ -166,6 +166,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddAuthzIndexFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRbacToAuthzFactory(sqlstore),
sqlmigration.NewMigratePublicDashboardsFactory(sqlstore),
sqlmigration.NewCreatePlannedMaintenanceV2Factory(sqlstore),
sqlmigration.NewCreateRuleStateHistoryV2Factory(telemetryStore),
)
}
@@ -191,9 +193,9 @@ func NewNotificationManagerProviderFactories(routeStore alertmanagertypes.RouteS
)
}
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, nfManager nfmanager.NotificationManager) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, nfManager nfmanager.NotificationManager, telemetryStore telemetrystore.TelemetryStore) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
return factory.MustNewNamedMap(
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager),
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager, telemetryStore),
)
}

View File

@@ -58,7 +58,7 @@ func TestNewProviderFactories(t *testing.T) {
assert.NotPanics(t, func() {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
notificationManager := nfmanagertest.NewMock()
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager)
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager, nil)
})
assert.NotPanics(t, func() {

View File

@@ -311,7 +311,7 @@ func New(
ctx,
providerSettings,
config.Alertmanager,
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager),
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager, telemetrystore),
config.Alertmanager.Provider,
)
if err != nil {
@@ -376,7 +376,6 @@ func New(
telemetrylogs.LogResourceKeysTblName,
telemetrymetadata.DBName,
telemetrymetadata.AttributesMetadataLocalTableName,
telemetrymetadata.ColumnEvolutionMetadataTableName,
)
global, err := factory.NewProviderFromNamedMap(

View File

@@ -0,0 +1,45 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type createPlannedMaintenanceV2 struct {
sqlstore sqlstore.SQLStore
}
func NewCreatePlannedMaintenanceV2Factory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("create_planned_maintenance_v2"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
return &createPlannedMaintenanceV2{sqlstore: sqlstore}, nil
})
}
func (migration *createPlannedMaintenanceV2) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *createPlannedMaintenanceV2) Up(ctx context.Context, db *bun.DB) error {
_, err := db.NewCreateTable().
Model((*alertmanagertypes.StorablePlannedMaintenance)(nil)).
IfNotExists().
Exec(ctx)
return err
}
func (migration *createPlannedMaintenanceV2) Down(ctx context.Context, db *bun.DB) error {
_, err := db.NewDropTable().
Model((*alertmanagertypes.StorablePlannedMaintenance)(nil)).
IfExists().
Exec(ctx)
return err
}

View File

@@ -0,0 +1,80 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type createRuleStateHistoryV2 struct {
telemetryStore telemetrystore.TelemetryStore
}
func NewCreateRuleStateHistoryV2Factory(telemetryStore telemetrystore.TelemetryStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("create_rule_state_history_v2"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
return &createRuleStateHistoryV2{telemetryStore: telemetryStore}, nil
})
}
func (migration *createRuleStateHistoryV2) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *createRuleStateHistoryV2) Up(ctx context.Context, db *bun.DB) error {
// Create the local MergeTree table.
if err := migration.telemetryStore.ClickhouseDB().Exec(ctx, `
CREATE TABLE IF NOT EXISTS signoz_analytics.rule_state_history_v2
(
org_id LowCardinality(String),
rule_id String,
rule_name String,
fingerprint UInt64,
labels String,
state LowCardinality(String),
state_changed Bool DEFAULT true,
value Float64,
unix_milli Int64,
overall_state LowCardinality(String),
overall_state_changed Bool
)
ENGINE = MergeTree()
PARTITION BY toDate(unix_milli / 1000)
ORDER BY (org_id, rule_id, fingerprint, unix_milli)
TTL toDate(unix_milli / 1000) + INTERVAL 90 DAY
`); err != nil {
return err
}
// Create the distributed table.
if err := migration.telemetryStore.ClickhouseDB().Exec(ctx, `
CREATE TABLE IF NOT EXISTS signoz_analytics.distributed_rule_state_history_v2
AS signoz_analytics.rule_state_history_v2
ENGINE = Distributed('cluster', 'signoz_analytics', 'rule_state_history_v2', cityHash64(rule_id))
`); err != nil {
return err
}
return nil
}
func (migration *createRuleStateHistoryV2) Down(ctx context.Context, db *bun.DB) error {
if err := migration.telemetryStore.ClickhouseDB().Exec(ctx, `
DROP TABLE IF EXISTS signoz_analytics.distributed_rule_state_history_v2
`); err != nil {
return err
}
if err := migration.telemetryStore.ClickhouseDB().Exec(ctx, `
DROP TABLE IF EXISTS signoz_analytics.rule_state_history_v2
`); err != nil {
return err
}
return nil
}

View File

@@ -25,34 +25,30 @@ func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
func (c *conditionBuilder) conditionFor(
ctx context.Context,
startNs, endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
columns, err := c.fm.ColumnFor(ctx, startNs, endNs, key)
column, err := c.fm.ColumnFor(ctx, key)
if err != nil {
return "", err
}
// TODO(Piyush): Update this to support multiple JSON columns based on evolutions
for _, column := range columns {
if column.IsJSONColumn() && querybuilder.BodyJSONQueryEnabled {
valueType, value := InferDataType(value, operator, key)
cond, err := NewJSONConditionBuilder(key, valueType).buildJSONCondition(operator, value, sb)
if err != nil {
return "", err
}
return cond, nil
if column.IsJSONColumn() && querybuilder.BodyJSONQueryEnabled {
valueType, value := InferDataType(value, operator, key)
cond, err := NewJSONConditionBuilder(key, valueType).buildJSONCondition(operator, value, sb)
if err != nil {
return "", err
}
return cond, nil
}
if operator.IsStringSearchOperator() {
value = querybuilder.FormatValueForContains(value)
}
tblFieldName, err := c.fm.FieldFor(ctx, startNs, endNs, key)
tblFieldName, err := c.fm.FieldFor(ctx, key)
if err != nil {
return "", err
}
@@ -178,31 +174,6 @@ func (c *conditionBuilder) conditionFor(
}
var value any
column := columns[0]
if len(key.Evolutions) > 0 {
// we will use the corresponding column and its evolution entry for the query
newColumns, _, err := selectEvolutionsForColumns(columns, key.Evolutions, startNs, endNs, key.Name)
if err != nil {
return "", err
}
if len(newColumns) == 0 {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "no valid evolution found for field %s in the given time range", key.Name)
}
// This mean tblFieldName is with multiIf, we just need to do a null check.
if len(newColumns) > 1 {
if operator == qbtypes.FilterOperatorExists {
return sb.IsNotNull(tblFieldName), nil
} else {
return sb.IsNull(tblFieldName), nil
}
}
// otherwise we have to find the correct exist operator based on the column type
column = newColumns[0]
}
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
if operator == qbtypes.FilterOperatorExists {
@@ -257,7 +228,6 @@ func (c *conditionBuilder) conditionFor(
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for column type %s", column.Type)
}
}
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported operator: %v", operator)
@@ -265,15 +235,14 @@ func (c *conditionBuilder) conditionFor(
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
_ uint64,
_ uint64,
) (string, error) {
condition, err := c.conditionFor(ctx, startNs, endNs, key, operator, value, sb)
condition, err := c.conditionFor(ctx, key, operator, value, sb)
if err != nil {
return "", err
}
@@ -281,12 +250,12 @@ func (c *conditionBuilder) ConditionFor(
if !(key.FieldContext == telemetrytypes.FieldContextBody && querybuilder.BodyJSONQueryEnabled) && operator.AddDefaultExistsFilter() {
// skip adding exists filter for intrinsic fields
// with an exception for body json search
field, _ := c.fm.FieldFor(ctx, startNs, endNs, key)
field, _ := c.fm.FieldFor(ctx, key)
if slices.Contains(maps.Keys(IntrinsicFields), field) && key.FieldContext != telemetrytypes.FieldContextBody {
return condition, nil
}
existsCondition, err := c.conditionFor(ctx, startNs, endNs, key, qbtypes.FilterOperatorExists, nil, sb)
existsCondition, err := c.conditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb)
if err != nil {
return "", err
}

View File

@@ -3,7 +3,6 @@ package telemetrylogs
import (
"context"
"testing"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -12,148 +11,14 @@ import (
"github.com/stretchr/testify/require"
)
func TestExistsConditionForWithEvolutions(t *testing.T) {
testCases := []struct {
name string
startTs uint64
endTs uint64
key telemetrytypes.TelemetryFieldKey
operator qbtypes.FilterOperator
value any
expectedSQL string
expectedArgs []any
expectedError error
}{
{
name: "New column",
startTs: uint64(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
endTs: uint64(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
FieldContext: telemetrytypes.FieldContextResource,
ColumnType: "Map(LowCardinality(String), String)",
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
},
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "WHERE resource.`service.name`::String IS NOT NULL",
expectedError: nil,
},
{
name: "Old column",
startTs: uint64(time.Date(2023, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
endTs: uint64(time.Date(2023, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
FieldContext: telemetrytypes.FieldContextResource,
ColumnType: "Map(LowCardinality(String), String)",
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
},
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "WHERE mapContains(resources_string, 'service.name') = ?",
expectedArgs: []any{true},
expectedError: nil,
},
{
name: "Both Old column and new - empty filter",
startTs: uint64(time.Date(2023, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
endTs: uint64(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
FieldContext: telemetrytypes.FieldContextResource,
ColumnType: "Map(LowCardinality(String), String)",
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
},
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL",
expectedError: nil,
},
}
fm := NewFieldMapper()
conditionBuilder := NewConditionBuilder(fm)
ctx := context.Background()
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, tc.startTs, tc.endTs, &tc.key, tc.operator, tc.value, sb)
sb.Where(cond)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
} else {
require.NoError(t, err)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, tc.expectedSQL)
assert.Equal(t, tc.expectedArgs, args)
}
})
}
}
func TestConditionFor(t *testing.T) {
ctx := context.Background()
mockEvolution := mockEvolutionData(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC))
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
operator qbtypes.FilterOperator
value any
evolutions []*telemetrytypes.EvolutionEntry
expectedSQL string
expectedArgs []any
expectedError error
@@ -375,11 +240,9 @@ func TestConditionFor(t *testing.T) {
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
evolutions: mockEvolution,
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "mapContains(resources_string, 'service.name') = ?",
expectedArgs: []any{true},
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL",
expectedError: nil,
},
{
@@ -389,11 +252,9 @@ func TestConditionFor(t *testing.T) {
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
evolutions: mockEvolution,
operator: qbtypes.FilterOperatorNotExists,
value: nil,
expectedSQL: "mapContains(resources_string, 'service.name') <> ?",
expectedArgs: []any{true},
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NULL",
expectedError: nil,
},
{
@@ -454,11 +315,10 @@ func TestConditionFor(t *testing.T) {
FieldDataType: telemetrytypes.FieldDataTypeString,
Materialized: true,
},
evolutions: mockEvolution,
operator: qbtypes.FilterOperatorRegexp,
value: "frontend-.*",
expectedSQL: "WHERE (match(`resource_string_service$$name`, ?) AND `resource_string_service$$name_exists` = ?)",
expectedArgs: []any{"frontend-.*", true},
expectedSQL: "(match(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists`==true, `resource_string_service$$name`, NULL), ?) AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists`==true, `resource_string_service$$name`, NULL) IS NOT NULL)",
expectedArgs: []any{"frontend-.*"},
expectedError: nil,
},
{
@@ -469,10 +329,9 @@ func TestConditionFor(t *testing.T) {
FieldDataType: telemetrytypes.FieldDataTypeString,
Materialized: true,
},
evolutions: mockEvolution,
operator: qbtypes.FilterOperatorNotRegexp,
value: "test-.*",
expectedSQL: "WHERE NOT match(`resource_string_service$$name`, ?)",
expectedSQL: "WHERE NOT match(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists`==true, `resource_string_service$$name`, NULL), ?)",
expectedArgs: []any{"test-.*"},
expectedError: nil,
},
@@ -512,13 +371,14 @@ func TestConditionFor(t *testing.T) {
expectedError: qbtypes.ErrColumnNotFound,
},
}
fm := NewFieldMapper()
conditionBuilder := NewConditionBuilder(fm)
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
tc.key.Evolutions = tc.evolutions
cond, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
sb.Where(cond)
if tc.expectedError != nil {
@@ -573,7 +433,7 @@ func TestConditionForMultipleKeys(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
var err error
for _, key := range tc.keys {
cond, err := conditionBuilder.conditionFor(ctx, 0, 0, &key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &key, tc.operator, tc.value, sb, 0, 0)
sb.Where(cond)
if err != nil {
t.Fatalf("Error getting condition for key %s: %v", key.Name, err)
@@ -830,7 +690,7 @@ func TestConditionForJSONBodySearch(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.conditionFor(ctx, 0, 0, &tc.key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
sb.Where(cond)
if tc.expectedError != nil {

View File

@@ -3,10 +3,7 @@ package telemetrylogs
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-otel-collector/utils"
@@ -71,36 +68,35 @@ func NewFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
columns := []*schema.Column{logsV2Columns["resources_string"], logsV2Columns["resource"]}
return columns, nil
return logsV2Columns["resource"], nil
case telemetrytypes.FieldContextScope:
switch key.Name {
case "name", "scope.name", "scope_name":
return []*schema.Column{logsV2Columns["scope_name"]}, nil
return logsV2Columns["scope_name"], nil
case "version", "scope.version", "scope_version":
return []*schema.Column{logsV2Columns["scope_version"]}, nil
return logsV2Columns["scope_version"], nil
}
return []*schema.Column{logsV2Columns["scope_string"]}, nil
return logsV2Columns["scope_string"], nil
case telemetrytypes.FieldContextAttribute:
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
return []*schema.Column{logsV2Columns["attributes_string"]}, nil
return logsV2Columns["attributes_string"], nil
case telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeFloat64, telemetrytypes.FieldDataTypeNumber:
return []*schema.Column{logsV2Columns["attributes_number"]}, nil
return logsV2Columns["attributes_number"], nil
case telemetrytypes.FieldDataTypeBool:
return []*schema.Column{logsV2Columns["attributes_bool"]}, nil
return logsV2Columns["attributes_bool"], nil
}
case telemetrytypes.FieldContextBody:
// Body context is for JSON body fields
// Use body_json if feature flag is enabled
if querybuilder.BodyJSONQueryEnabled {
return []*schema.Column{logsV2Columns[LogsV2BodyJSONColumn]}, nil
return logsV2Columns[LogsV2BodyJSONColumn], nil
}
// Fall back to legacy body column
return []*schema.Column{logsV2Columns["body"]}, nil
return logsV2Columns["body"], nil
case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextUnspecified:
col, ok := logsV2Columns[key.Name]
if !ok {
@@ -108,237 +104,96 @@ func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.Telemetry
if strings.HasPrefix(key.Name, telemetrytypes.BodyJSONStringSearchPrefix) {
// Use body_json if feature flag is enabled and we have a body condition builder
if querybuilder.BodyJSONQueryEnabled {
// TODO(Piyush): Update this to support multiple JSON columns based on evolutions
// i.e return both the body json and body json promoted and let the evolutions decide which one to use
// based on the query range time.
return []*schema.Column{logsV2Columns[LogsV2BodyJSONColumn]}, nil
return logsV2Columns[LogsV2BodyJSONColumn], nil
}
// Fall back to legacy body column
return []*schema.Column{logsV2Columns["body"]}, nil
return logsV2Columns["body"], nil
}
return nil, qbtypes.ErrColumnNotFound
}
return []*schema.Column{col}, nil
return col, nil
}
return nil, qbtypes.ErrColumnNotFound
}
// selectEvolutionsForColumns selects the appropriate evolution entries for each column based on the time range.
// Logic:
// - Finds the latest base evolution (<= tsStartTime) across ALL columns
// - Rejects all evolutions before this latest base evolution
// - For duplicate evolutions it considers the oldest one (first in ReleaseTime)
// - For each column, includes its evolution if it's >= latest base evolution and <= tsEndTime
// - Results are sorted by ReleaseTime descending (newest first)
func selectEvolutionsForColumns(columns []*schema.Column, evolutions []*telemetrytypes.EvolutionEntry, tsStart, tsEnd uint64, fieldName string) ([]*schema.Column, []*telemetrytypes.EvolutionEntry, error) {
sortedEvolutions := make([]*telemetrytypes.EvolutionEntry, len(evolutions))
copy(sortedEvolutions, evolutions)
// sort the evolutions by ReleaseTime ascending
sort.Slice(sortedEvolutions, func(i, j int) bool {
return sortedEvolutions[i].ReleaseTime.Before(sortedEvolutions[j].ReleaseTime)
})
tsStartTime := time.Unix(0, int64(tsStart))
tsEndTime := time.Unix(0, int64(tsEnd))
// Build evolution map: column name -> evolution
evolutionMap := make(map[string]*telemetrytypes.EvolutionEntry)
for _, evolution := range sortedEvolutions {
if _, exists := evolutionMap[evolution.ColumnName+":"+evolution.FieldName+":"+strconv.Itoa(int(evolution.Version))]; exists {
// since if there is duplicate we would just use the oldest one.
continue
}
evolutionMap[evolution.ColumnName+":"+evolution.FieldName+":"+strconv.Itoa(int(evolution.Version))] = evolution
}
// Find the latest base evolution (<= tsStartTime) across ALL columns
// Evolutions are sorted, so we can break early
var latestBaseEvolutionAcrossAll *telemetrytypes.EvolutionEntry
for _, evolution := range sortedEvolutions {
if evolution.ReleaseTime.After(tsStartTime) {
break
}
latestBaseEvolutionAcrossAll = evolution
}
// We shouldn't reach this, it basically means there is something wrong with the evolutions data
if latestBaseEvolutionAcrossAll == nil {
return nil, nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "no base evolution found for columns %v", columns)
}
columnLookUpMap := make(map[string]*schema.Column)
for _, column := range columns {
columnLookUpMap[column.Name] = column
}
// Collect column-evolution pairs
type colEvoPair struct {
column *schema.Column
evolution *telemetrytypes.EvolutionEntry
}
pairs := []colEvoPair{}
for _, evolution := range evolutionMap {
// Reject evolutions before the latest base evolution
if evolution.ReleaseTime.Before(latestBaseEvolutionAcrossAll.ReleaseTime) {
continue
}
// skip evolutions after tsEndTime
if evolution.ReleaseTime.After(tsEndTime) || evolution.ReleaseTime.Equal(tsEndTime) {
continue
}
if _, exists := columnLookUpMap[evolution.ColumnName]; !exists {
return nil, nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "evolution column %s not found in columns %v", evolution.ColumnName, columns)
}
pairs = append(pairs, colEvoPair{columnLookUpMap[evolution.ColumnName], evolution})
}
// If no pairs found, fall back to latestBaseEvolutionAcrossAll for matching columns
if len(pairs) == 0 {
for _, column := range columns {
// Use latestBaseEvolutionAcrossAll if this column name matches its column name
if column.Name == latestBaseEvolutionAcrossAll.ColumnName {
pairs = append(pairs, colEvoPair{column, latestBaseEvolutionAcrossAll})
}
}
}
// Sort by ReleaseTime descending (newest first)
for i := 0; i < len(pairs)-1; i++ {
for j := i + 1; j < len(pairs); j++ {
if pairs[i].evolution.ReleaseTime.Before(pairs[j].evolution.ReleaseTime) {
pairs[i], pairs[j] = pairs[j], pairs[i]
}
}
}
// Extract results
newColumns := make([]*schema.Column, len(pairs))
evolutionsEntries := make([]*telemetrytypes.EvolutionEntry, len(pairs))
for i, pair := range pairs {
newColumns[i] = pair.column
evolutionsEntries[i] = pair.evolution
}
return newColumns, evolutionsEntries, nil
}
func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
columns, err := m.getColumn(ctx, key)
func (m *fieldMapper) FieldFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) (string, error) {
column, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
var newColumns []*schema.Column
var evolutionsEntries []*telemetrytypes.EvolutionEntry
if len(key.Evolutions) > 0 {
// we will use the corresponding column and its evolution entry for the query
newColumns, evolutionsEntries, err = selectEvolutionsForColumns(columns, key.Evolutions, tsStart, tsEnd, key.Name)
if err != nil {
return "", err
}
} else {
newColumns = columns
}
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
// json is only supported for resource context as of now
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
oldColumn := logsV2Columns["resources_string"]
oldKeyName := fmt.Sprintf("%s['%s']", oldColumn.Name, key.Name)
exprs := []string{}
existExpr := []string{}
for i, column := range newColumns {
// Use evolution column name if available, otherwise use the column name
columnName := column.Name
if evolutionsEntries != nil && evolutionsEntries[i] != nil {
columnName = evolutionsEntries[i].ColumnName
}
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
existExpr = append(existExpr, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
case telemetrytypes.FieldContextBody:
if key.JSONDataType == nil {
return "", qbtypes.ErrColumnNotFound
}
if key.KeyNameContainsArray() && !key.JSONDataType.IsArray {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "FieldFor not supported for nested fields; only supported for flat paths (e.g. body.status.detail) and paths of Array type: %s(%s)", key.Name, key.FieldDataType)
}
expr, err := m.buildFieldForJSON(key)
if err != nil {
return "", err
}
exprs = append(exprs, expr)
default:
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource/body context fields are supported for json columns, got %s", key.FieldContext.String)
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
// once clickHouse dependency is updated, we need to check if we can remove it.
if key.Materialized {
oldKeyName = telemetrytypes.FieldKeyToMaterializedColumnName(key)
oldKeyNameExists := telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, %s==true, %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldKeyNameExists, oldKeyName), nil
}
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, mapContains(%s, '%s'), %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldColumn.Name, key.Name, oldKeyName), nil
case telemetrytypes.FieldContextBody:
if key.JSONDataType == nil {
return "", qbtypes.ErrColumnNotFound
}
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
exprs = append(exprs, column.Name)
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for low cardinality column type %s", elementType)
if key.KeyNameContainsArray() && !key.JSONDataType.IsArray {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "FieldFor not supported for nested fields; only supported for flat paths (e.g. body.status.detail) and paths of Array type: %s(%s)", key.Name, key.FieldDataType)
}
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
return m.buildFieldForJSON(key)
default:
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource/body context fields are supported for json columns, got %s", key.FieldContext.String)
}
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
return column.Name, nil
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for low cardinality column type %s", elementType)
}
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
return column.Name, nil
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
// a key could have been materialized, if so return the materialized column name
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
}
exprs = append(exprs, fmt.Sprintf("%s['%s']", columnName, key.Name))
existExpr = append(existExpr, fmt.Sprintf("mapContains(%s, '%s')", columnName, key.Name))
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
// a key could have been materialized, if so return the materialized column name
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
}
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
}
}
if len(exprs) == 1 {
return exprs[0], nil
} else if len(exprs) > 1 {
// Ensure existExpr has the same length as exprs
if len(existExpr) != len(exprs) {
return "", errors.New(errors.TypeInternal, errors.CodeInternal, "length of exist exprs doesn't match to that of exprs")
}
finalExprs := []string{}
for i, expr := range exprs {
finalExprs = append(finalExprs, fmt.Sprintf("%s, %s", existExpr[i], expr))
}
return "multiIf(" + strings.Join(finalExprs, ", ") + ", NULL)", nil
}
// should not reach here
return columns[0].Name, nil
return column.Name, nil
}
func (m *fieldMapper) ColumnFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
func (m *fieldMapper) ColumnFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
return m.getColumn(ctx, key)
}
func (m *fieldMapper) ColumnExpressionFor(
ctx context.Context,
tsStart, tsEnd uint64,
field *telemetrytypes.TelemetryFieldKey,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
colName, err := m.FieldFor(ctx, tsStart, tsEnd, field)
colName, err := m.FieldFor(ctx, field)
if errors.Is(err, qbtypes.ErrColumnNotFound) {
// the key didn't have the right context to be added to the query
// we try to use the context we know of
@@ -348,7 +203,7 @@ func (m *fieldMapper) ColumnExpressionFor(
if _, ok := logsV2Columns[field.Name]; ok {
// if it is, attach the column name directly
field.FieldContext = telemetrytypes.FieldContextLog
colName, _ = m.FieldFor(ctx, tsStart, tsEnd, field)
colName, _ = m.FieldFor(ctx, field)
} else {
// - the context is not provided
// - there are not keys for the field
@@ -366,12 +221,12 @@ func (m *fieldMapper) ColumnExpressionFor(
}
} else if len(keysForField) == 1 {
// we have a single key for the field, use it
colName, _ = m.FieldFor(ctx, tsStart, tsEnd, keysForField[0])
colName, _ = m.FieldFor(ctx, keysForField[0])
} else {
// select any non-empty value from the keys
args := []string{}
for _, key := range keysForField {
colName, _ = m.FieldFor(ctx, tsStart, tsEnd, key)
colName, _ = m.FieldFor(ctx, key)
args = append(args, fmt.Sprintf("toString(%s) != '', toString(%s)", colName, colName))
}
colName = fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", "))

View File

@@ -3,7 +3,6 @@ package telemetrylogs
import (
"context"
"testing"
"time"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -18,7 +17,7 @@ func TestGetColumn(t *testing.T) {
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
expectedCol []*schema.Column
expectedCol *schema.Column
expectedError error
}{
{
@@ -27,7 +26,7 @@ func TestGetColumn(t *testing.T) {
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
},
expectedCol: []*schema.Column{logsV2Columns["resources_string"], logsV2Columns["resource"]},
expectedCol: logsV2Columns["resource"],
expectedError: nil,
},
{
@@ -36,7 +35,7 @@ func TestGetColumn(t *testing.T) {
Name: "name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedCol: []*schema.Column{logsV2Columns["scope_name"]},
expectedCol: logsV2Columns["scope_name"],
expectedError: nil,
},
{
@@ -45,7 +44,7 @@ func TestGetColumn(t *testing.T) {
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedCol: []*schema.Column{logsV2Columns["scope_name"]},
expectedCol: logsV2Columns["scope_name"],
expectedError: nil,
},
{
@@ -54,7 +53,7 @@ func TestGetColumn(t *testing.T) {
Name: "scope_name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedCol: []*schema.Column{logsV2Columns["scope_name"]},
expectedCol: logsV2Columns["scope_name"],
expectedError: nil,
},
{
@@ -63,7 +62,7 @@ func TestGetColumn(t *testing.T) {
Name: "version",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedCol: []*schema.Column{logsV2Columns["scope_version"]},
expectedCol: logsV2Columns["scope_version"],
expectedError: nil,
},
{
@@ -72,7 +71,7 @@ func TestGetColumn(t *testing.T) {
Name: "custom.scope.field",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedCol: []*schema.Column{logsV2Columns["scope_string"]},
expectedCol: logsV2Columns["scope_string"],
expectedError: nil,
},
{
@@ -82,7 +81,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_string"]},
expectedCol: logsV2Columns["attributes_string"],
expectedError: nil,
},
{
@@ -92,7 +91,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_number"]},
expectedCol: logsV2Columns["attributes_number"],
expectedError: nil,
},
{
@@ -102,7 +101,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeInt64,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_number"]},
expectedCol: logsV2Columns["attributes_number"],
expectedError: nil,
},
{
@@ -112,7 +111,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_number"]},
expectedCol: logsV2Columns["attributes_number"],
expectedError: nil,
},
{
@@ -122,7 +121,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_bool"]},
expectedCol: logsV2Columns["attributes_bool"],
expectedError: nil,
},
{
@@ -131,7 +130,7 @@ func TestGetColumn(t *testing.T) {
Name: "timestamp",
FieldContext: telemetrytypes.FieldContextLog,
},
expectedCol: []*schema.Column{logsV2Columns["timestamp"]},
expectedCol: logsV2Columns["timestamp"],
expectedError: nil,
},
{
@@ -140,7 +139,7 @@ func TestGetColumn(t *testing.T) {
Name: "body",
FieldContext: telemetrytypes.FieldContextLog,
},
expectedCol: []*schema.Column{logsV2Columns["body"]},
expectedCol: logsV2Columns["body"],
expectedError: nil,
},
{
@@ -160,7 +159,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_bool"]},
expectedCol: logsV2Columns["attributes_bool"],
expectedError: nil,
},
}
@@ -169,7 +168,7 @@ func TestGetColumn(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
col, err := fm.ColumnFor(ctx, 0, 0, &tc.key)
col, err := fm.ColumnFor(ctx, &tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
@@ -184,14 +183,11 @@ func TestGetColumn(t *testing.T) {
func TestGetFieldKeyName(t *testing.T) {
ctx := context.Background()
resourceEvolution := mockEvolutionData(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC))
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
expectedResult string
expectedError error
addExistsFilter bool
name string
key telemetrytypes.TelemetryFieldKey
expectedResult string
expectedError error
}{
{
name: "Simple column type - timestamp",
@@ -199,9 +195,8 @@ func TestGetFieldKeyName(t *testing.T) {
Name: "timestamp",
FieldContext: telemetrytypes.FieldContextLog,
},
expectedResult: "timestamp",
expectedError: nil,
addExistsFilter: false,
expectedResult: "timestamp",
expectedError: nil,
},
{
name: "Map column type - string attribute",
@@ -210,9 +205,8 @@ func TestGetFieldKeyName(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
expectedResult: "attributes_string['user.id']",
expectedError: nil,
addExistsFilter: false,
expectedResult: "attributes_string['user.id']",
expectedError: nil,
},
{
name: "Map column type - number attribute",
@@ -221,9 +215,8 @@ func TestGetFieldKeyName(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
},
expectedResult: "attributes_number['request.size']",
expectedError: nil,
addExistsFilter: false,
expectedResult: "attributes_number['request.size']",
expectedError: nil,
},
{
name: "Map column type - bool attribute",
@@ -232,33 +225,28 @@ func TestGetFieldKeyName(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
expectedResult: "attributes_bool['request.success']",
expectedError: nil,
addExistsFilter: false,
expectedResult: "attributes_bool['request.success']",
expectedError: nil,
},
{
name: "Map column type - resource attribute",
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
Evolutions: resourceEvolution,
},
expectedResult: "resources_string['service.name']",
expectedError: nil,
addExistsFilter: false,
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)",
expectedError: nil,
},
{
name: "Map column type - resource attribute - Materialized - json",
name: "Map column type - resource attribute - Materialized",
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Materialized: true,
Evolutions: resourceEvolution,
},
expectedResult: "`resource_string_service$$name`",
expectedError: nil,
addExistsFilter: false,
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists`==true, `resource_string_service$$name`, NULL)",
expectedError: nil,
},
{
name: "Non-existent column",
@@ -274,7 +262,7 @@ func TestGetFieldKeyName(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fm := NewFieldMapper()
result, err := fm.FieldFor(ctx, 0, 0, &tc.key)
result, err := fm.FieldFor(ctx, &tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
@@ -285,581 +273,3 @@ func TestGetFieldKeyName(t *testing.T) {
})
}
}
func TestFieldForWithEvolutions(t *testing.T) {
ctx := context.Background()
key := &telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
}
testCases := []struct {
name string
evolutions []*telemetrytypes.EvolutionEntry
key *telemetrytypes.TelemetryFieldKey
tsStartTime time.Time
tsEndTime time.Time
expectedResult string
expectedError error
}{
{
name: "Single evolution before tsStartTime",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "resources_string['service.name']",
expectedError: nil,
},
{
name: "Single evolution exactly at tsStartTime",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "resources_string['service.name']",
expectedError: nil,
},
{
name: "Single evolution after tsStartTime",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)",
expectedError: nil,
},
// TODO(piyush): to be added once integration with JSON is done.
// {
// name: "Single evolution after tsStartTime - JSON body",
// evolutions: []*telemetrytypes.EvolutionEntry{
// {
// Signal: telemetrytypes.SignalLogs,
// ColumnName: LogsV2BodyJSONColumn,
// ColumnType: "JSON(max_dynamic_paths=0)",
// FieldContext: telemetrytypes.FieldContextBody,
// FieldName: "__all__",
// ReleaseTime: time.Unix(0, 0),
// },
// {
// Signal: telemetrytypes.SignalLogs,
// ColumnName: LogsV2BodyPromotedColumn,
// ColumnType: "JSON()",
// FieldContext: telemetrytypes.FieldContextBody,
// FieldName: "user.name",
// ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
// },
// },
// key: &telemetrytypes.TelemetryFieldKey{
// Name: "user.name",
// FieldContext: telemetrytypes.FieldContextBody,
// JSONDataType: &telemetrytypes.String,
// Materialized: true,
// },
// tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
// tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
// expectedResult: "coalesce(dynamicElement(body_json.`user.name`, 'String'), dynamicElement(body_promoted.`user.name`, 'String'))",
// expectedError: nil,
// },
{
name: "Multiple evolutions before tsStartTime - only latest should be included",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "resource.`service.name`::String",
expectedError: nil,
},
{
name: "Multiple evolutions after tsStartTime - all should be included",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Unix(0, 0),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)",
expectedError: nil,
},
{
name: "Duplicate evolutions after tsStartTime - all should be included",
// Note: on production when this happens, we should go ahead and clean it up if required
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "resource.`service.name`::String",
expectedError: nil,
},
{
name: "Evolution exactly at tsEndTime - should not be included",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "resources_string['service.name']",
expectedError: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fm := NewFieldMapper()
tsStart := uint64(tc.tsStartTime.UnixNano())
tsEnd := uint64(tc.tsEndTime.UnixNano())
tc.key.Evolutions = tc.evolutions
result, err := fm.FieldFor(ctx, tsStart, tsEnd, tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
}
})
}
}
func TestSelectEvolutionsForColumns(t *testing.T) {
testCases := []struct {
name string
columns []*schema.Column
evolutions []*telemetrytypes.EvolutionEntry
tsStart uint64
tsEnd uint64
fieldName string
expectedColumns []string // column names
expectedEvols []string // evolution column names
expectedError bool
errorStr string
}{
{
name: "New evolutions after tsStartTime - should include all",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC),
},
},
fieldName: "service.name",
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resource", "resources_string"}, // sorted by ReleaseTime desc
expectedEvols: []string{"resource", "resources_string"},
},
{
name: "Columns without matching evolutions - should exclude them",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"], // no evolution for this
logsV2Columns["attributes_string"], // no evolution for this
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
},
fieldName: "service.name",
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resources_string"},
expectedEvols: []string{"resources_string"},
},
{
name: "New evolutions after tsEndTime - should exclude all",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC),
},
},
fieldName: "service.name",
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resources_string"},
expectedEvols: []string{"resources_string"},
},
{
name: "Empty columns array",
columns: []*schema.Column{},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
},
fieldName: "service.name",
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{},
expectedEvols: []string{},
expectedError: true,
errorStr: "column resources_string not found",
},
{
name: "Duplicate evolutions - should use first encountered (oldest if sorted)",
columns: []*schema.Column{
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 1, 20, 0, 0, 0, 0, time.UTC),
},
},
fieldName: "service.name",
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resource"},
expectedEvols: []string{"resource"}, // should use first one (older)
},
{
name: "Genuine Duplicate evolutions with new version- should consider both",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 2,
ReleaseTime: time.Date(2024, 1, 20, 0, 0, 0, 0, time.UTC),
},
},
fieldName: "service.name",
tsStart: uint64(time.Date(2024, 1, 16, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resources_string", "resource"},
expectedEvols: []string{"resources_string", "resource"}, // should use first one (older)
},
{
name: "Evolution exactly at tsEndTime",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC), // exactly at tsEnd
},
},
fieldName: "service.name",
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resources_string"}, // resource excluded because After(tsEnd) is true
expectedEvols: []string{"resources_string"},
},
{
name: "Single evolution after tsStartTime - JSON body",
columns: []*schema.Column{
logsV2Columns[LogsV2BodyJSONColumn],
logsV2Columns[LogsV2BodyPromotedColumn],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: LogsV2BodyJSONColumn,
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: LogsV2BodyPromotedColumn,
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "user.name",
ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
},
},
fieldName: "user.name",
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{LogsV2BodyPromotedColumn, LogsV2BodyJSONColumn}, // sorted by ReleaseTime desc (newest first)
expectedEvols: []string{LogsV2BodyPromotedColumn, LogsV2BodyJSONColumn},
},
{
name: "No evolution after tsStartTime - JSON body",
columns: []*schema.Column{
logsV2Columns[LogsV2BodyJSONColumn],
logsV2Columns[LogsV2BodyPromotedColumn],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: LogsV2BodyJSONColumn,
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: LogsV2BodyPromotedColumn,
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "user.name",
ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
},
},
fieldName: "user.name",
tsStart: uint64(time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{LogsV2BodyPromotedColumn},
expectedEvols: []string{LogsV2BodyPromotedColumn},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
resultColumns, resultEvols, err := selectEvolutionsForColumns(tc.columns, tc.evolutions, tc.tsStart, tc.tsEnd, tc.fieldName)
if tc.expectedError {
assert.Contains(t, err.Error(), tc.errorStr)
} else {
require.NoError(t, err)
assert.Equal(t, len(tc.expectedColumns), len(resultColumns), "column count mismatch")
assert.Equal(t, len(tc.expectedEvols), len(resultEvols), "evolution count mismatch")
resultColumnNames := make([]string, len(resultColumns))
for i, col := range resultColumns {
resultColumnNames[i] = col.Name
}
resultEvolNames := make([]string, len(resultEvols))
for i, evol := range resultEvols {
resultEvolNames[i] = evol.ColumnName
}
for i := range tc.expectedColumns {
assert.Equal(t, resultColumnNames[i], tc.expectedColumns[i], "expected column missing: "+tc.expectedColumns[i])
}
for i := range tc.expectedEvols {
assert.Equal(t, resultEvolNames[i], tc.expectedEvols[i], "expected evolution missing: "+tc.expectedEvols[i])
}
// Verify sorting: should be descending by ReleaseTime
for i := 0; i < len(resultEvols)-1; i++ {
assert.True(t, !resultEvols[i].ReleaseTime.Before(resultEvols[i+1].ReleaseTime),
"evolutions should be sorted descending by ReleaseTime")
}
}
})
}
}

View File

@@ -1,7 +1,6 @@
package telemetrylogs
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -11,14 +10,12 @@ import (
// TestLikeAndILikeWithoutWildcards_Warns Tests that LIKE/ILIKE without wildcards add warnings and include docs URL
func TestLikeAndILikeWithoutWildcards_Warns(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
keys := buildCompleteFieldKeyMap()
opts := querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
@@ -36,7 +33,7 @@ func TestLikeAndILikeWithoutWildcards_Warns(t *testing.T) {
for _, expr := range tests {
t.Run(expr, func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(expr, opts)
clause, err := querybuilder.PrepareWhereClause(expr, opts, 0, 0)
require.NoError(t, err)
require.NotNil(t, clause)
@@ -55,7 +52,6 @@ func TestLikeAndILikeWithWildcards_NoWarn(t *testing.T) {
keys := buildCompleteFieldKeyMap()
opts := querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
@@ -73,7 +69,7 @@ func TestLikeAndILikeWithWildcards_NoWarn(t *testing.T) {
for _, expr := range tests {
t.Run(expr, func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(expr, opts)
clause, err := querybuilder.PrepareWhereClause(expr, opts, 0, 0)
require.NoError(t, err)
require.NotNil(t, clause)

View File

@@ -1,7 +1,6 @@
package telemetrylogs
import (
"context"
"fmt"
"testing"
@@ -20,7 +19,6 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
keys := buildCompleteFieldKeyMap()
opts := querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
@@ -163,7 +161,7 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
clause, err := querybuilder.PrepareWhereClause(tc.query, opts, 0, 0)
if tc.shouldPass {
if err != nil {

View File

@@ -1,11 +1,9 @@
package telemetrylogs
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -17,33 +15,19 @@ import (
// TestFilterExprLogs tests a comprehensive set of query patterns for logs search
func TestFilterExprLogs(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
ctx := context.Background()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
// Define a comprehensive set of field keys to support all test cases
keys := buildCompleteFieldKeyMap()
// for each key of resource attribute add evolution metadata
for i, telemetryKeys := range keys {
for j, telemetryKey := range telemetryKeys {
if telemetryKey.FieldContext == telemetrytypes.FieldContextResource {
keys[i][j].Evolutions = mockEvolutionData(releaseTime)
}
}
}
opts := querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonKeyToKey: GetBodyJSONKey,
StartNs: uint64(releaseTime.Add(-5 * time.Minute).UnixNano()),
EndNs: uint64(releaseTime.Add(5 * time.Minute).UnixNano()),
}
testCases := []struct {
@@ -482,7 +466,7 @@ func TestFilterExprLogs(t *testing.T) {
expectedErrorContains: "",
},
//fulltext with parenthesized expression
// fulltext with parenthesized expression
{
category: "FREETEXT with parentheses",
query: "error (status.code=500 OR status.code=503)",
@@ -2402,7 +2386,7 @@ func TestFilterExprLogs(t *testing.T) {
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
clause, err := querybuilder.PrepareWhereClause(tc.query, opts, 0, 0)
if tc.shouldPass {
if err != nil {
@@ -2458,7 +2442,6 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
}
opts := querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
@@ -2521,7 +2504,7 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
clause, err := querybuilder.PrepareWhereClause(tc.query, opts, 0, 0)
if tc.shouldPass {
if err != nil {

View File

@@ -268,7 +268,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
}
// get column expression for the field - use array index directly to avoid pointer to loop variable
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &query.SelectFields[index], keys)
colExpr, err := b.fm.ColumnExpressionFor(ctx, &query.SelectFields[index], keys)
if err != nil {
return nil, err
}
@@ -277,6 +277,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
}
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
@@ -286,8 +287,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
// Add order by
for _, orderBy := range query.Order {
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &orderBy.Key.TelemetryFieldKey, keys)
colExpr, err := b.fm.ColumnExpressionFor(ctx, &orderBy.Key.TelemetryFieldKey, keys)
if err != nil {
return nil, err
}
@@ -353,7 +353,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
// Keep original column expressions so we can build the tuple
fieldNames := make([]string, 0, len(query.GroupBy))
for _, gb := range query.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
if err != nil {
return nil, err
}
@@ -368,7 +368,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, start, end, agg.Expression,
ctx, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
)
@@ -500,7 +500,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
var allGroupByArgs []any
for _, gb := range query.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
if err != nil {
return nil, err
}
@@ -518,7 +518,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, start, end, aggExpr.Expression,
ctx, aggExpr.Expression,
rateInterval,
keys,
)
@@ -590,7 +590,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
// buildFilterCondition builds SQL condition from filter expression
func (b *logQueryStatementBuilder) addFilterCondition(
ctx context.Context,
_ context.Context,
sb *sqlbuilder.SelectBuilder,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
@@ -604,7 +604,6 @@ func (b *logQueryStatementBuilder) addFilterCondition(
if query.Filter != nil && query.Filter.Expression != "" {
// add filter expression
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
@@ -613,9 +612,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
StartNs: start,
EndNs: end,
})
}, start, end)
if err != nil {
return nil, err

View File

@@ -37,14 +37,7 @@ func resourceFilterStmtBuilder() qbtypes.StatementBuilder[qbtypes.LogAggregation
}
func TestStatementBuilderTimeSeries(t *testing.T) {
// Create a test release time
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTimeNano := uint64(releaseTime.UnixNano())
cases := []struct {
startTs uint64
endTs uint64
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
@@ -52,16 +45,14 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
expectedErr error
}{
{
startTs: releaseTimeNano + uint64(24*time.Hour.Nanoseconds()),
endTs: releaseTimeNano + uint64(48*time.Hour.Nanoseconds()),
name: "Time series with limit and count distinct on service.name",
name: "Time series with limit",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{
{
Expression: "count_distinct(service.name)",
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
@@ -77,22 +68,20 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, countDistinct(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, countDistinct(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 10, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
{
startTs: releaseTimeNano - uint64(24*time.Hour.Nanoseconds()),
endTs: releaseTimeNano + uint64(48*time.Hour.Nanoseconds()),
name: "Time series with OR b/w resource attr and attribute filter and count distinct on service.name",
name: "Time series with OR b/w resource attr and attribute filter",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{
{
Expression: "count_distinct(service.name)",
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
@@ -108,14 +97,12 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, countDistinct(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, countDistinct(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1705224600), uint64(1705485600), "redis-manual", "GET", true, "1705226400000000000", uint64(1705224600), "1705485600000000000", uint64(1705485600), 10, "redis-manual", "GET", true, "1705226400000000000", uint64(1705224600), "1705485600000000000", uint64(1705485600)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "redis-manual", "GET", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "redis-manual", "GET", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
{
startTs: releaseTimeNano + uint64(24*time.Hour.Nanoseconds()),
endTs: releaseTimeNano + uint64(48*time.Hour.Nanoseconds()),
name: "Time series with limit + custom order by",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -149,14 +136,12 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY `service.name` desc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name` ORDER BY `service.name` desc, ts desc",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 10, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY `service.name` desc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name` ORDER BY `service.name` desc, ts desc",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
{
startTs: releaseTimeNano + uint64(24*time.Hour.Nanoseconds()),
endTs: releaseTimeNano + uint64(48*time.Hour.Nanoseconds()),
name: "Time series with group by on materialized column",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -183,12 +168,10 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `materialized.key.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`materialized.key.name`) GLOBAL IN (SELECT `materialized.key.name` FROM __limit_cte) GROUP BY ts, `materialized.key.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), true, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 10, true, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
{
startTs: releaseTimeNano + uint64(24*time.Hour.Nanoseconds()),
endTs: releaseTimeNano + uint64(48*time.Hour.Nanoseconds()),
name: "Time series with materialised column using or with regex operator",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -206,29 +189,14 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (true OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists` = ?) OR (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists` = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts",
Args: []any{uint64(1705397400), uint64(1705485600), "redis.*", true, "memcached", true, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
Args: []any{uint64(1747945619), uint64(1747983448), "redis.*", true, "memcached", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
}
ctx := context.Background()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keysMap := buildCompleteFieldKeyMap()
// for each key of resource attribute add evolution metadata
for i, telemetryKeys := range keysMap {
for j, telemetryKey := range telemetryKeys {
if telemetryKey.FieldContext == telemetrytypes.FieldContextResource {
keysMap[i][j].Signal = telemetrytypes.SignalLogs
keysMap[i][j].Evolutions = mockEvolutionData(releaseTime)
}
}
}
mockMetadataStore.KeysMap = keysMap
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
@@ -250,7 +218,7 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, c.startTs, c.endTs, c.requestType, c.query, nil)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
@@ -347,10 +315,9 @@ func TestStatementBuilderListQuery(t *testing.T) {
},
}
ctx := context.Background()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
fm := NewFieldMapper()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
@@ -371,7 +338,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, c.requestType, c.query, nil)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
@@ -456,10 +423,9 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
},
}
ctx := context.Background()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
fm := NewFieldMapper()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
@@ -477,10 +443,12 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
GetBodyJSONKey,
)
//
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, c.requestType, c.query, nil)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
@@ -531,10 +499,9 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
},
}
ctx := context.Background()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
fm := NewFieldMapper()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
@@ -555,7 +522,7 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, c.requestType, c.query, nil)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErrContains != "" {
require.Error(t, err)
@@ -627,10 +594,9 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
},
}
ctx := context.Background()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
fm := NewFieldMapper()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMapCollision()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
@@ -651,7 +617,7 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, c.requestType, c.query, nil)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)

View File

@@ -2,7 +2,6 @@ package telemetrylogs
import (
"strings"
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -1008,24 +1007,3 @@ func buildCompleteFieldKeyMapCollision() map[string][]*telemetrytypes.TelemetryF
}
return keysMap
}
func mockEvolutionData(releaseTime time.Time) []*telemetrytypes.EvolutionEntry {
return []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
FieldContext: telemetrytypes.FieldContextResource,
ColumnType: "Map(LowCardinality(String), String)",
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: releaseTime,
},
}
}

View File

@@ -21,11 +21,12 @@ func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
_ uint64,
_ uint64,
) (string, error) {
switch operator {
@@ -38,13 +39,13 @@ func (c *conditionBuilder) ConditionFor(
value = querybuilder.FormatValueForContains(value)
}
columns, err := c.fm.ColumnFor(ctx, tsStart, tsEnd, key)
column, err := c.fm.ColumnFor(ctx, key)
if err != nil {
// if we don't have a column, we can't build a condition for related values
return "", nil
}
tblFieldName, err := c.fm.FieldFor(ctx, tsStart, tsEnd, key)
tblFieldName, err := c.fm.FieldFor(ctx, key)
if err != nil {
// if we don't have a table field name, we can't build a condition for related values
return "", nil
@@ -119,12 +120,12 @@ func (c *conditionBuilder) ConditionFor(
// in the query builder, `exists` and `not exists` are used for
// key membership checks, so depending on the column type, the condition changes
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
switch columns[0].Type {
switch column.Type {
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeString,
}:
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", columns[0].Name, key.Name)
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", column.Name, key.Name)
if operator == qbtypes.FilterOperatorExists {
cond = sb.E(leftOperand, true)
} else {
@@ -133,5 +134,5 @@ func (c *conditionBuilder) ConditionFor(
}
}
return fmt.Sprintf(expr, columns[0].Name, sb.Var(key.Name), cond), nil
return fmt.Sprintf(expr, column.Name, sb.Var(key.Name), cond), nil
}

View File

@@ -53,7 +53,7 @@ func TestConditionFor(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
sb.Where(cond)
if tc.expectedError != nil {

View File

@@ -33,48 +33,47 @@ func NewFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}
func (m *fieldMapper) getColumn(_ context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
return []*schema.Column{attributeMetadataColumns["resource_attributes"]}, nil
return attributeMetadataColumns["resource_attributes"], nil
case telemetrytypes.FieldContextAttribute:
return []*schema.Column{attributeMetadataColumns["attributes"]}, nil
return attributeMetadataColumns["attributes"], nil
}
return nil, qbtypes.ErrColumnNotFound
}
func (m *fieldMapper) ColumnFor(ctx context.Context, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
columns, err := m.getColumn(ctx, tsStart, tsEnd, key)
func (m *fieldMapper) ColumnFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
column, err := m.getColumn(ctx, key)
if err != nil {
return nil, err
}
return columns, nil
return column, nil
}
func (m *fieldMapper) FieldFor(ctx context.Context, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
columns, err := m.getColumn(ctx, startNs, endNs, key)
func (m *fieldMapper) FieldFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) (string, error) {
column, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
switch columns[0].Type {
switch column.Type {
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeString,
}:
return fmt.Sprintf("%s['%s']", columns[0].Name, key.Name), nil
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
}
return columns[0].Name, nil
return column.Name, nil
}
func (m *fieldMapper) ColumnExpressionFor(
ctx context.Context,
startNs, endNs uint64,
field *telemetrytypes.TelemetryFieldKey,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
colName, err := m.FieldFor(ctx, startNs, endNs, field)
colName, err := m.FieldFor(ctx, field)
if errors.Is(err, qbtypes.ErrColumnNotFound) {
// the key didn't have the right context to be added to the query
// we try to use the context we know of
@@ -84,7 +83,7 @@ func (m *fieldMapper) ColumnExpressionFor(
if _, ok := attributeMetadataColumns[field.Name]; ok {
// if it is, attach the column name directly
field.FieldContext = telemetrytypes.FieldContextSpan
colName, _ = m.FieldFor(ctx, startNs, endNs, field)
colName, _ = m.FieldFor(ctx, field)
} else {
// - the context is not provided
// - there are not keys for the field
@@ -102,12 +101,12 @@ func (m *fieldMapper) ColumnExpressionFor(
}
} else if len(keysForField) == 1 {
// we have a single key for the field, use it
colName, _ = m.FieldFor(ctx, startNs, endNs, keysForField[0])
colName, _ = m.FieldFor(ctx, keysForField[0])
} else {
// select any non-empty value from the keys
args := []string{}
for _, key := range keysForField {
colName, _ = m.FieldFor(ctx, startNs, endNs, key)
colName, _ = m.FieldFor(ctx, key)
args = append(args, fmt.Sprintf("toString(%s) != '', toString(%s)", colName, colName))
}
colName = fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", "))

View File

@@ -128,13 +128,13 @@ func TestGetColumn(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
col, err := fm.ColumnFor(context.Background(), 0, 0, &tc.key)
col, err := fm.ColumnFor(context.Background(), &tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expectedCol, col[0])
assert.Equal(t, tc.expectedCol, col)
}
})
}
@@ -145,8 +145,6 @@ func TestGetFieldKeyName(t *testing.T) {
testCases := []struct {
name string
tsStart uint64
tsEnd uint64
key telemetrytypes.TelemetryFieldKey
expectedResult string
expectedError error
@@ -205,7 +203,7 @@ func TestGetFieldKeyName(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := fm.FieldFor(ctx, tc.tsStart, tc.tsEnd, &tc.key)
result, err := fm.FieldFor(ctx, &tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)

View File

@@ -2,11 +2,9 @@ package telemetrymetadata
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
@@ -15,7 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -34,24 +31,23 @@ var (
)
type telemetryMetaStore struct {
logger *slog.Logger
telemetrystore telemetrystore.TelemetryStore
tracesDBName string
tracesFieldsTblName string
spanAttributesKeysTblName string
indexV3TblName string
metricsDBName string
metricsFieldsTblName string
meterDBName string
meterFieldsTblName string
logsDBName string
logsFieldsTblName string
logAttributeKeysTblName string
logResourceKeysTblName string
logsV2TblName string
relatedMetadataDBName string
relatedMetadataTblName string
columnEvolutionMetadataTblName string
logger *slog.Logger
telemetrystore telemetrystore.TelemetryStore
tracesDBName string
tracesFieldsTblName string
spanAttributesKeysTblName string
indexV3TblName string
metricsDBName string
metricsFieldsTblName string
meterDBName string
meterFieldsTblName string
logsDBName string
logsFieldsTblName string
logAttributeKeysTblName string
logResourceKeysTblName string
logsV2TblName string
relatedMetadataDBName string
relatedMetadataTblName string
fm qbtypes.FieldMapper
conditionBuilder qbtypes.ConditionBuilder
@@ -80,29 +76,27 @@ func NewTelemetryMetaStore(
logResourceKeysTblName string,
relatedMetadataDBName string,
relatedMetadataTblName string,
columnEvolutionMetadataTblName string,
) telemetrytypes.MetadataStore {
metadataSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetrymetadata")
t := &telemetryMetaStore{
logger: metadataSettings.Logger(),
telemetrystore: telemetrystore,
tracesDBName: tracesDBName,
tracesFieldsTblName: tracesFieldsTblName,
spanAttributesKeysTblName: spanAttributesKeysTblName,
indexV3TblName: indexV3TblName,
metricsDBName: metricsDBName,
metricsFieldsTblName: metricsFieldsTblName,
meterDBName: meterDBName,
meterFieldsTblName: meterFieldsTblName,
logsDBName: logsDBName,
logsV2TblName: logsV2TblName,
logsFieldsTblName: logsFieldsTblName,
logAttributeKeysTblName: logAttributeKeysTblName,
logResourceKeysTblName: logResourceKeysTblName,
relatedMetadataDBName: relatedMetadataDBName,
relatedMetadataTblName: relatedMetadataTblName,
columnEvolutionMetadataTblName: columnEvolutionMetadataTblName,
logger: metadataSettings.Logger(),
telemetrystore: telemetrystore,
tracesDBName: tracesDBName,
tracesFieldsTblName: tracesFieldsTblName,
spanAttributesKeysTblName: spanAttributesKeysTblName,
indexV3TblName: indexV3TblName,
metricsDBName: metricsDBName,
metricsFieldsTblName: metricsFieldsTblName,
meterDBName: meterDBName,
meterFieldsTblName: meterFieldsTblName,
logsDBName: logsDBName,
logsV2TblName: logsV2TblName,
logsFieldsTblName: logsFieldsTblName,
logAttributeKeysTblName: logAttributeKeysTblName,
logResourceKeysTblName: logResourceKeysTblName,
relatedMetadataDBName: relatedMetadataDBName,
relatedMetadataTblName: relatedMetadataTblName,
jsonColumnMetadata: map[telemetrytypes.Signal]map[telemetrytypes.FieldContext]telemetrytypes.JSONColumnMetadata{
telemetrytypes.SignalLogs: {
telemetrytypes.FieldContextBody: telemetrytypes.JSONColumnMetadata{
@@ -569,48 +563,9 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
keys = append(keys, bodyJSONPaths...)
complete = complete && finished
}
// fetch and add evolutions
evolutionMetadataKeySelectors := getEvolutionMetadataKeySelectors(keys)
evolutions, err := t.GetColumnEvolutionMetadataMulti(ctx, evolutionMetadataKeySelectors)
if err != nil {
return nil, false, err
}
for i, key := range keys {
// first check if there is evolutions that with field name as __all__
// then check for specific field name
selector := &telemetrytypes.EvolutionSelector{
Signal: key.Signal,
FieldContext: key.FieldContext,
FieldName: "__all__",
}
if keyEvolutions, ok := evolutions[telemetrytypes.GetEvolutionMetadataUniqueKey(selector)]; ok {
keys[i].Evolutions = keyEvolutions
}
selector.FieldName = key.Name
if keyEvolutions, ok := evolutions[telemetrytypes.GetEvolutionMetadataUniqueKey(selector)]; ok {
keys[i].Evolutions = keyEvolutions
}
}
return keys, complete, nil
}
func getEvolutionMetadataKeySelectors(keySelectors []*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.EvolutionSelector {
var metadataKeySelectors []*telemetrytypes.EvolutionSelector
for _, keySelector := range keySelectors {
selector := &telemetrytypes.EvolutionSelector{
Signal: keySelector.Signal,
FieldContext: keySelector.FieldContext,
FieldName: keySelector.Name,
}
metadataKeySelectors = append(metadataKeySelectors, selector)
}
return metadataKeySelectors
}
func getPriorityForContext(ctx telemetrytypes.FieldContext) int {
switch ctx {
case telemetrytypes.FieldContextLog:
@@ -1031,18 +986,18 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel
FieldDataType: fieldValueSelector.FieldDataType,
}
selectColumn, err := t.fm.FieldFor(ctx, 0, 0, key)
selectColumn, err := t.fm.FieldFor(ctx, key)
if err != nil {
// we don't have a explicit column to select from the related metadata table
// so we will select either from resource_attributes or attributes table
// in that order
resourceColumn, _ := t.fm.FieldFor(ctx, 0, 0, &telemetrytypes.TelemetryFieldKey{
resourceColumn, _ := t.fm.FieldFor(ctx, &telemetrytypes.TelemetryFieldKey{
Name: key.Name,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
})
attributeColumn, _ := t.fm.FieldFor(ctx, 0, 0, &telemetrytypes.TelemetryFieldKey{
attributeColumn, _ := t.fm.FieldFor(ctx, &telemetrytypes.TelemetryFieldKey{
Name: key.Name,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
@@ -1063,12 +1018,11 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel
}
whereClause, err := querybuilder.PrepareWhereClause(fieldValueSelector.ExistingQuery, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: t.logger,
FieldMapper: t.fm,
ConditionBuilder: t.conditionBuilder,
FieldKeys: keys,
})
}, 0, 0)
if err == nil {
sb.AddWhereClause(whereClause.WhereClause)
} else {
@@ -1092,20 +1046,20 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel
// search on attributes
key.FieldContext = telemetrytypes.FieldContextAttribute
cond, err := t.conditionBuilder.ConditionFor(ctx, 0, 0, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
cond, err := t.conditionBuilder.ConditionFor(ctx, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb, 0, 0)
if err == nil {
conds = append(conds, cond)
}
// search on resource
key.FieldContext = telemetrytypes.FieldContextResource
cond, err = t.conditionBuilder.ConditionFor(ctx, 0, 0, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
cond, err = t.conditionBuilder.ConditionFor(ctx, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb, 0, 0)
if err == nil {
conds = append(conds, cond)
}
key.FieldContext = origContext
} else {
cond, err := t.conditionBuilder.ConditionFor(ctx, 0, 0, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
cond, err := t.conditionBuilder.ConditionFor(ctx, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb, 0, 0)
if err == nil {
conds = append(conds, cond)
}
@@ -1796,103 +1750,6 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporality(ctx context.Cont
return result, nil
}
// CachedColumnEvolutionMetadata is a cacheable type for storing column evolution metadata
type CachedEvolutionEntry struct {
Metadata []*telemetrytypes.EvolutionEntry `json:"metadata"`
}
var _ cachetypes.Cacheable = (*CachedEvolutionEntry)(nil)
func (c *CachedEvolutionEntry) MarshalBinary() ([]byte, error) {
return json.Marshal(c)
}
func (c *CachedEvolutionEntry) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, c)
}
func (k *telemetryMetaStore) fetchEvolutionEntryFromClickHouse(ctx context.Context, selectors []*telemetrytypes.EvolutionSelector) ([]*telemetrytypes.EvolutionEntry, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("signal", "column_name", "column_type", "field_context", "field_name", "version", "release_time")
sb.From(fmt.Sprintf("%s.%s", k.relatedMetadataDBName, k.columnEvolutionMetadataTblName))
sb.OrderBy("release_time ASC")
var clauses []string
for _, selector := range selectors {
var clause string
if selector.FieldContext != telemetrytypes.FieldContextUnspecified {
clause = sb.E("field_context", selector.FieldContext)
}
if selector.FieldName != "" {
clause = sb.And(clause,
sb.Or(sb.E("field_name", selector.FieldName), sb.E("field_name", "__all__")),
)
} else {
clause = sb.And(clause, sb.E("field_name", "__all__"))
}
clauses = append(clauses, sb.And(sb.E("signal", selector.Signal), clause))
}
sb.Where(sb.Or(clauses...))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var entries []*telemetrytypes.EvolutionEntry
rows, err := k.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var entry telemetrytypes.EvolutionEntry
var releaseTimeNs float64
if err := rows.Scan(
&entry.Signal,
&entry.ColumnName,
&entry.ColumnType,
&entry.FieldContext,
&entry.FieldName,
&entry.Version,
&releaseTimeNs,
); err != nil {
return nil, err
}
// Convert nanoseconds to time.Time
releaseTime := time.Unix(0, int64(releaseTimeNs))
entry.ReleaseTime = releaseTime
entries = append(entries, &entry)
}
if err := rows.Err(); err != nil {
return nil, err
}
return entries, nil
}
// Get retrieves all evolutions for the given selectors from DB.
func (k *telemetryMetaStore) GetColumnEvolutionMetadataMulti(ctx context.Context, selectors []*telemetrytypes.EvolutionSelector) (map[string][]*telemetrytypes.EvolutionEntry, error) {
evolutions, err := k.fetchEvolutionEntryFromClickHouse(ctx, selectors)
if err != nil {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to fetch evolution from clickhouse %s", err.Error())
}
evolutionsByUniqueKey := make(map[string][]*telemetrytypes.EvolutionEntry)
for _, evolution := range evolutions {
key := telemetrytypes.GetEvolutionMetadataUniqueKey(&telemetrytypes.EvolutionSelector{
Signal: evolution.Signal,
FieldContext: evolution.FieldContext,
FieldName: evolution.FieldName,
})
evolutionsByUniqueKey[key] = append(evolutionsByUniqueKey[key], evolution)
}
return evolutionsByUniqueKey, nil
}
// chunkSizeFirstSeenMetricMetadata limits the number of tuples per SQL query to avoid hitting the max_query_size limit.
//
// Calculation Logic:

View File

@@ -39,7 +39,6 @@ func TestGetFirstSeenFromMetricMetadata(t *testing.T) {
telemetrylogs.LogResourceKeysTblName,
DBName,
AttributesMetadataLocalTableName,
ColumnEvolutionMetadataTableName,
)
lookupKeys := []telemetrytypes.MetricMetadataLookupKey{

View File

@@ -4,7 +4,6 @@ import (
"context"
"regexp"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -39,7 +38,6 @@ func newTestTelemetryMetaStoreTestHelper(store telemetrystore.TelemetryStore) te
telemetrylogs.LogResourceKeysTblName,
DBName,
AttributesMetadataLocalTableName,
ColumnEvolutionMetadataTableName,
)
}
@@ -386,386 +384,3 @@ func TestGetMetricFieldValuesIntrinsicBoolReturnsEmpty(t *testing.T) {
assert.Empty(t, values.BoolValues)
require.NoError(t, mock.ExpectationsWereMet())
}
var (
clickHouseQueryPatternWithFieldName = "SELECT.*signal.*column_name.*column_type.*field_context.*field_name.*version.*release_time.*FROM.*distributed_column_evolution_metadata.*WHERE.*signal.*=.*field_context.*=.*field_name.*=.*field_name.*=.*"
clickHouseQueryPatternWithoutFieldName = "SELECT.*signal.*column_name.*column_type.*field_context.*field_name.*version.*release_time.*FROM.*distributed_column_evolution_metadata.*WHERE.*signal.*=.*field_context.*=.*ORDER BY.*release_time.*ASC"
clickHouseColumns = []cmock.ColumnType{
{Name: "signal", Type: "String"},
{Name: "column_name", Type: "String"},
{Name: "column_type", Type: "String"},
{Name: "field_context", Type: "String"},
{Name: "field_name", Type: "String"},
{Name: "version", Type: "UInt32"},
{Name: "release_time", Type: "Float64"},
}
)
func createMockRows(values [][]any) *cmock.Rows {
return cmock.NewRows(clickHouseColumns, values)
}
func TestKeyEvolutionMetadata_Get_Multi_FetchFromClickHouse(t *testing.T) {
ctx := context.Background()
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := telemetryStore.Mock()
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
values := [][]any{
{
"logs",
"resources_string",
"Map(LowCardinality(String), String)",
"resource",
"__all__",
uint32(0),
float64(releaseTime.UnixNano()),
},
}
selector := &telemetrytypes.EvolutionSelector{
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
}
rows := createMockRows(values)
mock.ExpectQuery(clickHouseQueryPatternWithoutFieldName).WithArgs(telemetrytypes.SignalLogs, telemetrytypes.FieldContextResource, "__all__").WillReturnRows(rows)
metadata := newTestTelemetryMetaStoreTestHelper(telemetryStore)
result, err := metadata.GetColumnEvolutionMetadataMulti(ctx, []*telemetrytypes.EvolutionSelector{selector})
require.NoError(t, err)
expectedKey := "logs:resource:__all__"
require.Contains(t, result, expectedKey)
require.Len(t, result[expectedKey], 1)
assert.Equal(t, telemetrytypes.SignalLogs, result[expectedKey][0].Signal)
assert.Equal(t, "resources_string", result[expectedKey][0].ColumnName)
assert.Equal(t, "Map(LowCardinality(String), String)", result[expectedKey][0].ColumnType)
assert.Equal(t, telemetrytypes.FieldContextResource, result[expectedKey][0].FieldContext)
assert.Equal(t, "__all__", result[expectedKey][0].FieldName)
assert.Equal(t, releaseTime.UnixNano(), result[expectedKey][0].ReleaseTime.UnixNano())
require.NoError(t, mock.ExpectationsWereMet())
}
func TestKeyEvolutionMetadata_Get_Multi_MultipleMetadataEntries(t *testing.T) {
ctx := context.Background()
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := telemetryStore.Mock()
releaseTime1 := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTime2 := time.Date(2024, 2, 15, 10, 0, 0, 0, time.UTC)
values := [][]any{
{
"logs",
"resources_string",
"Map(LowCardinality(String), String)",
"resource",
"__all__",
uint32(0),
float64(releaseTime1.UnixNano()),
},
{
"logs",
"resource",
"JSON()",
"resource",
"__all__",
uint32(1),
float64(releaseTime2.UnixNano()),
},
}
rows := createMockRows(values)
mock.ExpectQuery(clickHouseQueryPatternWithoutFieldName).WithArgs(telemetrytypes.SignalLogs, telemetrytypes.FieldContextResource, "__all__").WillReturnRows(rows)
metadata := newTestTelemetryMetaStoreTestHelper(telemetryStore)
selector := &telemetrytypes.EvolutionSelector{
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
}
result, err := metadata.GetColumnEvolutionMetadataMulti(ctx, []*telemetrytypes.EvolutionSelector{selector})
require.NoError(t, err)
expectedKey := "logs:resource:__all__"
require.Contains(t, result, expectedKey)
require.Len(t, result[expectedKey], 2)
assert.Equal(t, "resources_string", result[expectedKey][0].ColumnName)
assert.Equal(t, "Map(LowCardinality(String), String)", result[expectedKey][0].ColumnType)
assert.Equal(t, "resource", result[expectedKey][0].FieldContext.StringValue())
assert.Equal(t, "__all__", result[expectedKey][0].FieldName)
assert.Equal(t, releaseTime1.UnixNano(), result[expectedKey][0].ReleaseTime.UnixNano())
assert.Equal(t, "resource", result[expectedKey][1].ColumnName)
assert.Equal(t, "JSON()", result[expectedKey][1].ColumnType)
assert.Equal(t, "resource", result[expectedKey][1].FieldContext.StringValue())
assert.Equal(t, "__all__", result[expectedKey][1].FieldName)
assert.Equal(t, releaseTime2.UnixNano(), result[expectedKey][1].ReleaseTime.UnixNano())
require.NoError(t, mock.ExpectationsWereMet())
}
func TestKeyEvolutionMetadata_Get_Multi_MultipleMetadataEntriesWithFieldName(t *testing.T) {
ctx := context.Background()
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := telemetryStore.Mock()
releaseTime1 := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTime2 := time.Date(2024, 2, 15, 10, 0, 0, 0, time.UTC)
releaseTime3 := time.Date(2024, 3, 15, 10, 0, 0, 0, time.UTC)
values := [][]any{
{
"logs",
"body",
"String",
"body",
"__all__",
uint32(0),
float64(releaseTime1.UnixNano()),
},
{
"logs",
"body_json",
"JSON()",
"body",
"__all__",
uint32(1),
float64(releaseTime2.UnixNano()),
},
{
"logs",
"body_promoted",
"JSON()",
"body",
"user.name",
uint32(2),
float64(releaseTime3.UnixNano()),
},
}
selector := &telemetrytypes.EvolutionSelector{
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "user.name",
}
rows := createMockRows(values)
mock.ExpectQuery(clickHouseQueryPatternWithFieldName).WithArgs(telemetrytypes.SignalLogs, telemetrytypes.FieldContextBody, selector.FieldName, "__all__").WillReturnRows(rows)
metadata := newTestTelemetryMetaStoreTestHelper(telemetryStore)
result, err := metadata.GetColumnEvolutionMetadataMulti(ctx, []*telemetrytypes.EvolutionSelector{selector})
require.NoError(t, err)
// Check entries for "__all__" field name
expectedKeyAll := "logs:body:__all__"
require.Contains(t, result, expectedKeyAll)
require.Len(t, result[expectedKeyAll], 2)
assert.Equal(t, "body", result[expectedKeyAll][0].ColumnName)
assert.Equal(t, "String", result[expectedKeyAll][0].ColumnType)
assert.Equal(t, "body", result[expectedKeyAll][0].FieldContext.StringValue())
assert.Equal(t, "__all__", result[expectedKeyAll][0].FieldName)
assert.Equal(t, releaseTime1.UnixNano(), result[expectedKeyAll][0].ReleaseTime.UnixNano())
assert.Equal(t, "body_json", result[expectedKeyAll][1].ColumnName)
assert.Equal(t, "JSON()", result[expectedKeyAll][1].ColumnType)
assert.Equal(t, "body", result[expectedKeyAll][1].FieldContext.StringValue())
assert.Equal(t, "__all__", result[expectedKeyAll][1].FieldName)
assert.Equal(t, releaseTime2.UnixNano(), result[expectedKeyAll][1].ReleaseTime.UnixNano())
// Check entries for "user.name" field name
expectedKeyUser := "logs:body:user.name"
require.Contains(t, result, expectedKeyUser)
require.Len(t, result[expectedKeyUser], 1)
assert.Equal(t, "body_promoted", result[expectedKeyUser][0].ColumnName)
assert.Equal(t, "JSON()", result[expectedKeyUser][0].ColumnType)
assert.Equal(t, "body", result[expectedKeyUser][0].FieldContext.StringValue())
assert.Equal(t, "user.name", result[expectedKeyUser][0].FieldName)
assert.Equal(t, releaseTime3.UnixNano(), result[expectedKeyUser][0].ReleaseTime.UnixNano())
require.NoError(t, mock.ExpectationsWereMet())
}
func TestKeyEvolutionMetadata_Get_Multi_MultipleMetadataEntriesWithMultipleSelectors(t *testing.T) {
ctx := context.Background()
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := telemetryStore.Mock()
// releaseTime1 := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTime2 := time.Date(2024, 2, 15, 10, 0, 0, 0, time.UTC)
releaseTime3 := time.Date(2024, 3, 15, 10, 0, 0, 0, time.UTC)
values := [][]any{
{
"logs",
"body_json",
"JSON()",
"body",
"__all__",
uint32(0),
float64(releaseTime2.UnixNano()),
},
{
"logs",
"body_promoted",
"JSON()",
"body",
"user.name",
uint32(1),
float64(releaseTime3.UnixNano()),
},
{
"traces",
"resources_string",
"map()",
telemetrytypes.FieldContextResource,
"__all__",
uint32(0),
float64(releaseTime2.UnixNano()),
},
{
telemetrytypes.SignalTraces,
"resource",
"JSON()",
telemetrytypes.FieldContextResource,
"__all__",
uint32(1),
float64(releaseTime3.UnixNano()),
},
}
selectors := []*telemetrytypes.EvolutionSelector{
{
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "user.name",
},
{
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "service.name",
},
}
query := `SELECT signal, column_name, column_type, field_context, field_name, version, release_time FROM signoz_metadata\.distributed_column_evolution_metadata WHERE ` +
`\(\(signal = \? AND \(field_context = \? AND \(field_name = \? OR field_name = \?\)\)\) OR ` +
`\(signal = \? AND \(field_context = \? AND \(field_name = \? OR field_name = \?\)\)\)\) ` +
`ORDER BY release_time ASC`
rows := createMockRows(values)
mock.ExpectQuery(query).WithArgs(
telemetrytypes.SignalLogs, telemetrytypes.FieldContextBody, selectors[0].FieldName, "__all__",
telemetrytypes.SignalTraces, telemetrytypes.FieldContextResource, selectors[1].FieldName, "__all__",
).WillReturnRows(rows)
metadata := newTestTelemetryMetaStoreTestHelper(telemetryStore)
_, err := metadata.GetColumnEvolutionMetadataMulti(ctx, selectors)
require.NoError(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestKeyEvolutionMetadata_Get_Multi_EmptyResultFromClickHouse(t *testing.T) {
ctx := context.Background()
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := telemetryStore.Mock()
rows := createMockRows([][]any{})
mock.ExpectQuery(clickHouseQueryPatternWithoutFieldName).WithArgs(telemetrytypes.SignalLogs, telemetrytypes.FieldContextResource, "__all__").WillReturnRows(rows)
metadata := newTestTelemetryMetaStoreTestHelper(telemetryStore)
selector := &telemetrytypes.EvolutionSelector{
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
}
result, err := metadata.GetColumnEvolutionMetadataMulti(ctx, []*telemetrytypes.EvolutionSelector{selector})
require.NoError(t, err)
assert.Empty(t, result)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestKeyEvolutionMetadata_Get_Multi_ClickHouseQueryError(t *testing.T) {
ctx := context.Background()
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := telemetryStore.Mock()
mock.ExpectQuery(clickHouseQueryPatternWithoutFieldName).WithArgs(telemetrytypes.SignalLogs, telemetrytypes.FieldContextResource, "__all__").WillReturnError(assert.AnError)
metadata := newTestTelemetryMetaStoreTestHelper(telemetryStore)
selector := &telemetrytypes.EvolutionSelector{
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
}
_, err := metadata.GetColumnEvolutionMetadataMulti(ctx, []*telemetrytypes.EvolutionSelector{selector})
require.Error(t, err)
}
func TestKeyEvolutionMetadata_Get_Multi_MultipleSelectors(t *testing.T) {
ctx := context.Background()
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := telemetryStore.Mock()
releaseTime1 := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTime2 := time.Date(2024, 2, 15, 10, 0, 0, 0, time.UTC)
values := [][]any{
{
telemetrytypes.SignalLogs,
"resources_string",
"Map(LowCardinality(String), String)",
telemetrytypes.FieldContextResource,
"__all__",
uint32(0),
float64(releaseTime1.UnixNano()),
},
{
telemetrytypes.SignalLogs,
"body",
"JSON()",
telemetrytypes.FieldContextBody,
"__all__",
uint32(1),
float64(releaseTime2.UnixNano()),
},
}
// When multiple selectors are provided, the query will have OR conditions
// The pattern should match queries with multiple OR clauses
queryPattern := "SELECT.*signal.*column_name.*column_type.*field_context.*field_name.*release_time.*FROM.*distributed_column_evolution_metadata.*WHERE.*ORDER BY.*release_time.*ASC"
rows := createMockRows(values)
mock.ExpectQuery(queryPattern).WillReturnRows(rows).WithArgs(telemetrytypes.SignalLogs, telemetrytypes.FieldContextResource, "__all__", "__all__", telemetrytypes.SignalLogs, telemetrytypes.FieldContextBody, "__all__", "__all__")
metadata := newTestTelemetryMetaStoreTestHelper(telemetryStore)
selectors := []*telemetrytypes.EvolutionSelector{
{
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
},
{
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "__all__",
},
}
result, err := metadata.GetColumnEvolutionMetadataMulti(ctx, selectors)
require.NoError(t, err)
// Should have entries for both selectors
expectedKey1 := "logs:resource:__all__"
expectedKey2 := "logs:body:__all__"
require.Contains(t, result, expectedKey1)
require.Contains(t, result, expectedKey2)
require.Len(t, result[expectedKey1], 1)
require.Len(t, result[expectedKey2], 1)
assert.Equal(t, "resources_string", result[expectedKey1][0].ColumnName)
assert.Equal(t, "body", result[expectedKey2][0].ColumnName)
require.NoError(t, mock.ExpectationsWereMet())
}

View File

@@ -6,7 +6,6 @@ const (
DBName = "signoz_metadata"
AttributesMetadataTableName = "distributed_attributes_metadata"
AttributesMetadataLocalTableName = "attributes_metadata"
ColumnEvolutionMetadataTableName = "distributed_column_evolution_metadata"
PathTypesTableName = otelcollectorconst.DistributedPathTypesTable
PromotedPathsTableName = otelcollectorconst.DistributedPromotedPathsTable
SkipIndexTableName = "system.data_skipping_indices"

View File

@@ -120,7 +120,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
stepSec,
))
for _, g := range query.GroupBy {
col, err := b.fm.ColumnExpressionFor(ctx, start, end, &g.TelemetryFieldKey, keys)
col, err := b.fm.ColumnExpressionFor(ctx, &g.TelemetryFieldKey, keys)
if err != nil {
return "", []any{}, err
}
@@ -142,16 +142,13 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
)
if query.Filter != nil && query.Filter.Expression != "" {
filterWhere, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
Variables: variables,
StartNs: start,
EndNs: end,
})
}, start, end)
if err != nil {
return "", []any{}, err
}
@@ -203,7 +200,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDelta(
))
for _, g := range query.GroupBy {
col, err := b.fm.ColumnExpressionFor(ctx, start, end, &g.TelemetryFieldKey, keys)
col, err := b.fm.ColumnExpressionFor(ctx, &g.TelemetryFieldKey, keys)
if err != nil {
return "", nil, err
}
@@ -228,16 +225,13 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDelta(
if query.Filter != nil && query.Filter.Expression != "" {
filterWhere, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
Variables: variables,
StartNs: start,
EndNs: end,
})
}, start, end)
if err != nil {
return "", nil, err
}
@@ -276,7 +270,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
stepSec,
))
for _, g := range query.GroupBy {
col, err := b.fm.ColumnExpressionFor(ctx, start, end, &g.TelemetryFieldKey, keys)
col, err := b.fm.ColumnExpressionFor(ctx, &g.TelemetryFieldKey, keys)
if err != nil {
return "", nil, err
}
@@ -295,16 +289,13 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
)
if query.Filter != nil && query.Filter.Expression != "" {
filterWhere, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
Variables: variables,
StartNs: start,
EndNs: end,
})
}, start, end)
if err != nil {
return "", nil, err
}

View File

@@ -23,8 +23,6 @@ func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
func (c *conditionBuilder) conditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
@@ -41,7 +39,7 @@ func (c *conditionBuilder) conditionFor(
value = querybuilder.FormatValueForContains(value)
}
tblFieldName, err := c.fm.FieldFor(ctx, startNs, endNs, key)
tblFieldName, err := c.fm.FieldFor(ctx, key)
if err != nil {
return "", err
}
@@ -137,14 +135,14 @@ func (c *conditionBuilder) conditionFor(
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
_ uint64,
_ uint64,
) (string, error) {
condition, err := c.conditionFor(ctx, startNs, endNs, key, operator, value, sb)
condition, err := c.conditionFor(ctx, key, operator, value, sb)
if err != nil {
return "", err
}

View File

@@ -234,7 +234,7 @@ func TestConditionFor(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
sb.Where(cond)
if tc.expectedError != nil {
@@ -289,7 +289,7 @@ func TestConditionForMultipleKeys(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
var err error
for _, key := range tc.keys {
cond, err := conditionBuilder.ConditionFor(ctx, 0, 0, &key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &key, tc.operator, tc.value, sb, 0, 0)
sb.Where(cond)
if err != nil {
t.Fatalf("Error getting condition for key %s: %v", key.Name, err)

View File

@@ -41,63 +41,62 @@ func NewFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}
func (m *fieldMapper) getColumn(_ context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource, telemetrytypes.FieldContextScope, telemetrytypes.FieldContextAttribute:
return []*schema.Column{timeSeriesV4Columns["labels"]}, nil
return timeSeriesV4Columns["labels"], nil
case telemetrytypes.FieldContextMetric:
col, ok := timeSeriesV4Columns[key.Name]
if !ok {
return []*schema.Column{}, qbtypes.ErrColumnNotFound
return nil, qbtypes.ErrColumnNotFound
}
return []*schema.Column{col}, nil
return col, nil
case telemetrytypes.FieldContextUnspecified:
col, ok := timeSeriesV4Columns[key.Name]
if !ok {
// if nothing is found, return labels column
// as we keep all the labels in the labels column
return []*schema.Column{timeSeriesV4Columns["labels"]}, nil
return timeSeriesV4Columns["labels"], nil
}
return []*schema.Column{col}, nil
return col, nil
}
return nil, qbtypes.ErrColumnNotFound
}
func (m *fieldMapper) FieldFor(ctx context.Context, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
columns, err := m.getColumn(ctx, startNs, endNs, key)
func (m *fieldMapper) FieldFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) (string, error) {
column, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
switch key.FieldContext {
case telemetrytypes.FieldContextResource, telemetrytypes.FieldContextScope, telemetrytypes.FieldContextAttribute:
return fmt.Sprintf("JSONExtractString(%s, '%s')", columns[0].Name, key.Name), nil
return fmt.Sprintf("JSONExtractString(%s, '%s')", column.Name, key.Name), nil
case telemetrytypes.FieldContextMetric:
return columns[0].Name, nil
return column.Name, nil
case telemetrytypes.FieldContextUnspecified:
if slices.Contains(IntrinsicFields, key.Name) {
return columns[0].Name, nil
return column.Name, nil
}
return fmt.Sprintf("JSONExtractString(%s, '%s')", columns[0].Name, key.Name), nil
return fmt.Sprintf("JSONExtractString(%s, '%s')", column.Name, key.Name), nil
}
return columns[0].Name, nil
return column.Name, nil
}
func (m *fieldMapper) ColumnFor(ctx context.Context, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
return m.getColumn(ctx, tsStart, tsEnd, key)
func (m *fieldMapper) ColumnFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
return m.getColumn(ctx, key)
}
func (m *fieldMapper) ColumnExpressionFor(
ctx context.Context,
startNs, endNs uint64,
field *telemetrytypes.TelemetryFieldKey,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
colName, err := m.FieldFor(ctx, startNs, endNs, field)
colName, err := m.FieldFor(ctx, field)
if err != nil {
return "", err
}

View File

@@ -123,13 +123,13 @@ func TestGetColumn(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
col, err := fm.ColumnFor(ctx, 0, 0, &tc.key)
col, err := fm.ColumnFor(ctx, &tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expectedCol, col[0])
assert.Equal(t, tc.expectedCol, col)
}
})
}
@@ -207,7 +207,7 @@ func TestGetFieldKeyName(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := fm.FieldFor(ctx, 0, 0, &tc.key)
result, err := fm.FieldFor(ctx, &tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)

View File

@@ -347,16 +347,13 @@ func (b *MetricQueryStatementBuilder) buildTimeSeriesCTE(
if query.Filter != nil && query.Filter.Expression != "" {
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
Variables: variables,
StartNs: start,
EndNs: end,
})
}, start, end)
if err != nil {
return "", nil, err
}
@@ -367,7 +364,7 @@ func (b *MetricQueryStatementBuilder) buildTimeSeriesCTE(
sb.Select("fingerprint")
for _, g := range query.GroupBy {
col, err := b.fm.ColumnExpressionFor(ctx, start, end, &g.TelemetryFieldKey, keys)
col, err := b.fm.ColumnExpressionFor(ctx, &g.TelemetryFieldKey, keys)
if err != nil {
return "", nil, err
}

View File

@@ -29,8 +29,6 @@ func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
func (c *conditionBuilder) conditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
@@ -48,13 +46,13 @@ func (c *conditionBuilder) conditionFor(
}
// first, locate the raw column type (so we can choose the right EXISTS logic)
columns, err := c.fm.ColumnFor(ctx, startNs, endNs, key)
column, err := c.fm.ColumnFor(ctx, key)
if err != nil {
return "", err
}
// then ask the mapper for the actual SQL reference
tblFieldName, err := c.fm.FieldFor(ctx, startNs, endNs, key)
tblFieldName, err := c.fm.FieldFor(ctx, key)
if err != nil {
return "", err
}
@@ -167,7 +165,7 @@ func (c *conditionBuilder) conditionFor(
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
var value any
switch columns[0].Type.GetType() {
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
if operator == qbtypes.FilterOperatorExists {
return sb.IsNotNull(tblFieldName), nil
@@ -184,7 +182,7 @@ func (c *conditionBuilder) conditionFor(
return sb.E(tblFieldName, value), nil
}
case schema.ColumnTypeEnumLowCardinality:
switch elementType := columns[0].Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
value = ""
if operator == qbtypes.FilterOperatorExists {
@@ -208,14 +206,14 @@ func (c *conditionBuilder) conditionFor(
return sb.E(tblFieldName, value), nil
}
case schema.ColumnTypeEnumMap:
keyType := columns[0].Type.(schema.MapColumnType).KeyType
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, columns[0].Type)
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch valueType := columns[0].Type.(schema.MapColumnType).ValueType; valueType.GetType() {
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", columns[0].Name, key.Name)
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", column.Name, key.Name)
if key.Materialized {
leftOperand = telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
}
@@ -228,7 +226,7 @@ func (c *conditionBuilder) conditionFor(
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for column type %s", columns[0].Type)
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for column type %s", column.Type)
}
}
return "", nil
@@ -236,25 +234,25 @@ func (c *conditionBuilder) conditionFor(
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
startNs uint64,
_ uint64,
) (string, error) {
if c.isSpanScopeField(key.Name) {
return c.buildSpanScopeCondition(key, operator, value, startNs)
}
condition, err := c.conditionFor(ctx, startNs, endNs, key, operator, value, sb)
condition, err := c.conditionFor(ctx, key, operator, value, sb)
if err != nil {
return "", err
}
if operator.AddDefaultExistsFilter() {
// skip adding exists filter for intrinsic fields
field, _ := c.fm.FieldFor(ctx, startNs, endNs, key)
field, _ := c.fm.FieldFor(ctx, key)
if slices.Contains(maps.Keys(IntrinsicFields), field) ||
slices.Contains(maps.Keys(IntrinsicFieldsDeprecated), field) ||
slices.Contains(maps.Keys(CalculatedFields), field) ||
@@ -262,7 +260,7 @@ func (c *conditionBuilder) ConditionFor(
return condition, nil
}
existsCondition, err := c.conditionFor(ctx, startNs, endNs, key, qbtypes.FilterOperatorExists, nil, sb)
existsCondition, err := c.conditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb)
if err != nil {
return "", err
}

View File

@@ -289,7 +289,7 @@ func TestConditionFor(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, 1761437108000000000, 1761458708000000000, &tc.key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 1761437108000000000, 1761458708000000000)
sb.Where(cond)
if tc.expectedError != nil {

View File

@@ -169,24 +169,23 @@ func NewFieldMapper() *defaultFieldMapper {
func (m *defaultFieldMapper) getColumn(
_ context.Context,
_, _ uint64,
key *telemetrytypes.TelemetryFieldKey,
) ([]*schema.Column, error) {
) (*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
return []*schema.Column{indexV3Columns["resource"]}, nil
return indexV3Columns["resource"], nil
case telemetrytypes.FieldContextScope:
return []*schema.Column{}, qbtypes.ErrColumnNotFound
return nil, qbtypes.ErrColumnNotFound
case telemetrytypes.FieldContextAttribute:
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
return []*schema.Column{indexV3Columns["attributes_string"]}, nil
return indexV3Columns["attributes_string"], nil
case telemetrytypes.FieldDataTypeInt64,
telemetrytypes.FieldDataTypeFloat64,
telemetrytypes.FieldDataTypeNumber:
return []*schema.Column{indexV3Columns["attributes_number"]}, nil
return indexV3Columns["attributes_number"], nil
case telemetrytypes.FieldDataTypeBool:
return []*schema.Column{indexV3Columns["attributes_bool"]}, nil
return indexV3Columns["attributes_bool"], nil
}
case telemetrytypes.FieldContextSpan, telemetrytypes.FieldContextUnspecified:
/*
@@ -197,7 +196,7 @@ func (m *defaultFieldMapper) getColumn(
// Check if this is a span scope field
if strings.ToLower(key.Name) == SpanSearchScopeRoot || strings.ToLower(key.Name) == SpanSearchScopeEntryPoint {
// The actual SQL will be generated in the condition builder
return []*schema.Column{{Name: key.Name, Type: schema.ColumnTypeBool}}, nil
return &schema.Column{Name: key.Name, Type: schema.ColumnTypeBool}, nil
}
// TODO(srikanthccv): remove this when it's safe to remove
@@ -211,18 +210,18 @@ func (m *defaultFieldMapper) getColumn(
if _, ok := CalculatedFieldsDeprecated[key.Name]; ok {
// Check if we have a mapping for the deprecated calculated field
if col, ok := indexV3Columns[oldToNew[key.Name]]; ok {
return []*schema.Column{col}, nil
return col, nil
}
}
if _, ok := IntrinsicFieldsDeprecated[key.Name]; ok {
// Check if we have a mapping for the deprecated intrinsic field
if col, ok := indexV3Columns[oldToNew[key.Name]]; ok {
return []*schema.Column{col}, nil
return col, nil
}
}
if col, ok := indexV3Columns[key.Name]; ok {
return []*schema.Column{col}, nil
return col, nil
}
}
return nil, qbtypes.ErrColumnNotFound
@@ -230,17 +229,15 @@ func (m *defaultFieldMapper) getColumn(
func (m *defaultFieldMapper) ColumnFor(
ctx context.Context,
startNs, endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
) ([]*schema.Column, error) {
return m.getColumn(ctx, startNs, endNs, key)
) (*schema.Column, error) {
return m.getColumn(ctx, key)
}
// FieldFor returns the table field name for the given key if it exists
// otherwise it returns qbtypes.ErrColumnNotFound
func (m *defaultFieldMapper) FieldFor(
ctx context.Context,
startNs, endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
) (string, error) {
// Special handling for span scope fields
@@ -250,11 +247,10 @@ func (m *defaultFieldMapper) FieldFor(
return key.Name, nil
}
columns, err := m.getColumn(ctx, startNs, endNs, key)
column, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
column := columns[0]
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
@@ -314,12 +310,11 @@ func (m *defaultFieldMapper) FieldFor(
// if it exists otherwise it returns qbtypes.ErrColumnNotFound
func (m *defaultFieldMapper) ColumnExpressionFor(
ctx context.Context,
startNs, endNs uint64,
field *telemetrytypes.TelemetryFieldKey,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
colName, err := m.FieldFor(ctx, startNs, endNs, field)
colName, err := m.FieldFor(ctx, field)
if errors.Is(err, qbtypes.ErrColumnNotFound) {
// the key didn't have the right context to be added to the query
// we try to use the context we know of
@@ -329,7 +324,7 @@ func (m *defaultFieldMapper) ColumnExpressionFor(
if _, ok := indexV3Columns[field.Name]; ok {
// if it is, attach the column name directly
field.FieldContext = telemetrytypes.FieldContextSpan
colName, _ = m.FieldFor(ctx, startNs, endNs, field)
colName, _ = m.FieldFor(ctx, field)
} else {
// - the context is not provided
// - there are not keys for the field
@@ -347,12 +342,12 @@ func (m *defaultFieldMapper) ColumnExpressionFor(
}
} else if len(keysForField) == 1 {
// we have a single key for the field, use it
colName, _ = m.FieldFor(ctx, startNs, endNs, keysForField[0])
colName, _ = m.FieldFor(ctx, keysForField[0])
} else {
// select any non-empty value from the keys
args := []string{}
for _, key := range keysForField {
colName, _ = m.FieldFor(ctx, startNs, endNs, key)
colName, _ = m.FieldFor(ctx, key)
args = append(args, fmt.Sprintf("toString(%s) != '', toString(%s)", colName, colName))
}
colName = fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", "))

View File

@@ -92,7 +92,7 @@ func TestGetFieldKeyName(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fm := NewFieldMapper()
result, err := fm.FieldFor(ctx, 0, 0, &tc.key)
result, err := fm.FieldFor(ctx, &tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)

View File

@@ -1,7 +1,6 @@
package telemetrytraces
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -76,16 +75,13 @@ func TestSpanScopeFilterExpression(t *testing.T) {
FieldContext: telemetrytypes.FieldContextSpan,
}}
whereClause, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
whereClause, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: fieldKeys,
Builder: sb,
StartNs: tt.startNs,
EndNs: 1761458708000000000,
})
}, tt.startNs, 1761458708000000000)
if tt.expectError {
assert.Error(t, err)
@@ -146,16 +142,13 @@ func TestSpanScopeWithResourceFilter(t *testing.T) {
FieldContext: telemetrytypes.FieldContextResource,
}}
_, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
_, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: fieldKeys,
SkipResourceFilter: false, // This would be set by the statement builder
StartNs: 1761437108000000000,
EndNs: 1761458708000000000,
})
}, 1761437108000000000, 1761458708000000000)
assert.NoError(t, err)
})

View File

@@ -313,7 +313,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
// TODO: should we deprecate `SelectFields` and return everything from a span like we do for logs?
for _, field := range query.SelectFields {
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &field, keys)
colExpr, err := b.fm.ColumnExpressionFor(ctx, &field, keys)
if err != nil {
return nil, err
}
@@ -331,7 +331,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
// Add order by
for _, orderBy := range query.Order {
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &orderBy.Key.TelemetryFieldKey, keys)
colExpr, err := b.fm.ColumnExpressionFor(ctx, &orderBy.Key.TelemetryFieldKey, keys)
if err != nil {
return nil, err
}
@@ -515,7 +515,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
// Keep original column expressions so we can build the tuple
fieldNames := make([]string, 0, len(query.GroupBy))
for _, gb := range query.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, nil)
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, nil)
if err != nil {
return nil, err
}
@@ -529,7 +529,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, start, end, agg.Expression,
ctx, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
)
@@ -657,7 +657,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
var allGroupByArgs []any
for _, gb := range query.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, nil)
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, nil)
if err != nil {
return nil, err
}
@@ -674,7 +674,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, start, end, aggExpr.Expression,
ctx, aggExpr.Expression,
rateInterval,
keys,
)
@@ -746,7 +746,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
// buildFilterCondition builds SQL condition from filter expression
func (b *traceQueryStatementBuilder) addFilterCondition(
ctx context.Context,
_ context.Context,
sb *sqlbuilder.SelectBuilder,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
@@ -760,16 +760,13 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
if query.Filter != nil && query.Filter.Expression != "" {
// add filter expression
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
SkipResourceFilter: true,
Variables: variables,
StartNs: start,
EndNs: end,
})
}, start, end)
if err != nil {
return nil, err

View File

@@ -232,15 +232,12 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
filterWhereClause, err := querybuilder.PrepareWhereClause(
query.Filter.Expression,
querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.stmtBuilder.logger,
FieldMapper: b.stmtBuilder.fm,
ConditionBuilder: b.stmtBuilder.cb,
FieldKeys: keys,
SkipResourceFilter: true,
StartNs: b.start,
EndNs: b.end,
},
}, b.start, b.end,
)
if err != nil {
b.stmtBuilder.logger.ErrorContext(ctx, "Failed to prepare where clause", "error", err, "filter", query.Filter.Expression)
@@ -453,7 +450,7 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
if selectedFields[field.Name] {
continue
}
colExpr, err := b.stmtBuilder.fm.ColumnExpressionFor(ctx, b.start, b.end, &field, keys)
colExpr, err := b.stmtBuilder.fm.ColumnExpressionFor(ctx, &field, keys)
if err != nil {
b.stmtBuilder.logger.WarnContext(ctx, "failed to map select field",
"field", field.Name, "error", err)
@@ -468,7 +465,7 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
// Add order by support using ColumnExpressionFor
orderApplied := false
for _, orderBy := range b.operator.Order {
colExpr, err := b.stmtBuilder.fm.ColumnExpressionFor(ctx, b.start, b.end, &orderBy.Key.TelemetryFieldKey, keys)
colExpr, err := b.stmtBuilder.fm.ColumnExpressionFor(ctx, &orderBy.Key.TelemetryFieldKey, keys)
if err != nil {
return nil, err
}
@@ -550,8 +547,6 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
for _, gb := range b.operator.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(
ctx,
b.start,
b.end,
&gb.TelemetryFieldKey,
b.stmtBuilder.fm,
b.stmtBuilder.cb,
@@ -576,8 +571,6 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.start,
b.end,
agg.Expression,
uint64(b.operator.StepInterval.Seconds()),
keys,
@@ -663,8 +656,6 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
for _, gb := range b.operator.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(
ctx,
b.start,
b.end,
&gb.TelemetryFieldKey,
b.stmtBuilder.fm,
b.stmtBuilder.cb,
@@ -691,8 +682,6 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.start,
b.end,
agg.Expression,
rateInterval,
keys,
@@ -806,8 +795,6 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
for _, gb := range b.operator.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(
ctx,
b.start,
b.end,
&gb.TelemetryFieldKey,
b.stmtBuilder.fm,
b.stmtBuilder.cb,
@@ -832,8 +819,6 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.start,
b.end,
agg.Expression,
uint64((b.end-b.start)/querybuilder.NsToSeconds),
keys,

View File

@@ -2,13 +2,12 @@ package telemetrytraces
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"log/slog"
)
type traceOperatorStatementBuilder struct {

View File

@@ -0,0 +1,445 @@
package alertmanagertypes
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/expr-lang/expr"
"github.com/uptrace/bun"
)
var (
ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
)
type StorablePlannedMaintenance struct {
bun.BaseModel `bun:"table:planned_maintenance_v2"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
Name string `bun:"name,type:text,notnull"`
Description string `bun:"description,type:text"`
Schedule *Schedule `bun:"schedule,type:text,notnull"`
RuleIDs string `bun:"rule_ids,type:text"`
Expression string `bun:"expression,type:text"`
OrgID string `bun:"org_id,type:text"`
}
type GettablePlannedMaintenance struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Schedule *Schedule `json:"schedule"`
RuleIDs []string `json:"ruleIds,omitempty"`
Expression string `json:"expression,omitempty"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy"`
Status string `json:"status"`
Kind string `json:"kind"`
}
func (m *GettablePlannedMaintenance) IsActive(now time.Time) bool {
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
currentTime := now.In(loc)
// fixed schedule
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
startTime := m.Schedule.StartTime.In(loc)
endTime := m.Schedule.EndTime.In(loc)
if currentTime.Equal(startTime) || currentTime.Equal(endTime) ||
(currentTime.After(startTime) && currentTime.Before(endTime)) {
return true
}
}
// recurring schedule
if m.Schedule.Recurrence != nil {
start := m.Schedule.Recurrence.StartTime
// Make sure the recurrence has started
if currentTime.Before(start.In(loc)) {
return false
}
// Check if recurrence has expired
if m.Schedule.Recurrence.EndTime != nil {
endTime := *m.Schedule.Recurrence.EndTime
if !endTime.IsZero() && currentTime.After(endTime.In(loc)) {
return false
}
}
switch m.Schedule.Recurrence.RepeatType {
case RepeatTypeDaily:
return m.checkDaily(currentTime, m.Schedule.Recurrence, loc)
case RepeatTypeWeekly:
return m.checkWeekly(currentTime, m.Schedule.Recurrence, loc)
case RepeatTypeMonthly:
return m.checkMonthly(currentTime, m.Schedule.Recurrence, loc)
}
}
return false
}
// checkDaily rebases the recurrence start to today (or yesterday if needed)
// and returns true if currentTime is within [candidate, candidate+Duration].
func (m *GettablePlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -1)
}
return currentTime.Sub(candidate) <= time.Duration(rec.Duration)
}
// checkWeekly finds the most recent allowed occurrence by rebasing the recurrence's
// time-of-day onto the allowed weekday. It does this for each allowed day and returns true
// if the current time falls within the candidate window.
func (m *GettablePlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
// If no days specified, treat as every day (like daily).
if len(rec.RepeatOn) == 0 {
return m.checkDaily(currentTime, rec, loc)
}
for _, day := range rec.RepeatOn {
allowedDay, ok := RepeatOnAllMap[day]
if !ok {
continue // skip invalid days
}
// Compute the day difference: allowedDay - current weekday.
delta := int(allowedDay) - int(currentTime.Weekday())
// Build a candidate occurrence by rebasing today's date to the allowed weekday.
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
).AddDate(0, 0, delta)
// If the candidate is in the future, subtract 7 days.
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -7)
}
if currentTime.Sub(candidate) <= time.Duration(rec.Duration) {
return true
}
}
return false
}
// checkMonthly rebases the candidate occurrence using the recurrence's day-of-month.
// If the candidate for the current month is in the future, it uses the previous month.
func (m *GettablePlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
refDay := rec.StartTime.Day()
year, month, _ := currentTime.Date()
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
day := refDay
if refDay > lastDay {
day = lastDay
}
candidate := time.Date(year, month, day,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
if candidate.After(currentTime) {
// Use previous month.
candidate = candidate.AddDate(0, -1, 0)
y, m, _ := candidate.Date()
lastDayPrev := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
if refDay > lastDayPrev {
candidate = time.Date(y, m, lastDayPrev,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
} else {
candidate = time.Date(y, m, refDay,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
}
}
return currentTime.Sub(candidate) <= time.Duration(rec.Duration)
}
// CurrentWindowEndTime returns the end time of the current active maintenance window.
// Returns zero time and false if the maintenance is not currently active.
func (m *GettablePlannedMaintenance) CurrentWindowEndTime(now time.Time) (time.Time, bool) {
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return time.Time{}, false
}
currentTime := now.In(loc)
// fixed schedule
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
startTime := m.Schedule.StartTime.In(loc)
endTime := m.Schedule.EndTime.In(loc)
if currentTime.Equal(startTime) || currentTime.Equal(endTime) ||
(currentTime.After(startTime) && currentTime.Before(endTime)) {
return endTime, true
}
}
// recurring schedule
if m.Schedule.Recurrence != nil {
start := m.Schedule.Recurrence.StartTime
if currentTime.Before(start.In(loc)) {
return time.Time{}, false
}
if m.Schedule.Recurrence.EndTime != nil {
endTime := *m.Schedule.Recurrence.EndTime
if !endTime.IsZero() && currentTime.After(endTime.In(loc)) {
return time.Time{}, false
}
}
var candidate time.Time
var active bool
switch m.Schedule.Recurrence.RepeatType {
case RepeatTypeDaily:
candidate, active = m.currentDailyWindowEnd(currentTime, m.Schedule.Recurrence, loc)
case RepeatTypeWeekly:
candidate, active = m.currentWeeklyWindowEnd(currentTime, m.Schedule.Recurrence, loc)
case RepeatTypeMonthly:
candidate, active = m.currentMonthlyWindowEnd(currentTime, m.Schedule.Recurrence, loc)
}
if active {
return candidate, true
}
}
return time.Time{}, false
}
func (m *GettablePlannedMaintenance) currentDailyWindowEnd(currentTime time.Time, rec *Recurrence, loc *time.Location) (time.Time, bool) {
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -1)
}
endTime := candidate.Add(time.Duration(rec.Duration))
if currentTime.Before(endTime) || currentTime.Equal(endTime) {
return endTime, true
}
return time.Time{}, false
}
func (m *GettablePlannedMaintenance) currentWeeklyWindowEnd(currentTime time.Time, rec *Recurrence, loc *time.Location) (time.Time, bool) {
if len(rec.RepeatOn) == 0 {
return m.currentDailyWindowEnd(currentTime, rec, loc)
}
for _, day := range rec.RepeatOn {
allowedDay, ok := RepeatOnAllMap[day]
if !ok {
continue
}
delta := int(allowedDay) - int(currentTime.Weekday())
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
).AddDate(0, 0, delta)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -7)
}
endTime := candidate.Add(time.Duration(rec.Duration))
if currentTime.Before(endTime) || currentTime.Equal(endTime) {
return endTime, true
}
}
return time.Time{}, false
}
func (m *GettablePlannedMaintenance) currentMonthlyWindowEnd(currentTime time.Time, rec *Recurrence, loc *time.Location) (time.Time, bool) {
refDay := rec.StartTime.Day()
year, month, _ := currentTime.Date()
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
day := refDay
if refDay > lastDay {
day = lastDay
}
candidate := time.Date(year, month, day,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, -1, 0)
y, m, _ := candidate.Date()
lastDayPrev := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
if refDay > lastDayPrev {
candidate = time.Date(y, m, lastDayPrev,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
} else {
candidate = time.Date(y, m, refDay,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
}
}
endTime := candidate.Add(time.Duration(rec.Duration))
if currentTime.Before(endTime) || currentTime.Equal(endTime) {
return endTime, true
}
return time.Time{}, false
}
func (m *GettablePlannedMaintenance) IsUpcoming() bool {
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
now := time.Now().In(loc)
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
return now.Before(m.Schedule.StartTime)
}
if m.Schedule.Recurrence != nil {
return now.Before(m.Schedule.Recurrence.StartTime)
}
return false
}
func (m *GettablePlannedMaintenance) IsRecurring() bool {
return m.Schedule.Recurrence != nil
}
func (m *GettablePlannedMaintenance) Validate() error {
if m.Name == "" {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing name in the payload")
}
if m.Schedule == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing schedule in the payload")
}
if m.Schedule.Timezone == "" {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing timezone in the payload")
}
_, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid timezone in the payload")
}
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
if m.Schedule.StartTime.After(m.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
}
}
if m.Schedule.Recurrence != nil {
if m.Schedule.Recurrence.RepeatType == "" {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing repeat type in the payload")
}
if m.Schedule.Recurrence.Duration == 0 {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing duration in the payload")
}
if m.Schedule.Recurrence.EndTime != nil && m.Schedule.Recurrence.EndTime.Before(m.Schedule.Recurrence.StartTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
}
}
if m.Expression != "" {
if _, err := expr.Compile(m.Expression); err != nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid expression: %v", err)
}
}
return nil
}
func (m GettablePlannedMaintenance) MarshalJSON() ([]byte, error) {
now := time.Now().In(time.FixedZone(m.Schedule.Timezone, 0))
var status string
if m.IsActive(now) {
status = "active"
} else if m.IsUpcoming() {
status = "upcoming"
} else {
status = "expired"
}
var kind string
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() && m.Schedule.EndTime.After(m.Schedule.StartTime) {
kind = "fixed"
} else {
kind = "recurring"
}
return json.Marshal(struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Schedule *Schedule `json:"schedule"`
RuleIDs []string `json:"ruleIds,omitempty"`
Expression string `json:"expression,omitempty"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy"`
Status string `json:"status"`
Kind string `json:"kind"`
}{
Id: m.Id,
Name: m.Name,
Description: m.Description,
Schedule: m.Schedule,
RuleIDs: m.RuleIDs,
Expression: m.Expression,
CreatedAt: m.CreatedAt,
CreatedBy: m.CreatedBy,
UpdatedAt: m.UpdatedAt,
UpdatedBy: m.UpdatedBy,
Status: status,
Kind: kind,
})
}
// ConvertStorableToGettable converts a StorablePlannedMaintenance to GettablePlannedMaintenance.
func ConvertStorableToGettable(s *StorablePlannedMaintenance) *GettablePlannedMaintenance {
var ruleIDs []string
if s.RuleIDs != "" {
if err := json.Unmarshal([]byte(s.RuleIDs), &ruleIDs); err != nil {
slog.Error("failed to unmarshal rule_ids from DB", "error", err, "raw", s.RuleIDs)
}
}
return &GettablePlannedMaintenance{
Id: s.ID.StringValue(),
Name: s.Name,
Description: s.Description,
Schedule: s.Schedule,
RuleIDs: ruleIDs,
Expression: s.Expression,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
CreatedBy: s.CreatedBy,
UpdatedBy: s.UpdatedBy,
}
}
type MaintenanceStore interface {
CreatePlannedMaintenance(context.Context, GettablePlannedMaintenance) (valuer.UUID, error)
DeletePlannedMaintenance(context.Context, valuer.UUID) error
GetPlannedMaintenanceByID(context.Context, valuer.UUID) (*GettablePlannedMaintenance, error)
EditPlannedMaintenance(context.Context, GettablePlannedMaintenance, valuer.UUID) error
GetAllPlannedMaintenance(context.Context, string) ([]*GettablePlannedMaintenance, error)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
package alertmanagertypes
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
)
type RepeatType string
const (
RepeatTypeDaily RepeatType = "daily"
RepeatTypeWeekly RepeatType = "weekly"
RepeatTypeMonthly RepeatType = "monthly"
)
type RepeatOn string
const (
RepeatOnSunday RepeatOn = "sunday"
RepeatOnMonday RepeatOn = "monday"
RepeatOnTuesday RepeatOn = "tuesday"
RepeatOnWednesday RepeatOn = "wednesday"
RepeatOnThursday RepeatOn = "thursday"
RepeatOnFriday RepeatOn = "friday"
RepeatOnSaturday RepeatOn = "saturday"
)
var RepeatOnAllMap = map[RepeatOn]time.Weekday{
RepeatOnSunday: time.Sunday,
RepeatOnMonday: time.Monday,
RepeatOnTuesday: time.Tuesday,
RepeatOnWednesday: time.Wednesday,
RepeatOnThursday: time.Thursday,
RepeatOnFriday: time.Friday,
RepeatOnSaturday: time.Saturday,
}
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
func (d *Duration) UnmarshalJSON(b []byte) error {
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
*d = Duration(time.Duration(value))
return nil
case string:
tmp, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = Duration(tmp)
return nil
default:
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid duration")
}
}
type Recurrence struct {
StartTime time.Time `json:"startTime"`
EndTime *time.Time `json:"endTime,omitempty"`
Duration Duration `json:"duration"`
RepeatType RepeatType `json:"repeatType"`
RepeatOn []RepeatOn `json:"repeatOn"`
}
func (r *Recurrence) Scan(src interface{}) error {
if data, ok := src.([]byte); ok {
return json.Unmarshal(data, r)
}
return nil
}
func (r *Recurrence) Value() (driver.Value, error) {
return json.Marshal(r)
}

View File

@@ -0,0 +1,132 @@
package alertmanagertypes
import (
"database/sql/driver"
"encoding/json"
"time"
)
type Schedule struct {
Timezone string `json:"timezone"`
StartTime time.Time `json:"startTime,omitempty"`
EndTime time.Time `json:"endTime,omitempty"`
Recurrence *Recurrence `json:"recurrence"`
}
func (s *Schedule) Scan(src interface{}) error {
if data, ok := src.([]byte); ok {
return json.Unmarshal(data, s)
}
return nil
}
func (s *Schedule) Value() (driver.Value, error) {
return json.Marshal(s)
}
func (s Schedule) MarshalJSON() ([]byte, error) {
loc, err := time.LoadLocation(s.Timezone)
if err != nil {
return nil, err
}
var startTime, endTime time.Time
if !s.StartTime.IsZero() {
startTime = time.Date(s.StartTime.Year(), s.StartTime.Month(), s.StartTime.Day(), s.StartTime.Hour(), s.StartTime.Minute(), s.StartTime.Second(), s.StartTime.Nanosecond(), loc)
}
if !s.EndTime.IsZero() {
endTime = time.Date(s.EndTime.Year(), s.EndTime.Month(), s.EndTime.Day(), s.EndTime.Hour(), s.EndTime.Minute(), s.EndTime.Second(), s.EndTime.Nanosecond(), loc)
}
var recurrence *Recurrence
if s.Recurrence != nil {
recStartTime := time.Date(s.Recurrence.StartTime.Year(), s.Recurrence.StartTime.Month(), s.Recurrence.StartTime.Day(), s.Recurrence.StartTime.Hour(), s.Recurrence.StartTime.Minute(), s.Recurrence.StartTime.Second(), s.Recurrence.StartTime.Nanosecond(), loc)
var recEndTime *time.Time
if s.Recurrence.EndTime != nil {
end := time.Date(s.Recurrence.EndTime.Year(), s.Recurrence.EndTime.Month(), s.Recurrence.EndTime.Day(), s.Recurrence.EndTime.Hour(), s.Recurrence.EndTime.Minute(), s.Recurrence.EndTime.Second(), s.Recurrence.EndTime.Nanosecond(), loc)
recEndTime = &end
}
recurrence = &Recurrence{
StartTime: recStartTime,
EndTime: recEndTime,
Duration: s.Recurrence.Duration,
RepeatType: s.Recurrence.RepeatType,
RepeatOn: s.Recurrence.RepeatOn,
}
}
return json.Marshal(&struct {
Timezone string `json:"timezone"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Recurrence *Recurrence `json:"recurrence,omitempty"`
}{
Timezone: s.Timezone,
StartTime: startTime.Format(time.RFC3339),
EndTime: endTime.Format(time.RFC3339),
Recurrence: recurrence,
})
}
func (s *Schedule) UnmarshalJSON(data []byte) error {
aux := &struct {
Timezone string `json:"timezone"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Recurrence *Recurrence `json:"recurrence,omitempty"`
}{}
if err := json.Unmarshal(data, aux); err != nil {
return err
}
loc, err := time.LoadLocation(aux.Timezone)
if err != nil {
return err
}
var startTime time.Time
if aux.StartTime != "" {
startTime, err = time.Parse(time.RFC3339, aux.StartTime)
if err != nil {
return err
}
s.StartTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(), loc)
}
var endTime time.Time
if aux.EndTime != "" {
endTime, err = time.Parse(time.RFC3339, aux.EndTime)
if err != nil {
return err
}
s.EndTime = time.Date(endTime.Year(), endTime.Month(), endTime.Day(), endTime.Hour(), endTime.Minute(), endTime.Second(), endTime.Nanosecond(), loc)
}
s.Timezone = aux.Timezone
if aux.Recurrence != nil {
recStartTime, err := time.Parse(time.RFC3339, aux.Recurrence.StartTime.Format(time.RFC3339))
if err != nil {
return err
}
var recEndTime *time.Time
if aux.Recurrence.EndTime != nil {
end, err := time.Parse(time.RFC3339, aux.Recurrence.EndTime.Format(time.RFC3339))
if err != nil {
return err
}
endConverted := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), end.Minute(), end.Second(), end.Nanosecond(), loc)
recEndTime = &endConverted
}
s.Recurrence = &Recurrence{
StartTime: time.Date(recStartTime.Year(), recStartTime.Month(), recStartTime.Day(), recStartTime.Hour(), recStartTime.Minute(), recStartTime.Second(), recStartTime.Nanosecond(), loc),
EndTime: recEndTime,
Duration: aux.Recurrence.Duration,
RepeatType: aux.Recurrence.RepeatType,
RepeatOn: aux.Recurrence.RepeatOn,
}
}
return nil
}

View File

@@ -0,0 +1,137 @@
package alertmanagertypes
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
ErrCodeInvalidStateHistoryQuery = errors.MustNewCode("invalid_state_history_query")
)
// AlertState represents the state of an alert series (firing, inactive, muted, no_data)
// or the overall state of a rule (firing, inactive).
type AlertState struct {
valuer.String
}
var (
AlertStateFiring = AlertState{valuer.NewString("firing")}
AlertStateInactive = AlertState{valuer.NewString("inactive")}
AlertStateMuted = AlertState{valuer.NewString("muted")}
AlertStateNoData = AlertState{valuer.NewString("no_data")}
)
// SortOrder represents the sort direction for query results.
type SortOrder struct {
valuer.String
}
var (
SortOrderAsc = SortOrder{valuer.NewString("asc")}
SortOrderDesc = SortOrder{valuer.NewString("desc")}
)
// RuleStateHistory represents a single state transition entry stored in ClickHouse.
// Only transitions are recorded, not every evaluation.
type RuleStateHistory struct {
OrgID string `json:"orgId"`
RuleID string `json:"ruleId"`
RuleName string `json:"ruleName"`
OverallState string `json:"overallState"` // aggregate rule state: "firing" if any series fires
OverallStateChanged bool `json:"overallStateChanged"` // true if this entry changed the overall state
State string `json:"state"` // per-series state: firing, inactive, muted, no_data
StateChanged bool `json:"stateChanged"` // always true in v2 (only transitions stored)
UnixMilli int64 `json:"unixMilli"`
Labels string `json:"labels"` // JSON-encoded label set
Fingerprint uint64 `json:"fingerprint"` // hash of the full label set
Value float64 `json:"value"`
}
// QueryRuleStateHistory is the request body for all v2 state history API endpoints.
type QueryRuleStateHistory struct {
Start int64 `json:"start"` // unix millis, required
End int64 `json:"end"` // unix millis, required
State AlertState `json:"state"` // optional filter: firing, inactive, muted
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
Order SortOrder `json:"order"`
}
func (q *QueryRuleStateHistory) Validate() error {
if q.Start == 0 || q.End == 0 {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidStateHistoryQuery, "start and end are required")
}
if q.Offset < 0 || q.Limit < 0 {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidStateHistoryQuery, "offset and limit must be greater than or equal to 0")
}
if q.Order.StringValue() != SortOrderAsc.StringValue() && q.Order.StringValue() != SortOrderDesc.StringValue() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidStateHistoryQuery, "order must be asc or desc")
}
return nil
}
// RuleStateTimeline is the paginated response for the timeline endpoint.
type RuleStateTimeline struct {
Items []RuleStateHistory `json:"items"`
Total uint64 `json:"total"`
Labels map[string][]string `json:"labels"` // distinct label keys/values for filter UI
}
// RuleStateHistoryContributor is an alert series ranked by firing frequency.
type RuleStateHistoryContributor struct {
Fingerprint uint64 `json:"fingerprint"`
Labels string `json:"labels"` // JSON-encoded label set
Count uint64 `json:"count"`
}
// RuleStateTransition represents a contiguous time period during which a rule
// was in a particular overall state (firing or inactive).
type RuleStateTransition struct {
State AlertState `json:"state"`
Start int64 `json:"start"`
End int64 `json:"end"`
}
// RuleStats compares trigger counts and avg resolution times between the current
// time period and a previous period of equal length.
type RuleStats struct {
TotalCurrentTriggers uint64 `json:"totalCurrentTriggers"`
TotalPastTriggers uint64 `json:"totalPastTriggers"`
CurrentTriggersSeries *Series `json:"currentTriggersSeries"`
PastTriggersSeries *Series `json:"pastTriggersSeries"`
CurrentAvgResolutionTime float64 `json:"currentAvgResolutionTime"`
PastAvgResolutionTime float64 `json:"pastAvgResolutionTime"`
CurrentAvgResolutionTimeSeries *Series `json:"currentAvgResolutionTimeSeries"`
PastAvgResolutionTimeSeries *Series `json:"pastAvgResolutionTimeSeries"`
}
type Series struct {
Labels map[string]string `json:"labels"`
Points []Point `json:"values"`
}
type Point struct {
Timestamp int64 `json:"timestamp"`
Value float64 `json:"value"`
}
// StateHistoryStore provides read and write access to rule state history in ClickHouse.
type StateHistoryStore interface {
WriteRuleStateHistory(ctx context.Context, entries []RuleStateHistory) error
// GetLastSavedRuleStateHistory returns the most recent transition per fingerprint,
// used to restore in-memory state after restart.
GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]RuleStateHistory, error)
GetRuleStateHistoryTimeline(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) (*RuleStateTimeline, error)
GetRuleStateHistoryTopContributors(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) ([]RuleStateHistoryContributor, error)
// GetOverallStateTransitions returns firing/inactive periods with gap-filling.
GetOverallStateTransitions(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) ([]RuleStateTransition, error)
GetTotalTriggers(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) (uint64, error)
GetTriggersByInterval(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) (*Series, error)
// GetAvgResolutionTime returns avg seconds between firing and next resolution.
GetAvgResolutionTime(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) (float64, error)
GetAvgResolutionTimeByInterval(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) (*Series, error)
}

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