Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
7497a7b971 refactor(qb): quote field names with the ClickHouse quoting helpers
Every plain field-name literal and identifier in the statement-builder
layer now goes through querybuilder.ClickHouseStringLiteral and
querybuilder.ClickHouseIdentifier: map reads, mapContains, JSON
subcolumn paths, JSONExtractString and JSONExtractKeys on labels,
simpleJSONExtractString and simpleJSONHas on the fingerprint labels,
and the column aliases.

The helpers escape backslashes, quotes, and backticks, so a field name
that contains a quote or a backtick can no longer break out of its
literal or identifier. For every other name the output is byte
identical, and the full test suite passes unchanged.

Out of scope, on purpose: LIKE index-hint fragments and body JSON path
builders (they have pattern and path semantics, not plain literals),
and the materialized-column name builders in telemetrytypes (importing
querybuilder there is an import cycle).

Assisted-by: Claude Fable 5
2026-08-18 04:34:59 +05:30
93 changed files with 732 additions and 2725 deletions

View File

@@ -8,19 +8,12 @@ import {
import ChangelogRenderer from '../components/ChangelogRenderer';
// Mock react-markdown to render children as plain text and a sample
// anchor through the `components.a` override
// Mock react-markdown to just render children as plain text
jest.mock(
'react-markdown',
() =>
function ReactMarkdown({ children, components }: any) {
const Anchor = components?.a;
return (
<div>
{children}
{Anchor && <Anchor href="https://signoz.io/docs">docs</Anchor>}
</div>
);
function ReactMarkdown({ children }: any) {
return <div>{children}</div>;
},
);
@@ -69,14 +62,4 @@ describe('ChangelogRenderer', () => {
expect(screen.getByAltText('Media')).toBeInTheDocument();
expect(screen.getByText('Description for feature 1')).toBeInTheDocument();
});
it('renders markdown links that open in a new tab', () => {
render(<ChangelogRenderer changelog={mockChangelog} />);
const links = screen.getAllByRole('link', { name: 'docs' });
expect(links.length).toBeGreaterThan(0);
links.forEach((link) => {
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});
});

View File

@@ -13,19 +13,6 @@ interface Props {
changelog: ChangelogSchema;
}
interface LinkProps {
href?: string;
children?: React.ReactNode;
}
function Link({ href, children }: LinkProps): JSX.Element {
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
function renderMedia(media: Media): JSX.Element | null {
if (SupportedImageTypes.includes(media.ext)) {
return (
@@ -75,9 +62,7 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div key={feature.id}>
<div className="changelog-renderer-section-title">{feature.title}</div>
{feature.media && renderMedia(feature.media)}
<ReactMarkdown components={{ a: Link }}>
{feature.description}
</ReactMarkdown>
<ReactMarkdown>{feature.description}</ReactMarkdown>
</div>
))}
</div>
@@ -86,9 +71,7 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="changelog-renderer-bug-fixes">
<div className="changelog-renderer-section-title">Bug Fixes</div>
{changelog.bug_fixes && (
<ReactMarkdown components={{ a: Link }}>
{changelog.bug_fixes}
</ReactMarkdown>
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
)}
</div>
)}
@@ -96,9 +79,7 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="changelog-renderer-maintenance">
<div className="changelog-renderer-section-title">Maintenance</div>
{changelog.maintenance && (
<ReactMarkdown components={{ a: Link }}>
{changelog.maintenance}
</ReactMarkdown>
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
)}
</div>
)}

View File

