Compare commits

..

2 Commits

Author SHA1 Message Date
Tushar Vats
fbea7d8263 feat(logs): scope search() to field contexts
search('needle', body, resource, ...) narrows the keyless full-text search to the named field contexts (body/attribute/resource/log). The first argument is the needle; any remaining arguments are scopes, quoted or bare. Keyless search('needle') still fans across every field.

Adds integration coverage for search() in querierlogs and querier_json_body: keyless and scoped forms, invalid-scope rejection, and the cost-guard trip/recover paths (add a filter, narrow the time range).
2026-07-24 01:17:03 +05:30
Tushar Vats
6f6e289b82 feat(logs): implement search() across all log fields
Replace the search() stub with the real implementation: the logs condition
builder fans a case-insensitive match of the needle across every searchable
column (log columns, body/body_v2, attribute + resource maps), gated by the
allow_logs_search feature flag. The where-clause visitor emits FilterOperatorSearch
and flags the statement as scan-heavy; the statement builder attaches a CostGuard
that the querier enforces via EXPLAIN ESTIMATE against a configurable scan budget.

Builds on the orgID plumbing and grammar/parse layers.
2026-07-23 03:40:11 +05:30
76 changed files with 1232 additions and 908 deletions

View File

@@ -2748,6 +2748,7 @@ components:
links:
items:
$ref: '#/components/schemas/DashboardtypesLink'
nullable: true
type: array
panels:
additionalProperties:
@@ -2764,7 +2765,6 @@ components:
- variables
- panels
- layouts
- links
type: object
DashboardtypesDashboardView:
properties:
@@ -2843,22 +2843,14 @@ components:
required:
- name
type: object
DashboardtypesDynamicVariableSignal:
enum:
- traces
- logs
- metrics
- all
type: string
DashboardtypesDynamicVariableSpec:
properties:
name:
type: string
signal:
$ref: '#/components/schemas/DashboardtypesDynamicVariableSignal'
$ref: '#/components/schemas/TelemetrytypesSignal'
required:
- name
- signal
type: object
DashboardtypesFillMode:
enum:
@@ -3401,6 +3393,7 @@ components:
links:
items:
$ref: '#/components/schemas/DashboardtypesLink'
nullable: true
type: array
plugin:
$ref: '#/components/schemas/DashboardtypesPanelPlugin'
@@ -3412,7 +3405,6 @@ components:
- display
- plugin
- queries
- links
type: object
DashboardtypesPatchOp:
enum:

View File

@@ -4626,9 +4626,9 @@ export interface DashboardtypesQueryDTO {
export interface DashboardtypesPanelSpecDTO {
display: DashboardtypesDisplayDTO;
/**
* @type array
* @type array,null
*/
links: DashboardtypesLinkDTO[];
links?: DashboardtypesLinkDTO[] | null;
plugin: DashboardtypesPanelPluginDTO;
/**
* @type array
@@ -4668,18 +4668,12 @@ export type DashboardtypesVariableDefaultValueDTO = string | string[];
export enum DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind {
'signoz/DynamicVariable' = 'signoz/DynamicVariable',
}
export enum DashboardtypesDynamicVariableSignalDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
all = 'all',
}
export interface DashboardtypesDynamicVariableSpecDTO {
/**
* @type string
*/
name: string;
signal: DashboardtypesDynamicVariableSignalDTO;
signal?: TelemetrytypesSignalDTO;
}
export interface DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTO {
@@ -4821,9 +4815,9 @@ export interface DashboardtypesDashboardSpecDTO {
*/
layouts: DashboardtypesLayoutDTO[];
/**
* @type array
* @type array,null
*/
links: DashboardtypesLinkDTO[];
links?: DashboardtypesLinkDTO[] | null;
/**
* @type object
*/

View File

@@ -9,7 +9,6 @@ import { extractQueryPairs } from 'utils/queryContextUtils';
import {
convertAggregationToExpression,
convertExpressionToFilters,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
formatValueForExpression,
@@ -1599,36 +1598,4 @@ describe('formatValueForExpression', () => {
expect(formatValueForExpression([123] as any)).toBe('[123]');
});
});
// Regression: an escaped single quote must not gain a backslash on each
// expression <-> filters round-trip (broke Create Alert from a panel).
describe('escaped quote round-trip', () => {
const EXPR = "name = 'it\\'s a ribbon'"; // name = 'it\'s a ribbon' (single backslash)
it('stores the unescaped value in the filter item', () => {
const items = convertExpressionToFilters(EXPR);
expect(items).toHaveLength(1);
expect(items[0].value).toBe("it's a ribbon");
});
it('is lossless: expression -> filters -> expression', () => {
const items = convertExpressionToFilters(EXPR);
expect(convertFiltersToExpression({ items, op: 'AND' }).expression).toBe(
EXPR,
);
});
it('is idempotent across repeated existing-query conversions', () => {
const pass1 = convertFiltersToExpressionWithExistingQuery(
{ items: [], op: 'AND' },
EXPR,
);
expect(pass1.filter.expression).toBe(EXPR);
const pass2 = convertFiltersToExpressionWithExistingQuery(
pass1.filters,
pass1.filter.expression,
);
expect(pass2.filter.expression).toBe(EXPR);
});
});
});

View File

@@ -176,9 +176,7 @@ function formatSingleValueForFilter(
}
if (isQuoted(value)) {
// Unescape `\'` → `'` (inverse of formatSingleValue) so the round-trip doesn't
// double the backslash each pass.
return unquote(value).replace(/\\'/g, "'");
return unquote(value);
}
}

View File

@@ -33,6 +33,7 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
// Stable references via destructuring, keep them as is
const onRowClick = context?.onRowClick;
const onRowClickNewTab = context?.onRowClickNewTab;
const onRowDeactivate = context?.onRowDeactivate;
const isRowActive = context?.isRowActive;
const getRowKeyData = context?.getRowKeyData;
const rowIndex = row.index;
@@ -50,12 +51,22 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
return;
}
// The table does not decide open vs. close — it reports the click and
// the row's active state, and the consumer owns the routing.
const isActive = isRowActive?.(rowData) ?? false;
onRowClick?.(rowData, itemKey, { isActive });
if (isActive && onRowDeactivate) {
onRowDeactivate();
} else {
onRowClick?.(rowData, itemKey);
}
},
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
[
isRowActive,
onRowDeactivate,
onRowClick,
onRowClickNewTab,
rowData,
getRowKeyData,
rowIndex,
],
);
if (itemKind === 'expansion') {
@@ -109,6 +120,7 @@ function areRowCellsPropsEqual<TData>(
prev.columnVisibilityKey === next.columnVisibilityKey &&
prev.context?.onRowClick === next.context?.onRowClick &&
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
prev.context?.isRowActive === next.context?.isRowActive &&
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
prev.context?.renderRowActions === next.context?.renderRowActions &&

View File

@@ -96,6 +96,7 @@ function TanStackTableInner<TData, TItemKey = string>(
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
onSort,
activeRowIndex,
renderExpandedRow,
@@ -381,6 +382,7 @@ function TanStackTableInner<TData, TItemKey = string>(
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
colCount: visibleColumnsCount,
@@ -398,6 +400,7 @@ function TanStackTableInner<TData, TItemKey = string>(
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
visibleColumnsCount,

View File

@@ -85,9 +85,7 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: false,
});
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
});
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
@@ -119,19 +117,17 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
isActive: false,
});
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
});
it('calls onRowClick with isActive: true when the row is active', async () => {
// The table no longer owns open/close — it reports the active state and the
// consumer routes the click. An active row must still fire onRowClick.
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowDeactivate,
isRowActive: () => true,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
@@ -156,44 +152,8 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: true,
});
});
it('calls onRowClick with isActive: false when the row is not active', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
isRowActive: () => false,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: false,
});
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
});
it('does not render renderRowActions before hover', () => {

View File

@@ -611,7 +611,6 @@ describe('TanStackTableView Integration', () => {
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
'1',
{ isActive: false },
);
});
@@ -640,7 +639,6 @@ describe('TanStackTableView Integration', () => {
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
{ id: '1', name: 'Item 1' },
{ isActive: false },
);
});
@@ -661,15 +659,15 @@ describe('TanStackTableView Integration', () => {
expect(row).toHaveClass('tableRowActive');
});
it('calls onRowClick with isActive: true when clicking the active row', async () => {
// The consumer owns open/close routing — the table just reports the
// active state via the click context.
it('calls onRowDeactivate when clicking active row', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowDeactivate,
isRowActive: (row) => row.id === '1',
},
});
@@ -680,11 +678,8 @@ describe('TanStackTableView Integration', () => {
await user.click(screen.getByText('Item 1'));
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
expect.anything(),
{ isActive: true },
);
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
});
it('opens in new tab on ctrl+click', async () => {

View File

@@ -116,11 +116,9 @@ export * from './useTableParams';
* getItemKey={(row) => row.id}
* isRowActive={(row) => row.id === selectedId}
* activeRowIndex={selectedIndex}
* // The table reports the click + the row's active state; the consumer owns open/close.
* onRowClick={(row, itemKey, { isActive }) =>
* setSelectedId(isActive ? undefined : itemKey)
* }
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
* onRowDeactivate={() => setSelectedId(undefined)}
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
* renderRowActions={(row) => <Button size="small">Open</Button>}

View File

@@ -81,23 +81,15 @@ export type FlatItem<TData> =
| { kind: 'row'; row: TanStackRowType<TData> }
| { kind: 'expansion'; row: TanStackRowType<TData> };
/**
* State of a row at click time, passed as the third argument to `onRowClick`.
* The consumer owns the open/close decision — e.g. `isActive ? close() : open()`.
*/
export type RowClickContext = {
/** Whether the clicked row is currently active (per `isRowActive`). */
isActive: boolean;
};
export type TableRowContext<TData, TItemKey = string> = {
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (
row: TData,
rowKey: string,
@@ -188,9 +180,10 @@ export type TanStackTableProps<TData, TItemKey = string> = {
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (
row: TData,

View File

@@ -65,5 +65,4 @@
min-width: 0;
padding-left: 12px;
padding-bottom: 12px;
padding-top: 8px;
}

View File

@@ -25,52 +25,20 @@ describe('calculateChartDimensions', () => {
});
});
it('RIGHT: reserves a side column capped at 320px / 40% of the width and keeps full height', () => {
it('RIGHT: reserves a side column capped at 30% of the width and keeps full height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
expect(dims.legendWidth).toBe(320);
expect(dims.width).toBe(680);
// 40-char labels approximate to 336px, capped at min(240, 30% of 1000).
expect(dims.legendWidth).toBe(240);
expect(dims.width).toBe(760);
expect(dims.height).toBe(400);
expect(dims.legendHeight).toBe(400);
});
it('RIGHT: sizes the column to the longest label when it fits under the cap', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(5, 20),
});
expect(dims.legendWidth).toBe(216);
expect(dims.width).toBe(784);
});
it('RIGHT: never shrinks the column below the 150px floor', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(3, 3),
});
expect(dims.legendWidth).toBe(150);
expect(dims.width).toBe(850);
});
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
const dims = calculateChartDimensions({
containerWidth: 300,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
expect(dims.legendWidth).toBe(120);
expect(dims.width).toBe(180);
});
it('BOTTOM: a single row of items reserves one legend row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,

View File

@@ -15,12 +15,6 @@ const BASE_LEGEND_WIDTH = 16;
const LEGEND_PADDING = 12;
const LEGEND_LINE_HEIGHT = 28;
// RIGHT legend is a vertical column with its own width budget (cap protects the donut).
const MAX_RIGHT_LEGEND_WIDTH = 320;
const RIGHT_LEGEND_WIDTH_RATIO = 0.4;
// Column padding + copy button, not covered by the text-length estimate.
const RIGHT_LEGEND_RESERVED_WIDTH = 40;
/**
* Calculates the average width of the legend items based on the labels of the series.
* @param legends - The labels of the series.
@@ -48,7 +42,7 @@ export function calculateAverageLegendWidth(legends: string[]): number {
* Implementation details (high level):
* - Approximates legend item width from label text length, using a fixed average char width.
* - RIGHT legend:
* - `legendWidth` fits the longest label, clamped to [150px, min(MAX_RIGHT_LEGEND_WIDTH, 40% width)].
* - `legendWidth` is clamped between 150px and min(MAX_LEGEND_WIDTH, 30% of container width).
* - Chart width is `containerWidth - legendWidth`.
* - BOTTOM legend:
* - Computes how many items fit per row, then uses at most 2 rows.
@@ -86,22 +80,9 @@ export function calculateChartDimensions({
const legendItemCount = seriesLabels.length;
if (legendConfig.position === LegendPosition.RIGHT) {
// Size the column to the longest name (up to the cap) so it doesn't ellipsize.
const longestLabelLength = seriesLabels.reduce(
(max, label) => Math.max(max, label.length),
0,
);
const desiredLegendWidth =
BASE_LEGEND_WIDTH +
longestLabelLength * AVG_CHAR_WIDTH +
RIGHT_LEGEND_RESERVED_WIDTH;
const maxRightLegendWidth = Math.min(
MAX_RIGHT_LEGEND_WIDTH,
containerWidth * RIGHT_LEGEND_WIDTH_RATIO,
);
const maxRightLegendWidth = Math.min(MAX_LEGEND_WIDTH, containerWidth * 0.3);
const rightLegendWidth = Math.min(
Math.max(150, desiredLegendWidth),
Math.max(150, approxLegendItemWidth),
maxRightLegendWidth,
);

View File

@@ -15,10 +15,6 @@
&--legend-right {
flex-direction: row;
.chart-layout__legend-wrapper {
padding-top: 8px;
}
}
&__legend-wrapper {

View File

@@ -60,7 +60,3 @@
}
}
}
.logs-linked-row td {
background-color: var(--row-active-bg) !important;
}

View File

@@ -204,9 +204,6 @@ function LiveLogsList({
data={formattedLogs}
isLoading={false}
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowClassName={(log): string =>
log.id === activeLogId ? 'logs-linked-row' : ''
}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
@@ -219,13 +216,10 @@ function LiveLogsList({
),
}) as CSSProperties
}
onRowClick={(log, _itemKey, { isActive }): void => {
if (isActive) {
handleCloseLogDetail();
} else {
handleSetActiveLog(log);
}
onRowClick={(log): void => {
handleSetActiveLog(log);
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons

View File

@@ -323,7 +323,3 @@
}
}
}
.logs-linked-row td {
background-color: var(--row-active-bg) !important;
}

View File

@@ -211,9 +211,8 @@ function LogsExplorerList({
data={logs}
isLoading={isLoading || isFetching}
onEndReached={onEndReached}
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowClassName={(log): string =>
log.id === activeLogId ? 'logs-linked-row' : ''
isRowActive={(log): boolean =>
log.id === activeLog?.id || log.id === activeLogId
}
getRowStyle={(log): CSSProperties =>
({
@@ -227,13 +226,10 @@ function LogsExplorerList({
),
}) as CSSProperties
}
onRowClick={(log, _itemKey, { isActive }): void => {
if (isActive) {
handleCloseLogDetail();
} else {
handleSetActiveLog(log);
}
onRowClick={(log): void => {
handleSetActiveLog(log);
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
@@ -286,7 +282,6 @@ function LogsExplorerList({
options.maxLines,
options.fontSize,
activeLogIndex,
activeLogId,
logs,
onEndReached,
getItemContent,

View File

@@ -55,7 +55,6 @@ export function useCreateExportDashboard({
layouts: [],
panels: {},
variables: [],
links: [],
},
}),
{

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesDynamicVariableSignalDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- fixed-option signal picker
import { Select } from 'antd';
@@ -15,16 +14,17 @@ import { isRetryableError } from 'utils/errorUtils';
import {
DYNAMIC_SIGNAL_LABEL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
signalForApi,
} from '../variableFormModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: DashboardtypesDynamicVariableSignalDTO;
signal: DynamicSignalOption;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: DashboardtypesDynamicVariableSignalDTO;
dynamicSignal?: DynamicSignalOption;
}) => void;
onPreview: (values: (string | number)[]) => void;
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
@@ -117,9 +117,7 @@ function DynamicVariableFields({
value: s,
}))}
onChange={(value): void =>
onChange({
dynamicSignal: value as DashboardtypesDynamicVariableSignalDTO,
})
onChange({ dynamicSignal: value as DynamicSignalOption })
}
data-testid="variable-signal-select"
/>

View File

@@ -13,7 +13,11 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import {
DYNAMIC_SIGNAL_ALL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
emptyVariableFormModel,
signalForApi,
VARIABLE_SORT_DISABLED,
type VariableFormModel,
} from './variableFormModel';
@@ -65,8 +69,12 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
// signal is a required wire field (`all` = every telemetry signal), used as-is.
dynamicSignal: plugin.spec.signal,
// Unrecognized/empty signal → "all telemetry", so the source always shows.
dynamicSignal: DYNAMIC_SIGNALS.includes(
plugin.spec.signal as DynamicSignalOption,
)
? (plugin.spec.signal as DynamicSignalOption)
: DYNAMIC_SIGNAL_ALL,
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
@@ -94,7 +102,7 @@ function buildPlugin(
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: model.dynamicSignal,
signal: signalForApi(model.dynamicSignal),
},
};
case 'QUERY':

View File

@@ -1,6 +1,6 @@
import {
DashboardtypesDynamicVariableSignalDTO,
DashboardtypesListVariableSpecSortDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesVariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import { sortBy } from 'lodash-es';
@@ -23,6 +23,23 @@ export const VARIABLE_TYPE_EVENT_LABEL: Record<VariableType, string> = {
DYNAMIC: 'dynamic',
};
/** Telemetry signal — the generated enum (traces / logs / metrics). */
// A query/variable signal is only logs/traces/metrics. TelemetrytypesSignalDTO
// also carries the empty "any" value used on field keys, which is not a valid
// query/variable signal, so exclude it here.
export type TelemetrySignal =
| TelemetrytypesSignalDTO.logs
| TelemetrytypesSignalDTO.traces
| TelemetrytypesSignalDTO.metrics;
/**
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
* generated `TelemetrytypesSignalDTO` has no "all") — it searches across every
* signal and maps to an omitted `signal` on the wire (see {@link signalForApi}).
*/
export const DYNAMIC_SIGNAL_ALL = 'all' as const;
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
/**
* Sort order for list-variable values, keyed by the generated wire enum so the
* form model and the DTO `sort` field share one source of truth. The friendly
@@ -64,38 +81,25 @@ export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
};
export const DYNAMIC_SIGNALS: DashboardtypesDynamicVariableSignalDTO[] = [
DashboardtypesDynamicVariableSignalDTO.all,
DashboardtypesDynamicVariableSignalDTO.traces,
DashboardtypesDynamicVariableSignalDTO.logs,
DashboardtypesDynamicVariableSignalDTO.metrics,
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
DYNAMIC_SIGNAL_ALL,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.metrics,
];
export const DYNAMIC_SIGNAL_LABEL: Record<
DashboardtypesDynamicVariableSignalDTO,
string
> = {
[DashboardtypesDynamicVariableSignalDTO.all]: 'All telemetry',
[DashboardtypesDynamicVariableSignalDTO.traces]: 'Traces',
[DashboardtypesDynamicVariableSignalDTO.logs]: 'Logs',
[DashboardtypesDynamicVariableSignalDTO.metrics]: 'Metrics',
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
[TelemetrytypesSignalDTO.traces]: 'Traces',
[TelemetrytypesSignalDTO.logs]: 'Logs',
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
};
/**
* Field-keys/values API param. The `all` signal is omitted (that endpoint only
* accepts a concrete signal), everything else passes through.
*/
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
export function signalForApi(
signal: DashboardtypesDynamicVariableSignalDTO,
):
| Exclude<
DashboardtypesDynamicVariableSignalDTO,
DashboardtypesDynamicVariableSignalDTO.all
>
| undefined {
return signal === DashboardtypesDynamicVariableSignalDTO.all
? undefined
: signal;
signal: DynamicSignalOption,
): TelemetrySignal | undefined {
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
}
type SortableValues = (string | number | boolean)[];
@@ -140,7 +144,7 @@ export interface VariableFormModel {
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: DashboardtypesDynamicVariableSignalDTO; // DYNAMIC — the telemetry signal (`all` = every signal)
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
@@ -162,6 +166,6 @@ export function emptyVariableFormModel(): VariableFormModel {
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: DashboardtypesDynamicVariableSignalDTO.all,
dynamicSignal: DYNAMIC_SIGNAL_ALL,
};
}

View File

@@ -17,6 +17,8 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
[TelemetrytypesSignalDTO.logs]: 'logs',
[TelemetrytypesSignalDTO.traces]: 'traces',
[TelemetrytypesSignalDTO.metrics]: 'metrics',
// The empty "any" signal only appears on field keys, never on a panel query;
// mapped for exhaustiveness.
[TelemetrytypesSignalDTO['']]: '',
};