@@ -1,5 +1,9 @@
// temporary flag to be removed with old log details code.
export const isLogDetailsV2 = true;
import getLocalStorage from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
// Temp feature flag before actual roll-out
export const isLogDetailsV2 =
getLocalStorage(LOCALSTORAGE.LOG_DETAILS_V2) === 'true';
export const VIEW_TYPES = {
OVERVIEW: 'OVERVIEW',

View File

@@ -100,7 +100,6 @@ function LogDetailInner({
// Don't close if clicking on drawer content, overlays, or portal elements
if (
target.closest('[data-log-detail-ignore="true"]') ||
target.closest('.log-detail-drawer') ||
target.closest('.cm-tooltip-autocomplete') ||
target.closest('.drawer-popover') ||
target.closest('.query-status-popover') ||

View File

@@ -13,6 +13,7 @@ export enum LOCALSTORAGE {
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOG_DETAILS_V2 = 'LOG_DETAILS_V2',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
CHAT_SUPPORT = 'CHAT_SUPPORT',

View File

@@ -5,7 +5,6 @@ import BarChart from 'container/DashboardContainer/visualization/charts/BarChart
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { StackMode } from 'lib/uPlotV2/config/types';
import {
LegendPosition,
TooltipRenderArgs,
@@ -132,9 +131,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
<div ref={graphRef} className={styles.graphContainer}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
stack={StackMode.Normal}
config={config}
data={chartData}
isStackedBarChart
legendConfig={{ position: LegendPosition.BOTTOM }}
customTooltip={renderBillingTooltip}
width={containerDimensions.width}

View File

@@ -58,17 +58,26 @@ describe('prepareBillingBarConfig', () => {
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
});
it('sets padding and focus alpha for behavioral parity', () => {
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
});
const config = builder.getConfig();
// Stacking bands come from the chart now — see useChartStacking.
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
expect(config.focus).toStrictEqual({ alpha: 0.3 });
});
it('sets no bands when result is empty', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse([]),
});
const config = builder.getConfig();
expect(config.bands).toBeUndefined();
});
it('uses queryName as label when legend is undefined', () => {
const apiResponse: MetricRangePayloadProps = {
data: {

View File

@@ -1,6 +1,7 @@
import { Color } from '@signozhq/design-tokens';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
@@ -62,6 +63,7 @@ export function prepareBillingBarConfig({
});
});
builder.setBands(getInitialStackedBands(results.length));
builder.setPadding([32, 32, 16, 16]);
builder.setFocus({ alpha: 0.3 });

View File

@@ -6,24 +6,25 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
import { BarChartProps } from '../types';
export default function BarChart(props: BarChartProps): JSX.Element {
const {
children,
isStackedBarChart,
customTooltip,
config,
data,
stack = StackMode.None,
pinnedTooltipElement,
...rest
} = props;
// Written during render so it lands before UPlotChart's effect reads the config,
// which derives the fill bands, percent axis unit and percent range from it.
config.setStack(stack);
const chartData = useBarChartStacking({
data,
isStackedBarChart,
config,
});
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
@@ -36,6 +37,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
@@ -46,6 +48,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.timezone,
rest.yAxisUnit,
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
@@ -55,7 +58,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
<ChartWrapper
{...rest}
config={config}
data={data}
data={chartData}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>

View File

@@ -6,15 +6,12 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
import { StackMode } from 'lib/uPlotV2/config/types';
import { prepareAlignedData } from 'lib/uPlotV2/components/UPlotChart/utils';
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 { ChartWrapperProps } from '../types';
import { useChartStacking } from './useChartStacking';
import { ChartProps } from '../types';
const TOOLTIP_WIDTH_PADDING = 120;
const TOOLTIP_MIN_WIDTH = 300;
@@ -42,20 +39,9 @@ export default function ChartWrapper({
pinnedTooltipElement,
tooltipPortalRoot,
'data-testid': testId,
}: ChartWrapperProps): JSX.Element {
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const stack = config.getStackMode();
const chartData = useChartStacking({ data, config });
// Tooltips need pre-stack values, gap-processed exactly as UPlotChart processes the
// plot data — otherwise the cursor's index addresses a shorter array.
const unstackedData = useMemo(
() =>
stack === StackMode.None ? undefined : prepareAlignedData({ data, config }),
[data, config, stack],
);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
if (!showLegend) {
@@ -75,11 +61,11 @@ export default function ChartWrapper({
const renderTooltipCallback = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (customTooltip) {
return customTooltip({ ...args, unstackedData });
return customTooltip(args);
}
return null;
},
[customTooltip, unstackedData],
[customTooltip],
);
const syncMetadata = useMemo(
@@ -105,7 +91,7 @@ export default function ChartWrapper({
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={chartData}
data={data}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {

View File

@@ -1,98 +0,0 @@
import { renderHook } from '@testing-library/react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { StackMode } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import { useChartStacking } from '../useChartStacking';
type Hooks = Record<string, (...args: unknown[]) => void>;
function createConfig(stack: StackMode): {
config: UPlotConfigBuilder;
hooks: Hooks;
} {
const hooks: Hooks = {};
const config = {
getStackMode: (): StackMode => stack,
addHook: jest.fn((type: string, hook: (...args: unknown[]) => void) => {
hooks[type] = hook;
return jest.fn();
}),
} as unknown as UPlotConfigBuilder;
return { config, hooks };
}
const data = [[1], [30], [10]] as unknown as uPlot.AlignedData;
describe('useChartStacking', () => {
it('returns the data untouched and registers nothing when the config says `none`', () => {
const { config } = createConfig(StackMode.None);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toBe(data);
expect(config.addHook).not.toHaveBeenCalled();
});
it('treats a missing config as unstacked', () => {
const { result } = renderHook(() => useChartStacking({ data, config: null }));
expect(result.current).toBe(data);
});
it('accumulates raw values when the config declares `normal`', () => {
const { config } = createConfig(StackMode.Normal);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toStrictEqual([[1], [40], [10]]);
});
it('rescales each column to its total when the config declares `percent`', () => {
const { config } = createConfig(StackMode.Percent);
const { result } = renderHook(() => useChartStacking({ data, config }));
expect(result.current).toStrictEqual([[1], [100], [25]]);
});
it('registers the uPlot hooks that re-stack on data and visibility changes', () => {
const { config } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
expect(
(config.addHook as jest.Mock).mock.calls.map(([type]) => type),
).toStrictEqual(['setData', 'setSeries']);
});
it('re-stacks from the raw values when the legend hides a series', () => {
const { config, hooks } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
const plot = {
data: [[1]],
series: [{}, { show: true }, { show: false }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
};
hooks.setSeries(plot, 2, { show: false });
// The hidden series keeps its raw value and stops contributing to the total.
expect(plot.setData).toHaveBeenCalledWith([[1], [30], [10]]);
expect(plot.delBand).toHaveBeenCalledWith(null);
});
it('ignores a focus-only setSeries so hovering does not re-stack', () => {
const { config, hooks } = createConfig(StackMode.Normal);
renderHook(() => useChartStacking({ data, config }));
const plot = {
data: [[1]],
series: [{}, { show: true }, { show: true }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
};
hooks.setSeries(plot, 1, { focus: true });
expect(plot.setData).not.toHaveBeenCalled();
});
});

View File

@@ -6,16 +6,10 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, customTooltip, stack = StackMode.None, ...rest } = props;
// Written during render so it lands before UPlotChart's effect reads the config,
// which derives the fill bands, percent axis unit and percent range from it.
rest.config.setStack(stack);
const { children, customTooltip, ...rest } = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {

View File

@@ -14,7 +14,6 @@ import {
ChartClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { StackMode } from 'lib/uPlotV2/config/types';
interface BaseChartProps {
width: number;
@@ -53,26 +52,27 @@ interface UPlotChartDataProps {
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
}
/** Everything the shared uPlot shell consumes; each chart's props narrow it. */
export interface ChartWrapperProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {}
export interface TimeSeriesChartProps extends ChartWrapperProps {
export interface TimeSeriesChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
timezone?: Timezone;
/** How series compose. Defaults to `none`, which draws them independently. */
stack?: StackMode;
}
export interface BarChartProps extends ChartWrapperProps {
timezone?: Timezone;
/** How series compose. Defaults to `none`, which draws them independently. */
stack?: StackMode;
}
export interface HistogramChartProps extends ChartWrapperProps {
export interface HistogramChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
isQueriesMerged?: boolean;
}
export interface BarChartProps
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
isStackedBarChart?: boolean;
timezone?: Timezone;
}
export type ChartProps =
| TimeSeriesChartProps
| BarChartProps
| HistogramChartProps;
/**
* One resolved pie/donut slice: a display label, its (already parsed) positive
* numeric value, and the colour used for the arc + legend swatch.

View File

@@ -1,158 +0,0 @@
import { AlignedData } from 'uplot';
import { StackMode } from 'lib/uPlotV2/config/types';
import { stackSeries } from '../stackSeriesUtils';
const includeAll = (): boolean => false;
// Stacking is top-down: the first series carries the column total, the last its own
// raw value. Every expectation below reads in that order.
describe('stackSeries', () => {
it('is a no-op under `none`, returning the data and no bands', () => {
const data: AlignedData = [[1], [30], [10]];
const { data: result, bands } = stackSeries(data, includeAll, StackMode.None);
expect(result).toBe(data);
expect(bands).toStrictEqual([]);
});
describe('normal', () => {
it('accumulates raw values from the bottom series upward', () => {
const data: AlignedData = [
[1, 2],
[10, 20],
[1, 2],
];
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
[1, 2],
[11, 22],
[1, 2],
]);
});
it('treats nulls as 0 without breaking the running total', () => {
const data: AlignedData = [
[1, 2],
[10, null],
[1, 2],
];
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
[1, 2],
[11, 2],
[1, 2],
]);
});
it('emits one band per adjacent pair of participating series', () => {
const data: AlignedData = [[1], [10], [5], [1]];
expect(stackSeries(data, includeAll, StackMode.Normal).bands).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('copies omitted series through unstacked and skips their bands', () => {
const data: AlignedData = [[1], [10], [5], [1]];
const omitMiddle = (seriesIndex: number): boolean => seriesIndex === 2;
const { data: stacked, bands } = stackSeries(
data,
omitMiddle,
StackMode.Normal,
);
expect(stacked).toStrictEqual([[1], [11], [5], [1]]);
expect(bands).toStrictEqual([{ series: [1, 3] }]);
});
});
describe('percent', () => {
it('rescales each column to its total so the top series reads 100', () => {
const data: AlignedData = [
[1, 2],
[30, 10],
[10, 10],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[100, 100],
[25, 50],
]);
});
it('normalises per column, so an identical series differs across x', () => {
const data: AlignedData = [
[1, 2],
[1, 3],
[1, 1],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[100, 100],
[50, 25],
]);
});
it('excludes omitted series from the total, so the visible ones still reach 100', () => {
const data: AlignedData = [[1], [30], [10], [60]];
const omitLast = (seriesIndex: number): boolean => seriesIndex === 3;
expect(stackSeries(data, omitLast, StackMode.Percent).data).toStrictEqual([
[1],
[100],
[25],
[60],
]);
});
it('yields 0 for a column whose participating series sum to zero', () => {
const data: AlignedData = [
[1, 2],
[0, 5],
[0, 5],
];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1, 2],
[0, 100],
[0, 50],
]);
});
it('divides by the signed total when a column mixes signs', () => {
// 30 + (-10) = 20, so the shares are 150% and -50% and still sum to 100.
const data: AlignedData = [[1], [30], [-10]];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1],
[100],
[-50],
]);
});
it('yields 0 across a column whose signed total cancels to zero', () => {
const data: AlignedData = [[1], [10], [-10]];
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
[1],
[0],
[0],
]);
});
});
it('defaults to normal when no mode is given', () => {
const data: AlignedData = [[1], [30], [10]];
expect(stackSeries(data, includeAll).data).toStrictEqual(
stackSeries(data, includeAll, StackMode.Normal).data,
);
});
});

View File

@@ -1,20 +1,13 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series keeps its raw values and
* contributes nothing to the total. `None` is a no-op.
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
*/
export function stackSeries(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
mode: StackMode = StackMode.Normal,
): { data: AlignedData; bands: uPlot.Band[] } {
if (mode === StackMode.None) {
return { data, bands: [] };
}
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
@@ -24,7 +17,6 @@ export function stackSeries(
valueSeriesCount,
pointCount,
omit,
mode,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
@@ -39,34 +31,6 @@ interface BuildStackedSeriesParams {
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
mode: StackMode;
}
/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */
function columnTotals({
data,
valueSeriesCount,
pointCount,
omit,
}: Omit<BuildStackedSeriesParams, 'mode'>): number[] {
const totals = Array(pointCount).fill(0) as number[];
for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const rawValues = data[seriesIndex] as (number | null)[];
rawValues.forEach((rawValue, pointIndex) => {
totals[pointIndex] += rawValue == null ? 0 : Number(rawValue);
});
}
return totals;
}
/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */
function toPercent(value: number, total: number): number {
return total === 0 ? 0 : (value / total) * 100;
}
/**
@@ -78,15 +42,9 @@ function buildStackedSeries({
valueSeriesCount,
pointCount,
omit,
mode,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
// Known up front: totals span series the accumulation below has not reached yet.
const totals =
mode === StackMode.Percent
? columnTotals({ data, valueSeriesCount, pointCount, omit })
: undefined;
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
@@ -96,10 +54,7 @@ function buildStackedSeries({
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
const contribution = totals
? toPercent(numericValue, totals[pointIndex])
: numericValue;
return (cumulativeSums[pointIndex] += contribution);
return (cumulativeSums[pointIndex] += numericValue);
});
}
}
@@ -146,3 +101,16 @@ function findNextVisibleSeriesIndex(
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -0,0 +1,313 @@
import { renderHook } from '@testing-library/react';
import uPlot from 'uplot';
import type { UseBarChartStackingParams } from '../useBarChartStacking';
import { useBarChartStacking } from '../useBarChartStacking';
type MockConfig = { addHook: jest.Mock };
function asConfig(c: MockConfig): UseBarChartStackingParams['config'] {
return c as unknown as UseBarChartStackingParams['config'];
}
function createMockConfig(): {
config: MockConfig;
invokeSetData: (plot: uPlot) => void;
invokeSetSeries: (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
) => void;
removeSetData: jest.Mock;
removeSetSeries: jest.Mock;
} {
let setDataHandler: ((plot: uPlot) => void) | null = null;
let setSeriesHandler:
| ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
| null = null;
const removeSetData = jest.fn();
const removeSetSeries = jest.fn();
const addHook = jest.fn(
(
hookName: string,
handler: (plot: uPlot, ...args: unknown[]) => void,
): (() => void) => {
if (hookName === 'setData') {
setDataHandler = handler as (plot: uPlot) => void;
return removeSetData;
}
if (hookName === 'setSeries') {
setSeriesHandler = handler as (
plot: uPlot,
seriesIndex: number | null,
opts: uPlot.Series,
) => void;
return removeSetSeries;
}
return jest.fn();
},
);
const config: MockConfig = { addHook };
const invokeSetData = (plot: uPlot): void => {
setDataHandler?.(plot);
};
const invokeSetSeries = (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
): void => {
setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series);
};
return {
config,
invokeSetData,
invokeSetSeries,
removeSetData,
removeSetSeries,
};
}
function createMockPlot(overrides: Partial<uPlot> = {}): uPlot {
return {
data: [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
],
series: [{ show: true }, { show: true }, { show: true }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
...overrides,
} as unknown as uPlot;
}
describe('useBarChartStacking', () => {
it('returns data as-is when isStackedBarChart is false', () => {
const data: uPlot.AlignedData = [
[100, 200],
[1, 2],
[3, 4],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: null,
}),
);
expect(result.current).toBe(data);
});
it('returns data as-is when config is null and isStackedBarChart is true', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[4, 5],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
// Still returns stacked data (computed in useMemo); no hooks registered
expect(result.current[0]).toStrictEqual([0, 1]);
expect(result.current[1]).toStrictEqual([5, 7]); // stacked
expect(result.current[2]).toStrictEqual([4, 5]);
});
it('returns stacked data when isStackedBarChart is true and multiple value series', () => {
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current[0]).toStrictEqual([0, 1, 2]);
expect(result.current[1]).toStrictEqual([12, 15, 18]); // s1+s2+s3
expect(result.current[2]).toStrictEqual([11, 13, 15]); // s2+s3
expect(result.current[3]).toStrictEqual([7, 8, 9]);
});
it('returns data as-is when only one value series (no stacking needed)', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current).toStrictEqual(data);
});
it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function));
expect(config.addHook).toHaveBeenCalledWith(
'setSeries',
expect.any(Function),
);
});
it('does not register hooks when isStackedBarChart is false', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: asConfig(config),
}),
);
expect(config.addHook).not.toHaveBeenCalled();
});
it('calls cleanup when unmounted', () => {
const { config, removeSetData, removeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const { unmount } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
unmount();
expect(removeSetData).toHaveBeenCalled();
expect(removeSetSeries).toHaveBeenCalled();
});
it('re-stacks and updates plot when setData hook is invoked', () => {
const { config, invokeSetData } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
];
const plot = createMockPlot({
data: [
[0, 1, 2],
[5, 7, 9],
[4, 5, 6],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetData(plot);
expect(plot.delBand).toHaveBeenCalledWith(null);
expect(plot.addBand).toHaveBeenCalled();
expect(plot.setData).toHaveBeenCalledWith(
expect.arrayContaining([
[0, 1, 2],
expect.any(Array), // stacked row 1
expect.any(Array), // stacked row 2
]),
);
});
it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[10, 20],
[5, 10],
];
// Plot data must match unstacked length so canApplyStacking passes
const plot = createMockPlot({
data: [
[0, 1],
[15, 30],
[5, 10],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetSeries(plot, 1, { show: false });
expect(plot.setData).toHaveBeenCalled();
});
it('does not re-stack when setSeries is called with focus option', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const plot = createMockPlot();
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
(plot.setData as jest.Mock).mockClear();
invokeSetSeries(plot, 1, { focus: true } as uPlot.Series);
expect(plot.setData).not.toHaveBeenCalled();
});
});

View File

@@ -6,11 +6,10 @@ import {
useRef,
} from 'react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { StackMode } from 'lib/uPlotV2/config/types';
import { has } from 'lodash-es';
import uPlot from 'uplot';
import { stackSeries } from '../utils/stackSeriesUtils';
import { stackSeries } from '../charts/utils/stackSeriesUtils';
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
@@ -32,12 +31,12 @@ function canApplyStacking(
function setupStackingHooks(
config: UPlotConfigBuilder,
restack: (plot: uPlot) => void,
applyStackingToChart: (plot: uPlot) => void,
isUpdatingRef: MutableRefObject<boolean>,
): () => void {
const onDataChange = (plot: uPlot): void => {
if (!isUpdatingRef.current) {
restack(plot);
applyStackingToChart(plot);
}
};
@@ -46,9 +45,8 @@ function setupStackingHooks(
_seriesIdx: number | null,
opts: uPlot.Series,
): void => {
// uPlot fires setSeries for hover focus too; only visibility changes restack.
if (!has(opts, 'focus')) {
restack(plot);
applyStackingToChart(plot);
}
};
@@ -64,69 +62,64 @@ function setupStackingHooks(
};
}
export interface UseChartStackingParams {
export interface UseBarChartStackingParams {
data: uPlot.AlignedData;
isStackedBarChart?: boolean;
config: UPlotConfigBuilder | null;
}
/**
* Stacks a chart's data for the mode declared on its config, and re-stacks on data or
* visibility changes. The pre-stack values live in a ref because the uPlot hooks that
* read them run outside React's render cycle.
* Handles stacking for bar charts: computes initial stacked data and re-stacks
* when data or series visibility changes (e.g. legend toggles).
*/
export function useChartStacking({
export function useBarChartStacking({
data,
isStackedBarChart = false,
config,
}: UseChartStackingParams): uPlot.AlignedData {
const stack = config?.getStackMode() ?? StackMode.None;
}: UseBarChartStackingParams): uPlot.AlignedData {
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
unstackedDataRef.current = stack === 'none' ? null : data;
unstackedDataRef.current = isStackedBarChart ? data : null;
// Guards the re-entrant setData below, which would otherwise re-trigger our own hook.
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
const isUpdatingChartRef = useRef(false);
const chartData = useMemo((): uPlot.AlignedData => {
if (stack === StackMode.None || !data || data.length < 2) {
if (!isStackedBarChart || !data || data.length < 2) {
return data;
}
const noSeriesHidden = (): boolean => false; // include all series in initial stack
return stackSeries(data, noSeriesHidden, stack).data;
}, [data, stack]);
const { data: stacked } = stackSeries(data, noSeriesHidden);
return stacked;
}, [data, isStackedBarChart]);
const restack = useCallback(
(plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
if (
!unstacked ||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
) {
return;
}
const applyStackingToChart = useCallback((plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
if (
!unstacked ||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
) {
return;
}
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stackSeries(
unstacked,
shouldExcludeSeries,
stack,
);
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
},
[stack],
);
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
}, []);
useLayoutEffect(() => {
if (stack === StackMode.None || !config) {
if (!isStackedBarChart || !config) {
return undefined;
}
return setupStackingHooks(config, restack, isUpdatingChartRef);
}, [stack, config, restack]);
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
}, [isStackedBarChart, config, applyStackingToChart]);
return chartData;
}

View File

@@ -22,7 +22,6 @@ import { prepareBarPanelConfig } from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { StackMode } from 'lib/uPlotV2/config/types';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -148,7 +147,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={`${syncMode}-${syncFilterMode}`}
stack={widget.stackedBarChart ? StackMode.Normal : StackMode.None}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -161,6 +159,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
height={containerDimensions.height}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}

View File

@@ -35,10 +35,20 @@ jest.mock('lib/getLabelName', () => ({
),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({
getInitialStackedBands: jest.fn().mockReturnValue([]),
}),
);
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
.getLegend as jest.Mock;
const getLabelNameMock = jest.requireMock('lib/getLabelName')
.default as jest.Mock;
const getInitialStackedBandsMock = jest.requireMock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
).getInitialStackedBands as jest.Mock;
const createApiResponse = (
result: MetricRangePayloadProps['data']['result'] = [],
@@ -237,5 +247,36 @@ describe('BarPanel utils', () => {
}).getConfig();
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
});
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
const widget = createWidget({ stackedBarChart: true });
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
// seriesCount = result.length + 1 = 3
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
});
it('does not call getInitialStackedBands for non-stacked chart', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, apiResponse });
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,7 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
@@ -68,6 +69,11 @@ export function prepareBarPanelConfig({
return builder;
}
if (widget.stackedBarChart) {
const seriesCount = (apiResponse.data.result.length ?? 0) + 1; // +1 for 1-based uPlot series indices
builder.setBands(getInitialStackedBands(seriesCount));
}
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,

View File

@@ -1,91 +0,0 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useUpdatedQuery from '../useResolveQuery';
const mockGetSubstituteVars = jest.fn();
const mockDynamicVariables: unknown[] = [];
jest.mock('api/dashboard/substitute_vars', () => ({
getSubstituteVars: (...args: unknown[]): unknown =>
mockGetSubstituteVars(...args),
}));
jest.mock('api/v5/v5', () => ({
prepareQueryRangePayloadV5: (): { queryPayload: unknown } => ({
queryPayload: { start: 0, end: 1 },
}),
}));
jest.mock(
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
() => ({
mapQueryDataFromApi: (): Query => ({ resolved: true }) as unknown as Query,
}),
);
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
useDashboardVariablesByType: (): unknown[] => mockDynamicVariables,
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): unknown => ({
selectedTime: 'GLOBAL_TIME',
}),
}));
const QUERY = { builder: { queryData: [] } } as unknown as Query;
const WIDGET_CONFIG = {
query: QUERY,
panelTypes: PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME' as const,
};
describe('useResolveQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDynamicVariables.length = 0;
});
it('skips the substitute_vars round-trip when there are no variables', async () => {
const { result } = renderHook(() => useUpdatedQuery(), {
wrapper: MockQueryClientProvider,
});
const resolved = await result.current.getUpdatedQuery({
widgetConfig: WIDGET_CONFIG,
});
expect(mockGetSubstituteVars).not.toHaveBeenCalled();
expect(resolved).toBe(QUERY);
});
it('resolves through substitute_vars when the dashboard has variables', async () => {
mockGetSubstituteVars.mockResolvedValue({
httpStatusCode: 200,
data: { compositeQuery: {} },
});
const { result } = renderHook(() => useUpdatedQuery(), {
wrapper: MockQueryClientProvider,
});
const resolved = await result.current.getUpdatedQuery({
widgetConfig: WIDGET_CONFIG,
dashboardData: {
data: {
variables: {
env: { name: 'env', selectedValue: 'prod' },
},
},
},
});
expect(mockGetSubstituteVars).toHaveBeenCalledTimes(1);
expect(resolved).toStrictEqual({ resolved: true });
});
});

View File

@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { isEmpty } from 'lodash-es';
import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -47,21 +46,13 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
widgetConfig,
dashboardData,
}: UseUpdatedQueryOptions): Promise<Query> => {
const variables = getDashboardVariables(dashboardData?.data?.variables);
// `/substitute_vars` only rewrites `$variable` references, so on surfaces with no
// dashboard behind them (APM, Celery, API monitoring) the round-trip is a no-op.
if (isEmpty(variables) && isEmpty(dashboardDynamicVariables)) {
return widgetConfig.query;
}
// Prepare query payload with resolved variables
const { queryPayload } = prepareQueryRangePayloadV5({
query: widgetConfig.query,
graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance,
globalSelectedInterval,
variables,
variables: getDashboardVariables(dashboardData?.data?.variables),
originalGraphType: widgetConfig.panelTypes,
dynamicVariables: dashboardDynamicVariables,
});

View File

@@ -37,7 +37,7 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
import styles from './K8sExpandedRow.module.scss';
import { buildExpressionFromGroupMeta } from './utils';
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { getUnstableCurrentSearchParams } from 'container/TopNav/DateTimeSelectionV2/utils/getUnstableCurrentSearchParams';
import { QueryParams } from 'constants/query';
const EXPANDED_ROW_LIMIT = 10;

View File

@@ -9,11 +9,7 @@ function Overview(): JSX.Element {
return (
<div className={styles.overview} data-testid="llm-observability-overview">
<DashboardContainer
dashboard={dashboard}
refetch={refetch}
canEditDashboardOverride={false}
/>
<DashboardContainer dashboard={dashboard} refetch={refetch} />
</div>
);
}

View File

@@ -1,7 +1,7 @@
{
"id": "llm-observability-overview",
"orgId": "",
"locked": false,
"locked": true,
"name": "AI Observability Overview",
"schemaVersion": "v6",
"source": "system",
@@ -1146,4 +1146,4 @@
}
]
}
}
}

View File

@@ -23,9 +23,9 @@ import { useLogAttributeActions } from './hooks/useLogAttributeActions';
import TableView from './TableView';
import {
aggregateAttributesResourcesToObject,
buildPrettyViewData,
getBodyDisplayString,
getSanitizedLogBody,
parseJsonStringBody,
removeEscapeCharacters,
} from './utils';
@@ -71,7 +71,11 @@ function Overview({
if (isLogDetailsV2) {
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = buildPrettyViewData(raw);
const prettyData = Object.fromEntries(
Object.entries({ ...raw, body: parseJsonStringBody(raw.body) }).filter(
([, value]) => value !== undefined,
),
);
return (
<div className="overview-container">
<DataViewer

View File

@@ -1,6 +1,6 @@
export enum LogAttributeBucket {
ATTRIBUTES = 'attributes',
RESOURCES = 'resource',
RESOURCES = 'resources',
SCOPE = 'scope',
}

View File

@@ -33,7 +33,7 @@ describe('buildLogFilterTarget', () => {
it('maps `resources` with Resource type', () => {
expect(
buildLogFilterTarget(['resource', 'service.name'], 'api', true),
buildLogFilterTarget(['resources', 'service.name'], 'api', true),
).toMatchObject({
fieldKey: 'service.name',
metricsType: MetricsType.Resource,
@@ -53,30 +53,6 @@ describe('buildLogFilterTarget', () => {
});
});
describe('nested attribute values (parsed JSON)', () => {
it('marks a sub-field of a parsed attribute copy-only (restricted, no group-by)', () => {
const t = buildLogFilterTarget(['attributes', 'payload', 'x'], 1, true);
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it('leaves a top-level attribute (depth 2) filterable', () => {
const t = buildLogFilterTarget(['attributes', 'payload'], 'v', true);
expect(t.isRestricted).toBe(false);
expect(t.groupBySupported).toBe(true);
});
it('does not restrict nested resource/scope values', () => {
expect(
buildLogFilterTarget(['resource', 'k8s', 'pod'], 'p', true).isRestricted,
).toBe(false);
expect(
buildLogFilterTarget(['scope', 'a', 'b'], 'v', true).isRestricted,
).toBe(false);
});
});
describe('restricted fields (timestamp / id)', () => {
it.each(['timestamp', 'id'])(
'marks %s restricted with no group-by',
@@ -89,30 +65,6 @@ describe('buildLogFilterTarget', () => {
);
});
describe('group-by-restricted fields (trace_id)', () => {
it('allows filtering but not group-by on top-level trace_id', () => {
const t = buildLogFilterTarget(['trace_id'], 'abc123', true);
expect(t.isRestricted).toBe(false);
expect(t.filterInOperator).toBe('=');
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it.each([
['resource', ['resource', 'trace_id']],
['attributes', ['attributes', 'trace_id']],
])(
'blocks group-by on a %s field named trace_id, keeping filter',
(_bucket, path) => {
const t = buildLogFilterTarget(path as string[], 'abc123', true);
expect(t.isRestricted).toBe(false);
expect(t.filterInOperator).toBe('=');
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
},
);
});
describe('body scalars', () => {
it('maps a top-level body scalar to body.<key> with =/!=, groupable when json body on', () => {
const t = buildLogFilterTarget(['body', 'message'], 'hello', true);

View File

@@ -5,10 +5,7 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { OPERATORS as QUERY_BUILDER_OPERATORS } from 'constants/queryBuilder';
import {
RESTRICTED_GROUP_BY_FIELDS,
RESTRICTED_SELECTED_FIELDS,
} from 'container/LogsFilters/config';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
@@ -86,24 +83,15 @@ export const buildLogFilterTarget = (
if (root !== 'body') {
const fieldKey =
fieldKeyPath.length > 1 ? fieldKeyPath.slice(1).join('.') : String(root);
// Temporarily removing filter/group-by support for nested attributes.
// This will be removed once backend starts to support these actions.
const isNestedAttributeValue =
root === LogAttributeBucket.ATTRIBUTES && fieldKeyPath.length > 2;
const isRestricted =
RESTRICTED_SELECTED_FIELDS.includes(fieldKey) || isNestedAttributeValue;
const groupBySupported =
!isRestricted && !RESTRICTED_GROUP_BY_FIELDS.includes(fieldKey);
const isRestricted = RESTRICTED_SELECTED_FIELDS.includes(fieldKey);
return {
fieldKey,
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
metricsType: metricsTypeForRoot(root),
groupBySupported,
groupByKey: groupBySupported ? fieldKey : undefined,
groupBySupported: !isRestricted,
groupByKey: isRestricted ? undefined : fieldKey,
isRestricted,
};
}

View File

@@ -3,79 +3,45 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
aggregateAttributesResourcesToObject,
buildPrettyViewData,
flattenObject,
getDataTypes,
getSanitizedLogBody,
parseJsonStringValue,
parseJsonStringBody,
recursiveParseJSON,
} from './utils';
describe('parseJsonStringValue', () => {
describe('parseJsonStringBody', () => {
it('parses a JSON-object string into an object', () => {
expect(parseJsonStringValue('{"a":1,"b":{"c":2}}')).toStrictEqual({
expect(parseJsonStringBody('{"a":1,"b":{"c":2}}')).toStrictEqual({
a: 1,
b: { c: 2 },
});
});
it('parses a JSON-array string into an array', () => {
expect(parseJsonStringValue('[1,2,3]')).toStrictEqual([1, 2, 3]);
expect(parseJsonStringBody('[1,2,3]')).toStrictEqual([1, 2, 3]);
});
it('returns a plain (non-JSON) string unchanged', () => {
expect(parseJsonStringValue('plain log line')).toBe('plain log line');
expect(parseJsonStringBody('plain log line')).toBe('plain log line');
});
it('returns a string that is not object/array-looking unchanged', () => {
expect(parseJsonStringValue('42')).toBe('42');
expect(parseJsonStringBody('42')).toBe('42');
});
it('returns an invalid JSON string unchanged', () => {
expect(parseJsonStringValue('{not valid}')).toBe('{not valid}');
expect(parseJsonStringBody('{not valid}')).toBe('{not valid}');
});
it('returns an already-object value unchanged (same reference)', () => {
const value = { message: 'hi', a: 1 };
expect(parseJsonStringValue(value)).toBe(value);
it('returns an already-object body unchanged (same reference)', () => {
const body = { message: 'hi', a: 1 };
expect(parseJsonStringBody(body)).toBe(body);
});
it('leaves a value larger than the 128KB parse guard as a string', () => {
it('leaves a body larger than the 128KB parse guard as a string', () => {
const huge = `{"x":"${'a'.repeat(130 * 1024)}"}`;
expect(parseJsonStringValue(huge)).toBe(huge);
});
});
describe('buildPrettyViewData', () => {
const baseRaw = {
id: 'log-1',
timestamp: 1234,
body: 'hello',
attributes: {},
resource: {},
scope: {},
} as any;
it('parses a JSON-string body into a tree', () => {
const result = buildPrettyViewData({ ...baseRaw, body: '{"a":1}' });
expect(result.body).toStrictEqual({ a: 1 });
});
it('parses attribute values that are JSON strings, leaves others as-is', () => {
const result = buildPrettyViewData({
...baseRaw,
attributes: { payload: '{"x":1}', name: 'cart', count: 3 },
});
expect(result.attributes).toStrictEqual({
payload: { x: 1 },
name: 'cart',
count: 3,
});
});
it('drops undefined fields so they do not render as empty rows', () => {
const result = buildPrettyViewData({ ...baseRaw, trace_id: undefined });
expect('trace_id' in result).toBe(false);
expect(parseJsonStringBody(huge)).toBe(huge);
});
});
@@ -99,7 +65,7 @@ describe('aggregateAttributesResourcesToObject', () => {
'http.method': 'GET',
retries: 3,
});
expect(result.resource).toStrictEqual({ 'service.name': 'cart' });
expect(result.resources).toStrictEqual({ 'service.name': 'cart' });
expect(result.scope).toStrictEqual({ lib: 'otel' });
expect(result.body).toBe('hello');
expect(result.id).toBe('log-1');

View File

@@ -276,7 +276,7 @@ export const aggregateAttributesResourcesToObject = (
traceFlags: logData.traceFlags,
traceId: logData.traceId,
attributes: {},
resource: {},
resources: {},
scope: {},
severity_text: logData.severity_text,
severity_number: logData.severity_number,
@@ -290,8 +290,8 @@ export const aggregateAttributesResourcesToObject = (
outputJson.attributes = outputJson.attributes || {};
Object.assign(outputJson.attributes, logData[key as keyof ILog]);
} else if (key.startsWith('resources_')) {
outputJson.resource = outputJson.resource || {};
Object.assign(outputJson.resource, logData[key as keyof ILog]);
outputJson.resources = outputJson.resources || {};
Object.assign(outputJson.resources, logData[key as keyof ILog]);
} else if (key.startsWith('scope_string')) {
outputJson.scope = outputJson.scope || {};
Object.assign(outputJson.scope, logData[key as keyof ILog]);
@@ -315,57 +315,30 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => {
}
};
const MAX_JSON_PARSE_BYTES = 128 * 1024;
const MAX_JSON_BODY_PARSE_BYTES = 128 * 1024;
// A JSON-encoded object/array string is parsed so DataViewer renders it as a tree
// instead of one escaped string; non-JSON / plain-text values are returned unchanged.
// A JSON-encoded object/array `body` is parsed so DataViewer renders it as a
// tree instead of one escaped string; plain-text bodies are returned unchanged.
// Guarded against very large payloads.
export const parseJsonStringValue = (value: unknown): unknown => {
if (typeof value !== 'string') {
return value;
export const parseJsonStringBody = (body: ILog['body']): ILog['body'] => {
if (typeof body !== 'string') {
return body;
}
const trimmed = value.trim();
const trimmed = body.trim();
const looksLikeJson = trimmed.startsWith('{') || trimmed.startsWith('[');
if (!looksLikeJson || trimmed.length > MAX_JSON_PARSE_BYTES) {
return value;
if (!looksLikeJson || trimmed.length > MAX_JSON_BODY_PARSE_BYTES) {
return body;
}
try {
const parsed = JSON.parse(trimmed);
return parsed !== null && typeof parsed === 'object' ? parsed : value;
return parsed !== null && typeof parsed === 'object'
? (parsed as ILogBody)
: body;
} catch {
return value;
return body;
}
};
// Parse each attribute value that's a stringified JSON string into an object
// Non-JSON values are left unchanged.
const parseAttributeJsonValues = (
attributes: Record<string, unknown>,
): Record<string, unknown> => {
const parsed: Record<string, unknown> = {};
Object.keys(attributes).forEach((key) => {
parsed[key] = parseJsonStringValue(attributes[key]);
});
return parsed;
};
export const buildPrettyViewData = (
raw: ILogAggregateAttributesResources,
): Record<string, unknown> => {
const prettyData: Record<string, unknown> = { ...raw };
prettyData.body = parseJsonStringValue(raw.body);
prettyData.attributes = parseAttributeJsonValues(raw.attributes);
// drop undefined fields so they don't render as empty rows
Object.keys(prettyData).forEach((key) => {
if (prettyData[key] === undefined) {
delete prettyData[key];
}
});
return prettyData;
};
const isFloat = (num: number): boolean => num % 1 !== 0;
const isBooleanString = (str: string): boolean =>

View File

@@ -2,9 +2,6 @@ import { blue, red } from '@ant-design/colors';
export const RESTRICTED_SELECTED_FIELDS = ['timestamp', 'id'];
// Fields that can be filtered on but not grouped by in the log details view.
export const RESTRICTED_GROUP_BY_FIELDS = ['body', 'trace_id'];
export const ICON_STYLE = {
PLUS: { color: blue[5] },
CLOSE: { color: red[5] },

View File

@@ -9,7 +9,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { AppState } from 'store/reducers';
@@ -138,7 +137,6 @@ function TimeSeries({
key={`${WIDGET_ID}-${index}`}
>
<BarChart
stack={StackMode.Normal}
config={chart.config}
legendConfig={{
position: LegendPosition.BOTTOM,
@@ -146,6 +144,7 @@ function TimeSeries({
data={chart.chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
isStackedBarChart
yAxisUnit={yAxisUnit || 'short'}
timezone={timezone}
/>

View File

@@ -1,5 +1,6 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -88,6 +89,9 @@ export function buildMeterChartConfig({
return builder;
}
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
builder.setBands(getInitialStackedBands(seriesCount));
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,

View File

@@ -124,9 +124,6 @@ function Application(): JSX.Element {
start: minTime,
end: maxTime,
}),
// the time range is part of the key, so without this every window change blanks the
// operations list and the widgets below are rebuilt with an empty `operation in []`
keepPreviousData: true,
});
const selectedTraceTags: string = JSON.stringify(

View File

@@ -2,17 +2,14 @@ import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Form, Select, Space } from 'antd';
import { FeatureKeys } from 'constants/features';
import { ModalFooterTitle } from 'container/PipelinePage/styles';
import { useAppContext } from 'providers/App/App';
import { ProcessorData } from 'types/api/pipeline/def';
import { formValidationRules } from '../config';
import { ProcessorFormField } from './config';
import { processorFields, ProcessorFormField } from './config';
import CSVInput from './FormFields/CSVInput';
import JsonFlattening from './FormFields/JsonFlattening';
import { FormWrapper, PipelineIndexIcon, StyledSelect } from './styles';
import { resolveProcessorFields } from './utils';
import './styles.scss';
@@ -136,23 +133,16 @@ function ProcessorForm({
selectedProcessorData,
isAdd,
}: ProcessorFormProps): JSX.Element {
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return (
<div className="processor-form-container">
{resolveProcessorFields(processorType, isBodyJsonEnabled).map(
(fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
selectedProcessorData={selectedProcessorData}
isAdd={isAdd}
/>
),
)}
{processorFields[processorType]?.map((fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
selectedProcessorData={selectedProcessorData}
isAdd={isAdd}
/>
))}
</div>
);
}

View File

@@ -1,24 +0,0 @@
import { processorFields, ProcessorFormField } from './config';
const BODY_PARSE_FROM = 'body';
const JSON_BODY_PARSE_FROM = 'body.message';
// With use_json_body the collector normalizes every body into a map before user
// operators run, so a parser pointed at `body` gets a map it cannot read and
// silently extracts nothing. The log text lives at body.message.
export function resolveProcessorFields(
processorType: string,
isBodyJsonEnabled: boolean,
): Array<ProcessorFormField> {
const fields = processorFields[processorType] ?? [];
if (!isBodyJsonEnabled) {
return fields;
}
return fields.map((field) =>
field.name === 'parse_from' && field.initialValue === BODY_PARSE_FROM
? { ...field, initialValue: JSON_BODY_PARSE_FROM }
: field,
);
}

View File

@@ -1,45 +0,0 @@
import { processorFields } from '../PipelineListsView/AddNewProcessor/config';
import { resolveProcessorFields } from '../PipelineListsView/AddNewProcessor/utils';
const parseFromDefault = (
fields: ReturnType<typeof resolveProcessorFields>,
): unknown => fields.find((field) => field.name === 'parse_from')?.initialValue;
describe('resolveProcessorFields', () => {
it.each(['grok_parser', 'regex_parser', 'json_parser'])(
'defaults %s parse_from to body.message when use_json_body is on',
(processorType) => {
expect(parseFromDefault(resolveProcessorFields(processorType, true))).toBe(
'body.message',
);
},
);
it.each(['grok_parser', 'regex_parser', 'json_parser'])(
'keeps %s parse_from as body when use_json_body is off',
(processorType) => {
expect(parseFromDefault(resolveProcessorFields(processorType, false))).toBe(
'body',
);
},
);
it('leaves parse_from defaults that do not point at the body alone', () => {
expect(parseFromDefault(resolveProcessorFields('time_parser', true))).toBe(
'attributes.timestamp',
);
expect(
parseFromDefault(resolveProcessorFields('severity_parser', true)),
).toBe('attributes.logLevel');
});
it('does not mutate the shared config', () => {
resolveProcessorFields('grok_parser', true);
expect(parseFromDefault(processorFields.grok_parser)).toBe('body');
});
it('returns an empty list for an unknown processor type', () => {
expect(resolveProcessorFields('does_not_exist', true)).toStrictEqual([]);
});
});

View File

@@ -1,88 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useBaseAggregateOptions from '../useBaseAggregateOptions';
const mockGetUpdatedQuery = jest.fn();
const mockNotificationsError = jest.fn();
jest.mock('container/GridCardLayout/useResolveQuery', () => ({
__esModule: true,
default: (): unknown => ({
getUpdatedQuery: mockGetUpdatedQuery,
isLoading: false,
}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): unknown => ({
notifications: { error: mockNotificationsError },
}),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): unknown => ({ dashboardData: undefined }),
}));
jest.mock('hooks/dashboard/useContextVariables', () => ({
__esModule: true,
default: (): unknown => ({ processedVariables: {} }),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): unknown => ({ safeNavigate: jest.fn() }),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({ pathname: '/services/socky-api' }),
}));
const QUERY = {
builder: {
queryData: [{ queryName: 'A', dataSource: 'traces', aggregations: [] }],
},
} as unknown as Query;
const AGGREGATE_DATA = { queryName: 'A', filters: [] };
const renderOptions = (): ReturnType<typeof renderHook> =>
renderHook(() =>
useBaseAggregateOptions({
query: QUERY,
onClose: jest.fn(),
subMenu: '',
setSubMenu: jest.fn(),
aggregateData: AGGREGATE_DATA,
fieldVariables: {},
}),
);
describe('useBaseAggregateOptions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('notifies and keeps the unresolved query when variable resolution fails', async () => {
mockGetUpdatedQuery.mockRejectedValue(
new Error('syntax errors in expression'),
);
renderOptions();
await waitFor(() =>
expect(mockNotificationsError).toHaveBeenCalledWith({
message: 'Unable to resolve variables',
}),
);
});
it('does not notify when variable resolution succeeds', async () => {
mockGetUpdatedQuery.mockResolvedValue(QUERY);
renderOptions();
await waitFor(() => expect(mockGetUpdatedQuery).toHaveBeenCalled());
expect(mockNotificationsError).not.toHaveBeenCalled();
});
});

View File

@@ -6,7 +6,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useNotifications } from 'hooks/useNotifications';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
@@ -51,25 +50,23 @@ const useBaseAggregateOptions = ({
const { getUpdatedQuery, isLoading: isResolveQueryLoading } =
useUpdatedQuery();
const { dashboardData } = useDashboardStore();
const { notifications } = useNotifications();
useEffect(() => {
if (!aggregateData) {
return;
}
getUpdatedQuery({
widgetConfig: {
query,
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
})
.then(setResolvedQuery)
.catch(() => {
setResolvedQuery(query);
notifications.error({ message: 'Unable to resolve variables' });
const resolveQuery = async (): Promise<void> => {
const updatedQuery = await getUpdatedQuery({
widgetConfig: {
query,
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
});
setResolvedQuery(updatedQuery);
};
resolveQuery();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, aggregateData, panelType]);

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -24,7 +24,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -5,7 +5,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper, createMockMoment } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -54,7 +54,7 @@ import {
Time,
TimeRange,
} from './types';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { getUnstableCurrentSearchParams } from './utils/getUnstableCurrentSearchParams';
import './DateTimeSelectionV2.styles.scss';

View File

@@ -9,7 +9,6 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
(): TooltipContentItem[] =>
buildTooltipContent({
data: props.uPlotInstance.data,
unstackedData: props.unstackedData,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
@@ -22,7 +21,6 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
}),
[
props.uPlotInstance,
props.unstackedData,
props.seriesIndex,
props.dataIndexes,
props.yAxisUnit,

View File

@@ -11,7 +11,6 @@ export default function TimeSeriesTooltip(
(): TooltipContentItem[] =>
buildTooltipContent({
data: props.uPlotInstance.data,
unstackedData: props.unstackedData,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
@@ -23,7 +22,6 @@ export default function TimeSeriesTooltip(
}),
[
props.uPlotInstance,
props.unstackedData,
props.seriesIndex,
props.dataIndexes,
props.yAxisUnit,

View File

@@ -72,35 +72,6 @@ describe('Tooltip utils', () => {
expect(result).toBe(20);
});
it('reports the pre-stack value, identically for normal and percent', () => {
const unstackedData: AlignedData = [[0], [30], [10]];
const series = [{}, { show: true }, { show: true }] as Series[];
const read = (data: AlignedData): number | null =>
getTooltipBaseValue({
data,
unstackedData,
index: 1,
dataIndex: 0,
isStackedBarChart: true,
series,
});
expect(read([[0], [40], [10]])).toBe(30);
expect(read([[0], [100], [25]])).toBe(30);
});
it('falls back to subtraction when no pre-stack data is given', () => {
const result = getTooltipBaseValue({
data: [[0], [40], [10]],
index: 1,
dataIndex: 0,
isStackedBarChart: true,
series: [{}, { show: true }, { show: true }] as Series[],
});
expect(result).toBe(30);
});
it('returns null when value is missing', () => {
const data: AlignedData = [
[0, 1],

View File

@@ -23,25 +23,17 @@ export function resolveSeriesColor(
export function getTooltipBaseValue({
data,
unstackedData,
index,
dataIndex,
isStackedBarChart,
series,
}: {
data: AlignedData;
unstackedData?: AlignedData;
index: number;
dataIndex: number;
isStackedBarChart?: boolean;
series?: Series[];
}): number | null {
// The subtraction below only recovers the raw value under `normal` stacking.
const unstackedSeries = unstackedData?.[index];
if (unstackedSeries) {
return unstackedSeries[dataIndex] ?? null;
}
let baseValue = data[index][dataIndex] ?? null;
// Top-down stacking (first series at top): raw = stacked[i] - stacked[nextVisible].
// When series are hidden, we must use the next *visible* series, not index+1,
@@ -64,7 +56,6 @@ export function getTooltipBaseValue({
export function buildTooltipContent({
data,
unstackedData,
series,
dataIndexes,
activeSeriesIndex,
@@ -76,7 +67,6 @@ export function buildTooltipContent({
syncFilterMode,
}: {
data: AlignedData;
unstackedData?: AlignedData;
series: Series[];
dataIndexes: Array<number | null>;
activeSeriesIndex: number | null;
@@ -125,7 +115,6 @@ export function buildTooltipContent({
const baseValue = getTooltipBaseValue({
data,
unstackedData,
index: seriesIndex,
dataIndex,
isStackedBarChart,

View File

@@ -69,11 +69,6 @@ export interface TooltipRenderArgs {
syncedSeriesIndexes?: number[] | null;
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
syncFilterMode?: SyncTooltipFilterMode;
/**
* Pre-stack values, injected by `ChartWrapper`. `Percent` discards the column total,
* so the raw value cannot be recovered from the plot's own cumulative data.
*/
unstackedData?: uPlot.AlignedData;
}
export interface IRenderTooltipFooterArgs {

View File

@@ -20,7 +20,6 @@ import {
ConfigBuilderProps,
LegendItem,
SelectionPreferencesSource,
StackMode,
} from './types';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
@@ -29,11 +28,6 @@ import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
/**
* Type definitions for uPlot option objects
*/
/** Renders a 0100 number as `50%`, unlike the 01 `percentunit`. */
const PERCENT_AXIS_UNIT = 'percent';
const PERCENT_AXIS_MAX = 100;
type LegendConfig = {
show?: boolean;
live?: boolean;
@@ -63,8 +57,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private bands: uPlot.Band[] = [];
private stack: StackMode = StackMode.None;
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
@@ -151,15 +143,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.axes[scaleKey] = new UPlotAxisBuilder(props);
}
/** Drives the fill bands, the percent axis unit and the percent range below. */
setStack(stack: StackMode): void {
this.stack = stack;
}
getStackMode(): StackMode {
return this.stack;
}
/**
* Add or merge a scale configuration
*/
@@ -228,41 +211,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.bands = bands;
}
/**
* The panel's own limits are in the source unit, which means nothing once values are
* normalised. Soft rather than hard, so mixed-sign shares outside 0100 stay visible.
*/
private resolveScale(scale: UPlotScaleBuilder): UPlotScaleBuilder {
if (this.stack !== StackMode.Percent || scale.props.scaleKey !== 'y') {
return scale;
}
return new UPlotScaleBuilder({
...scale.props,
min: undefined,
max: undefined,
softMin: 0,
softMax: PERCENT_AXIS_MAX,
// Thresholds still draw, but a 500ms one must not stretch the axis to 0500.
thresholds: undefined,
});
}
/** Explicit bands win; otherwise a stack fills between consecutive series. */
private resolveBands(): uPlot.Band[] | undefined {
if (this.bands.length > 0) {
return this.bands;
}
if (this.stack === StackMode.None || this.series.length < 2) {
return undefined;
}
return (
this.series
.slice(0, -1)
// uPlot series are 1-based (index 0 is the timestamp axis).
.map((_, index) => ({ series: [index + 1, index + 2] as [number, number] }))
);
}
/**
* Set cursor configuration
*/
@@ -496,19 +444,9 @@ export class UPlotConfigBuilder extends ConfigBuilder<
};
}),
];
config.axes = Object.entries(this.axes).map(([scaleKey, axis]) => {
if (scaleKey !== 'y' || this.stack !== StackMode.Percent) {
return axis.getConfig();
}
// Ticks read as percentages; the panel unit still applies to tooltips and
// thresholds, so build from a copy rather than touching the axis props.
return new UPlotAxisBuilder({
...axis.props,
yAxisUnit: PERCENT_AXIS_UNIT,
}).getConfig();
});
config.axes = Object.values(this.axes).map((a) => a.getConfig());
config.scales = this.scales.reduce(
(acc, s) => ({ ...acc, ...this.resolveScale(s).getConfig() }),
(acc, s) => ({ ...acc, ...s.getConfig() }),
{} as Record<string, uPlot.Scale>,
);
@@ -518,7 +456,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
config.cursor = this.getCursorConfig();
config.tzDate = this.tzDate;
config.plugins = this.plugins.length > 0 ? this.plugins : undefined;
config.bands = this.resolveBands();
config.bands = this.bands.length > 0 ? this.bands : undefined;
if (Array.isArray(this.padding)) {
config.padding = this.padding;

View File

@@ -5,7 +5,7 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../../constants';
import type { SeriesProps } from '../types';
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
import { DrawStyle, SelectionPreferencesSource } from '../types';
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
// Mock only the real boundary that hits localStorage
@@ -496,161 +496,3 @@ describe('UPlotConfigBuilder', () => {
expect(config.bands).toBeUndefined();
});
});
describe('UPlotConfigBuilder stacking', () => {
beforeEach(() => {
jest.clearAllMocks();
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([]);
});
/**
* Soft limits end up captured in the scale's range closure, so the only way to read
* them back is to run it and inspect the range config it hands uPlot.
*/
function scaleSoftLimits(
builder: UPlotConfigBuilder,
scaleKey: string,
): { min: number; max: number } {
const rangeNum = jest.fn().mockReturnValue([0, 0]);
(uPlot as unknown as { rangeNum: unknown }).rangeNum = rangeNum;
const range = builder.getConfig().scales?.[scaleKey]?.range as (
u: unknown,
min: number,
max: number,
key: string,
) => void;
range({ scales: { [scaleKey]: { distr: 1 } } }, 40, 60, scaleKey);
const [, , rangeConfig] = rangeNum.mock.calls[0] as [
number,
number,
{ min: { soft: number }; max: { soft: number } },
];
return { min: rangeConfig.min.soft, max: rangeConfig.max.soft };
}
/** Renders y-axis ticks the way uPlot would, so unit formatting is observable. */
function yAxisTicks(builder: UPlotConfigBuilder, ticks: number[]): string[] {
const yAxis = builder.getConfig().axes?.find((a) => a.scale === 'y');
const values = yAxis?.values as (
u: unknown,
splits: number[],
) => (string | null)[];
return values(null, ticks).map((v) => String(v));
}
function builderFor(stack?: StackMode, seriesCount = 3): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder({ id: 'stack-test' });
if (stack) {
builder.setStack(stack);
}
builder.addAxis({ scaleKey: 'y', show: true, side: 3, yAxisUnit: 'ms' });
for (let i = 0; i < seriesCount; i++) {
builder.addSeries({
scaleKey: 'y',
label: `S${i}`,
drawStyle: DrawStyle.Bar,
colorMapping: {},
isDarkMode: false,
} as SeriesProps);
}
return builder;
}
it('defaults to no stacking, so no bands and the panel unit on the axis', () => {
const builder = builderFor();
expect(builder.getStackMode()).toBe('none');
expect(builder.getConfig().bands).toBeUndefined();
expect(yAxisTicks(builder, [1000])).toStrictEqual(['1 s']);
});
it('derives one band per adjacent series pair once a stack is declared', () => {
expect(builderFor(StackMode.Normal).getConfig().bands).toStrictEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('emits no bands for a single series', () => {
expect(builderFor(StackMode.Normal, 1).getConfig().bands).toBeUndefined();
});
it('keeps the panel unit on the axis for a normal stack', () => {
expect(yAxisTicks(builderFor(StackMode.Normal), [1000])).toStrictEqual([
'1 s',
]);
});
it('formats the axis as percentages for a percent stack', () => {
expect(yAxisTicks(builderFor(StackMode.Percent), [0, 50, 100])).toStrictEqual(
['0%', '50%', '100%'],
);
});
it('leaves other axes on their own unit under a percent stack', () => {
const builder = builderFor(StackMode.Percent);
builder.addAxis({ scaleKey: 'x', show: true, side: 2 });
expect(builder.getConfig().axes?.map((a) => a.scale)).toStrictEqual([
'y',
'x',
]);
});
it('pins the y scale to the 0100 band under a percent stack, dropping panel limits', () => {
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
builder.setStack(StackMode.Percent);
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
// Soft, not hard: mixed-sign shares fall outside 0100 and must stay visible.
expect(builder.getConfig().scales?.y).toMatchObject({ auto: true });
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
});
it('leaves the panel limits alone when the stack is not percent', () => {
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
builder.setStack(StackMode.Normal);
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 5, max: 500 });
});
it.each([StackMode.Normal, StackMode.Percent])(
'draws thresholds under a %s stack',
(stack) => {
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
builder.setStack(stack);
builder.addThresholds({
scaleKey: 'y',
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
yAxisUnit: 'ms',
});
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
},
);
it('keeps a source-unit threshold from stretching the percent band', () => {
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
builder.setStack(StackMode.Percent);
const thresholds = {
scaleKey: 'y',
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
yAxisUnit: 'ms',
};
builder.addThresholds(thresholds);
builder.addScale({ scaleKey: 'y', thresholds });
// Without this the 500ms threshold would widen a percentage axis to 0500.
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
});
it('lets explicit bands win over the derived ones', () => {
const builder = builderFor(StackMode.Normal);
builder.setBands([{ series: [1, 3] }]);
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
});
});

View File

@@ -33,13 +33,6 @@ export enum SelectionPreferencesSource {
/**
* Props for configuring the uPlot config builder
*/
/** `Percent` rescales each x-slice to its column total, so every column fills to 100. */
export enum StackMode {
None = 'none',
Normal = 'normal',
Percent = 'percent',
}
export interface ConfigBuilderProps {
id: string;
onDragSelect?: (startTime: number, endTime: number) => void;

View File

@@ -281,20 +281,3 @@ describe('dataUtils', () => {
});
});
});
describe('insertLargeGapNullsIntoAlignedData index alignment', () => {
// ChartWrapper gap-processes the pre-stack series to keep tooltip indices aligned;
// that only holds because insertions are decided from the x axis, never from y.
it('inserts at the same positions regardless of the y values', () => {
const x = [0, 100, 200];
const options = [{ spanGaps: 50 }];
const raw = [x, [1, 2, 3]] as uPlot.AlignedData;
const stacked = [x, [10, 20, 30]] as uPlot.AlignedData;
const fromRaw = insertLargeGapNullsIntoAlignedData(raw, options);
const fromStacked = insertLargeGapNullsIntoAlignedData(stacked, options);
expect(fromRaw[0]).toStrictEqual(fromStacked[0]);
expect(fromRaw[1]).toHaveLength((fromStacked[1] as unknown[]).length);
});
});

View File

@@ -189,8 +189,7 @@ function DashboardActions({
onClick: (): void => void handleClone(),
});
}
if (canEditDashboard && (isAuthor || user.role === USER_ROLES.ADMIN)) {
if (isAuthor || user.role === USER_ROLES.ADMIN) {
dashboardGroup.push({
key: 'lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',

View File

@@ -7,7 +7,6 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import {
flattenTimeSeries,
getExecStats,
@@ -220,9 +219,7 @@ function BarPanelRenderer({
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
stack={
spec.visualization?.stackedBarChart ? StackMode.Normal : StackMode.None
}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={enableDrillDown ? handleChartClick : undefined}
/>

View File

@@ -1,6 +1,7 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
@@ -100,6 +101,12 @@ function addSeries({
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
if (spec.visualization?.stackedBarChart) {
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
// `+1` keeps the band targets aligned with the series we're about to add.
builder.setBands(getInitialStackedBands(series.length + 1));
}
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);

View File

@@ -46,11 +46,23 @@ beforeAll(() => {
});
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest
@@ -192,12 +204,9 @@ function Harness(): JSX.Element {
);
}
const INITIAL_ROUTE = '/dashboard/dash-1';
const renderHarness = (): void => {
window.history.replaceState(null, '', INITIAL_ROUTE);
render(
<MemoryRouter initialEntries={[INITIAL_ROUTE]}>
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>

View File

@@ -16,11 +16,23 @@ import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
@@ -138,12 +150,9 @@ function Harness(): JSX.Element {
);
}
const INITIAL_ROUTE = '/dashboard/dash-1';
const renderHarness = (): void => {
window.history.replaceState(null, '', INITIAL_ROUTE);
render(
<MemoryRouter initialEntries={[INITIAL_ROUTE]}>
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>

View File

@@ -14,11 +14,23 @@ import { useViewPanelMode } from '../ViewPanelModal/useViewPanelMode';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
@@ -172,12 +184,9 @@ function Harness(): JSX.Element {
);
}
const INITIAL_ROUTE = '/dashboard/dash-1';
const renderHarness = (): void => {
window.history.replaceState(null, '', INITIAL_ROUTE);
render(
<MemoryRouter initialEntries={[INITIAL_ROUTE]}>
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryBuilderProvider>
<Harness />

View File

@@ -19,20 +19,11 @@ import { resolveDashboardImage } from 'pages/DashboardPageV2/DashboardContainer/
interface DashboardContainerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
/**
* @deprecated
* `canEditDashboardOverride` is a temporary solution to allow the dashboard to be view only.
* This is only used for LLM Observability.
* It will be removed in the future.
* TODO: @Ashwin / @Abhi — remove when the final solution is implemented.
*/
canEditDashboardOverride?: boolean;
}
function DashboardContainer({
dashboard,
refetch,
canEditDashboardOverride,
}: DashboardContainerProps): JSX.Element {
const spec = dashboard.spec;
const image = resolveDashboardImage(dashboard.image);
@@ -54,11 +45,10 @@ function DashboardContainer({
// Seed during render (not an effect) so the first Panel render already sees the id —
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
const setEditContext = useDashboardStore((s) => s.setEditContext);
setEditContext({
dashboardId: dashboard.id,
isLocked,
canEditDashboard: canEditDashboardOverride ?? canEditDashboard,
canEditDashboard,
refetch,
});

View File

@@ -37,6 +37,7 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
@@ -65,7 +66,6 @@ import {
} from 'types/common/queryBuilder';
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
import { v4 as uuid } from 'uuid';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
export const QueryBuilderContext = createContext<QueryBuilderContextType>({
currentQuery: initialQueriesMap.metrics,
@@ -105,6 +105,7 @@ export const QueryBuilderContext = createContext<QueryBuilderContextType>({
export function QueryBuilderProvider({
children,
}: PropsWithChildren): JSX.Element {
const urlQuery = useUrlQuery();
const location = useLocation();
const currentPathnameRef = useRef<string | null>(location.pathname);
@@ -121,7 +122,7 @@ export function QueryBuilderProvider({
null,
);
const panelTypeQueryParams = getUnstableCurrentSearchParams().get(
const panelTypeQueryParams = urlQuery.get(
QueryParams.panelTypes,
) as PANEL_TYPES | null;
@@ -975,7 +976,6 @@ export function QueryBuilderProvider({
unit: query.unit || initialQueryState.unit,
};
const urlQuery = getUnstableCurrentSearchParams();
const pagination = urlQuery.get(QueryParams.pagination);
if (pagination) {
@@ -1014,7 +1014,7 @@ export function QueryBuilderProvider({
safeNavigate(generatedUrl, { newTab });
},
[location.pathname, safeNavigate],
[location.pathname, safeNavigate, urlQuery],
);
const handleSetConfig = useCallback(

View File

@@ -1,54 +0,0 @@
// Mock factory for suites that need `useSafeNavigate` to navigate for real.
//
// `jest.config.ts` maps every `hooks/useSafeNavigate` import to the no-op
// `__tests__/safeNavigateMock.ts`, so a suite that drives navigation has to opt
// out with its own `jest.mock`.
//
// In production `safeNavigate` goes through `createBrowserHistory`, which writes
// `window.location` as well as notifying the router. `MemoryRouter` never touches
// `window`, so anything reading `getUnstableCurrentSearchParams()` sees an empty
// search and drops the params the test just navigated with. This mock writes both.
//
// The `jest.mock` factory is hoisted above imports, so require it inside:
//
// jest.mock('hooks/useSafeNavigate', () =>
// jest
// .requireActual('tests/browser-history-safe-navigate')
// .createBrowserHistorySafeNavigateMock(),
// );
import type { History } from 'history';
interface SafeNavigateOptions {
replace?: boolean;
}
interface UseSafeNavigateModule {
useSafeNavigate: () => {
safeNavigate: (to: string, options?: SafeNavigateOptions) => void;
};
}
export function createBrowserHistorySafeNavigateMock(): UseSafeNavigateModule {
const { useHistory } = jest.requireActual<{ useHistory: () => History }>(
'react-router-dom',
);
return {
useSafeNavigate: () => {
const history = useHistory();
return {
safeNavigate: (to: string, options?: SafeNavigateOptions): void => {
if (options?.replace) {
window.history.replaceState(null, '', to);
history.replace(to);
} else {
window.history.pushState(null, '', to);
history.push(to);
}
},
};
},
};
}

View File

@@ -41,6 +41,6 @@ type OmitAttributesResources = Pick<
export type ILogAggregateAttributesResources = OmitAttributesResources & {
attributes: Record<string, never>;
resource: Record<string, never>;
resources: Record<string, never>;
scope: Record<string, never>;
};

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
@@ -185,7 +186,18 @@ func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, er
}
}
resp, err := notify.PostJSON(ctx, n.client, n.conf.WebhookURL.String(), buf) //nolint:bodyclose
// Thread same-rule alerts together: threadKey is a stable hash of the
// alert group key. Changing a rule's grouping starts a new thread.
u, err := url.Parse(n.conf.WebhookURL.String())
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse google chat webhook url")
}
q := u.Query()
q.Set("threadKey", key.Hash())
q.Set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD")
u.RawQuery = q.Encode()
resp, err := notify.PostJSON(ctx, n.client, u.String(), buf) //nolint:bodyclose
if err != nil {
return true, notify.RedactURL(err)
}

View File

@@ -245,7 +245,7 @@ func TestGoogleChatMessageSizeLimit(t *testing.T) {
assert.LessOrEqual(t, bodyLen, maxMessageBytes, "posted body must be within the size limit")
}
func TestGoogleChatWebhookURLVerbatim(t *testing.T) {
func TestGoogleChatThreading(t *testing.T) {
var query url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.Query()
@@ -253,11 +253,25 @@ func TestGoogleChatWebhookURLVerbatim(t *testing.T) {
}))
defer server.Close()
n := newTestNotifier(t, server.URL+"?key=abc&token=xyz", "T", "")
_, err := n.Notify(newTestContext(), newTestAlerts("X")...)
require.NoError(t, err)
cases := []struct{ name, groupKey string }{
{"rule a", "{ruleId=\"aaa\"}"},
{"rule b", "{ruleId=\"bbb\"}"},
}
seen := map[string]string{}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n := newTestNotifier(t, server.URL, "T", "")
ctx := notify.WithGroupKey(context.Background(), c.groupKey)
_, err := n.Notify(ctx, newTestAlerts("X")...)
require.NoError(t, err)
assert.Equal(t, url.Values{"key": {"abc"}, "token": {"xyz"}}, query, "configured webhook URL must be posted verbatim, with no params added")
assert.Equal(t, "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD", query.Get("messageReplyOption"))
threadKey := query.Get("threadKey")
assert.Equal(t, notify.Key(c.groupKey).Hash(), threadKey, "threadKey must be the group key hash")
seen[c.name] = threadKey
})
}
assert.NotEqual(t, seen["rule a"], seen["rule b"], "distinct group keys must yield distinct threadKeys")
}
func TestGoogleChatCustomTemplateMarkdown(t *testing.T) {

View File

@@ -51,28 +51,6 @@
},
"name": "Region"
}
},
{
"kind": "ListVariable",
"spec": {
"display": {
"name": "FunctionName",
"description": "Name of the Lambda function"
},
"allowAllValue": true,
"allowMultiple": true,
"customAllValue": "",
"capturingRegexp": "",
"sort": "none",
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {
"name": "FunctionName",
"signal": "metrics"
}
},
"name": "FunctionName"
}
}
],
"panels": {
@@ -140,7 +118,7 @@
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
},
"groupBy": [
{
@@ -240,7 +218,7 @@
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
},
"groupBy": [
{
@@ -340,7 +318,7 @@
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
},
"groupBy": [
{
@@ -440,7 +418,7 @@
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
},
"groupBy": [
{
@@ -540,7 +518,7 @@
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
},
"groupBy": [
{
@@ -640,7 +618,7 @@
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
},
"groupBy": [
{
@@ -740,7 +718,7 @@
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS)"
},
"groupBy": [
{
@@ -853,4 +831,4 @@
"refreshInterval": "",
"links": []
}
}
}

View File

@@ -88,5 +88,5 @@ func (m *fieldMapper) ColumnExpressionFor(ctx context.Context, orgID valuer.UUID
if err != nil {
return "", err
}
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(colName), field.Name), nil
return fmt.Sprintf("%s AS %s", sqlbuilder.Escape(colName), querybuilder.ClickHouseIdentifier(field.Name)), nil
}

View File

@@ -29,7 +29,6 @@ type builderQuery[T any] struct {
telemetryStore telemetrystore.TelemetryStore
orgID valuer.UUID
stmtBuilder qbtypes.StatementBuilder[T]
queryType qbtypes.QueryType
spec qbtypes.QueryBuilderQuery[T]
variables map[string]qbtypes.VariableItem
@@ -52,7 +51,6 @@ func newBuilderQuery[T any](
telemetryStore telemetrystore.TelemetryStore,
orgID valuer.UUID,
stmtBuilder qbtypes.StatementBuilder[T],
queryType qbtypes.QueryType,
spec qbtypes.QueryBuilderQuery[T],
tr qbtypes.TimeRange,
kind qbtypes.RequestType,
@@ -64,7 +62,6 @@ func newBuilderQuery[T any](
telemetryStore: telemetryStore,
orgID: orgID,
stmtBuilder: stmtBuilder,
queryType: queryType,
spec: spec,
variables: variables,
fromMS: tr.From,
@@ -84,7 +81,7 @@ func (q *builderQuery[T]) Fingerprint() string {
// Create a deterministic fingerprint for builder queries
// This needs to include all fields that affect the query results
parts := []string{q.queryType.StringValue()}
parts := []string{"builder"}
// Add signal type
parts = append(parts, fmt.Sprintf("signal=%s", q.spec.Signal.StringValue()))

View File

@@ -3,7 +3,6 @@ package querier
import (
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -21,8 +20,7 @@ func TestBuilderQueryFingerprint(t *testing.T) {
{
name: "fingerprint includes shiftby when ShiftBy field is set",
query: &builderQuery[qbtypes.MetricAggregation]{
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
kind: qbtypes.RequestTypeTimeSeries,
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
ShiftBy: 3600,
@@ -42,8 +40,7 @@ func TestBuilderQueryFingerprint(t *testing.T) {
{
name: "fingerprint includes shiftby but not other functions",
query: &builderQuery[qbtypes.MetricAggregation]{
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
kind: qbtypes.RequestTypeTimeSeries,
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
ShiftBy: 3600,
@@ -66,8 +63,7 @@ func TestBuilderQueryFingerprint(t *testing.T) {
{
name: "no shiftby in fingerprint when ShiftBy is zero",
query: &builderQuery[qbtypes.MetricAggregation]{
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
kind: qbtypes.RequestTypeTimeSeries,
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
ShiftBy: 0,
@@ -98,29 +94,6 @@ func TestBuilderQueryFingerprint(t *testing.T) {
}
}
func TestBuilderQueryFingerprintQueryType(t *testing.T) {
spec := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model EXISTS"},
}
regular := &builderQuery[qbtypes.TraceAggregation]{
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
spec: spec,
}
ai := &builderQuery[qbtypes.TraceAggregation]{
queryType: qbtypes.QueryTypeBuilderAI,
kind: qbtypes.RequestTypeTimeSeries,
spec: spec,
}
assert.True(t, strings.HasPrefix(regular.Fingerprint(), qbtypes.QueryTypeBuilder.StringValue()+"&"))
assert.True(t, strings.HasPrefix(ai.Fingerprint(), qbtypes.QueryTypeBuilderAI.StringValue()+"&"))
assert.NotEqual(t, regular.Fingerprint(), ai.Fingerprint())
}
func TestMakeBucketsOrder(t *testing.T) {
// Test that makeBuckets returns buckets in reverse chronological order by default
// Using milliseconds as input - need > 1 hour range to get multiple buckets

View File

@@ -305,7 +305,7 @@ func (q *querier) buildQueries(
}
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryTypeBuilder:
@@ -313,7 +313,7 @@ func (q *querier) buildQueries(
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
@@ -323,7 +323,7 @@ func (q *querier) buildQueries(
if spec.Source == telemetrytypes.SourceAudit {
stmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -340,9 +340,9 @@ func (q *querier) buildQueries(
if spec.Source == telemetrytypes.SourceMeter {
event.Source = telemetrytypes.SourceMeter.StringValue()
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
} else {
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
}
queries[spec.Name] = bq
@@ -618,7 +618,7 @@ func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qb
if spec.Source == telemetrytypes.SourceAudit {
liveTailStmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, liveTailStmtBuilder, query.Type, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, liveTailStmtBuilder, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
"id": {
Value: updatedLogID,
},
@@ -941,9 +941,8 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
// reuse the original query's statement builder and type so an AI query
// keeps its AI builder and cache key
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, qt.builderConfig)
// reuse the original query's statement builder so an AI query keeps its AI builder
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
case *builderQuery[qbtypes.LogAggregation]:
specCopy := qt.spec.Copy()
@@ -953,16 +952,16 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
if qt.spec.Source == telemetrytypes.SourceAudit {
shiftStmtBuilder = q.auditStmtBuilder
}
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
case *builderQuery[qbtypes.MetricAggregation]:
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
if qt.spec.Source == telemetrytypes.SourceMeter {
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
}
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
case *traceOperatorQuery:
specCopy := qt.spec.Copy()
return &traceOperatorQuery{

View File

@@ -43,7 +43,7 @@ func ExistsExpression(columns []*schema.Column, key *telemetrytypes.TelemetryFie
if len(evolutionsEntries) > 0 && evolutionsEntries[0] != nil {
columnName = evolutionsEntries[0].ColumnName
}
rawPath := fmt.Sprintf("%s.`%s`", columnName, key.Name)
rawPath := fmt.Sprintf("%s.%s", columnName, ClickHouseIdentifier(key.Name))
if exists {
return rawPath + " IS NOT NULL", nil
}
@@ -88,7 +88,7 @@ func ExistsExpression(columns []*schema.Column, key *telemetrytypes.TelemetryFie
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", column.Name, key.Name)
leftOperand := fmt.Sprintf("mapContains(%s, %s)", column.Name, ClickHouseStringLiteral(key.Name))
if key.Materialized {
leftOperand = telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
}

View File

@@ -242,7 +242,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewRestructureAuthDomainConfigFactory(sqlstore),
sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore),
sqlmigration.NewDeleteOrphanUserRolesFactory(),
sqlmigration.NewMigrateLambdaDashboardsFactory(),
)
}

View File

@@ -1,160 +0,0 @@
package sqlmigration
import (
"bytes"
"context"
"embed"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
//go:embed 116_migrate_lambda_dashboards
var lambdaDashboardFiles embed.FS
// These values mirror the cloud integration and dashboard packages but are duplicated
// here so this migration keeps targeting and writing the same rows even if those
// constants are later renamed or changed.
const (
lambdaDashboardFile = "116_migrate_lambda_dashboards/aws/lambda/overview.json"
lambdaDashboardSlug = "aws-lambda-overview"
cloudIntegrationDashboardProvider = "cloud_integration"
integrationDashboardSource = "integration"
dashboardSchemaVersion = "v6"
)
type migrateLambdaDashboards struct{}
type lambdaDashboardRow struct {
bun.BaseModel `bun:"table:dashboard,alias:dashboard"`
ID string `bun:"id"`
Data string `bun:"data"`
}
// lambdaDashboardDefinition is the part of the embedded dashboard this migration reads:
// its spec, which is what the cloud integration stores under data.spec.
type lambdaDashboardDefinition struct {
Spec map[string]any `json:"spec"`
}
func NewMigrateLambdaDashboardsFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_lambda_dashboards"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateLambdaDashboards{}, nil
},
)
}
func (m *migrateLambdaDashboards) Register(migrations *migrate.Migrations) error {
return migrations.Register(m.Up, m.Down)
}
// Up rewrites the spec of every provisioned AWS Lambda overview dashboard to the
// embedded revision that added the FunctionName variable. Cloud integration dashboards
// are provisioned once and never updated afterwards, so existing installs only pick up
// this change through a migration. Only the spec is replaced; the row keeps its id, name,
// tags and metadata, so the dashboard is updated in place rather than recreated.
func (m *migrateLambdaDashboards) Up(ctx context.Context, db *bun.DB) error {
spec, err := m.loadSpec()
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*lambdaDashboardRow
if err := tx.NewSelect().
Model(&rows).
Join("JOIN integration_dashboard AS id ON id.dashboard_id = dashboard.id").
Where("id.provider = ?", cloudIntegrationDashboardProvider).
Where("id.slug = ?", lambdaDashboardSlug).
Where("dashboard.source = ?", integrationDashboardSource).
Scan(ctx); err != nil {
return err
}
for _, row := range rows {
data := map[string]any{}
if err := json.Unmarshal([]byte(row.Data), &data); err != nil {
return err
}
// The embedded spec is v6-shaped, so only rewrite a row already carrying a v6 spec;
// anything else is left alone rather than turned into a broken mix of versions.
if !m.hasV6Spec(data) {
continue
}
data["spec"] = spec
encoded, err := m.marshalUnescaped(data)
if err != nil {
return err
}
// Skip rows already carrying this spec so a re-run does not needlessly rewrite them.
if string(encoded) == row.Data {
continue
}
if _, err := tx.NewUpdate().
Model((*lambdaDashboardRow)(nil)).
Set("data = ?", string(encoded)).
Set("updated_at = ?", time.Now()).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (m *migrateLambdaDashboards) Down(context.Context, *bun.DB) error {
return nil
}
// hasV6Spec reports whether the stored data is a v6 dashboard with a spec object, which
// is the shape whose spec this migration replaces.
func (m *migrateLambdaDashboards) hasV6Spec(data map[string]any) bool {
metadata, _ := data["metadata"].(map[string]any)
version, _ := metadata["schemaVersion"].(string)
if version != dashboardSchemaVersion {
return false
}
_, ok := data["spec"].(map[string]any)
return ok
}
func (m *migrateLambdaDashboards) marshalUnescaped(v any) ([]byte, error) {
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(v); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
func (m *migrateLambdaDashboards) loadSpec() (map[string]any, error) {
raw, err := lambdaDashboardFiles.ReadFile(lambdaDashboardFile)
if err != nil {
return nil, err
}
var dashboard lambdaDashboardDefinition
if err := json.Unmarshal(raw, &dashboard); err != nil {
return nil, err
}
return dashboard.Spec, nil
}

View File

@@ -1,856 +0,0 @@
{
"schemaVersion": "v6",
"image": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAwcHgiIGhlaWdodD0iODAwcHgiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjRkE3RTE0IiBkPSJNNy45ODMgOC4zN2MtLjA1My4wNzMtLjA5OC4xMzMtLjE0MS4xOTRMNS43NzUgMTEuNWMtLjY0LjkxLTEuMjgyIDEuODItMS45MjQgMi43M2EuMTI4LjEyOCAwIDAxLS4wOTIuMDUxYy0uOTA2LS4wMDctMS44MTMtLjAxNy0yLjcxOS0uMDI4LS4wMSAwLS4wMi0uMDAzLS4wNC0uMDA2YS40NTUuNDU1IDAgMDEuMDI1LS4wNTMgMTM5NzcuNDk2IDEzOTc3LjQ5NiAwIDAxNS40NDYtOC4xNDZjLjA5Mi0uMTM4LjE4OC0uMjczLjI3NS0uNDEzYS4xNjUuMTY1IDAgMDAuMDE4LS4xMjRjLS4xNjctLjUxNS0uMzM4LTEuMDMtLjUwOC0xLjU0My0uMDczLS4yMi0uMTUtLjQ0LS4yMTgtLjY2LS4wMjItLjA3Mi0uMDU5LS4wOTQtLjEzNC0uMDkzLS41Ny4wMDItMS4xMzYuMDAxLTEuNzA0LjAwMS0uMTA4IDAtLjEwOCAwLS4xMDgtLjEwMyAwLS42NzQgMC0xLjM0Ny0uMDAyLTIuMDIxIDAtLjA3NS4wMjYtLjA5Mi4wOTktLjA5MiAxLjE0My4wMDIgMi4yODYuMDAyIDMuNDMgMGEuMTEzLjExMyAwIDAxLjA3Ni4wMTcuMTA3LjEwNyAwIDAxLjA0NS4wNjEgMTgyNjYuMTg0IDE4MjY2LjE4NCAwIDAwMy45MiA5LjUxYy4yMTguNTMuNDM4IDEuMDU5LjY1NCAxLjU5LjAyNi4wNjQuMDUzLjA3Ni4xMi4wNTYuNi0uMTc4IDEuMi0uMzUyIDEuOC0uNTMxLjA3NS0uMDIzLjEwMi0uMDA4LjEyNi4wNjQuMjA0LjYyLjQxMiAxLjIzOS42MiAxLjg1OGwuMDIuMDczYy0uMDQzLjAxNS0uMDgzLjAzMi0uMTI0LjA0M2wtNC4wODUgMS4yNWMtLjA2NS4wMi0uMDg1IDAtLjEwNi0uMDU0bC0xLjI1LTMuMDQ4LTEuMjI2LTIuOTg0LS4xODMtLjQ0OWMtLjAxLS4wMjYtLjAyMy0uMDQ4LS4wNDMtLjA4N3oiLz48L3N2Zz4=",
"name": "",
"generateName": true,
"tags": [],
"spec": {
"display": {
"name": "AWS Lambda Overview",
"description": "Overview of AWS Lambda functions"
},
"variables": [
{
"kind": "ListVariable",
"spec": {
"display": {
"name": "Account",
"description": "AWS Account"
},
"allowAllValue": false,
"allowMultiple": false,
"customAllValue": "",
"capturingRegexp": "",
"sort": "none",
"plugin": {
"kind": "signoz/QueryVariable",
"spec": {
"queryValue": "SELECT JSONExtractString(labels, 'cloud.account.id') as `cloud.account.id`\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE \n metric_name like 'aws_Lambda_Invocations_sum'\nGROUP BY `cloud.account.id`\n\n"
}
},
"name": "Account"
}
},
{
"kind": "ListVariable",
"spec": {
"display": {
"name": "Region",
"description": "AWS Region"
},
"allowAllValue": false,
"allowMultiple": false,
"customAllValue": "",
"capturingRegexp": "",
"sort": "none",
"plugin": {
"kind": "signoz/QueryVariable",
"spec": {
"queryValue": "SELECT JSONExtractString(labels, 'cloud.region') as `cloud.region`\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE \n metric_name like 'aws_Lambda_Invocations_sum'\n and JSONExtractString(labels, 'cloud.account.id') IN {{.Account}}\nGROUP BY `cloud.region`\n"
}
},
"name": "Region"
}
},
{
"kind": "ListVariable",
"spec": {
"display": {
"name": "FunctionName",
"description": "Name of the Lambda function"
},
"allowAllValue": true,
"allowMultiple": true,
"customAllValue": "",
"capturingRegexp": "",
"sort": "none",
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {
"name": "FunctionName",
"signal": "metrics"
}
},
"name": "FunctionName"
}
}
],
"panels": {
"2516c785-b025-49b3-aeb4-a4735ccb2709": {
"kind": "Panel",
"spec": {
"display": {
"name": "Errors",
"description": "The number of invocations that result in a function error. Function errors include exceptions that your code throws and exceptions that the Lambda runtime throws. The runtime returns errors for issues such as timeouts and configuration errors. To calculate the error rate, divide the value of Errors by the value of Invocations. Note that the timestamp on an error metric reflects when the function was invoked, not when the error occurred.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "none",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "bottom",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"stepInterval": 60,
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "aws_Lambda_Errors_sum",
"temporality": "",
"timeAggregation": "sum",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
},
"groupBy": [
{
"name": "cloud.account.id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "cloud.region",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "FunctionName",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{FunctionName}}"
}
}
}
}
],
"links": []
}
},
"4119a1e5-32a8-4859-96e9-a5451114782b": {
"kind": "Panel",
"spec": {
"display": {
"name": "Async events dropped",
"description": "The number of events that are dropped without successfully executing the function. If you configure a dead-letter queue (DLQ) or OnFailure destination, then events are sent there before they're dropped. Events are dropped for various reasons. For example, events can exceed the maximum event age or exhaust the maximum retry attempts, or reserved concurrency might be set to 0. To troubleshoot why events are dropped, look at the Errors metric to identify function errors and the Throttles metric to identify concurrency issues.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "none",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "bottom",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"stepInterval": 60,
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "aws_Lambda_AsyncEventsDropped_sum",
"temporality": "",
"timeAggregation": "sum",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
},
"groupBy": [
{
"name": "cloud.account.id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "cloud.region",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "FunctionName",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{FunctionName}}"
}
}
}
}
],
"links": []
}
},
"6354ea62-e82b-4323-a33d-eef92519e843": {
"kind": "Panel",
"spec": {
"display": {
"name": "Throttles",
"description": "The number of invocation requests that are throttled. When all function instances are processing requests and no concurrency is available to scale up, Lambda rejects additional requests with a TooManyRequestsException error. Throttled requests and other invocation errors don't count as either Invocations or Errors.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "none",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "bottom",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"stepInterval": 60,
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "aws_Lambda_Throttles_sum",
"temporality": "",
"timeAggregation": "sum",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
},
"groupBy": [
{
"name": "cloud.account.id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "cloud.region",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "FunctionName",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{FunctionName}}"
}
}
}
}
],
"links": []
}
},
"853d3a92-b396-4064-8762-18d7487989e0": {
"kind": "Panel",
"spec": {
"display": {
"name": "Async events received",
"description": "The number of events that Lambda successfully queues for processing. This metric provides insight into the number of events that a Lambda function receives. Monitor this metric and set alarms for thresholds to check for issues. For example, to detect an undesirable number of events sent to Lambda, and to quickly diagnose issues resulting from incorrect trigger or function configurations. Mismatches between AsyncEventsReceived and Invocations can indicate a disparity in processing, events being dropped, or a potential queue backlog.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "none",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "bottom",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"stepInterval": 60,
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "aws_Lambda_AsyncEventsReceived_sum",
"temporality": "",
"timeAggregation": "sum",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
},
"groupBy": [
{
"name": "cloud.account.id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "cloud.region",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "FunctionName",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{FunctionName}}"
}
}
}
}
],
"links": []
}
},
"877bb5c8-331c-492f-b666-2054c2ae39bd": {
"kind": "Panel",
"spec": {
"display": {
"name": "Invocations",
"description": "The number of times that your function code is invoked, including successful invocations and invocations that result in a function error. Invocations aren't recorded if the invocation request is throttled or otherwise results in an invocation error. The value of Invocations equals the number of requests billed.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "none",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "bottom",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"stepInterval": 60,
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "aws_Lambda_Invocations_sum",
"temporality": "",
"timeAggregation": "sum",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
},
"groupBy": [
{
"name": "cloud.account.id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "cloud.region",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "FunctionName",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{FunctionName}}"
}
}
}
}
],
"links": []
}
},
"ae6d7c81-d921-4d4c-95ec-6b42d900ea45": {
"kind": "Panel",
"spec": {
"display": {
"name": "Max Async Event Age",
"description": "The time between when Lambda successfully queues the event and when the function is invoked. The value of this metric increases when events are being retried due to invocation failures or throttling. Monitor this metric and set alarms for thresholds on different statistics for when a queue buildup occurs. To troubleshoot an increase in this metric, look at the Errors metric to identify function errors and the Throttles metric to identify concurrency issues.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "ms",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "bottom",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"stepInterval": 60,
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "aws_Lambda_AsyncEventAge_max",
"temporality": "",
"timeAggregation": "max",
"spaceAggregation": "max",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
},
"groupBy": [
{
"name": "cloud.account.id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "cloud.region",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "FunctionName",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{FunctionName}}"
}
}
}
}
],
"links": []
}
},
"b038520d-0756-4e46-a915-12a2f19a0254": {
"kind": "Panel",
"spec": {
"display": {
"name": "Max Duration",
"description": "The amount of time that your function code spends processing an event. The billed duration for an invocation is the value of Duration rounded up to the nearest millisecond. Duration does not include cold start time.\n\nSee more at https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics-types.html"
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "ms",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "bottom",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"stepInterval": 60,
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "aws_Lambda_Duration_max",
"temporality": "",
"timeAggregation": "max",
"spaceAggregation": "max",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "(cloud.account.id = $Account AND cloud.region = $Region AND FunctionName EXISTS AND Resource NOT EXISTS) AND FunctionName IN $FunctionName"
},
"groupBy": [
{
"name": "cloud.account.id",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "cloud.region",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "FunctionName",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{FunctionName}}"
}
}
}
}
],
"links": []
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/877bb5c8-331c-492f-b666-2054c2ae39bd"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/b038520d-0756-4e46-a915-12a2f19a0254"
}
},
{
"x": 0,
"y": 6,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/2516c785-b025-49b3-aeb4-a4735ccb2709"
}
},
{
"x": 6,
"y": 6,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/6354ea62-e82b-4323-a33d-eef92519e843"
}
},
{
"x": 0,
"y": 12,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/853d3a92-b396-4064-8762-18d7487989e0"
}
},
{
"x": 6,
"y": 12,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/ae6d7c81-d921-4d4c-95ec-6b42d900ea45"
}
},
{
"x": 0,
"y": 18,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/4119a1e5-32a8-4859-96e9-a5451114782b"
}
}
]
}
}
],
"duration": "",
"refreshInterval": "",
"links": []
}
}

View File

@@ -322,38 +322,6 @@ func TestStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "test_bool_label_filter",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "signoz_calls_total",
Type: metrictypes.SumType,
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationSum,
},
},
Filter: &qbtypes.Filter{
Expression: "success = true",
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND accurateCastOrNull(JSONExtractString(labels, 'success'), 'Bool') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", true, "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
},
expectedErr: nil,
},
}
fm := metricstelemetryschema.NewFieldMapper()

View File

@@ -31,14 +31,6 @@
"signal": "metrics"
}
],
"success": [
{
"name": "success",
"fieldContext": "attribute",
"fieldDataType": "bool",
"signal": "metrics"
}
],
"materialized.key.name": [
{
"name": "materialized.key.name",

View File

@@ -96,7 +96,7 @@ func valueIndexCondition(
func memberPresenceCondition(sb *sqlbuilder.SelectBuilder, column string, members []*telemetrytypes.TelemetryFieldKey, exists bool) string {
conditions := make([]string, 0, len(members))
for _, member := range members {
field := fmt.Sprintf("simpleJSONHas(%s, '%s')", column, member.Name)
field := fmt.Sprintf("simpleJSONHas(%s, %s)", column, querybuilder.ClickHouseStringLiteral(member.Name))
if exists {
conditions = append(conditions, sb.E(field, true))
} else {

View File

@@ -5,6 +5,7 @@ import (
"fmt"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -66,7 +67,7 @@ func (m *defaultFieldMapper) FieldFor(
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)", columns[0].Name, querybuilder.ClickHouseStringLiteral(key.Name)), nil
}
return columns[0].Name, nil
}
@@ -91,7 +92,7 @@ func (m *defaultFieldMapper) ExistsFor(
}
return "false", nil
}
pred := fmt.Sprintf("simpleJSONHas(%s, '%s')", columns[0].Name, key.Name)
pred := fmt.Sprintf("simpleJSONHas(%s, %s)", columns[0].Name, querybuilder.ClickHouseStringLiteral(key.Name))
if exists {
return pred, nil
}
@@ -110,5 +111,5 @@ func (m *defaultFieldMapper) ColumnExpressionFor(
if err != nil {
return "", err
}
return fmt.Sprintf("%s AS `%s`", fieldExpression, key.Name), nil
return fmt.Sprintf("%s AS %s", fieldExpression, querybuilder.ClickHouseIdentifier(key.Name)), nil
}

View File

@@ -168,7 +168,7 @@ func (c *conditionBuilder) conditionForKey(
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)", columns[0].Name, querybuilder.ClickHouseStringLiteral(key.Name))
if operator == qbtypes.FilterOperatorExists {
cond = sb.E(leftOperand, true)
} else {

View File

@@ -7,6 +7,7 @@ import (
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -64,7 +65,7 @@ func (m *fieldMapper) ExistsFor(ctx context.Context, _ valuer.UUID, tsStart, tsE
if err != nil {
return "", err
}
pred := fmt.Sprintf("mapContains(%s, '%s')", columns[0].Name, key.Name)
pred := fmt.Sprintf("mapContains(%s, %s)", columns[0].Name, querybuilder.ClickHouseStringLiteral(key.Name))
if exists {
return pred, nil
}
@@ -82,7 +83,7 @@ func (m *fieldMapper) FieldFor(ctx context.Context, _ valuer.UUID, startNs, endN
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]", columns[0].Name, querybuilder.ClickHouseStringLiteral(key.Name)), nil
}
return columns[0].Name, nil
}
@@ -130,5 +131,5 @@ func (m *fieldMapper) ColumnExpressionFor(
}
}
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
return fmt.Sprintf("%s AS %s", sqlbuilder.Escape(fieldExpression), querybuilder.ClickHouseIdentifier(field.Name)), nil
}

View File

@@ -68,7 +68,7 @@ func (m *fieldMapper) FieldFor(ctx context.Context, _ valuer.UUID, _, _ uint64,
if key.FieldContext != telemetrytypes.FieldContextResource {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "only resource context fields are supported for json columns in audit, got %s", key.FieldContext.String)
}
return fmt.Sprintf("%s.`%s`::String", column.Name, key.Name), nil
return fmt.Sprintf("%s.%s::String", column.Name, querybuilder.ClickHouseIdentifier(key.Name)), nil
case schema.ColumnTypeEnumLowCardinality:
return column.Name, nil
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
@@ -84,7 +84,7 @@ func (m *fieldMapper) FieldFor(ctx context.Context, _ valuer.UUID, _, _ uint64,
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
}
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
return fmt.Sprintf("%s[%s]", column.Name, querybuilder.ClickHouseStringLiteral(key.Name)), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported map value type %s", valueType)
}
@@ -156,7 +156,7 @@ func (m *fieldMapper) ColumnExpressionFor(
return fmt.Sprintf("multiIf(%s, %s, NULL)", guard, coerced), nil
}
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
return fmt.Sprintf("%s AS %s", sqlbuilder.Escape(fieldExpression), querybuilder.ClickHouseIdentifier(field.Name)), nil
}
// CandidateKeys returns nil: audit has no synthesize-on-unknown-key fallback, so an

View File

@@ -141,8 +141,8 @@ func (m *fieldMapper) FieldFor(ctx context.Context, orgID valuer.UUID, tsStart,
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))
exprs = append(exprs, fmt.Sprintf("%s.%s::String", columnName, querybuilder.ClickHouseIdentifier(key.Name)))
existExpr = append(existExpr, fmt.Sprintf("%s.%s IS NOT NULL", columnName, querybuilder.ClickHouseIdentifier(key.Name)))
case telemetrytypes.FieldContextBody:
if key.Name == messageSubField {
exprs = append(exprs, messageSubColumn)
@@ -186,8 +186,8 @@ func (m *fieldMapper) FieldFor(ctx context.Context, orgID valuer.UUID, tsStart,
exprs = append(exprs, telemetrytypes.FieldKeyToMaterializedColumnName(key))
existExpr = append(existExpr, telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key))
} else {
exprs = append(exprs, fmt.Sprintf("%s['%s']", columnName, key.Name))
existExpr = append(existExpr, fmt.Sprintf("mapContains(%s, '%s')", columnName, key.Name))
exprs = append(exprs, fmt.Sprintf("%s[%s]", columnName, querybuilder.ClickHouseStringLiteral(key.Name)))
existExpr = append(existExpr, fmt.Sprintf("mapContains(%s, %s)", columnName, querybuilder.ClickHouseStringLiteral(key.Name)))
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
@@ -415,7 +415,7 @@ func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey) (
elemType = telemetrytypes.String
}
fieldPath := fmt.Sprintf("%s.`%s`", LogsV2BodyV2Column, key.Name)
fieldPath := fmt.Sprintf("%s.%s", LogsV2BodyV2Column, querybuilder.ClickHouseIdentifier(key.Name))
return fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue()), nil
}

View File

@@ -41,7 +41,7 @@ func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperato
// path index
if operator.AddDefaultExistsFilter() {
pathIndex := fmt.Sprintf(`has(%s, '%s')`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), c.key.ArrayParentPaths()[0])
pathIndex := fmt.Sprintf(`has(%s, %s)`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), querybuilder.ClickHouseStringLiteral(c.key.ArrayParentPaths()[0]))
return sb.And(baseCond, pathIndex), nil
}

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"slices"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -23,28 +22,6 @@ func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
return &conditionBuilder{fm: fm}
}
// Labels read back as String from the `labels` JSON whatever type the metadata claims, so the
// collision is always String vs the literal; intrinsic columns keep their own type.
func resolveTypeCollisionForFieldName(fieldExpression string, value any) string {
if col, isColumn := timeSeriesV4Columns[fieldExpression]; isColumn {
columnType := col.Type.GetType()
if lowCardinality, ok := col.Type.(schema.LowCardinalityColumnType); ok {
columnType = lowCardinality.ElementType.GetType()
}
if columnType != schema.ColumnTypeEnumString {
return fieldExpression
}
}
switch value.(type) {
case bool:
return fmt.Sprintf("accurateCastOrNull(%s, 'Bool')", fieldExpression)
case float64:
return fmt.Sprintf("toFloat64OrNull(%s)", fieldExpression)
}
return fieldExpression
}
func (c *conditionBuilder) conditionFor(
ctx context.Context,
orgID valuer.UUID,
@@ -65,8 +42,17 @@ func (c *conditionBuilder) conditionFor(
return "", err
}
// TODO(srikanthccv): use querybuilder.DataTypeCollisionHandledFieldName when metrics schemas are updated
fieldExpression = resolveTypeCollisionForFieldName(fieldExpression, value)
// TODO(srikanthccv): use the same data type collision handling when metrics schemas are updated
switch v := value.(type) {
case float64:
fieldExpression = fmt.Sprintf("toFloat64OrNull(%s)", fieldExpression)
case []any:
if len(v) > 0 && (operator == qbtypes.FilterOperatorBetween || operator == qbtypes.FilterOperatorNotBetween) {
if _, ok := v[0].(float64); ok {
fieldExpression = fmt.Sprintf("toFloat64OrNull(%s)", fieldExpression)
}
}
}
switch operator {
case qbtypes.FilterOperatorEqual:
@@ -114,8 +100,6 @@ func (c *conditionBuilder) conditionFor(
if len(values) != 2 {
return "", qbtypes.ErrBetweenValues
}
// both bounds share one expression, so the lower bound picks the cast
fieldExpression = resolveTypeCollisionForFieldName(fieldExpression, values[0])
return sb.Between(fieldExpression, values[0], values[1]), nil
case qbtypes.FilterOperatorNotBetween:
values, ok := value.([]any)
@@ -125,7 +109,6 @@ func (c *conditionBuilder) conditionFor(
if len(values) != 2 {
return "", qbtypes.ErrBetweenValues
}
fieldExpression = resolveTypeCollisionForFieldName(fieldExpression, values[0])
return sb.NotBetween(fieldExpression, values[0], values[1]), nil
// in and not in
@@ -134,23 +117,13 @@ func (c *conditionBuilder) conditionFor(
if !ok {
return "", qbtypes.ErrInValues
}
// instead of using IN, we use `=` + `OR` to make use of index
conditions := []string{}
for _, item := range values {
conditions = append(conditions, sb.E(resolveTypeCollisionForFieldName(fieldExpression, item), item))
}
return sb.Or(conditions...), nil
return sb.In(fieldExpression, values), nil
case qbtypes.FilterOperatorNotIn:
values, ok := value.([]any)
if !ok {
return "", qbtypes.ErrInValues
}
// instead of using NOT IN, we use `!=` + `AND` to make use of index
conditions := []string{}
for _, item := range values {
conditions = append(conditions, sb.NE(resolveTypeCollisionForFieldName(fieldExpression, item), item))
}
return sb.And(conditions...), nil
return sb.NotIn(fieldExpression, values), nil
// exists and not exists
// in the UI based query builder, `exists` and `not exists` are used for
@@ -163,9 +136,9 @@ func (c *conditionBuilder) conditionFor(
}
if operator == qbtypes.FilterOperatorExists {
return fmt.Sprintf("has(JSONExtractKeys(labels), '%s')", key.Name), nil
return fmt.Sprintf("has(JSONExtractKeys(labels), %s)", querybuilder.ClickHouseStringLiteral(key.Name)), nil
}
return fmt.Sprintf("not has(JSONExtractKeys(labels), '%s')", key.Name), nil
return fmt.Sprintf("not has(JSONExtractKeys(labels), %s)", querybuilder.ClickHouseStringLiteral(key.Name)), nil
}
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported operator: %v", operator)
}

View File

@@ -119,8 +119,8 @@ func TestConditionFor(t *testing.T) {
},
operator: qbtypes.FilterOperatorIn,
value: []any{"http.server.duration", "http.server.request.duration", "http.server.response.duration"},
expectedSQL: "(metric_name = ? OR metric_name = ? OR metric_name = ?)",
expectedArgs: []any{"http.server.duration", "http.server.request.duration", "http.server.response.duration"},
expectedSQL: "metric_name IN (?)",
expectedArgs: []any{[]any{"http.server.duration", "http.server.request.duration", "http.server.response.duration"}},
expectedError: nil,
},
{
@@ -155,8 +155,8 @@ func TestConditionFor(t *testing.T) {
},
operator: qbtypes.FilterOperatorNotIn,
value: []any{"debug", "info", "trace"},
expectedSQL: "(metric_name <> ? AND metric_name <> ? AND metric_name <> ?)",
expectedArgs: []any{"debug", "info", "trace"},
expectedSQL: "metric_name NOT IN (?)",
expectedArgs: []any{[]any{"debug", "info", "trace"}},
expectedError: nil,
},
{
@@ -227,120 +227,6 @@ func TestConditionFor(t *testing.T) {
expectedSQL: "",
expectedError: qbtypes.ErrColumnNotFound,
},
{
name: "Equal operator - bool label casts the JSON read to Bool",
key: telemetrytypes.TelemetryFieldKey{
Name: "success",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
operator: qbtypes.FilterOperatorEqual,
value: true,
expectedSQL: "accurateCastOrNull(JSONExtractString(labels, 'success'), 'Bool') = ?",
expectedArgs: []any{true},
expectedError: nil,
},
{
name: "Not Equal operator - bool label casts the JSON read to Bool",
key: telemetrytypes.TelemetryFieldKey{
Name: "success",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
operator: qbtypes.FilterOperatorNotEqual,
value: false,
expectedSQL: "accurateCastOrNull(JSONExtractString(labels, 'success'), 'Bool') <> ?",
expectedArgs: []any{false},
expectedError: nil,
},
{
name: "Equal operator - bool value on a label the metadata calls a string",
key: telemetrytypes.TelemetryFieldKey{
Name: "success",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorEqual,
value: true,
expectedSQL: "accurateCastOrNull(JSONExtractString(labels, 'success'), 'Bool') = ?",
expectedArgs: []any{true},
expectedError: nil,
},
{
name: "In operator - all-bool set casts the JSON read to Bool",
key: telemetrytypes.TelemetryFieldKey{
Name: "success",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
operator: qbtypes.FilterOperatorIn,
value: []any{true, false},
expectedSQL: "(accurateCastOrNull(JSONExtractString(labels, 'success'), 'Bool') = ? OR accurateCastOrNull(JSONExtractString(labels, 'success'), 'Bool') = ?)",
expectedArgs: []any{true, false},
expectedError: nil,
},
{
name: "In operator - a mixed set casts each value on its own",
key: telemetrytypes.TelemetryFieldKey{
Name: "success",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
operator: qbtypes.FilterOperatorIn,
value: []any{true, "maybe"},
expectedSQL: "(accurateCastOrNull(JSONExtractString(labels, 'success'), 'Bool') = ? OR JSONExtractString(labels, 'success') = ?)",
expectedArgs: []any{true, "maybe"},
expectedError: nil,
},
{
name: "Greater Than operator - a numeric column is compared without a cast",
key: telemetrytypes.TelemetryFieldKey{
Name: "unix_milli",
FieldContext: telemetrytypes.FieldContextMetric,
},
operator: qbtypes.FilterOperatorGreaterThan,
value: float64(1747947419000),
expectedSQL: "unix_milli > ?",
expectedArgs: []any{float64(1747947419000)},
expectedError: nil,
},
{
name: "Equal operator - the is_monotonic column is already Bool, no cast",
key: telemetrytypes.TelemetryFieldKey{
Name: "is_monotonic",
FieldContext: telemetrytypes.FieldContextMetric,
},
operator: qbtypes.FilterOperatorEqual,
value: true,
expectedSQL: "is_monotonic = ?",
expectedArgs: []any{true},
expectedError: nil,
},
{
name: "Between operator - the bounds cast the JSON read to Float64",
key: telemetrytypes.TelemetryFieldKey{
Name: "latency",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
},
operator: qbtypes.FilterOperatorBetween,
value: []any{float64(10), float64(20)},
expectedSQL: "toFloat64OrNull(JSONExtractString(labels, 'latency')) BETWEEN ? AND ?",
expectedArgs: []any{float64(10), float64(20)},
expectedError: nil,
},
{
name: "Between operator - a numeric column is compared without a cast",
key: telemetrytypes.TelemetryFieldKey{
Name: "unix_milli",
FieldContext: telemetrytypes.FieldContextMetric,
},
operator: qbtypes.FilterOperatorBetween,
value: []any{float64(1747947419000), float64(1747947429000)},
expectedSQL: "unix_milli BETWEEN ? AND ?",
expectedArgs: []any{float64(1747947419000), float64(1747947429000)},
expectedError: nil,
},
}
fm := NewFieldMapper()

View File

@@ -6,6 +6,7 @@ import (
"slices"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -79,14 +80,14 @@ func (m *fieldMapper) FieldFor(ctx context.Context, _ valuer.UUID, startNs, endN
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)", columns[0].Name, querybuilder.ClickHouseStringLiteral(key.Name)), nil
case telemetrytypes.FieldContextMetric:
return columns[0].Name, nil
case telemetrytypes.FieldContextUnspecified:
if slices.Contains(IntrinsicFields, key.Name) {
return columns[0].Name, nil
}
return fmt.Sprintf("JSONExtractString(%s, '%s')", columns[0].Name, key.Name), nil
return fmt.Sprintf("JSONExtractString(%s, %s)", columns[0].Name, querybuilder.ClickHouseStringLiteral(key.Name)), nil
}
return columns[0].Name, nil
@@ -103,9 +104,9 @@ func (m *fieldMapper) ExistsFor(_ context.Context, _ valuer.UUID, _, _ uint64, k
return "true", nil
}
if exists {
return fmt.Sprintf("has(JSONExtractKeys(labels), '%s')", key.Name), nil
return fmt.Sprintf("has(JSONExtractKeys(labels), %s)", querybuilder.ClickHouseStringLiteral(key.Name)), nil
}
return fmt.Sprintf("not has(JSONExtractKeys(labels), '%s')", key.Name), nil
return fmt.Sprintf("not has(JSONExtractKeys(labels), %s)", querybuilder.ClickHouseStringLiteral(key.Name)), nil
}
func (m *fieldMapper) ColumnExpressionFor(

View File

@@ -298,8 +298,8 @@ func (m *fieldMapper) resolveColumnExprs(
}
// 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.
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
exprs = append(exprs, fmt.Sprintf("%s.%s::String", columnName, querybuilder.ClickHouseIdentifier(key.Name)))
existExprs = append(existExprs, fmt.Sprintf("%s.%s IS NOT NULL", columnName, querybuilder.ClickHouseIdentifier(key.Name)))
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64,
schema.ColumnTypeEnumUInt32,
@@ -329,8 +329,8 @@ func (m *fieldMapper) resolveColumnExprs(
exprs = append(exprs, telemetrytypes.FieldKeyToMaterializedColumnName(key))
existExprs = append(existExprs, telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key))
} else {
exprs = append(exprs, fmt.Sprintf("%s['%s']", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("mapContains(%s, '%s')", columnName, key.Name))
exprs = append(exprs, fmt.Sprintf("%s[%s]", columnName, querybuilder.ClickHouseStringLiteral(key.Name)))
existExprs = append(existExprs, fmt.Sprintf("mapContains(%s, %s)", columnName, querybuilder.ClickHouseStringLiteral(key.Name)))
}
default:
return nil, nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "value type %s is not supported for map column type %s", valueType, column.Type)

View File

@@ -4,9 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
brace-expansion@>=5.0.0 <5.0.9: '>=5.0.9 <6'
importers:
.:
@@ -380,9 +377,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
@@ -848,7 +845,7 @@ snapshots:
balanced-match@4.0.4: {}
brace-expansion@5.0.9:
brace-expansion@5.0.5:
dependencies:
balanced-match: 4.0.4
@@ -1001,7 +998,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.5
ms@2.1.3: {}

View File

@@ -1,6 +0,0 @@
# Security floors for vulnerable transitive deps. Where possible, targets are
# capped to avoid crossing breaking versions (major; and minor for 0.x).
overrides:
# via: eslint-plugin-playwright > eslint@10 > minimatch@10.2.5 (brace-expansion ^5.0.5)
# remove: blocked — minimatch@10.2.6 (latest) only widens to ^5.0.8, still vulnerable
'brace-expansion@>=5.0.0 <5.0.9': '>=5.0.9 <6'

View File

@@ -1,66 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import querier, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
METRIC = "test.metric.boollabel"
def test_metrics_filter_bool_label(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(
[
Metrics(
metric_name=METRIC,
labels=labels,
timestamp=now - timedelta(seconds=1),
temporality="Unspecified",
type_="Gauge",
is_monotonic=False,
value=value,
)
for labels, value in [
({"success": "true"}, 30.0),
({"success": "false"}, 10.0),
({"success": "1"}, 5.0),
({"success": "maybe"}, 3.0),
({"region": "us"}, 7.0),
]
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# `true` selects "true" and "1"; `false` selects only "false". "maybe" and the series
# carrying no `success` label cast to NULL, so they are in neither result.
for expr, expected in [
("success = true", 35.0),
("success = false", 10.0),
("success != true", 10.0),
("success IN [true]", 35.0),
("success IN [true, false]", 45.0),
]:
response = querier.make_scalar_query_request(
signoz,
token,
now,
[
querier.build_scalar_query(
name="A",
signal="metrics",
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified", reduce_to="last")],
filter_expression=expr,
)
],
)
assert response.status_code == HTTPStatus.OK, f"{expr}: {response.text}"
data = querier.get_scalar_table_data(response.json())
assert len(data) == 1, f"{expr}: {data}"
assert data[0][-1] == expected, f"{expr}: {data}"