View File

@@ -120,7 +120,7 @@ export const SECTION_REGISTRY: {
[SectionKind.ContextLinks]: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
get: (spec): DashboardtypesLinkDTO[] => spec.links,
get: (spec): DashboardtypesLinkDTO[] | undefined => spec.links ?? undefined,
update: (spec, links): PanelSpec => ({ ...spec, links }),
},
// One editor for every threshold variant (label / comparison / table); the kind's

View File

@@ -49,14 +49,6 @@ describe('newPanelRoute', () => {
return { path, params: new URLSearchParams(search) };
};
// Mirrors useGetCompositeQueryParam: it decodes the param twice.
const readCompositeQuery = (params: URLSearchParams): unknown =>
JSON.parse(
decodeURIComponent(
(params.get('compositeQuery') as string).replace(/\+/g, ' '),
),
);
it.each([
[PANEL_TYPES.TIME_SERIES, 'signoz/TimeSeriesPanel'],
[PANEL_TYPES.TABLE, 'signoz/TablePanel'],
@@ -75,34 +67,16 @@ describe('newPanelRoute', () => {
);
});
it('carries the query as a compositeQuery param the reader can decode', () => {
it('carries the query as a decodable compositeQuery param', () => {
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
});
const { params } = parseLink(link);
expect(readCompositeQuery(params)).toStrictEqual(query);
});
// Regression: a bare `%`/`+` must survive the reader's double-decode.
it('round-trips a filter expression containing % and + literals', () => {
const queryWithLiterals = {
id: 'q1',
queryType: 'builder',
builder: {
queryData: [
{ filter: { expression: "severity_text ILIKE 'Inf%' AND path = 'a+b'" } },
],
},
} as unknown as Query;
const link = buildExportPanelLink({
dashboardId: 'dash-1',
panelType: PANEL_TYPES.LIST,
query: queryWithLiterals,
});
const { params } = parseLink(link);
expect(readCompositeQuery(params)).toStrictEqual(queryWithLiterals);
expect(JSON.parse(params.get('compositeQuery') as string)).toStrictEqual(
query,
);
});
it('returns null for a panel type with no V2 kind', () => {

View File

@@ -45,11 +45,9 @@ export function parseNewPanelKind(
/**
* New-panel editor link that exports an explorer query into a V2 dashboard. Carries the
* raw `Query` as `compositeQuery` (conversion happens in the editor). `null` when the panel
* type has no V2 kind, so the caller skips the export instead of landing on an unrelated kind.
*
* Double-encoded on purpose: `useGetCompositeQueryParam` decodes twice, so a single encode
* would let a bare `%`/`+` (e.g. `ILIKE 'Inf%'`) break its second decode and drop the query.
* raw `Query` as `compositeQuery` encoded as the V1 link so `useGetCompositeQueryParam`
* reads it identically (conversion happens in the editor). `null` when the panel type has
* no V2 kind, so the caller skips the export instead of landing on an unrelated kind.
*/
export function buildExportPanelLink({
dashboardId,
@@ -70,7 +68,7 @@ export function buildExportPanelLink({
});
return `${path}${newPanelSearch(kind)}&${
QueryParams.compositeQuery
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
}=${encodeURIComponent(JSON.stringify(query))}`;
}
/** Target section index for a new panel, or undefined when unset/invalid. */

View File

@@ -1,80 +0,0 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { countEnabledQueries } from '../countEnabledQueries';
function compositeQuery(
envelopes: { type: string; disabled?: boolean }[],
): DashboardtypesQueryDTO {
return {
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: envelopes.map(({ type, disabled }) => ({
type,
spec: { disabled },
})),
},
},
},
} as unknown as DashboardtypesQueryDTO;
}
function bareBuilderQuery(disabled?: boolean): DashboardtypesQueryDTO {
return {
spec: {
plugin: { kind: 'signoz/BuilderQuery', spec: { signal: 'logs', disabled } },
},
} as unknown as DashboardtypesQueryDTO;
}
describe('countEnabledQueries', () => {
it('returns 0 when there are no queries', () => {
expect(countEnabledQueries([])).toBe(0);
});
it('counts every enabled envelope inside the composite wrapper', () => {
expect(
countEnabledQueries([
compositeQuery([{ type: 'builder_query' }, { type: 'builder_query' }]),
]),
).toBe(2);
});
it('does not count disabled envelopes', () => {
expect(
countEnabledQueries([
compositeQuery([
{ type: 'builder_query' },
{ type: 'builder_query', disabled: true },
]),
]),
).toBe(1);
});
it('counts formula envelopes alongside builder queries', () => {
expect(
countEnabledQueries([
compositeQuery([
{ type: 'builder_query' },
{ type: 'builder_query' },
{ type: 'builder_formula' },
]),
]),
).toBe(3);
});
it('treats an absent disabled flag as enabled', () => {
expect(
countEnabledQueries([compositeQuery([{ type: 'builder_query' }])]),
).toBe(1);
});
it('unwraps a bare builder query (List panel shape)', () => {
expect(countEnabledQueries([bareBuilderQuery()])).toBe(1);
});
it('does not count a disabled bare builder query', () => {
expect(countEnabledQueries([bareBuilderQuery(true)])).toBe(0);
});
});

View File

@@ -1,8 +0,0 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { toQueryEnvelopes } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
/** Counts enabled queries, unwrapping the single `signoz/CompositeQuery` wrapper. */
export function countEnabledQueries(queries: DashboardtypesQueryDTO[]): number {
return toQueryEnvelopes(queries).filter((envelope) => !envelope.spec?.disabled)
.length;
}

View File

@@ -1,7 +1,4 @@
import {
DashboardtypesDynamicVariableSignalDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
@@ -20,23 +17,3 @@ export function resolveDrilldownSignal(
return TelemetrytypesSignalDTO.metrics;
}
}
/**
* Maps a clicked query's telemetry signal to a dynamic-variable signal (used
* when a drilldown seeds a new variable). Concrete signals map 1:1; an unset
* signal (no active drilldown) falls back to `all`.
*/
export function dynamicSignalFromQuerySignal(
signal?: TelemetrytypesSignalDTO,
): DashboardtypesDynamicVariableSignalDTO {
switch (signal) {
case TelemetrytypesSignalDTO.traces:
return DashboardtypesDynamicVariableSignalDTO.traces;
case TelemetrytypesSignalDTO.logs:
return DashboardtypesDynamicVariableSignalDTO.logs;
case TelemetrytypesSignalDTO.metrics:
return DashboardtypesDynamicVariableSignalDTO.metrics;
default:
return DashboardtypesDynamicVariableSignalDTO.all;
}
}

View File

@@ -15,7 +15,6 @@ import PanelHeaderSearch from './PanelHeaderSearch';
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
import {
panelStatusFromError,
panelStatusFromMultipleEnabledQueries,
panelStatusFromWarning,
} from '../PanelStatus/utils';
import styles from './PanelHeader.module.scss';
@@ -75,24 +74,11 @@ function PanelHeader({
[warning],
);
// Client-derived: warn a Number panel that has more than one enabled query (#9512).
const multiQueryWarningDetail = useMemo(
() => panelStatusFromMultipleEnabledQueries(panel),
[panel],
);
/**
* Hide the entire header when there's no title, description, or status to show,
* and the actions menu is suppressed (editor preview).
*/
if (
!name &&
!description &&
!errorDetail &&
!warningDetail &&
!multiQueryWarningDetail &&
hideActions
) {
if (!name && !description && !errorDetail && !warningDetail && hideActions) {
return <Fragment />;
}
@@ -138,13 +124,6 @@ function PanelHeader({
{warningDetail && (
<PanelStatusPopover variant="warning" detail={warningDetail} />
)}
{multiQueryWarningDetail && (
<PanelStatusPopover
variant="warning"
detail={multiQueryWarningDetail}
testId="panel-status-config-warning"
/>
)}
{/* Renders nothing when no action survives its gates (kind/role/context). */}
{!hideActions && (
<PanelActionsMenu

View File

@@ -17,8 +17,6 @@ const VARIANT_CONFIG: Record<
interface PanelStatusPopoverProps {
variant: PanelStatusVariant;
detail: PanelStatusDetail;
/** Overrides the trigger's test id; defaults to `panel-status-<variant>`. */
testId?: string;
}
/**
@@ -28,7 +26,6 @@ interface PanelStatusPopoverProps {
function PanelStatusPopover({
variant,
detail,
testId,
}: PanelStatusPopoverProps): JSX.Element {
const { color, ariaLabel } = VARIANT_CONFIG[variant];
const Icon = variant === 'error' ? CircleX : TriangleAlert;
@@ -44,7 +41,7 @@ function PanelStatusPopover({
<span
className={styles.trigger}
aria-label={ariaLabel}
data-testid={testId ?? `panel-status-${variant}`}
data-testid={`panel-status-${variant}`}
>
<Icon size={16} color={color} />
</span>

View File

@@ -1,16 +1,9 @@
import type {
DashboardtypesPanelDTO,
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import { StatusCodes } from 'http-status-codes';
import {
panelStatusFromError,
panelStatusFromMultipleEnabledQueries,
panelStatusFromWarning,
} from '../utils';
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
// The query layer rejects with the raw AxiosError from the generated client
// (it is not pre-converted to APIError), so the tests mirror that wire shape.
@@ -94,62 +87,3 @@ describe('panelStatusFromWarning', () => {
});
});
});
function panel(
kind: string,
envelopes: { disabled?: boolean }[],
): DashboardtypesPanelDTO {
return {
spec: {
plugin: { kind, spec: {} },
queries: [
{
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: envelopes.map(({ disabled }) => ({
type: 'builder_query',
spec: { disabled },
})),
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('panelStatusFromMultipleEnabledQueries', () => {
it('warns when a Number panel has more than one enabled query', () => {
const detail = panelStatusFromMultipleEnabledQueries(
panel('signoz/NumberPanel', [{}, {}]),
);
expect(detail).not.toBeNull();
expect(detail?.message).toMatch(/single value/i);
expect(detail?.messages).toHaveLength(1);
});
it('counts only enabled queries (a disabled second query is fine)', () => {
expect(
panelStatusFromMultipleEnabledQueries(
panel('signoz/NumberPanel', [{}, { disabled: true }]),
),
).toBeNull();
});
it('does not warn when a Number panel has a single enabled query', () => {
expect(
panelStatusFromMultipleEnabledQueries(panel('signoz/NumberPanel', [{}])),
).toBeNull();
});
it('does not warn for other panel kinds even with multiple enabled queries', () => {
expect(
panelStatusFromMultipleEnabledQueries(
panel('signoz/TimeSeriesPanel', [{}, {}]),
),
).toBeNull();
});
});

View File

@@ -1,11 +1,7 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type {
DashboardtypesPanelDTO,
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { countEnabledQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/countEnabledQueries';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelStatusDetail } from './types';
@@ -56,25 +52,3 @@ export function panelStatusFromWarning(
.filter((message): message is string => Boolean(message)),
};
}
/**
* A Number panel renders only the first query's value, so more than one enabled
* query silently hides the rest. Warns for that case; null otherwise.
*/
export function panelStatusFromMultipleEnabledQueries(
panel: DashboardtypesPanelDTO,
): PanelStatusDetail | null {
if (panel.spec.plugin.kind !== 'signoz/NumberPanel') {
return null;
}
if (countEnabledQueries(panel.spec.queries) <= 1) {
return null;
}
return {
message:
'This panel shows a single value, but more than one query is enabled.',
messages: [
"Disable the queries you don't want to display, keeping only the one whose value you want to show.",
],
};
}

View File

@@ -41,40 +41,6 @@ function makePanel(overrides?: {
} as unknown as DashboardtypesPanelDTO;
}
// Blank name so the editor-preview guard test relies on the warning alone.
function makePanelWithQueries(
kind: string,
enabled: number,
disabled = 0,
): DashboardtypesPanelDTO {
const envelope = (isDisabled: boolean): unknown => ({
type: 'builder_query',
spec: { disabled: isDisabled },
});
return {
kind: 'Panel',
spec: {
display: { name: '' },
plugin: { kind, spec: {} },
queries: [
{
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
...Array.from({ length: enabled }, () => envelope(false)),
...Array.from({ length: disabled }, () => envelope(true)),
],
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const baseProps = {
panel: makePanel(),
panelId: 'panel-1',
@@ -135,53 +101,6 @@ describe('PanelHeader status indicators', () => {
});
});
describe('PanelHeader multi-query config warning (issue #9512)', () => {
it('warns when a Number panel has more than one enabled query', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/NumberPanel', 2)}
/>,
);
expect(screen.getByTestId('panel-status-config-warning')).toBeInTheDocument();
});
it('does not warn when only one query is enabled (others disabled)', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/NumberPanel', 1, 2)}
/>,
);
expect(
screen.queryByTestId('panel-status-config-warning'),
).not.toBeInTheDocument();
});
it('does not warn for non-Number panels with multiple enabled queries', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/TimeSeriesPanel', 3)}
/>,
);
expect(
screen.queryByTestId('panel-status-config-warning'),
).not.toBeInTheDocument();
});
it('shows the warning in the editor preview (hideActions, no title)', () => {
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanelWithQueries('signoz/NumberPanel', 2)}
hideActions
/>,
);
expect(screen.getByTestId('panel-status-config-warning')).toBeInTheDocument();
});
});
describe('PanelHeader search', () => {
it('renders no search affordance when the panel is not searchable', () => {
renderWithProvider(<PanelHeader {...baseProps} />);

View File

@@ -1,6 +1,6 @@
import { render, renderHook, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardtypesDynamicVariableSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
@@ -63,6 +63,7 @@ jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel',
() => ({
emptyVariableFormModel: (): unknown => ({}),
DYNAMIC_SIGNAL_ALL: 'all',
}),
);
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
@@ -83,7 +84,7 @@ function renderItems(): void {
const { result } = renderHook(() =>
useDrilldownDashboardVariables({
filters,
signal: DashboardtypesDynamicVariableSignalDTO.metrics,
signal: TelemetrytypesSignalDTO.metrics,
onClose: jest.fn(),
}),
);

View File

@@ -19,7 +19,6 @@ import { buildAggregateData } from 'pages/DashboardPageV2/DashboardContainer/Pan
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { dynamicSignalFromQuerySignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/signal';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { EQueryType } from 'types/common/dashboard';
@@ -164,7 +163,7 @@ export function useDrilldown(
const dashboardVariables = useDrilldownDashboardVariables({
filters: context?.filters ?? EMPTY_FILTERS,
signal: dynamicSignalFromQuerySignal(context?.signal),
signal: context?.signal,
onClose: handleClose,
});
@@ -222,7 +221,7 @@ export function useDrilldown(
context={context}
query={v1Query}
isResolving={isResolving}
links={panel.spec.links}
links={panel.spec.links ?? undefined}
canSetDashboardVariables={dashboardVariables.hasFieldVariables}
onViewLogs={(): void => navigate('view_logs')}
onViewTraces={(): void => navigate('view_traces')}

View File

@@ -1,13 +1,14 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import { DashboardtypesDynamicVariableSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import {
dtoToFormModel,
formModelToDto,
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import {
DYNAMIC_SIGNAL_ALL,
emptyVariableFormModel,
type VariableFormModel,
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel';
@@ -22,8 +23,8 @@ import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
interface UseDrilldownDashboardVariablesArgs {
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
filters: FilterData[];
/** Dynamic-variable signal derived from the clicked query — seeds a created variable's `dynamicSignal`. */
signal?: DashboardtypesDynamicVariableSignalDTO;
/** Clicked query's telemetry signal — seeds a created variable's `dynamicSignal`. */
signal?: TelemetrytypesSignalDTO;
onClose: () => void;
}
@@ -110,7 +111,8 @@ export function useDrilldownDashboardVariables({
type: 'DYNAMIC',
multiSelect: true,
dynamicAttribute: fieldName,
dynamicSignal: signal ?? DashboardtypesDynamicVariableSignalDTO.all,
// `||` (not `??`): an empty "any" signal maps to All, same as unset.
dynamicSignal: signal || DYNAMIC_SIGNAL_ALL,
};
try {
await patchAsync(

View File

@@ -1,8 +1,5 @@
import { useEffect, useMemo } from 'react';
import {
DashboardtypesDynamicVariableSignalDTO,
type DashboardtypesGettableDashboardV2DTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import type {
IDashboardVariable,
@@ -11,6 +8,7 @@ import type {
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import {
DYNAMIC_SIGNAL_ALL,
type VariableFormModel,
type VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
@@ -37,7 +35,7 @@ function toV1Variable(model: VariableFormModel): IDashboardVariable {
showALLOption: model.showAllOption,
dynamicVariablesAttribute: model.dynamicAttribute,
dynamicVariablesSource:
model.dynamicSignal === DashboardtypesDynamicVariableSignalDTO.all
model.dynamicSignal === DYNAMIC_SIGNAL_ALL
? 'all sources'
: model.dynamicSignal,
};

View File

@@ -49,7 +49,6 @@ export function createDefaultPanel(
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
links: [],
},
};
}

View File

@@ -62,7 +62,6 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
layouts: [],
panels: {},
variables: [],
links: [],
},
});
void logEvent(DashboardListEvents.DashboardCreated, {

View File

@@ -112,15 +112,12 @@ functionCall
;
/*
* Full-text search call: search('needle')
*
* Uses the shared functionParamList so future scoped forms like
* search(body, 'abc') / search(attribute, 'abc') need no grammar change. Today
* only a single needle is supported. Unlike bare/quoted free text (`fullText`),
* which only targets the body column, search() fans out across every field.
* Full-text search: search('needle') or scoped search('needle', body, ...).
* First param is the needle; the rest are field-context scopes (body/attribute/
* resource/log), quoted or bare. Handled in the visitor — no grammar change.
*/
searchCall
: SEARCH LPAREN functionParamList RPAREN
: SEARCH LPAREN valueList RPAREN
;
// Function parameters can be keys, single scalar values, or arrays

View File

@@ -29,13 +29,19 @@ func New(t *testing.T) flagger.Flagger {
// WithUseJSONBody returns a Flagger with use_json_body set to the given value.
func WithUseJSONBody(t *testing.T, enabled bool) flagger.Flagger {
return WithBooleanFlags(t, map[string]bool{
flagger.FeatureUseJSONBody.String(): enabled,
})
}
// WithBooleanFlags returns a Flagger with the given boolean feature flags set to
// the provided values (keyed by feature name, e.g. flagger.FeatureX.String()).
func WithBooleanFlags(t *testing.T, flags map[string]bool) flagger.Flagger {
t.Helper()
registry := flagger.MustNewRegistry()
cfg := flagger.Config{}
if enabled {
cfg.Config.Boolean = map[string]bool{
flagger.FeatureUseJSONBody.String(): true,
}
if len(flags) > 0 {
cfg.Config.Boolean = flags
}
fl, err := flagger.New(
context.Background(),

View File

@@ -35,7 +35,7 @@ func (c *conditionBuilder) ConditionFor(
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only; reject for rule state history.
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for rule state history.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}

File diff suppressed because one or more lines are too long

View File

@@ -137,8 +137,8 @@ func filterqueryParserInit() {
0, 0, 191, 19, 1, 0, 0, 0, 192, 190, 1, 0, 0, 0, 193, 194, 7, 2, 0, 0,
194, 21, 1, 0, 0, 0, 195, 196, 7, 3, 0, 0, 196, 197, 5, 1, 0, 0, 197, 198,
3, 26, 13, 0, 198, 199, 5, 2, 0, 0, 199, 23, 1, 0, 0, 0, 200, 201, 5, 27,
0, 0, 201, 202, 5, 1, 0, 0, 202, 203, 3, 26, 13, 0, 203, 204, 5, 2, 0,
0, 204, 25, 1, 0, 0, 0, 205, 210, 3, 28, 14, 0, 206, 207, 5, 5, 0, 0, 207,
0, 0, 201, 202, 5, 1, 0, 0, 202, 203, 3, 18, 9, 0, 203, 204, 5, 2, 0, 0,
204, 25, 1, 0, 0, 0, 205, 210, 3, 28, 14, 0, 206, 207, 5, 5, 0, 0, 207,
209, 3, 28, 14, 0, 208, 206, 1, 0, 0, 0, 209, 212, 1, 0, 0, 0, 210, 208,
1, 0, 0, 0, 210, 211, 1, 0, 0, 0, 211, 27, 1, 0, 0, 0, 212, 210, 1, 0,
0, 0, 213, 217, 3, 34, 17, 0, 214, 217, 3, 32, 16, 0, 215, 217, 3, 30,
@@ -2945,7 +2945,7 @@ type ISearchCallContext interface {
// Getter signatures
SEARCH() antlr.TerminalNode
LPAREN() antlr.TerminalNode
FunctionParamList() IFunctionParamListContext
ValueList() IValueListContext
RPAREN() antlr.TerminalNode
// IsSearchCallContext differentiates from other interfaces.
@@ -2992,10 +2992,10 @@ func (s *SearchCallContext) LPAREN() antlr.TerminalNode {
return s.GetToken(FilterQueryParserLPAREN, 0)
}
func (s *SearchCallContext) FunctionParamList() IFunctionParamListContext {
func (s *SearchCallContext) ValueList() IValueListContext {
var t antlr.RuleContext
for _, ctx := range s.GetChildren() {
if _, ok := ctx.(IFunctionParamListContext); ok {
if _, ok := ctx.(IValueListContext); ok {
t = ctx.(antlr.RuleContext)
break
}
@@ -3005,7 +3005,7 @@ func (s *SearchCallContext) FunctionParamList() IFunctionParamListContext {
return nil
}
return t.(IFunctionParamListContext)
return t.(IValueListContext)
}
func (s *SearchCallContext) RPAREN() antlr.TerminalNode {
@@ -3064,7 +3064,7 @@ func (p *FilterQueryParser) SearchCall() (localctx ISearchCallContext) {
}
{
p.SetState(202)
p.FunctionParamList()
p.ValueList()
}
{
p.SetState(203)

View File

@@ -20,6 +20,8 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
const estimateTimeout = 5 * time.Second
const traceOutsideRangeWarn = "Query %s references a trace_id that exists between %s and %s (UTC) but lies outside the selected time range; adjust the time range to see results"
type builderQuery[T any] struct {
@@ -247,6 +249,10 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return nil, err
}
if err := q.enforceEstimate(ctx, stmt); err != nil {
return nil, err
}
// Execute the query with proper context for partial value detection
result, err := q.executeWithContext(ctx, stmt.Query, stmt.Args)
if err != nil {
@@ -258,6 +264,70 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return result, nil
}
// estimateRows runs EXPLAIN ESTIMATE for a cost-guarded statement and returns its
// estimated total scan rows. guarded=false means there is nothing to enforce (no
// CostGuard or a non-positive budget). A non-nil error means the query must be
// rejected: either the estimate itself failed (fail closed — we cannot honor the
// budget without it) or the parent context was cancelled (surfaced as-is). Callers
// enforce the budget so it can be applied per-statement or cumulatively.
func (q *builderQuery[T]) estimateRows(ctx context.Context, stmt *qbtypes.Statement) (int64, bool, error) {
if stmt.CostGuard == nil || stmt.CostGuard.MaxScanRows <= 0 {
return 0, false, nil
}
estCtx, cancel := context.WithTimeout(ctx, estimateTimeout)
defer cancel()
entries, err := q.telemetryStore.Estimate(estCtx, stmt.Query, stmt.Args...)
if err != nil {
// Parent cancellation isn't the budget's concern — surface it unchanged.
if ctx.Err() != nil {
return 0, true, ctx.Err()
}
// Fail closed: this is a scan-heavy statement and without an estimate we
// cannot bound its cost, so running it unbounded is exactly what the guard
// exists to prevent.
reason := fmt.Sprintf("This query is too broad to plan within %s; narrow the time range or add a more selective filter.", estimateTimeout)
if estCtx.Err() != context.DeadlineExceeded {
reason = "Could not estimate this query's scan cost; narrow the time range or add a more selective filter."
q.logger.WarnContext(ctx, "EXPLAIN ESTIMATE failed; rejecting scan-heavy query (fail-closed)", errors.Attr(err))
}
return 0, true, errors.NewInvalidInputf(errors.CodeInvalidInput, "%s", withAdvisory(stmt.CostGuard.Warning, reason))
}
var rows int64
for _, e := range entries {
rows += e.Rows
}
return rows, true, nil
}
// enforceEstimate rejects a single scan-heavy statement (Statement.CostGuard) whose
// EXPLAIN ESTIMATE rows exceed its budget, before executing. Budget 0 disables.
func (q *builderQuery[T]) enforceEstimate(ctx context.Context, stmt *qbtypes.Statement) error {
rows, guarded, err := q.estimateRows(ctx, stmt)
if err != nil {
return err
}
if !guarded {
return nil
}
if budget := stmt.CostGuard.MaxScanRows; rows > budget {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "%s",
withAdvisory(stmt.CostGuard.Warning, fmt.Sprintf("This query would scan about %d rows in this range, over the limit of %d; narrow the time range or add a more selective filter.", rows, budget)))
}
return nil
}
// withAdvisory prefixes reason with the requirement's advisory (e.g. the search()
// warning) when present, so the rejection leads with why the query is expensive.
func withAdvisory(advisory, reason string) string {
if advisory == "" {
return reason
}
return strings.TrimRight(advisory, ". ") + ". " + reason
}
// narrowWindowByTraceID inspects the filter for trace_id predicates and clamps
// [fromMS,toMS] to the time range stored in signoz_traces.distributed_trace_summary.
// Returns the (possibly narrowed) window, overlap=false when the trace lies
@@ -491,6 +561,11 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
var warnings []string
var warningsDocURL string
// Cost guard is enforced against the cumulative estimate across the buckets we
// actually visit — a broad search() that fans every bucket must be bounded by
// the per-query budget, not let each bucket pass its slice independently.
var estimatedScan int64
for _, r := range buckets {
q.spec.Offset = 0
q.spec.Limit = need
@@ -501,6 +576,17 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
}
warnings = stmt.Warnings
warningsDocURL = stmt.WarningsDocURL
rowsEst, guarded, err := q.estimateRows(ctx, stmt)
if err != nil {
return nil, err
}
if guarded {
estimatedScan += rowsEst
if budget := stmt.CostGuard.MaxScanRows; estimatedScan > budget {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "%s",
withAdvisory(stmt.CostGuard.Warning, fmt.Sprintf("This query would scan about %d rows across the time range, over the limit of %d; narrow the time range or add a more selective filter.", estimatedScan, budget)))
}
}
// Execute with proper context for partial value detection
res, err := q.executeWithContext(ctx, stmt.Query, stmt.Args)
if err != nil {

View File

@@ -27,6 +27,8 @@ type Config struct {
SkipResourceFingerprint SkipResourceFingerprint `yaml:"skip_resource_fingerprint" mapstructure:"skip_resource_fingerprint"`
// LogTraceIDWindowPadding is the padding added to narrowed down timerange from trace summary to logs with trace_id filter.
LogTraceIDWindowPadding time.Duration `yaml:"log_trace_id_window_padding" mapstructure:"log_trace_id_window_padding"`
// SearchMaxScanRows caps the rows a search() query may scan, enforced via
SearchMaxScanRows int64 `yaml:"search_max_scan_rows" mapstructure:"search_max_scan_rows"`
}
// NewConfigFactory creates a new config factory for querier.
@@ -45,6 +47,7 @@ func newConfig() factory.Config {
Threshold: 100000,
},
LogTraceIDWindowPadding: 5 * time.Minute,
SearchMaxScanRows: 100_000_000,
}
}
@@ -65,6 +68,9 @@ func (c Config) Validate() error {
if c.LogTraceIDWindowPadding < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "log_trace_id_window_padding must not be negative, got %v", c.LogTraceIDWindowPadding)
}
if c.SearchMaxScanRows < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "search_max_scan_rows must not be negative, got %v", c.SearchMaxScanRows)
}
return nil
}

View File

@@ -124,6 +124,7 @@ func newProvider(
telemetryStore,
cfg.SkipResourceFingerprint.Enabled,
cfg.SkipResourceFingerprint.Threshold,
telemetrylogs.WithSearchMaxScanRows(cfg.SearchMaxScanRows),
)
// Create audit statement builder

View File

@@ -8,6 +8,10 @@ const (
// BodyFullTextSearchDefaultWarning is emitted when a full-text search or "body" searches are hit
// with New JSON Body enhancements.
BodyFullTextSearchDefaultWarning = "Full text searches default to `body.message:string`. Use `body.<key>` to search a different field inside body"
// SearchWarning is emitted on every search() call. search() scans all fields,
// so it is slow and expensive; a specific field is cheaper.
SearchWarning = "search() runs across all fields and can be slow and expensive. Prefer a specific field, e.g. `<context>.<field_key>:<type>`"
)
var (

View File

@@ -161,14 +161,16 @@ func inferDataTypesFromList(values []any) []telemetrytypes.FieldDataType {
return out
}
// NewFunctionUnsupportedError returns the error for a has/hasAny/hasAll/hasToken operator
// on a builder that doesn't support it (logs body only), or nil for other operators.
// NewFunctionUnsupportedError returns the error for a has/hasAny/hasAll/hasToken/search
// operator on a builder that doesn't support it (logs only), or nil for other operators.
func NewFunctionUnsupportedError(operator qbtypes.FilterOperator) error {
switch operator {
case qbtypes.FilterOperatorHasToken:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `hasToken` only supports body field as first parameter").WithUrl(hasTokenFunctionDocURL)
case qbtypes.FilterOperatorHas, qbtypes.FilterOperatorHasAny, qbtypes.FilterOperatorHasAll:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` supports only body JSON search", operator.FunctionName()).WithUrl(functionBodyJSONSearchDocURL)
case qbtypes.FilterOperatorSearch:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `search` is only supported for logs")
default:
return nil
}

View File

@@ -43,6 +43,8 @@ type filterExpressionVisitor struct {
keysWithWarnings map[string]bool
startNs uint64
endNs uint64
requiresCostGuard bool
}
type FilterExprVisitorOpts struct {
@@ -81,9 +83,10 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
}
type PreparedWhereClause struct {
WhereClause *sqlbuilder.WhereClause
Warnings []string
WarningsDocURL string
WhereClause *sqlbuilder.WhereClause
Warnings []string
WarningsDocURL string
RequiresCostGuard bool
}
func (p PreparedWhereClause) IsEmpty() bool {
@@ -165,12 +168,12 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (PreparedWhere
// Return empty where clause so callers can skip the WHERE clause
if cond == "" || cond == SkipConditionLiteral {
return PreparedWhereClause{WhereClause: nil, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
return PreparedWhereClause{WhereClause: nil, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL, RequiresCostGuard: visitor.requiresCostGuard}, nil
}
whereClause := sqlbuilder.NewWhereClause().AddWhereExpr(visitor.builder.Args, cond)
return PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
return PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL, RequiresCostGuard: visitor.requiresCostGuard}, nil
}
// Visit dispatches to the specific visit method based on node type.
@@ -776,11 +779,79 @@ func normalizeFunctionValue(operator qbtypes.FilterOperator, functionName string
return valueParams, nil
}
// VisitSearchCall handles search('needle'). The search() function is parsed but
// not yet implemented; reject it with a clear invalid-input error.
// VisitSearchCall handles search('needle') and its scoped forms, e.g.
// search('needle', body, resource). The first arg is the case-insensitive needle; the
// rest are field-context scopes (bare or quoted). Emits one FilterOperatorSearch per
// scope (none = keyless, covering every field) and ORs them.
func (v *filterExpressionVisitor) VisitSearchCall(ctx *grammar.SearchCallContext) any {
v.errors = append(v.errors, "function `search` is not yet supported")
return ErrorConditionLiteral
// Flag scan-heavy so the statement builder attaches the cost guard.
v.requiresCostGuard = true
valueList := ctx.ValueList()
if valueList == nil {
v.errors = append(v.errors, "function `search` expects a needle, e.g. search('error')")
return ErrorConditionLiteral
}
params := valueList.AllValue()
if len(params) == 0 {
v.errors = append(v.errors, "function `search` expects a needle, e.g. search('error')")
return ErrorConditionLiteral
}
searchText, ok := searchParamText(params[0])
if !ok {
v.errors = append(v.errors, "function `search` expects a needle as its first argument, e.g. search('error')")
return ErrorConditionLiteral
}
var fieldContexts []telemetrytypes.FieldContext
if len(params) == 1 {
fieldContexts = []telemetrytypes.FieldContext{telemetrytypes.FieldContextUnspecified}
} else {
for _, p := range params[1:] {
scopeText, sok := searchParamText(p)
if !sok {
v.errors = append(v.errors, "function `search` expects each scope to be a context, e.g. search('error', body, resource)")
return ErrorConditionLiteral
}
fc, fok := telemetrytypes.FieldContextFromText(scopeText)
if !fok {
v.errors = append(v.errors, fmt.Sprintf("invalid search scope %q; expected a field context: body, attribute, resource, or log", scopeText))
return ErrorConditionLiteral
}
fieldContexts = append(fieldContexts, fc)
}
}
var conds []string
for _, fieldContext := range fieldContexts {
key := telemetrytypes.NewTelemetryFieldKey("", fieldContext, telemetrytypes.FieldDataTypeUnspecified)
scoped, cok := v.buildConditions(key, nil, qbtypes.FilterOperatorSearch, searchText)
if !cok {
return ErrorConditionLiteral
}
conds = append(conds, scoped...)
}
if len(conds) == 0 {
return SkipConditionLiteral
}
if len(conds) == 1 {
return conds[0]
}
return v.builder.Or(conds...)
}
// searchParamText returns the raw token text of a search() argument (quoted or bare),
// not the visited value — so a bare word stays literal and search(1000000) doesn't
// become "1e+06".
func searchParamText(val grammar.IValueContext) (string, bool) {
if val == nil {
return "", false
}
if val.QUOTED_TEXT() != nil {
return trimQuotes(val.QUOTED_TEXT().GetText()), true
}
return val.GetText(), true
}
// VisitFunctionParamList handles the parameter list for function calls.

View File

@@ -134,7 +134,7 @@ func (c *conditionBuilder) ConditionFor(
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only; reject for audit.
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for audit.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}

View File

@@ -3,6 +3,7 @@ package telemetrylogs
import (
"context"
"fmt"
"regexp"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
@@ -27,6 +28,52 @@ func NewConditionBuilder(fm qbtypes.FieldMapper, fl flagger.Flagger) *conditionB
return &conditionBuilder{fm: fm, fl: fl}
}
// conditionForSearch ORs a case-insensitive match of the needle across the searchable
// columns of the key's field context (an unspecified context covers every column).
func (c *conditionBuilder) conditionForSearch(
ctx context.Context,
orgID valuer.UUID,
key *telemetrytypes.TelemetryFieldKey,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// Literal case-insensitive substring match: QuoteMeta the needle and LOWER both sides
// so the body path can use the LOWER(toString(body_v2)) skip index (a (?i) regex could not).
needle := regexp.QuoteMeta(fmt.Sprintf("%v", value))
useJSONBody := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
var conditions []string
for _, col := range searchColumns(key.FieldContext, useJSONBody) {
switch col.Type.GetType() {
case schema.ColumnTypeEnumMap:
keysExpr := fmt.Sprintf("mapKeys(%s)", col.Name)
valsExpr := fmt.Sprintf("mapValues(%s)", col.Name)
// match() needs a String array; cast non-string map values first.
if mc, ok := col.Type.(schema.MapColumnType); ok && mc.ValueType.GetType() != schema.ColumnTypeEnumString {
valsExpr = fmt.Sprintf("arrayMap(x -> toString(x), mapValues(%s))", col.Name)
}
conditions = append(conditions, sb.Or(
fmt.Sprintf("arrayExists(x -> match(LOWER(x), LOWER(%s)), %s)", sb.Var(needle), keysExpr),
fmt.Sprintf("arrayExists(x -> match(LOWER(x), LOWER(%s)), %s)", sb.Var(needle), valsExpr),
))
case schema.ColumnTypeEnumJSON:
conditions = append(conditions, fmt.Sprintf("match(LOWER(toString(%s)), LOWER(%s))", col.Name, sb.Var(needle)))
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumLowCardinality:
conditions = append(conditions, fmt.Sprintf("match(LOWER(%s), LOWER(%s))", col.Name, sb.Var(needle)))
default:
return nil, nil, errors.NewInternalf(errors.CodeInternal, "search does not support the column type of %q", col.Name)
}
}
if len(conditions) == 0 {
return nil, nil, nil
}
// The advisory rides on CostGuard (set by the visitor), not warnings.
return []string{sb.Or(conditions...)}, nil, nil
}
// isBodyJSONSearch reports whether a key addresses a path within the body JSON. Only
// an explicit Body context qualifies; a bare, context-less `body` (e.g. full-text
// `count_distinct(body)` or `body EXISTS`) is a full-text match, not a `$.body` path.
@@ -378,6 +425,11 @@ func (c *conditionBuilder) ConditionFor(
matches := querybuilder.MatchingFieldKeys(key, fieldKeys)
skipResourceFilter := options.SkipResourceFilter
// search() resolves its own (optional) scope; handle it before key resolution.
if operator == qbtypes.FilterOperatorSearch {
return c.conditionForSearch(ctx, orgID, key, value, sb)
}
keys, warning := querybuilder.ResolveKeys(key, matches)
var warnings []string
if warning != "" {

View File

@@ -635,3 +635,37 @@ func (m *fieldMapper) existsExpressionFor(
}
return querybuilder.ExistsExpression(columns, key, tsStart, tsEnd, fieldExpression, exists)
}
// searchColumns is the single source of truth for the columns search() fans out
// across, by field context. Body is body_v2 JSON when useJSONBody, else body string.
func searchColumns(fieldContext telemetrytypes.FieldContext, useJSONBody bool) []*schema.Column {
switch fieldContext {
case telemetrytypes.FieldContextLog:
return []*schema.Column{
logsV2Columns[LogsV2SeverityTextColumn],
logsV2Columns[LogsV2TraceIDColumn],
logsV2Columns[LogsV2SpanIDColumn],
}
case telemetrytypes.FieldContextBody:
if useJSONBody {
return []*schema.Column{logsV2Columns[LogsV2BodyV2Column]}
}
return []*schema.Column{logsV2Columns[LogsV2BodyColumn]}
case telemetrytypes.FieldContextAttribute:
return []*schema.Column{
logsV2Columns[LogsV2AttributesStringColumn],
logsV2Columns[LogsV2AttributesNumberColumn],
logsV2Columns[LogsV2AttributesBoolColumn],
}
case telemetrytypes.FieldContextResource:
return []*schema.Column{
logsV2Columns[LogsV2ResourcesStringColumn],
}
default:
columns := searchColumns(telemetrytypes.FieldContextLog, useJSONBody)
columns = append(columns, searchColumns(telemetrytypes.FieldContextBody, useJSONBody)...)
columns = append(columns, searchColumns(telemetrytypes.FieldContextAttribute, useJSONBody)...)
columns = append(columns, searchColumns(telemetrytypes.FieldContextResource, useJSONBody)...)
return columns
}
}

View File

@@ -0,0 +1,322 @@
package telemetrylogs
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"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/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/require"
)
// searchFanOut returns the WHERE fragment search() fans out to. bodyExpr is the
// body match expression, which differs between the legacy string body and the
// body_v2 JSON column.
func searchFanOut(bodyExpr string) string {
return "(match(LOWER(severity_text), LOWER(?)) OR match(LOWER(trace_id), LOWER(?)) OR match(LOWER(span_id), LOWER(?)) OR " +
bodyExpr + " OR " +
"(arrayExists(x -> match(LOWER(x), LOWER(?)), mapKeys(attributes_string)) OR arrayExists(x -> match(LOWER(x), LOWER(?)), mapValues(attributes_string))) OR " +
"(arrayExists(x -> match(LOWER(x), LOWER(?)), mapKeys(attributes_number)) OR arrayExists(x -> match(LOWER(x), LOWER(?)), arrayMap(x -> toString(x), mapValues(attributes_number)))) OR " +
"(arrayExists(x -> match(LOWER(x), LOWER(?)), mapKeys(attributes_bool)) OR arrayExists(x -> match(LOWER(x), LOWER(?)), arrayMap(x -> toString(x), mapValues(attributes_bool)))) OR " +
"(arrayExists(x -> match(LOWER(x), LOWER(?)), mapKeys(resources_string)) OR arrayExists(x -> match(LOWER(x), LOWER(?)), mapValues(resources_string))))"
}
// searchArgs returns v repeated once per bound parameter search() emits — one per
// searchable column expression (currently 12).
func searchArgs(v any) []any {
const searchColumnParams = 12
args := make([]any, searchColumnParams)
for i := range args {
args[i] = v
}
return args
}
// TestFilterExprSearch covers the search('needle') function, which fans out
// across every searchable column via FilterOperatorSearch.
func TestFilterExprSearch(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
inWindowStart := uint64(releaseTime.Add(-5 * time.Minute).UnixNano())
inWindowEnd := uint64(releaseTime.Add(5 * time.Minute).UnixNano())
legacyBody := "match(LOWER(body), LOWER(?))"
jsonBody := "match(LOWER(toString(body_v2)), LOWER(?))"
// Single-context scope fragments (the fan-out narrowed to one context).
logScope := "(match(LOWER(severity_text), LOWER(?)) OR match(LOWER(trace_id), LOWER(?)) OR match(LOWER(span_id), LOWER(?)))"
resourceScope := "(arrayExists(x -> match(LOWER(x), LOWER(?)), mapKeys(resources_string)) OR arrayExists(x -> match(LOWER(x), LOWER(?)), mapValues(resources_string)))"
serviceNameEq := "(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? " +
"AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)"
testCases := []struct {
name string
query string
jsonBodyEnabled bool
fullTextColumn *telemetrytypes.TelemetryFieldKey
startNs uint64
endNs uint64
shouldPass bool
expectedQuery string
expectedArgs []any
expectWarning bool
expectedErrorContains string
}{
{
name: "quoted, legacy body",
query: "search('error')",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE " + searchFanOut(legacyBody),
expectedArgs: searchArgs("error"),
expectWarning: true,
},
{
name: "quoted, json body",
query: "search('error')",
jsonBodyEnabled: true,
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE " + searchFanOut(jsonBody),
expectedArgs: searchArgs("error"),
expectWarning: true,
},
{
name: "bare word",
query: "search(timeout)",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE " + searchFanOut(legacyBody),
expectedArgs: searchArgs("timeout"),
expectWarning: true,
},
{
name: "negated",
query: "NOT search('error')",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE NOT (" + searchFanOut(legacyBody) + ")",
expectedArgs: searchArgs("error"),
expectWarning: true,
},
{
name: "combined with field filter",
query: "search('error') AND service.name=\"api\"",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE (" + searchFanOut(legacyBody) + " AND " + serviceNameEq + ")",
expectedArgs: append(searchArgs("error"), "api"),
expectWarning: true,
},
{
// A wide window is allowed at build time; scan cost is bounded by the
// querier's EXPLAIN ESTIMATE gate, not a window cap in the builder.
name: "wide window builds (estimate gate lives in querier)",
query: "search('error')",
fullTextColumn: DefaultFullTextColumn,
startNs: uint64(releaseTime.Add(-10 * time.Hour).UnixNano()),
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE " + searchFanOut(legacyBody),
expectedArgs: searchArgs("error"),
expectWarning: true,
},
{
// search() is keyless and independent of fullTextColumn (which only
// governs bare/quoted free text). It must work even when unset.
name: "independent of full text column",
query: "search('error')",
fullTextColumn: nil,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE " + searchFanOut(legacyBody),
expectedArgs: searchArgs("error"),
expectWarning: true,
},
{
// A context-prefixed bare word must be used as the literal needle, not
// normalized into a field key (Normalize would strip "resource.").
name: "bare word with context prefix is not normalized",
query: "search(resource.deployment)",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE " + searchFanOut(legacyBody),
expectedArgs: searchArgs("resource\\.deployment"),
expectWarning: true,
},
{
// A numeric needle must be the literal digits, not the %v rendering of a
// parsed float64 (which would make search(1000000) scan for "1e+06").
name: "numeric needle is not scientific notation",
query: "search(1000000)",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE " + searchFanOut(legacyBody),
expectedArgs: searchArgs("1000000"),
expectWarning: true,
},
{
name: "scoped to body, legacy",
query: "search('error', body)",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE (" + legacyBody + ")",
expectedArgs: []any{"error"},
expectWarning: true,
},
{
name: "scoped to body, json",
query: "search('error', body)",
jsonBodyEnabled: true,
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE (" + jsonBody + ")",
expectedArgs: []any{"error"},
expectWarning: true,
},
{
name: "scoped to resource (quoted scope)",
query: "search('error', 'resource')",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE (" + resourceScope + ")",
expectedArgs: []any{"error", "error"},
expectWarning: true,
},
{
name: "scoped to log fields",
query: "search('error', log)",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE " + logScope,
expectedArgs: []any{"error", "error", "error"},
expectWarning: true,
},
{
name: "scoped to multiple contexts",
query: "search('error', body, resource)",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: true,
expectedQuery: "WHERE ((" + legacyBody + ") OR (" + resourceScope + "))",
expectedArgs: []any{"error", "error", "error"},
expectWarning: true,
},
{
name: "invalid scope",
query: "search('error', 'timeout')",
fullTextColumn: DefaultFullTextColumn,
startNs: inWindowStart,
endNs: inWindowEnd,
shouldPass: false,
expectedErrorContains: "invalid search scope",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureUseJSONBody.String(): tc.jsonBodyEnabled,
})
fm := NewFieldMapper(fl)
cb := NewConditionBuilder(fm, fl)
keys := buildCompleteFieldKeyMap(releaseTime)
opts := querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: tc.fullTextColumn,
StartNs: tc.startNs,
EndNs: tc.endNs,
}
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
if !tc.shouldPass {
require.Error(t, err)
require.True(t, detailContains(err, tc.expectedErrorContains),
"error %v should contain %q", err, tc.expectedErrorContains)
return
}
require.NoError(t, err)
require.False(t, clause.IsEmpty())
sql, args := clause.WhereClause.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, tc.expectedQuery, sql)
require.Equal(t, tc.expectedArgs, args)
if tc.expectWarning {
// The visitor only flags the cost guard; the statement builder
// materializes the advisory + budget from config downstream.
require.True(t, clause.RequiresCostGuard)
}
})
}
}
// TestSearchCostGuard covers the search() path end-to-end through Build: the
// statement carries a CostGuard with its scan budget and the advisory warning.
func TestSearchCostGuard(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
ctx := context.Background()
start := uint64(releaseTime.Add(-5 * time.Minute).UnixMilli())
end := uint64(releaseTime.UnixMilli())
fl := flaggertest.WithBooleanFlags(t, map[string]bool{})
fm := NewFieldMapper(fl)
cb := NewConditionBuilder(fm, fl)
store := telemetrytypestest.NewMockMetadataStore()
store.KeysMap = buildCompleteFieldKeyMap(releaseTime)
rewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)
sb := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
store, fm, cb, rewriter, DefaultFullTextColumn, fl, nil, false, 100000,
WithSearchMaxScanRows(100000),
)
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "search('error')"},
Limit: 1,
}
stmt, err := sb.Build(ctx, valuer.UUID{}, start, end, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err)
require.NotNil(t, stmt.CostGuard)
require.Contains(t, stmt.Warnings, querybuilder.SearchWarning)
}

View File

@@ -29,11 +29,20 @@ type logQueryStatementBuilder struct {
fl flagger.Flagger
skipResourceFingerprintEnabled bool
fullTextColumn *telemetrytypes.TelemetryFieldKey
fullTextColumn *telemetrytypes.TelemetryFieldKey
searchMaxScanRows int64
}
var _ qbtypes.StatementBuilder[qbtypes.LogAggregation] = (*logQueryStatementBuilder)(nil)
type LogQueryStatementBuilderOption func(*logQueryStatementBuilder)
// WithSearchMaxScanRows sets the estimated-rows budget the querier enforces for
// search() statements (0 disables the gate).
func WithSearchMaxScanRows(n int64) LogQueryStatementBuilderOption {
return func(b *logQueryStatementBuilder) { b.searchMaxScanRows = n }
}
func NewLogQueryStatementBuilder(
settings factory.ProviderSettings,
metadataStore telemetrytypes.MetadataStore,
@@ -45,6 +54,7 @@ func NewLogQueryStatementBuilder(
telemetryStore telemetrystore.TelemetryStore,
skipResourceFingerprintEnable bool,
skipResourceFingerprintThreshold uint64,
opts ...LogQueryStatementBuilderOption,
) *logQueryStatementBuilder {
logsSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetrylogs")
@@ -61,7 +71,7 @@ func NewLogQueryStatementBuilder(
skipResourceFingerprintThreshold,
)
return &logQueryStatementBuilder{
b := &logQueryStatementBuilder{
logger: logsSettings.Logger(),
metadataStore: metadataStore,
fm: fieldMapper,
@@ -72,6 +82,10 @@ func NewLogQueryStatementBuilder(
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
fullTextColumn: fullTextColumn,
}
for _, opt := range opts {
opt(b)
}
return b
}
// Build builds a SQL query for logs based on the given parameters.
@@ -117,9 +131,23 @@ func (b *logQueryStatementBuilder) Build(
}
stmt.Warnings = append(stmt.Warnings, warnings...)
// The search flag is gated in the condition builder; here the advisory rides on
// the statement so the querier can enforce the scan budget from the CostGuard.
if stmt.CostGuard != nil && stmt.CostGuard.Warning != "" {
stmt.Warnings = append(stmt.Warnings, stmt.CostGuard.Warning)
}
return stmt, nil
}
// costGuardFor builds the cost guard for a scan-heavy (search()) statement,
// pairing the advisory with the configured scan budget. Returns nil otherwise.
func (b *logQueryStatementBuilder) costGuardFor(required bool) *qbtypes.CostGuard {
if !required {
return nil
}
return &qbtypes.CostGuard{Warning: querybuilder.SearchWarning, MaxScanRows: b.searchMaxScanRows}
}
func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], bodyJSONEnabled bool) ([]*telemetrytypes.FieldKeySelector, []string) {
var keySelectors []*telemetrytypes.FieldKeySelector
var warnings []string
@@ -357,6 +385,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
CostGuard: b.costGuardFor(preparedWhereClause.RequiresCostGuard),
}
return stmt, nil
@@ -523,6 +552,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
CostGuard: b.costGuardFor(preparedWhereClause.RequiresCostGuard),
}
return stmt, nil
@@ -650,6 +680,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
WarningsDocURL: preparedWhereClause.WarningsDocURL,
CostGuard: b.costGuardFor(preparedWhereClause.RequiresCostGuard),
}
return stmt, nil

View File

@@ -157,7 +157,7 @@ func (c *conditionBuilder) ConditionFor(
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only; reject for metrics.
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for metrics.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}

View File

@@ -205,7 +205,7 @@ func (c *conditionBuilder) ConditionFor(
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only; reject for traces.
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for traces.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}

View File

@@ -54,12 +54,10 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
},
},
},
Links: []Link{},
},
},
},
Layouts: []Layout{},
Links: []Link{},
}
return &DashboardV2{

View File

@@ -26,7 +26,7 @@ type DashboardSpec struct {
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval"`
Links []Link `json:"links" required:"true" nullable:"false"`
Links []Link `json:"links,omitzero"`
}
// ══════════════════════════════════════════════
@@ -45,16 +45,6 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
return d.Validate()
}
// validateLinks rejects a missing/null spec.links value: a typed client must
// send [] rather than omitting links, so its value round-trips faithfully.
// Panel links are the panel spec's concern, validated in validatePanels.
func (d *DashboardSpec) validateLinks() error {
if d.Links == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.links is required; send [] when there are no links")
}
return nil
}
// ══════════════════════════════════════════════
// Cross-field validation
// ══════════════════════════════════════════════
@@ -63,9 +53,6 @@ func (d *DashboardSpec) Validate() error {
if err := d.Display.Validate("dashboard", "spec.display.name"); err != nil {
return err
}
if err := d.validateLinks(); err != nil {
return err
}
if err := d.validateVariables(); err != nil {
return err
}
@@ -117,9 +104,6 @@ func (d *DashboardSpec) validatePanels() error {
if err := panel.Spec.Display.Validate("panel", path+".spec.display.name"); err != nil {
return err
}
if err := panel.Spec.validateLinks(path); err != nil {
return err
}
panelKind := panel.Spec.Plugin.Kind
if len(panel.Spec.Queries) != 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)

View File

@@ -23,7 +23,6 @@ const basePostableJSON = `{
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
"spec": {
"display": {"name": "Service overview"},
"links": [],
"variables": [
{
"kind": "ListVariable",
@@ -42,7 +41,6 @@ const basePostableJSON = `{
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -66,7 +64,6 @@ const basePostableJSON = `{
"p2": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
@@ -185,7 +182,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -220,7 +216,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -332,7 +327,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
// Appending needs a not-yet-placed panel, so add one in the same patch;
// re-placing p1 or p2 would be a duplicate reference.
out, err := decode(t, `[
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"links": [], "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
{"op": "add", "path": "/spec/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
]`).Apply(base)
require.NoError(t, err)
@@ -351,7 +346,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -502,7 +496,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {"links": [], "plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
}
}]`).Apply(base)
require.Error(t, err)
@@ -518,7 +512,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
@@ -544,7 +537,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {

View File

@@ -51,12 +51,10 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "NonExistentPanel", "spec": {}}
}
}
},
"links": [],
"layouts": []
}`)
@@ -78,7 +76,7 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
func TestValidateEmptySpec(t *testing.T) {
// no variables no panels
data := []byte(`{"links": []}`)
data := []byte(`{}`)
_, err := unmarshalDashboard(data)
assert.NoError(t, err, "expected valid")
}
@@ -109,7 +107,6 @@ func TestValidateOnlyVariables(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -136,7 +133,6 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -161,7 +157,6 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
}
@@ -191,7 +186,6 @@ func TestInvalidatePanelKey(t *testing.T) {
"bad key!": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -202,7 +196,6 @@ func TestInvalidatePanelKey(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -226,7 +219,6 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
}
@@ -290,7 +282,6 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
cases := map[string][]byte{
"text variable": []byte(`{
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
"links": [],
"layouts": []
}`),
"list variable": []byte(`{
@@ -303,7 +294,6 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
"plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}
}
}],
"links": [],
"layouts": []
}`),
}
@@ -329,12 +319,10 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "NonExistentPanel", "spec": {}}
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "NonExistentPanel",
@@ -350,7 +338,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknown panel kind",
@@ -362,7 +349,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -373,7 +359,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "FakeQueryPlugin",
@@ -385,7 +370,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
@@ -396,7 +380,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknown request type",
@@ -408,7 +391,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "",
@@ -419,7 +401,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknown request type",
@@ -436,7 +417,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"plugin": {"kind": "FakeVariable", "spec": {}}
}
}],
"links": [],
"layouts": []
}`,
wantContain: "FakeVariable",
@@ -450,7 +430,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"plugin": {"kind": "FakeDatasource", "spec": {}}
}
},
"links": [],
"layouts": []
}`,
wantContain: "FakeDatasource",
@@ -471,14 +450,13 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
"panels": {
"good": {
"kind": "Panel",
"spec": {"links": [],"plugin": {"kind": "signoz/NumberPanel", "spec": {}}}
"spec": {"plugin": {"kind": "signoz/NumberPanel", "spec": {}}}
},
"bad": {
"kind": "Panel",
"spec": {"links": [],"plugin": {"kind": "FakePanel", "spec": {}}}
"spec": {"plugin": {"kind": "FakePanel", "spec": {}}}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -491,7 +469,6 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -503,7 +480,7 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
}
}`
layout := func(items string) []byte {
return []byte(`{` + validPanels + `, "links": [], "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
return []byte(`{` + validPanels + `, "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
}
tests := []struct {
@@ -559,7 +536,6 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"bogusField": true}
@@ -567,7 +543,6 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "bogusField",
@@ -579,7 +554,6 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -593,7 +567,6 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknownThing",
@@ -613,7 +586,6 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
}
}
}],
"links": [],
"layouts": []
}`,
wantContain: "extraField",
@@ -642,7 +614,6 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"visualization": {"fillSpans": "notabool"}}
@@ -650,7 +621,6 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "fillSpans",
@@ -662,7 +632,6 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -676,7 +645,6 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "",
@@ -696,7 +664,6 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
}
}
}],
"links": [],
"layouts": []
}`,
wantContain: "",
@@ -727,7 +694,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
@@ -744,7 +710,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "signal",
@@ -756,7 +721,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"lineInterpolation": "cubic"}}
@@ -764,7 +728,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "line interpolation",
@@ -776,7 +739,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"lineStyle": "dotted"}}
@@ -784,7 +746,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "line style",
@@ -796,7 +757,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"fillMode": "striped"}}
@@ -804,7 +764,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "fill mode",
@@ -816,7 +775,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"spanGaps": {"fillOnlyBelow": true, "fillLessThan": "notaduration"}}}
@@ -824,7 +782,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "duration",
@@ -836,7 +793,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"visualization": {"timePreference": "last2Hr"}}
@@ -844,7 +800,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "timePreference",
@@ -856,7 +811,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {"legend": {"position": "top"}}
@@ -864,7 +818,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "legend position",
@@ -876,7 +829,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {"legend": {"mode": "grid"}}
@@ -884,7 +836,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "legend mode",
@@ -896,7 +847,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "operator": "above", "color": "Red", "format": "Color"}]}
@@ -904,7 +854,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "threshold format",
@@ -916,7 +865,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "operator": "!=", "color": "Red", "format": "text"}]}
@@ -924,7 +872,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "comparison operator",
@@ -936,7 +883,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"formatting": {"decimalPrecision": "9"}}
@@ -944,7 +890,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "precision",
@@ -976,13 +921,11 @@ func TestThresholdLabelOptional(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"thresholds": [` + tt.threshold + `]}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
@@ -1000,10 +943,9 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
"panels": {
"p1": {
"kind": "Panel",
"spec": {"links": [],"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}}}
"spec": {"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}}}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -1017,13 +959,11 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": []
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -1039,7 +979,6 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "metrics"}}}},
@@ -1048,7 +987,6 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -1068,7 +1006,6 @@ func TestValidateRequiredFields(t *testing.T) {
"plugin": {"kind": "` + pluginKind + `", "spec": ` + pluginSpec + `}
}
}],
"links": [],
"layouts": []
}`
}
@@ -1078,12 +1015,10 @@ func TestValidateRequiredFields(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": ` + panelSpec + `}
}
}
},
"links": [],
"layouts": []
}`
}
@@ -1148,7 +1083,6 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
@@ -1157,7 +1091,6 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
@@ -1200,7 +1133,6 @@ func TestNumberPanelDefaults(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
@@ -1209,7 +1141,6 @@ func TestNumberPanelDefaults(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
@@ -1263,7 +1194,6 @@ func TestStorageRoundTrip(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
@@ -1274,7 +1204,6 @@ func TestStorageRoundTrip(t *testing.T) {
"p2": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
@@ -1283,7 +1212,6 @@ func TestStorageRoundTrip(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
@@ -1344,7 +1272,7 @@ func TestStorageRoundTrip(t *testing.T) {
}
func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
const validSpec = `"spec": {"panels": {}, "layouts": [], "links": []}`
const validSpec = `"spec": {"panels": {}, "layouts": []}`
tests := []struct {
scenario string
@@ -1356,13 +1284,13 @@ func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
}{
{
scenario: "flag true with display.name derives name on conversion",
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"panels":{},"layouts":[],"links":[]}}`,
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"panels":{},"layouts":[]}}`,
wantName: "",
wantDisplay: "My Dashboard!",
},
{
scenario: "flag true with non-empty name is rejected",
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"panels":{},"layouts":[],"links":[]}}`,
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"panels":{},"layouts":[]}}`,
wantErr: true,
wantErrMatch: "name must be empty when generateName is true",
},
@@ -1522,24 +1450,20 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
mkQuery := func(panelKind, queryKind, querySpec string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": {}},
"queries": [{"kind": "` + requestKind(panelKind) + `", "spec": {"plugin": {"kind": "` + queryKind + `", "spec": ` + querySpec + `}}}]
}}},
"links": [],
"layouts": []
}`)
}
mkComposite := func(panelKind, subType, subSpec string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": {}},
"queries": [{"kind": "` + requestKind(panelKind) + `", "spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {
"queries": [{"type": "` + subType + `", "spec": ` + subSpec + `}]
}}}}]
}}},
"links": [],
"layouts": []
}`)
}
@@ -1583,13 +1507,11 @@ func TestCommaSeparatedAggregationRejectedOnWrite(t *testing.T) {
buildDashboardWithLogsAggregation := func(aggregationsJSON string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "logs", "aggregations": ` + aggregationsJSON + `
}}}}]
}}},
"links": [],
"layouts": []
}`)
}
@@ -1703,10 +1625,9 @@ func TestValidateGridItemLimit(t *testing.T) {
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"links": [],
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
@@ -1723,9 +1644,8 @@ func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
func TestInvalidateDuplicatePanelReference(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"links": [],
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
@@ -1755,31 +1675,31 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
}{
{
scenario: "dashboard display name",
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "links": [], "layouts": []}`,
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
},
{
scenario: "panel display name",
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [],"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
},
{
scenario: "list variable display name",
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "text variable display name",
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "links": [], "layouts": []}`,
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "layout title",
dashboardJSON: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
dashboardJSON: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
},
@@ -1799,7 +1719,7 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
// A display name at exactly the limit is accepted.
func TestValidateDisplayNameAtMaxLength(t *testing.T) {
atLimit := strings.Repeat("x", MaxDisplayNameLen)
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "links": [], "layouts": []}`))
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "layouts": []}`))
assert.NoError(t, err)
}

View File

@@ -228,7 +228,7 @@ func TestRedactVariableQueries(t *testing.T) {
t.Run("leaves non-query variables untouched", func(t *testing.T) {
spec := DashboardSpec{Variables: []Variable{
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "signal", Plugin: VariablePlugin{Kind: VariableKindDynamic, Spec: &DynamicVariableSpec{Name: "service.name", Signal: DynamicVariableSignalTraces}}}},
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "signal", Plugin: VariablePlugin{Kind: VariableKindDynamic, Spec: &DynamicVariableSpec{Name: "service.name", Signal: telemetrytypes.SignalTraces}}}},
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "env", Plugin: VariablePlugin{Kind: VariableKindCustom, Spec: &CustomVariableSpec{CustomValue: "prod,staging"}}}},
}}

View File

@@ -78,17 +78,7 @@ type PanelSpec struct {
Display Display `json:"display" required:"true"`
Plugin PanelPlugin `json:"plugin" required:"true"`
Queries []Query `json:"queries" required:"true" nullable:"false"`
Links []Link `json:"links" required:"true" nullable:"false"`
}
// validateLinks rejects a missing/null links field, where path is the panel's
// location (e.g. "spec.panels.<key>"). A typed client must send [] rather than
// omitting links, so its value round-trips faithfully.
func (s *PanelSpec) validateLinks(path string) error {
if s.Links == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.links is required; send [] when there are no links", path)
}
return nil
Links []Link `json:"links,omitzero"`
}
// Link replicates dashboard.Link (Perses) so its zero-valued fields survive the

View File

@@ -32,49 +32,7 @@ type DynamicVariableSpec struct {
// Name is the name of the attribute being fetched dynamically from the
// signal. This could be extended to a richer selector in the future.
Name string `json:"name" validate:"required" required:"true"`
Signal DynamicVariableSignal `json:"signal" required:"true" nullable:"false"`
}
// DynamicVariableSignal is the telemetry signal a dynamic variable draws its
// values from. Separate from telemetrytypes.Signal because it carries "all"
// (values span every signal) rather than "" for an unpinned query signal.
type DynamicVariableSignal struct{ valuer.String }
var (
DynamicVariableSignalTraces = DynamicVariableSignal{valuer.NewString("traces")}
DynamicVariableSignalLogs = DynamicVariableSignal{valuer.NewString("logs")}
DynamicVariableSignalMetrics = DynamicVariableSignal{valuer.NewString("metrics")}
DynamicVariableSignalAll = DynamicVariableSignal{valuer.NewString("all")} // default
)
func (DynamicVariableSignal) Enum() []any {
return []any{DynamicVariableSignalTraces, DynamicVariableSignalLogs, DynamicVariableSignalMetrics, DynamicVariableSignalAll}
}
func (s DynamicVariableSignal) ValueOrDefault() string {
if s.IsZero() {
return DynamicVariableSignalAll.StringValue()
}
return s.StringValue()
}
func (s DynamicVariableSignal) MarshalJSON() ([]byte, error) {
return json.Marshal(s.ValueOrDefault())
}
func (s *DynamicVariableSignal) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid signal: must be a string, one of `traces`, `logs`, `metrics`, or `all`")
}
sig := DynamicVariableSignal{valuer.NewString(v)}
switch sig {
case DynamicVariableSignalTraces, DynamicVariableSignalLogs, DynamicVariableSignalMetrics, DynamicVariableSignalAll:
*s = sig
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid signal %q: must be `traces`, `logs`, `metrics`, or `all`", v)
}
Signal telemetrytypes.Signal `json:"signal"`
}
type QueryVariableSpec struct {

View File

@@ -80,7 +80,6 @@
}
}
],
"links": [],
"panels": {
"24e2697b": {
"kind": "Panel",
@@ -168,7 +167,6 @@
"ff2f72f1": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "fraction of calls",
"description": ""
@@ -255,7 +253,6 @@
"011605e7": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "total resp size"
},
@@ -307,7 +304,6 @@
"e23516fc": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "num traces for service"
},
@@ -363,7 +359,6 @@
"130c8d6b": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "num logs for service"
},
@@ -403,7 +398,6 @@
"246f7c6d": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "num traces for service per resp code"
},
@@ -457,7 +451,6 @@
"21f7d4d0": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "average latency per service"
},
@@ -500,7 +493,6 @@
"ad5fd556": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "logs from service"
},
@@ -565,7 +557,6 @@
"f07b59ee": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "response size buckets"
},
@@ -605,7 +596,6 @@
"e1a41831": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "trace operator",
"description": ""
@@ -682,7 +672,6 @@
"f0d70491": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "no results in this promql",
"description": ""
@@ -724,7 +713,6 @@
"0e6eb4ca": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "no results in this promql",
"description": ""

View File

@@ -12,12 +12,10 @@
}
}
},
"links": [],
"panels": {
"b424e23b": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": ""
},
@@ -60,7 +58,6 @@
"251df4d5": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": ""
},

View File

@@ -118,6 +118,10 @@ const (
FilterOperatorHasToken
FilterOperatorHasAny
FilterOperatorHasAll
// FilterOperatorSearch backs search('needle'): a keyless search the condition
// builder fans out across every searchable column.
FilterOperatorSearch
)
var operatorInverseMapping = map[FilterOperator]FilterOperator{
@@ -186,7 +190,8 @@ func (f FilterOperator) IsNegativeOperator() bool {
FilterOperatorIn,
FilterOperatorExists,
FilterOperatorRegexp,
FilterOperatorContains:
FilterOperatorContains,
FilterOperatorSearch:
return false
}
return true
@@ -243,10 +248,11 @@ func (f FilterOperator) IsArrayFunctionOperator() bool {
}
// IsFunctionOperator reports whether the operator is a query function
// (has/hasAny/hasAll/hasToken); these apply to the logs body column only.
// (has/hasAny/hasAll/hasToken/search). These are logs-only; other signals reject
// them and the resource-fingerprint builder skips them.
func (f FilterOperator) IsFunctionOperator() bool {
switch f {
case FilterOperatorHas, FilterOperatorHasAny, FilterOperatorHasAll, FilterOperatorHasToken:
case FilterOperatorHas, FilterOperatorHasAny, FilterOperatorHasAll, FilterOperatorHasToken, FilterOperatorSearch:
return true
default:
return false
@@ -265,6 +271,8 @@ func (f FilterOperator) FunctionName() string {
return "hasAll"
case FilterOperatorHasToken:
return "hasToken"
case FilterOperatorSearch:
return "search"
default:
return ""
}

View File

@@ -57,6 +57,12 @@ type Statement struct {
Args []any
Warnings []string
WarningsDocURL string
CostGuard *CostGuard
}
type CostGuard struct {
Warning string
MaxScanRows int64
}
// StatementBuilder builds the query.

View File

@@ -79,6 +79,14 @@ var (
}
)
// FieldContextFromText resolves a context word (e.g. "body", "resource") to its
// FieldContext, applying the same aliases as key parsing (e.g. "tag" -> attribute). ok
// is false for an unknown word, so callers can reject it instead of getting unspecified.
func FieldContextFromText(text string) (FieldContext, bool) {
fc, ok := fieldContexts[strings.ToLower(strings.TrimSpace(text))]
return fc, ok
}
// UnmarshalJSON implements the json.Unmarshaler interface.
func (f *FieldContext) UnmarshalJSON(data []byte) error {
var str string

View File

@@ -412,10 +412,16 @@ func (v *variableReplacementVisitor) VisitFunctionCall(ctx *grammar.FunctionCall
}
func (v *variableReplacementVisitor) VisitSearchCall(ctx *grammar.SearchCallContext) any {
if ctx.FunctionParamList() == nil {
if ctx.ValueList() == nil {
return "search()"
}
return "search(" + v.Visit(ctx.FunctionParamList()).(string) + ")"
// VisitValueList already wraps the args in parens and returns the skip
// marker if any arg resolves to __all__.
result := v.Visit(ctx.ValueList()).(string)
if result == specialSkipMarker {
return specialSkipMarker
}
return "search" + result
}
func (v *variableReplacementVisitor) VisitFunctionParamList(ctx *grammar.FunctionParamListContext) any {

View File

@@ -113,7 +113,7 @@ def test_create_rejects_unknown_field(
json={
"schemaVersion": "v6",
"name": "rejects-unknown",
"spec": {"display": {"name": "Rejects Unknown"}, "links": []},
"spec": {"display": {"name": "Rejects Unknown"}},
"tags": [],
"unknownfield": "boom",
},
@@ -209,7 +209,6 @@ def test_create_rejects_invalid_grid_layout(
"kind": "Panel",
"spec": {
"display": {"name": name},
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [
{
@@ -250,7 +249,6 @@ def test_create_rejects_invalid_grid_layout(
},
}
],
"links": [],
},
"tags": [],
},
@@ -282,7 +280,6 @@ def test_create_rejects_invalid_grid_layout(
},
}
],
"links": [],
},
"tags": [],
},
@@ -308,7 +305,6 @@ def test_create_rejects_invalid_grid_layout(
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
}
],
"links": [],
},
"tags": [],
},
@@ -414,7 +410,7 @@ def test_update_missing_dashboard_returns_not_found(
json={
"schemaVersion": "v6",
"name": "missing-dashboard",
"spec": {"display": {"name": "Missing Dashboard"}, "links": []},
"spec": {"display": {"name": "Missing Dashboard"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -539,7 +535,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}, "links": []},
"spec": {"display": {"name": display}},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
@@ -901,7 +897,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
update_body = {
"schemaVersion": "v6",
"name": "lc-alpha",
"spec": {"display": {"name": "Alpha Overview"}, "links": []},
"spec": {"display": {"name": "Alpha Overview"}},
"tags": [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
@@ -945,7 +941,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
beta_body = {
"schemaVersion": "v6",
"name": "lc-beta",
"spec": {"display": {"name": "Beta Overview"}, "links": []},
"spec": {"display": {"name": "Beta Overview"}},
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
}
response = requests.put(
@@ -1049,7 +1045,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": created_order},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": created_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1075,7 +1071,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": reordered},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": reordered},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1104,7 +1100,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": new_order},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": new_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1141,7 +1137,7 @@ def test_dashboard_v2_pin_limit(
json={
"schemaVersion": "v6",
"name": f"pl-{i}",
"spec": {"display": {"name": f"Pin Limit {i}"}, "links": []},
"spec": {"display": {"name": f"Pin Limit {i}"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1239,7 +1235,7 @@ def test_dashboard_v2_like_escaping(
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}, "links": []},
"spec": {"display": {"name": display}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1330,13 +1326,11 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-builder",
"spec": {
"display": {"name": "by-metric-builder"},
"links": [],
"panels": {
"p-builder": {
"kind": "Panel",
"spec": {
"display": {"name": "D1 builder target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1381,13 +1375,11 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-ch-promql",
"spec": {
"display": {"name": "by-metric-ch-promql"},
"links": [],
"panels": {
"p-ch": {
"kind": "Panel",
"spec": {
"display": {"name": "D2 clickhouse target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1409,7 +1401,6 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": "D2 promql target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1446,13 +1437,11 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-promql",
"spec": {
"display": {"name": "by-metric-promql"},
"links": [],
"panels": {
"p-promql": {
"kind": "Panel",
"spec": {
"display": {"name": "D3 promql target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1491,13 +1480,11 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-false-positive",
"spec": {
"display": {"name": "by-metric-false-positive"},
"links": [],
"panels": {
"p-builder": {
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} builder"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1526,7 +1513,6 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} clickhouse"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1548,7 +1534,6 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} promql"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1622,13 +1607,11 @@ def test_dashboard_v2_rejects_comma_separated_aggregation(
"tags": [],
"spec": {
"display": {"name": "Aggregation"},
"links": [],
"panels": {
"p-agg": {
"kind": "Panel",
"spec": {
"display": {"name": "agg"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1780,7 +1763,6 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "timeseries"},
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"thresholds": [{"value": 0, "color": "#c2780b"}]},
@@ -1803,7 +1785,6 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "promql"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1823,7 +1804,6 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "clickhouse"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1919,9 +1899,11 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
for description, spec, key in absent_cases:
assert key not in spec, description
# links is a required, non-nullable field: an explicit [] round-trips as [],
# so a typed client always reads a concrete array (never null or absent).
assert panels["timeseries"]["spec"]["links"] == [], "panel links round-trip as []"
# A panel with no links comes back with no links value (null or absent);
# either is fine for a typed client (an unset optional attribute stays
# unset), so this is not a drift. The explicit [] case above is what the
# fix guarantees round-trips.
assert panels["timeseries"]["spec"].get("links") is None, "unset panel links stays unset"
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
@@ -1973,7 +1955,6 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
},
}
],
"links": [],
},
},
"num": {
@@ -1995,11 +1976,9 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
},
}
],
"links": [],
},
},
},
"links": [],
},
}

View File

@@ -59,7 +59,6 @@ def test_public_dashboard_v2(
"spec": {
"display": {"name": "Sample Dashboard", "description": "Used for integration tests"},
"duration": "1h",
"links": [],
"variables": [
{
"kind": "ListVariable",
@@ -78,7 +77,6 @@ def test_public_dashboard_v2(
"kind": "Panel",
"spec": {
"display": {"name": "total"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"visualization": {"fillSpans": True}}},
"queries": [
{

View File

@@ -0,0 +1,124 @@
import json
from collections import namedtuple
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import build_order_by, build_raw_query, get_rows, make_query_request
# search() with use_json_body on (see conftest.py): body matches run against body_v2 via
# LOWER(toString(body_v2)); the map/log fan-out is unchanged from querierlogs/15_search.
# The response `body` comes back as parsed JSON, so a plain-string body is {"message": <body>}.
Bodies = namedtuple("Bodies", ["a", "b", "c", "d"])
@pytest.mark.parametrize(
"expression,expected",
[
# body matches now run through body_v2 (toString of {"message": <body>})
pytest.param("search('login')", lambda b: {b.a}, id="keyless_body"),
pytest.param("search('checkout')", lambda b: {b.a, b.d}, id="keyless_body_and_resource"),
pytest.param("search('CHECKOUT')", lambda b: {b.a, b.d}, id="keyless_case_insensitive"),
pytest.param("search('login', body)", lambda b: {b.a}, id="scope_body"),
pytest.param("search('checkout', body)", lambda b: {b.a}, id="scope_body_excludes_resource"),
# the map / log fan-out is flag-independent — sanity that it still works here
pytest.param("search('checkout', resource)", lambda b: {b.a, b.d}, id="scope_resource"),
pytest.param("search('acme', attribute)", lambda b: {b.a, b.c}, id="scope_attribute"),
pytest.param("search('error', log)", lambda b: {b.b, b.d}, id="scope_log_severity"),
pytest.param("search('checkout', body, resource)", lambda b: {b.a, b.d}, id="scopes_union"),
pytest.param("NOT search('login')", lambda b: {b.b, b.c, b.d}, id="negated"),
],
)
def test_search(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
expected: Callable[[Bodies], set[str]],
) -> None:
"""Four self-naming logs assert keyless/body-scoped search() matches through the
body_v2 JSON column, while resource/attribute/log scopes fan out unchanged."""
body = Bodies(
a="alpha checkout login ok", # service checkout / region useast / tenant acme / INFO
b="bravo declined", # service payment / region euwest / tenant globex / ERROR
c="charlie miss", # service cart / region useast / tenant acme / WARN
d="delta slow", # service checkout / region apac / tenant initech / ERROR
)
# (body, resources, attributes, severity_text)
specs = [
(body.a, {"service.name": "checkout", "region": "useast"}, {"tenant": "acme"}, "INFO"),
(body.b, {"service.name": "payment", "region": "euwest"}, {"tenant": "globex"}, "ERROR"),
(body.c, {"service.name": "cart", "region": "useast"}, {"tenant": "acme"}, "WARN"),
(body.d, {"service.name": "checkout", "region": "apac"}, {"tenant": "initech"}, "ERROR"),
]
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs = [Logs(timestamp=now - timedelta(seconds=i + 1), resources=res, attributes=attrs, body=b, severity_text=sev) for i, (b, res, attrs, sev) in enumerate(specs)]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
# body_v2 comes back parsed; a plain-string body is {"message": <body>}.
assert {row["data"]["body"]["message"] for row in get_rows(response)} == expected(body)
@pytest.mark.parametrize(
"needle",
[
pytest.param("eve@acme.io", id="nested_string_value"),
pytest.param("503", id="nested_numeric_value"),
pytest.param("status", id="nested_key"),
],
)
def test_search_body_reaches_nested_json(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
needle: str,
) -> None:
"""A body-scoped search matches values and keys nested inside the body_v2 JSON."""
# searchable content lives only in nested fields, not a top-level message
nested_body = json.dumps({"user": {"email": "eve@acme.io"}, "http": {"status": 503}}, separators=(",", ":"))
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "api"}, body=nested_body, severity_text="INFO")])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[build_raw_query("A", "logs", filter_expression=f"search('{needle}', body)", order=[build_order_by("timestamp", "desc")], limit=100)],
)
assert response.status_code == HTTPStatus.OK, response.text
rows = get_rows(response)
assert len(rows) == 1
assert rows[0]["data"]["body"]["user"]["email"] == "eve@acme.io"

View File

@@ -0,0 +1,211 @@
from collections import namedtuple
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import build_order_by, build_raw_query, get_column_data_from_response, get_rows, make_query_request
# search(): keyless fans across every field; scoped search('needle', <ctx>...) narrows
# to the named contexts (body/attribute/resource/log). Flag off here, so body matches the
# `body` String column (querier_json_body mirrors this over body_v2). `expected` is a
# lambda over the body markers so cases stay declarative while the dataset lives in one place.
Bodies = namedtuple("Bodies", ["a", "b", "c", "d"])
@pytest.mark.parametrize(
"expression,expected",
[
# ── keyless: fans across every field ────────────────────────────────
pytest.param("search('login')", lambda b: {b.a}, id="keyless_body"),
pytest.param("search('checkout')", lambda b: {b.a, b.d}, id="keyless_body_and_resource"),
pytest.param("search('useast')", lambda b: {b.a, b.c}, id="keyless_resource_value"),
pytest.param("search('acme')", lambda b: {b.a, b.c}, id="keyless_attribute_value"),
pytest.param("search('tenant')", lambda b: {b.a, b.b, b.c, b.d}, id="keyless_attribute_key"),
pytest.param("search('error')", lambda b: {b.b, b.d}, id="keyless_severity_case_insensitive"),
pytest.param("search('CHECKOUT')", lambda b: {b.a, b.d}, id="keyless_needle_case_insensitive"),
# ── scoped: narrows to one context ──────────────────────────────────
pytest.param("search('login', body)", lambda b: {b.a}, id="scope_body"),
pytest.param("search('login', 'body')", lambda b: {b.a}, id="scope_body_quoted"),
pytest.param("search('checkout', body)", lambda b: {b.a}, id="scope_body_excludes_resource"),
pytest.param("search('checkout', resource)", lambda b: {b.a, b.d}, id="scope_resource"),
pytest.param("search('acme', attribute)", lambda b: {b.a, b.c}, id="scope_attribute"),
pytest.param("search('error', log)", lambda b: {b.b, b.d}, id="scope_log_severity"),
pytest.param("search('acme', body)", lambda b: set(), id="scope_body_no_match"),
pytest.param("search('checkout', attribute)", lambda b: set(), id="scope_attribute_no_match"),
# ── multiple scopes: union of the named contexts ────────────────────
pytest.param("search('login', body, resource)", lambda b: {b.a}, id="scopes_body_resource_body_only"),
pytest.param("search('checkout', body, resource)", lambda b: {b.a, b.d}, id="scopes_body_resource_union"),
# ── composition with boolean / field filters ────────────────────────
pytest.param("NOT search('login')", lambda b: {b.b, b.c, b.d}, id="negated"),
pytest.param("search('useast') AND severity_text = 'INFO'", lambda b: {b.a}, id="and_field_filter"),
],
)
def test_search(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
expected: Callable[[Bodies], set[str]],
) -> None:
"""Four self-naming logs, each with a token planted in a distinct place (body,
resource, attribute, severity), assert search() reaches exactly the right ones."""
body = Bodies(
a="alpha checkout login ok", # service checkout / region useast / tenant acme / INFO
b="bravo declined", # service payment / region euwest / tenant globex / ERROR
c="charlie miss", # service cart / region useast / tenant acme / WARN
d="delta slow", # service checkout / region apac / tenant initech / ERROR
)
# (body, resources, attributes, severity_text)
specs = [
(body.a, {"service.name": "checkout", "region": "useast"}, {"tenant": "acme"}, "INFO"),
(body.b, {"service.name": "payment", "region": "euwest"}, {"tenant": "globex"}, "ERROR"),
(body.c, {"service.name": "cart", "region": "useast"}, {"tenant": "acme"}, "WARN"),
(body.d, {"service.name": "checkout", "region": "apac"}, {"tenant": "initech"}, "ERROR"),
]
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs = [Logs(timestamp=now - timedelta(seconds=i + 1), resources=res, attributes=attrs, body=b, severity_text=sev) for i, (b, res, attrs, sev) in enumerate(specs)]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
assert set(get_column_data_from_response(response.json(), "body")) == expected(body)
@pytest.mark.parametrize(
"expression",
[
pytest.param("search('login', bogus)", id="unknown_scope_word"),
pytest.param("search('login', body.message)", id="qualified_field_not_a_scope"),
],
)
def test_search_invalid_scope_rejected(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
expression: str,
) -> None:
"""A scope that is not a field context (an unknown word, or a qualified
`context.field`) is rejected at build time with a 400."""
now = datetime.now(tz=UTC)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[build_raw_query("A", "logs", filter_expression=expression, limit=100)],
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "invalid search scope" in response.text
# search() is scan-heavy, so the querier gates it on EXPLAIN ESTIMATE against
# search_max_scan_rows (50000 for this instance, see conftest.py). A broad search over a
# busy range is rejected; the two ways the advisory suggests to recover — add a more
# selective filter, or narrow the time range — both bring the scan under budget.
def test_search_cost_guard_trips_then_passes_with_filter(
signoz_search_scan_budget: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A broad search() over ~61000 logs exceeds the 50000-row budget and is rejected;
the same search narrowed to the 1000-log 'checkout' service scans under budget and
succeeds — the advisory's "add a more selective filter" made real."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# 60000 'catalog' logs in the current bucket + 1000 'checkout' logs ~45m back (an
# earlier ts_bucket bucket), so the checkout fingerprint occupies its own marks and a
# resource filter on it prunes the scan.
logs = [Logs(timestamp=now - timedelta(seconds=1 + i % 30), resources={"service.name": "catalog"}, body="log line") for i in range(60000)]
logs += [Logs(timestamp=now - timedelta(minutes=45, seconds=i % 30), resources={"service.name": "checkout"}, body="log line") for i in range(1000)]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=60)).timestamp() * 1000)
end_ms = int((now + timedelta(minutes=1)).timestamp() * 1000)
def run(expression: str):
return make_query_request(
signoz_search_scan_budget,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[build_raw_query("A", "logs", filter_expression=expression, order=[build_order_by("timestamp", "desc")], limit=100)],
)
# Broad search over the whole range is over budget -> rejected before executing.
over_budget = run("search('log')")
assert over_budget.status_code == HTTPStatus.BAD_REQUEST, over_budget.text
assert "over the limit" in over_budget.text
# Adding a selective resource filter prunes the scan under budget -> runs.
within_budget = run("search('log') AND resource.service.name = 'checkout'")
assert within_budget.status_code == HTTPStatus.OK, within_budget.text
assert len(get_rows(within_budget)) > 0
def test_search_cost_guard_passes_with_narrower_time_range(
signoz_search_scan_budget: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A steady stream of ~80000 logs (~4/sec over the last ~5.5h). A search() over the
whole window is over the 50000-row budget and rejected; the same search over the last
15 minutes scans only a few thousand rows and runs — the advisory's "narrow the time
range" made real."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# ~4 logs/sec, oldest ~5.5h back, newest ~now.
logs = [Logs(timestamp=now - timedelta(seconds=i // 4), resources={"service.name": "app"}, body="log line") for i in range(80000)]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def run(lookback_minutes: int):
return make_query_request(
signoz_search_scan_budget,
token,
start_ms=int((now - timedelta(minutes=lookback_minutes)).timestamp() * 1000),
end_ms=int((now + timedelta(minutes=1)).timestamp() * 1000),
request_type="raw",
queries=[build_raw_query("A", "logs", filter_expression="search('log')", order=[build_order_by("timestamp", "desc")], limit=100)],
)
# The last 6 hours cover the whole stream (~80000) -> over budget -> rejected.
wide = run(360)
assert wide.status_code == HTTPStatus.BAD_REQUEST, wide.text
assert "over the limit" in wide.text
# The last 15 minutes hold only ~3600 logs -> under budget -> runs, returning rows.
narrow = run(15)
assert narrow.status_code == HTTPStatus.OK, narrow.text
assert len(get_rows(narrow)) > 0

View File

@@ -0,0 +1,35 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz_search_scan_budget", scope="package")
def signoz_search_scan_budget(
network: Network,
migrator: types.Operation, # pylint: disable=unused-argument
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""SigNoz with a conservative search_max_scan_rows (50000) so a broad search() over a
busy range trips the cost guard, while a more selective one (by service or by a
narrower time range) stays under it. Shares the default instance's sqlstore +
clickhouse, so the same admin token and seeded logs work against it."""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-search-scan-budget-50k",
env_overrides={
"SIGNOZ_QUERIER_SEARCH__MAX__SCAN__ROWS": 50000,
},
)