mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-25 15:30:33 +01:00
Compare commits
5 Commits
b3-search
...
fix/tansta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a63bc71c6b | ||
|
|
056e48a265 | ||
|
|
25cc65ace2 | ||
|
|
38340b5027 | ||
|
|
fba56e9a64 |
@@ -2748,7 +2748,6 @@ components:
|
||||
links:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesLink'
|
||||
nullable: true
|
||||
type: array
|
||||
panels:
|
||||
additionalProperties:
|
||||
@@ -2765,6 +2764,7 @@ components:
|
||||
- variables
|
||||
- panels
|
||||
- layouts
|
||||
- links
|
||||
type: object
|
||||
DashboardtypesDashboardView:
|
||||
properties:
|
||||
@@ -2843,14 +2843,22 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesDynamicVariableSignal:
|
||||
enum:
|
||||
- traces
|
||||
- logs
|
||||
- metrics
|
||||
- all
|
||||
type: string
|
||||
DashboardtypesDynamicVariableSpec:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
signal:
|
||||
$ref: '#/components/schemas/TelemetrytypesSignal'
|
||||
$ref: '#/components/schemas/DashboardtypesDynamicVariableSignal'
|
||||
required:
|
||||
- name
|
||||
- signal
|
||||
type: object
|
||||
DashboardtypesFillMode:
|
||||
enum:
|
||||
@@ -3393,7 +3401,6 @@ components:
|
||||
links:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesLink'
|
||||
nullable: true
|
||||
type: array
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelPlugin'
|
||||
@@ -3405,6 +3412,7 @@ components:
|
||||
- display
|
||||
- plugin
|
||||
- queries
|
||||
- links
|
||||
type: object
|
||||
DashboardtypesPatchOp:
|
||||
enum:
|
||||
|
||||
@@ -4626,9 +4626,9 @@ export interface DashboardtypesQueryDTO {
|
||||
export interface DashboardtypesPanelSpecDTO {
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
links: DashboardtypesLinkDTO[];
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
* @type array
|
||||
@@ -4668,12 +4668,18 @@ 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?: TelemetrytypesSignalDTO;
|
||||
signal: DashboardtypesDynamicVariableSignalDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTO {
|
||||
@@ -4815,9 +4821,9 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
*/
|
||||
layouts: DashboardtypesLayoutDTO[];
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
links: DashboardtypesLinkDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertExpressionToFilters,
|
||||
convertFiltersToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
formatValueForExpression,
|
||||
@@ -1598,4 +1599,36 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,9 @@ function formatSingleValueForFilter(
|
||||
}
|
||||
|
||||
if (isQuoted(value)) {
|
||||
return unquote(value);
|
||||
// Unescape `\'` → `'` (inverse of formatSingleValue) so the round-trip doesn't
|
||||
// double the backslash each pass.
|
||||
return unquote(value).replace(/\\'/g, "'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ 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;
|
||||
@@ -51,22 +50,12 @@ 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;
|
||||
if (isActive && onRowDeactivate) {
|
||||
onRowDeactivate();
|
||||
} else {
|
||||
onRowClick?.(rowData, itemKey);
|
||||
}
|
||||
onRowClick?.(rowData, itemKey, { isActive });
|
||||
},
|
||||
[
|
||||
isRowActive,
|
||||
onRowDeactivate,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
rowData,
|
||||
getRowKeyData,
|
||||
rowIndex,
|
||||
],
|
||||
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
|
||||
);
|
||||
|
||||
if (itemKind === 'expansion') {
|
||||
@@ -120,7 +109,6 @@ 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 &&
|
||||
|
||||
@@ -96,7 +96,6 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
onSort,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
@@ -382,7 +381,6 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
colCount: visibleColumnsCount,
|
||||
@@ -400,7 +398,6 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
visibleColumnsCount,
|
||||
|
||||
@@ -85,7 +85,9 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
|
||||
@@ -117,17 +119,19 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
|
||||
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.
|
||||
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,
|
||||
@@ -152,8 +156,44 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render renderRowActions before hover', () => {
|
||||
|
||||
@@ -611,6 +611,7 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
'1',
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -639,6 +640,7 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -659,15 +661,15 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(row).toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate when clicking active row', async () => {
|
||||
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.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: (row) => row.id === '1',
|
||||
},
|
||||
});
|
||||
@@ -678,8 +680,11 @@ describe('TanStackTableView Integration', () => {
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
expect.anything(),
|
||||
{ isActive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('opens in new tab on ctrl+click', async () => {
|
||||
|
||||
@@ -116,9 +116,11 @@ export * from './useTableParams';
|
||||
* getItemKey={(row) => row.id}
|
||||
* isRowActive={(row) => row.id === selectedId}
|
||||
* activeRowIndex={selectedIndex}
|
||||
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
|
||||
* // The table reports the click + the row's active state; the consumer owns open/close.
|
||||
* onRowClick={(row, itemKey, { isActive }) =>
|
||||
* setSelectedId(isActive ? undefined : 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>}
|
||||
|
||||
@@ -81,15 +81,23 @@ 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) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
rowKey: string,
|
||||
@@ -180,10 +188,9 @@ export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
getRowClassName?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
|
||||
@@ -65,4 +65,5 @@
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
@@ -25,20 +25,52 @@ describe('calculateChartDimensions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('RIGHT: reserves a side column capped at 30% of the width and keeps full height', () => {
|
||||
it('RIGHT: reserves a side column capped at 320px / 40% of the width and keeps full height', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
// 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.legendWidth).toBe(320);
|
||||
expect(dims.width).toBe(680);
|
||||
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,
|
||||
|
||||
@@ -15,6 +15,12 @@ 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.
|
||||
@@ -42,7 +48,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` is clamped between 150px and min(MAX_LEGEND_WIDTH, 30% of container width).
|
||||
* - `legendWidth` fits the longest label, clamped to [150px, min(MAX_RIGHT_LEGEND_WIDTH, 40% width)].
|
||||
* - Chart width is `containerWidth - legendWidth`.
|
||||
* - BOTTOM legend:
|
||||
* - Computes how many items fit per row, then uses at most 2 rows.
|
||||
@@ -80,9 +86,22 @@ export function calculateChartDimensions({
|
||||
const legendItemCount = seriesLabels.length;
|
||||
|
||||
if (legendConfig.position === LegendPosition.RIGHT) {
|
||||
const maxRightLegendWidth = Math.min(MAX_LEGEND_WIDTH, containerWidth * 0.3);
|
||||
// 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 rightLegendWidth = Math.min(
|
||||
Math.max(150, approxLegendItemWidth),
|
||||
Math.max(150, desiredLegendWidth),
|
||||
maxRightLegendWidth,
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
|
||||
.chart-layout__legend-wrapper {
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__legend-wrapper {
|
||||
|
||||
@@ -60,3 +60,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,9 @@ 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(
|
||||
@@ -216,10 +219,13 @@ function LiveLogsList({
|
||||
),
|
||||
}) as CSSProperties
|
||||
}
|
||||
onRowClick={(log): void => {
|
||||
handleSetActiveLog(log);
|
||||
onRowClick={(log, _itemKey, { isActive }): void => {
|
||||
if (isActive) {
|
||||
handleCloseLogDetail();
|
||||
} else {
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
}}
|
||||
onRowDeactivate={handleCloseLogDetail}
|
||||
activeRowIndex={activeLogIndex}
|
||||
renderRowActions={(log): ReactNode => (
|
||||
<LogLinesActionButtons
|
||||
|
||||
@@ -323,3 +323,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -211,8 +211,9 @@ function LogsExplorerList({
|
||||
data={logs}
|
||||
isLoading={isLoading || isFetching}
|
||||
onEndReached={onEndReached}
|
||||
isRowActive={(log): boolean =>
|
||||
log.id === activeLog?.id || log.id === activeLogId
|
||||
isRowActive={(log): boolean => log.id === activeLog?.id}
|
||||
getRowClassName={(log): string =>
|
||||
log.id === activeLogId ? 'logs-linked-row' : ''
|
||||
}
|
||||
getRowStyle={(log): CSSProperties =>
|
||||
({
|
||||
@@ -226,10 +227,13 @@ function LogsExplorerList({
|
||||
),
|
||||
}) as CSSProperties
|
||||
}
|
||||
onRowClick={(log): void => {
|
||||
handleSetActiveLog(log);
|
||||
onRowClick={(log, _itemKey, { isActive }): void => {
|
||||
if (isActive) {
|
||||
handleCloseLogDetail();
|
||||
} else {
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
}}
|
||||
onRowDeactivate={handleCloseLogDetail}
|
||||
activeRowIndex={activeLogIndex}
|
||||
renderRowActions={(log): ReactNode => (
|
||||
<LogLinesActionButtons
|
||||
@@ -282,6 +286,7 @@ function LogsExplorerList({
|
||||
options.maxLines,
|
||||
options.fontSize,
|
||||
activeLogIndex,
|
||||
activeLogId,
|
||||
logs,
|
||||
onEndReached,
|
||||
getItemContent,
|
||||
|
||||
@@ -55,6 +55,7 @@ export function useCreateExportDashboard({
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
links: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -14,17 +15,16 @@ 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: DynamicSignalOption;
|
||||
signal: DashboardtypesDynamicVariableSignalDTO;
|
||||
onChange: (patch: {
|
||||
dynamicAttribute?: string;
|
||||
dynamicSignal?: DynamicSignalOption;
|
||||
dynamicSignal?: DashboardtypesDynamicVariableSignalDTO;
|
||||
}) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
|
||||
@@ -117,7 +117,9 @@ function DynamicVariableFields({
|
||||
value: s,
|
||||
}))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as DynamicSignalOption })
|
||||
onChange({
|
||||
dynamicSignal: value as DashboardtypesDynamicVariableSignalDTO,
|
||||
})
|
||||
}
|
||||
data-testid="variable-signal-select"
|
||||
/>
|
||||
|
||||
@@ -13,11 +13,7 @@ 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';
|
||||
@@ -69,12 +65,8 @@ export function dtoToFormModel(
|
||||
...listCommon,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: plugin.spec.name ?? '',
|
||||
// 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,
|
||||
// signal is a required wire field (`all` = every telemetry signal), used as-is.
|
||||
dynamicSignal: plugin.spec.signal,
|
||||
};
|
||||
}
|
||||
// Default to Query (also covers a query plugin or a missing/unknown plugin).
|
||||
@@ -102,7 +94,7 @@ function buildPlugin(
|
||||
kind: DynamicPluginKind['signoz/DynamicVariable'],
|
||||
spec: {
|
||||
name: model.dynamicAttribute,
|
||||
signal: signalForApi(model.dynamicSignal),
|
||||
signal: model.dynamicSignal,
|
||||
},
|
||||
};
|
||||
case 'QUERY':
|
||||
|
||||
@@ -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,23 +23,6 @@ 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
|
||||
@@ -81,25 +64,38 @@ export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
|
||||
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
|
||||
};
|
||||
|
||||
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
export const DYNAMIC_SIGNALS: DashboardtypesDynamicVariableSignalDTO[] = [
|
||||
DashboardtypesDynamicVariableSignalDTO.all,
|
||||
DashboardtypesDynamicVariableSignalDTO.traces,
|
||||
DashboardtypesDynamicVariableSignalDTO.logs,
|
||||
DashboardtypesDynamicVariableSignalDTO.metrics,
|
||||
];
|
||||
|
||||
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
|
||||
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
|
||||
[TelemetrytypesSignalDTO.traces]: 'Traces',
|
||||
[TelemetrytypesSignalDTO.logs]: 'Logs',
|
||||
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
|
||||
export const DYNAMIC_SIGNAL_LABEL: Record<
|
||||
DashboardtypesDynamicVariableSignalDTO,
|
||||
string
|
||||
> = {
|
||||
[DashboardtypesDynamicVariableSignalDTO.all]: 'All telemetry',
|
||||
[DashboardtypesDynamicVariableSignalDTO.traces]: 'Traces',
|
||||
[DashboardtypesDynamicVariableSignalDTO.logs]: 'Logs',
|
||||
[DashboardtypesDynamicVariableSignalDTO.metrics]: 'Metrics',
|
||||
};
|
||||
|
||||
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
|
||||
/**
|
||||
* Field-keys/values API param. The `all` signal is omitted (that endpoint only
|
||||
* accepts a concrete signal), everything else passes through.
|
||||
*/
|
||||
export function signalForApi(
|
||||
signal: DynamicSignalOption,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
|
||||
signal: DashboardtypesDynamicVariableSignalDTO,
|
||||
):
|
||||
| Exclude<
|
||||
DashboardtypesDynamicVariableSignalDTO,
|
||||
DashboardtypesDynamicVariableSignalDTO.all
|
||||
>
|
||||
| undefined {
|
||||
return signal === DashboardtypesDynamicVariableSignalDTO.all
|
||||
? undefined
|
||||
: signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
@@ -144,7 +140,7 @@ export interface VariableFormModel {
|
||||
textValue: string; // TEXT
|
||||
textConstant: boolean; // TEXT
|
||||
dynamicAttribute: string; // DYNAMIC — the telemetry field name
|
||||
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
|
||||
dynamicSignal: DashboardtypesDynamicVariableSignalDTO; // DYNAMIC — the telemetry signal (`all` = every signal)
|
||||
|
||||
/**
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
@@ -166,6 +162,6 @@ export function emptyVariableFormModel(): VariableFormModel {
|
||||
textValue: '',
|
||||
textConstant: false,
|
||||
dynamicAttribute: '',
|
||||
dynamicSignal: DYNAMIC_SIGNAL_ALL,
|
||||
dynamicSignal: DashboardtypesDynamicVariableSignalDTO.all,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ 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['']]: '',
|
||||
};
|
||||
|
||||
|
||||
@@ -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[] | undefined => spec.links ?? undefined,
|
||||
get: (spec): DashboardtypesLinkDTO[] => spec.links,
|
||||
update: (spec, links): PanelSpec => ({ ...spec, links }),
|
||||
},
|
||||
// One editor for every threshold variant (label / comparison / table); the kind's
|
||||
|
||||
@@ -49,6 +49,14 @@ 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'],
|
||||
@@ -67,16 +75,34 @@ describe('newPanelRoute', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the query as a decodable compositeQuery param', () => {
|
||||
it('carries the query as a compositeQuery param the reader can decode', () => {
|
||||
const link = buildExportPanelLink({
|
||||
dashboardId: 'dash-1',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
query,
|
||||
});
|
||||
const { params } = parseLink(link);
|
||||
expect(JSON.parse(params.get('compositeQuery') as string)).toStrictEqual(
|
||||
query,
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
it('returns null for a panel type with no V2 kind', () => {
|
||||
|
||||
@@ -45,9 +45,11 @@ export function parseNewPanelKind(
|
||||
|
||||
/**
|
||||
* New-panel editor link that exports an explorer query into a V2 dashboard. Carries the
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function buildExportPanelLink({
|
||||
dashboardId,
|
||||
@@ -68,7 +70,7 @@ export function buildExportPanelLink({
|
||||
});
|
||||
return `${path}${newPanelSearch(kind)}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${encodeURIComponent(JSON.stringify(query))}`;
|
||||
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
|
||||
}
|
||||
|
||||
/** Target section index for a new panel, or undefined when unset/invalid. */
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesDynamicVariableSignalDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
@@ -17,3 +20,23 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import PanelHeaderSearch from './PanelHeaderSearch';
|
||||
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
|
||||
import {
|
||||
panelStatusFromError,
|
||||
panelStatusFromMultipleEnabledQueries,
|
||||
panelStatusFromWarning,
|
||||
} from '../PanelStatus/utils';
|
||||
import styles from './PanelHeader.module.scss';
|
||||
@@ -74,11 +75,24 @@ 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 && hideActions) {
|
||||
if (
|
||||
!name &&
|
||||
!description &&
|
||||
!errorDetail &&
|
||||
!warningDetail &&
|
||||
!multiQueryWarningDetail &&
|
||||
hideActions
|
||||
) {
|
||||
return <Fragment />;
|
||||
}
|
||||
|
||||
@@ -124,6 +138,13 @@ 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
|
||||
|
||||
@@ -17,6 +17,8 @@ const VARIANT_CONFIG: Record<
|
||||
interface PanelStatusPopoverProps {
|
||||
variant: PanelStatusVariant;
|
||||
detail: PanelStatusDetail;
|
||||
/** Overrides the trigger's test id; defaults to `panel-status-<variant>`. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,7 @@ interface PanelStatusPopoverProps {
|
||||
function PanelStatusPopover({
|
||||
variant,
|
||||
detail,
|
||||
testId,
|
||||
}: PanelStatusPopoverProps): JSX.Element {
|
||||
const { color, ariaLabel } = VARIANT_CONFIG[variant];
|
||||
const Icon = variant === 'error' ? CircleX : TriangleAlert;
|
||||
@@ -41,7 +44,7 @@ function PanelStatusPopover({
|
||||
<span
|
||||
className={styles.trigger}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={`panel-status-${variant}`}
|
||||
data-testid={testId ?? `panel-status-${variant}`}
|
||||
>
|
||||
<Icon size={16} color={color} />
|
||||
</span>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
|
||||
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, panelStatusFromWarning } from '../utils';
|
||||
import {
|
||||
panelStatusFromError,
|
||||
panelStatusFromMultipleEnabledQueries,
|
||||
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.
|
||||
@@ -87,3 +94,62 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { countEnabledQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/countEnabledQueries';
|
||||
|
||||
import type { PanelStatusDetail } from './types';
|
||||
|
||||
@@ -52,3 +56,25 @@ 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.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +41,40 @@ 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',
|
||||
@@ -101,6 +135,53 @@ 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} />);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, renderHook, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesDynamicVariableSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
|
||||
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
|
||||
@@ -63,7 +63,6 @@ jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel',
|
||||
() => ({
|
||||
emptyVariableFormModel: (): unknown => ({}),
|
||||
DYNAMIC_SIGNAL_ALL: 'all',
|
||||
}),
|
||||
);
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
@@ -84,7 +83,7 @@ function renderItems(): void {
|
||||
const { result } = renderHook(() =>
|
||||
useDrilldownDashboardVariables({
|
||||
filters,
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
signal: DashboardtypesDynamicVariableSignalDTO.metrics,
|
||||
onClose: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ 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';
|
||||
|
||||
@@ -163,7 +164,7 @@ export function useDrilldown(
|
||||
|
||||
const dashboardVariables = useDrilldownDashboardVariables({
|
||||
filters: context?.filters ?? EMPTY_FILTERS,
|
||||
signal: context?.signal,
|
||||
signal: dynamicSignalFromQuerySignal(context?.signal),
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
@@ -221,7 +222,7 @@ export function useDrilldown(
|
||||
context={context}
|
||||
query={v1Query}
|
||||
isResolving={isResolving}
|
||||
links={panel.spec.links ?? undefined}
|
||||
links={panel.spec.links}
|
||||
canSetDashboardVariables={dashboardVariables.hasFieldVariables}
|
||||
onViewLogs={(): void => navigate('view_logs')}
|
||||
onViewTraces={(): void => navigate('view_traces')}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesDynamicVariableSignalDTO } 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';
|
||||
@@ -23,8 +22,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[];
|
||||
/** Clicked query's telemetry signal — seeds a created variable's `dynamicSignal`. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Dynamic-variable signal derived from the clicked query — seeds a created variable's `dynamicSignal`. */
|
||||
signal?: DashboardtypesDynamicVariableSignalDTO;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -111,8 +110,7 @@ export function useDrilldownDashboardVariables({
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
dynamicAttribute: fieldName,
|
||||
// `||` (not `??`): an empty "any" signal maps to All, same as unset.
|
||||
dynamicSignal: signal || DYNAMIC_SIGNAL_ALL,
|
||||
dynamicSignal: signal ?? DashboardtypesDynamicVariableSignalDTO.all,
|
||||
};
|
||||
try {
|
||||
await patchAsync(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesDynamicVariableSignalDTO,
|
||||
type DashboardtypesGettableDashboardV2DTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import type {
|
||||
IDashboardVariable,
|
||||
@@ -8,7 +11,6 @@ import type {
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import {
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
type VariableFormModel,
|
||||
type VariableType,
|
||||
} from '../DashboardSettings/Variables/variableFormModel';
|
||||
@@ -35,7 +37,7 @@ function toV1Variable(model: VariableFormModel): IDashboardVariable {
|
||||
showALLOption: model.showAllOption,
|
||||
dynamicVariablesAttribute: model.dynamicAttribute,
|
||||
dynamicVariablesSource:
|
||||
model.dynamicSignal === DYNAMIC_SIGNAL_ALL
|
||||
model.dynamicSignal === DashboardtypesDynamicVariableSignalDTO.all
|
||||
? 'all sources'
|
||||
: model.dynamicSignal,
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ export function createDefaultPanel(
|
||||
spec: pluginSpec,
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
links: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
links: [],
|
||||
},
|
||||
});
|
||||
void logEvent(DashboardListEvents.DashboardCreated, {
|
||||
|
||||
@@ -112,12 +112,15 @@ functionCall
|
||||
;
|
||||
|
||||
/*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
searchCall
|
||||
: SEARCH LPAREN valueList RPAREN
|
||||
: SEARCH LPAREN functionParamList RPAREN
|
||||
;
|
||||
|
||||
// Function parameters can be keys, single scalar values, or arrays
|
||||
|
||||
@@ -29,19 +29,13 @@ 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 len(flags) > 0 {
|
||||
cfg.Config.Boolean = flags
|
||||
if enabled {
|
||||
cfg.Config.Boolean = map[string]bool{
|
||||
flagger.FeatureUseJSONBody.String(): true,
|
||||
}
|
||||
}
|
||||
fl, err := flagger.New(
|
||||
context.Background(),
|
||||
|
||||
@@ -35,7 +35,7 @@ func (c *conditionBuilder) ConditionFor(
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) ([]string, []string, error) {
|
||||
|
||||
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for rule state history.
|
||||
// has/hasAny/hasAll/hasToken are logs-body-only; 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
@@ -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, 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,
|
||||
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,
|
||||
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
|
||||
ValueList() IValueListContext
|
||||
FunctionParamList() IFunctionParamListContext
|
||||
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) ValueList() IValueListContext {
|
||||
func (s *SearchCallContext) FunctionParamList() IFunctionParamListContext {
|
||||
var t antlr.RuleContext
|
||||
for _, ctx := range s.GetChildren() {
|
||||
if _, ok := ctx.(IValueListContext); ok {
|
||||
if _, ok := ctx.(IFunctionParamListContext); ok {
|
||||
t = ctx.(antlr.RuleContext)
|
||||
break
|
||||
}
|
||||
@@ -3005,7 +3005,7 @@ func (s *SearchCallContext) ValueList() IValueListContext {
|
||||
return nil
|
||||
}
|
||||
|
||||
return t.(IValueListContext)
|
||||
return t.(IFunctionParamListContext)
|
||||
}
|
||||
|
||||
func (s *SearchCallContext) RPAREN() antlr.TerminalNode {
|
||||
@@ -3064,7 +3064,7 @@ func (p *FilterQueryParser) SearchCall() (localctx ISearchCallContext) {
|
||||
}
|
||||
{
|
||||
p.SetState(202)
|
||||
p.ValueList()
|
||||
p.FunctionParamList()
|
||||
}
|
||||
{
|
||||
p.SetState(203)
|
||||
|
||||
@@ -20,8 +20,6 @@ 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 {
|
||||
@@ -249,10 +247,6 @@ 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 {
|
||||
@@ -264,70 +258,6 @@ 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
|
||||
@@ -561,11 +491,6 @@ 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
|
||||
@@ -576,17 +501,6 @@ 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 {
|
||||
|
||||
@@ -27,8 +27,6 @@ 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.
|
||||
@@ -47,7 +45,6 @@ func newConfig() factory.Config {
|
||||
Threshold: 100000,
|
||||
},
|
||||
LogTraceIDWindowPadding: 5 * time.Minute,
|
||||
SearchMaxScanRows: 100_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +65,6 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,6 @@ func newProvider(
|
||||
telemetryStore,
|
||||
cfg.SkipResourceFingerprint.Enabled,
|
||||
cfg.SkipResourceFingerprint.Threshold,
|
||||
telemetrylogs.WithSearchMaxScanRows(cfg.SearchMaxScanRows),
|
||||
)
|
||||
|
||||
// Create audit statement builder
|
||||
|
||||
@@ -8,10 +8,6 @@ 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 (
|
||||
|
||||
@@ -161,16 +161,14 @@ func inferDataTypesFromList(values []any) []telemetrytypes.FieldDataType {
|
||||
return out
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -43,8 +43,6 @@ type filterExpressionVisitor struct {
|
||||
keysWithWarnings map[string]bool
|
||||
startNs uint64
|
||||
endNs uint64
|
||||
|
||||
requiresCostGuard bool
|
||||
}
|
||||
|
||||
type FilterExprVisitorOpts struct {
|
||||
@@ -83,10 +81,9 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
|
||||
}
|
||||
|
||||
type PreparedWhereClause struct {
|
||||
WhereClause *sqlbuilder.WhereClause
|
||||
Warnings []string
|
||||
WarningsDocURL string
|
||||
RequiresCostGuard bool
|
||||
WhereClause *sqlbuilder.WhereClause
|
||||
Warnings []string
|
||||
WarningsDocURL string
|
||||
}
|
||||
|
||||
func (p PreparedWhereClause) IsEmpty() bool {
|
||||
@@ -168,12 +165,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, RequiresCostGuard: visitor.requiresCostGuard}, nil
|
||||
return PreparedWhereClause{WhereClause: nil, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
|
||||
}
|
||||
|
||||
whereClause := sqlbuilder.NewWhereClause().AddWhereExpr(visitor.builder.Args, cond)
|
||||
|
||||
return PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL, RequiresCostGuard: visitor.requiresCostGuard}, nil
|
||||
return PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
|
||||
}
|
||||
|
||||
// Visit dispatches to the specific visit method based on node type.
|
||||
@@ -779,79 +776,11 @@ func normalizeFunctionValue(operator qbtypes.FilterOperator, functionName string
|
||||
return valueParams, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// VisitSearchCall handles search('needle'). The search() function is parsed but
|
||||
// not yet implemented; reject it with a clear invalid-input error.
|
||||
func (v *filterExpressionVisitor) VisitSearchCall(ctx *grammar.SearchCallContext) any {
|
||||
// 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
|
||||
v.errors = append(v.errors, "function `search` is not yet supported")
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
|
||||
// VisitFunctionParamList handles the parameter list for function calls.
|
||||
|
||||
@@ -134,7 +134,7 @@ func (c *conditionBuilder) ConditionFor(
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) ([]string, []string, error) {
|
||||
|
||||
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for audit.
|
||||
// has/hasAny/hasAll/hasToken are logs-body-only; reject for audit.
|
||||
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package telemetrylogs
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -28,52 +27,6 @@ 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.
|
||||
@@ -425,11 +378,6 @@ 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 != "" {
|
||||
|
||||
@@ -635,37 +635,3 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -29,20 +29,11 @@ type logQueryStatementBuilder struct {
|
||||
fl flagger.Flagger
|
||||
skipResourceFingerprintEnabled bool
|
||||
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
||||
searchMaxScanRows int64
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -54,7 +45,6 @@ func NewLogQueryStatementBuilder(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
skipResourceFingerprintEnable bool,
|
||||
skipResourceFingerprintThreshold uint64,
|
||||
opts ...LogQueryStatementBuilderOption,
|
||||
) *logQueryStatementBuilder {
|
||||
logsSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetrylogs")
|
||||
|
||||
@@ -71,7 +61,7 @@ func NewLogQueryStatementBuilder(
|
||||
skipResourceFingerprintThreshold,
|
||||
)
|
||||
|
||||
b := &logQueryStatementBuilder{
|
||||
return &logQueryStatementBuilder{
|
||||
logger: logsSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
@@ -82,10 +72,6 @@ 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.
|
||||
@@ -131,23 +117,9 @@ 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
|
||||
@@ -385,7 +357,6 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
CostGuard: b.costGuardFor(preparedWhereClause.RequiresCostGuard),
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -552,7 +523,6 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
CostGuard: b.costGuardFor(preparedWhereClause.RequiresCostGuard),
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -680,7 +650,6 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
CostGuard: b.costGuardFor(preparedWhereClause.RequiresCostGuard),
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
|
||||
@@ -157,7 +157,7 @@ func (c *conditionBuilder) ConditionFor(
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) ([]string, []string, error) {
|
||||
|
||||
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for metrics.
|
||||
// has/hasAny/hasAll/hasToken are logs-body-only; reject for metrics.
|
||||
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ func (c *conditionBuilder) ConditionFor(
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) ([]string, []string, error) {
|
||||
|
||||
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for traces.
|
||||
// has/hasAny/hasAll/hasToken are logs-body-only; reject for traces.
|
||||
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -54,10 +54,12 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
|
||||
},
|
||||
},
|
||||
},
|
||||
Links: []Link{},
|
||||
},
|
||||
},
|
||||
},
|
||||
Layouts: []Layout{},
|
||||
Links: []Link{},
|
||||
}
|
||||
|
||||
return &DashboardV2{
|
||||
|
||||
@@ -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,omitzero"`
|
||||
Links []Link `json:"links" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -45,6 +45,16 @@ 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
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -53,6 +63,9 @@ 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
|
||||
}
|
||||
@@ -104,6 +117,9 @@ 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)
|
||||
|
||||
@@ -23,6 +23,7 @@ const basePostableJSON = `{
|
||||
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
|
||||
"spec": {
|
||||
"display": {"name": "Service overview"},
|
||||
"links": [],
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
@@ -41,6 +42,7 @@ const basePostableJSON = `{
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
@@ -64,6 +66,7 @@ const basePostableJSON = `{
|
||||
"p2": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
@@ -182,6 +185,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
@@ -216,6 +220,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
@@ -327,7 +332,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": {"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": {"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/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
@@ -346,6 +351,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
@@ -496,7 +502,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
"path": "/spec/panels/p1",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
|
||||
"spec": {"links": [], "plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
@@ -512,6 +518,7 @@ 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"}}}}]
|
||||
}
|
||||
@@ -537,6 +544,7 @@ 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": {
|
||||
|
||||
@@ -51,10 +51,12 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "NonExistentPanel", "spec": {}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
|
||||
@@ -76,7 +78,7 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
|
||||
|
||||
func TestValidateEmptySpec(t *testing.T) {
|
||||
// no variables no panels
|
||||
data := []byte(`{}`)
|
||||
data := []byte(`{"links": []}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
assert.NoError(t, err, "expected valid")
|
||||
}
|
||||
@@ -107,6 +109,7 @@ func TestValidateOnlyVariables(t *testing.T) {
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
@@ -133,6 +136,7 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
@@ -157,6 +161,7 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
@@ -186,6 +191,7 @@ func TestInvalidatePanelKey(t *testing.T) {
|
||||
"bad key!": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
@@ -196,6 +202,7 @@ func TestInvalidatePanelKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
@@ -219,6 +226,7 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
@@ -282,6 +290,7 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
|
||||
cases := map[string][]byte{
|
||||
"text variable": []byte(`{
|
||||
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`),
|
||||
"list variable": []byte(`{
|
||||
@@ -294,6 +303,7 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
|
||||
"plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}
|
||||
}
|
||||
}],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`),
|
||||
}
|
||||
@@ -319,10 +329,12 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "NonExistentPanel", "spec": {}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "NonExistentPanel",
|
||||
@@ -338,6 +350,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "unknown panel kind",
|
||||
@@ -349,6 +362,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
@@ -359,6 +373,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "FakeQueryPlugin",
|
||||
@@ -370,6 +385,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "TimeSeriesQuery",
|
||||
@@ -380,6 +396,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "unknown request type",
|
||||
@@ -391,6 +408,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "",
|
||||
@@ -401,6 +419,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "unknown request type",
|
||||
@@ -417,6 +436,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
"plugin": {"kind": "FakeVariable", "spec": {}}
|
||||
}
|
||||
}],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "FakeVariable",
|
||||
@@ -430,6 +450,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
"plugin": {"kind": "FakeDatasource", "spec": {}}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "FakeDatasource",
|
||||
@@ -450,13 +471,14 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
"panels": {
|
||||
"good": {
|
||||
"kind": "Panel",
|
||||
"spec": {"plugin": {"kind": "signoz/NumberPanel", "spec": {}}}
|
||||
"spec": {"links": [],"plugin": {"kind": "signoz/NumberPanel", "spec": {}}}
|
||||
},
|
||||
"bad": {
|
||||
"kind": "Panel",
|
||||
"spec": {"plugin": {"kind": "FakePanel", "spec": {}}}
|
||||
"spec": {"links": [],"plugin": {"kind": "FakePanel", "spec": {}}}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
@@ -469,6 +491,7 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
@@ -480,7 +503,7 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
layout := func(items string) []byte {
|
||||
return []byte(`{` + validPanels + `, "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
|
||||
return []byte(`{` + validPanels + `, "links": [], "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -536,6 +559,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {"bogusField": true}
|
||||
@@ -543,6 +567,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "bogusField",
|
||||
@@ -554,6 +579,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
@@ -567,6 +593,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "unknownThing",
|
||||
@@ -586,6 +613,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "extraField",
|
||||
@@ -614,6 +642,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {"visualization": {"fillSpans": "notabool"}}
|
||||
@@ -621,6 +650,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "fillSpans",
|
||||
@@ -632,6 +662,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
@@ -645,6 +676,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "",
|
||||
@@ -664,6 +696,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "",
|
||||
@@ -694,6 +727,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {}
|
||||
@@ -710,6 +744,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "signal",
|
||||
@@ -721,6 +756,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {"chartAppearance": {"lineInterpolation": "cubic"}}
|
||||
@@ -728,6 +764,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "line interpolation",
|
||||
@@ -739,6 +776,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {"chartAppearance": {"lineStyle": "dotted"}}
|
||||
@@ -746,6 +784,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "line style",
|
||||
@@ -757,6 +796,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {"chartAppearance": {"fillMode": "striped"}}
|
||||
@@ -764,6 +804,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "fill mode",
|
||||
@@ -775,6 +816,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {"chartAppearance": {"spanGaps": {"fillOnlyBelow": true, "fillLessThan": "notaduration"}}}
|
||||
@@ -782,6 +824,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "duration",
|
||||
@@ -793,6 +836,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {"visualization": {"timePreference": "last2Hr"}}
|
||||
@@ -800,6 +844,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "timePreference",
|
||||
@@ -811,6 +856,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/BarChartPanel",
|
||||
"spec": {"legend": {"position": "top"}}
|
||||
@@ -818,6 +864,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "legend position",
|
||||
@@ -829,6 +876,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/BarChartPanel",
|
||||
"spec": {"legend": {"mode": "grid"}}
|
||||
@@ -836,6 +884,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "legend mode",
|
||||
@@ -847,6 +896,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/NumberPanel",
|
||||
"spec": {"thresholds": [{"value": 100, "operator": "above", "color": "Red", "format": "Color"}]}
|
||||
@@ -854,6 +904,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "threshold format",
|
||||
@@ -865,6 +916,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/NumberPanel",
|
||||
"spec": {"thresholds": [{"value": 100, "operator": "!=", "color": "Red", "format": "text"}]}
|
||||
@@ -872,6 +924,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "comparison operator",
|
||||
@@ -883,6 +936,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {"formatting": {"decimalPrecision": "9"}}
|
||||
@@ -890,6 +944,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "precision",
|
||||
@@ -921,11 +976,13 @@ 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)
|
||||
@@ -943,9 +1000,10 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}}}
|
||||
"spec": {"links": [],"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}}}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
@@ -959,11 +1017,13 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
@@ -979,6 +1039,7 @@ 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"}}}},
|
||||
@@ -987,6 +1048,7 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
@@ -1006,6 +1068,7 @@ func TestValidateRequiredFields(t *testing.T) {
|
||||
"plugin": {"kind": "` + pluginKind + `", "spec": ` + pluginSpec + `}
|
||||
}
|
||||
}],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`
|
||||
}
|
||||
@@ -1015,10 +1078,12 @@ func TestValidateRequiredFields(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + panelKind + `", "spec": ` + panelSpec + `}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`
|
||||
}
|
||||
@@ -1083,6 +1148,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {}
|
||||
@@ -1091,6 +1157,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
@@ -1133,6 +1200,7 @@ func TestNumberPanelDefaults(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/NumberPanel",
|
||||
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
|
||||
@@ -1141,6 +1209,7 @@ func TestNumberPanelDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
@@ -1194,6 +1263,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TimeSeriesPanel",
|
||||
"spec": {}
|
||||
@@ -1204,6 +1274,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
"p2": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/NumberPanel",
|
||||
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
|
||||
@@ -1212,6 +1283,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
|
||||
@@ -1272,7 +1344,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
|
||||
const validSpec = `"spec": {"panels": {}, "layouts": []}`
|
||||
const validSpec = `"spec": {"panels": {}, "layouts": [], "links": []}`
|
||||
|
||||
tests := []struct {
|
||||
scenario string
|
||||
@@ -1284,13 +1356,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":[]}}`,
|
||||
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"panels":{},"layouts":[],"links":[]}}`,
|
||||
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":[]}}`,
|
||||
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"panels":{},"layouts":[],"links":[]}}`,
|
||||
wantErr: true,
|
||||
wantErrMatch: "name must be empty when generateName is true",
|
||||
},
|
||||
@@ -1450,20 +1522,24 @@ 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": []
|
||||
}`)
|
||||
}
|
||||
@@ -1507,11 +1583,13 @@ 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": []
|
||||
}`)
|
||||
}
|
||||
@@ -1625,9 +1703,10 @@ func TestValidateGridItemLimit(t *testing.T) {
|
||||
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"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()"}]}}}}]}}
|
||||
"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()"}]}}}}]}}
|
||||
},
|
||||
"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"}}
|
||||
@@ -1644,8 +1723,9 @@ func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
|
||||
func TestInvalidateDuplicatePanelReference(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"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()"}]}}}}]}}
|
||||
"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()"}]}}}}]}}
|
||||
},
|
||||
"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"}}
|
||||
@@ -1675,31 +1755,31 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
scenario: "dashboard display name",
|
||||
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "layouts": []}`,
|
||||
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "panel display name",
|
||||
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
|
||||
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [],"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "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"}}}}], "layouts": []}`,
|
||||
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "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 + `"}}}], "layouts": []}`,
|
||||
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "layout title",
|
||||
dashboardJSON: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
|
||||
dashboardJSON: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
},
|
||||
@@ -1719,7 +1799,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 + `"}, "layouts": []}`))
|
||||
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "links": [], "layouts": []}`))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -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: telemetrytypes.SignalTraces}}}},
|
||||
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "signal", Plugin: VariablePlugin{Kind: VariableKindDynamic, Spec: &DynamicVariableSpec{Name: "service.name", Signal: DynamicVariableSignalTraces}}}},
|
||||
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "env", Plugin: VariablePlugin{Kind: VariableKindCustom, Spec: &CustomVariableSpec{CustomValue: "prod,staging"}}}},
|
||||
}}
|
||||
|
||||
|
||||
@@ -78,7 +78,17 @@ 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,omitzero"`
|
||||
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
|
||||
}
|
||||
|
||||
// Link replicates dashboard.Link (Perses) so its zero-valued fields survive the
|
||||
|
||||
@@ -32,7 +32,49 @@ 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 telemetrytypes.Signal `json:"signal"`
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
type QueryVariableSpec struct {
|
||||
|
||||
12
pkg/types/dashboardtypes/testdata/perses.json
vendored
12
pkg/types/dashboardtypes/testdata/perses.json
vendored
@@ -80,6 +80,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"panels": {
|
||||
"24e2697b": {
|
||||
"kind": "Panel",
|
||||
@@ -167,6 +168,7 @@
|
||||
"ff2f72f1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "fraction of calls",
|
||||
"description": ""
|
||||
@@ -253,6 +255,7 @@
|
||||
"011605e7": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "total resp size"
|
||||
},
|
||||
@@ -304,6 +307,7 @@
|
||||
"e23516fc": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "num traces for service"
|
||||
},
|
||||
@@ -359,6 +363,7 @@
|
||||
"130c8d6b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "num logs for service"
|
||||
},
|
||||
@@ -398,6 +403,7 @@
|
||||
"246f7c6d": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "num traces for service per resp code"
|
||||
},
|
||||
@@ -451,6 +457,7 @@
|
||||
"21f7d4d0": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "average latency per service"
|
||||
},
|
||||
@@ -493,6 +500,7 @@
|
||||
"ad5fd556": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "logs from service"
|
||||
},
|
||||
@@ -557,6 +565,7 @@
|
||||
"f07b59ee": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "response size buckets"
|
||||
},
|
||||
@@ -596,6 +605,7 @@
|
||||
"e1a41831": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "trace operator",
|
||||
"description": ""
|
||||
@@ -672,6 +682,7 @@
|
||||
"f0d70491": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "no results in this promql",
|
||||
"description": ""
|
||||
@@ -713,6 +724,7 @@
|
||||
"0e6eb4ca": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": "no results in this promql",
|
||||
"description": ""
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"panels": {
|
||||
"b424e23b": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": ""
|
||||
},
|
||||
@@ -58,6 +60,7 @@
|
||||
"251df4d5": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"display": {
|
||||
"name": ""
|
||||
},
|
||||
|
||||
@@ -118,10 +118,6 @@ 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{
|
||||
@@ -190,8 +186,7 @@ func (f FilterOperator) IsNegativeOperator() bool {
|
||||
FilterOperatorIn,
|
||||
FilterOperatorExists,
|
||||
FilterOperatorRegexp,
|
||||
FilterOperatorContains,
|
||||
FilterOperatorSearch:
|
||||
FilterOperatorContains:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -248,11 +243,10 @@ func (f FilterOperator) IsArrayFunctionOperator() bool {
|
||||
}
|
||||
|
||||
// IsFunctionOperator reports whether the operator is a query function
|
||||
// (has/hasAny/hasAll/hasToken/search). These are logs-only; other signals reject
|
||||
// them and the resource-fingerprint builder skips them.
|
||||
// (has/hasAny/hasAll/hasToken); these apply to the logs body column only.
|
||||
func (f FilterOperator) IsFunctionOperator() bool {
|
||||
switch f {
|
||||
case FilterOperatorHas, FilterOperatorHasAny, FilterOperatorHasAll, FilterOperatorHasToken, FilterOperatorSearch:
|
||||
case FilterOperatorHas, FilterOperatorHasAny, FilterOperatorHasAll, FilterOperatorHasToken:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -271,8 +265,6 @@ func (f FilterOperator) FunctionName() string {
|
||||
return "hasAll"
|
||||
case FilterOperatorHasToken:
|
||||
return "hasToken"
|
||||
case FilterOperatorSearch:
|
||||
return "search"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -57,12 +57,6 @@ type Statement struct {
|
||||
Args []any
|
||||
Warnings []string
|
||||
WarningsDocURL string
|
||||
CostGuard *CostGuard
|
||||
}
|
||||
|
||||
type CostGuard struct {
|
||||
Warning string
|
||||
MaxScanRows int64
|
||||
}
|
||||
|
||||
// StatementBuilder builds the query.
|
||||
|
||||
@@ -79,14 +79,6 @@ 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
|
||||
|
||||
@@ -412,16 +412,10 @@ func (v *variableReplacementVisitor) VisitFunctionCall(ctx *grammar.FunctionCall
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitSearchCall(ctx *grammar.SearchCallContext) any {
|
||||
if ctx.ValueList() == nil {
|
||||
if ctx.FunctionParamList() == nil {
|
||||
return "search()"
|
||||
}
|
||||
// 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
|
||||
return "search(" + v.Visit(ctx.FunctionParamList()).(string) + ")"
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitFunctionParamList(ctx *grammar.FunctionParamListContext) any {
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_create_rejects_unknown_field(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-unknown",
|
||||
"spec": {"display": {"name": "Rejects Unknown"}},
|
||||
"spec": {"display": {"name": "Rejects Unknown"}, "links": []},
|
||||
"tags": [],
|
||||
"unknownfield": "boom",
|
||||
},
|
||||
@@ -209,6 +209,7 @@ def test_create_rejects_invalid_grid_layout(
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": name},
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
@@ -249,6 +250,7 @@ def test_create_rejects_invalid_grid_layout(
|
||||
},
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
@@ -280,6 +282,7 @@ def test_create_rejects_invalid_grid_layout(
|
||||
},
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
@@ -305,6 +308,7 @@ def test_create_rejects_invalid_grid_layout(
|
||||
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
@@ -410,7 +414,7 @@ def test_update_missing_dashboard_returns_not_found(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "missing-dashboard",
|
||||
"spec": {"display": {"name": "Missing Dashboard"}},
|
||||
"spec": {"display": {"name": "Missing Dashboard"}, "links": []},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -535,7 +539,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": name,
|
||||
"spec": {"display": {"name": display}},
|
||||
"spec": {"display": {"name": display}, "links": []},
|
||||
"tags": tags,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -897,7 +901,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"}},
|
||||
"spec": {"display": {"name": "Alpha Overview"}, "links": []},
|
||||
"tags": [
|
||||
{"key": "team", "value": "pulse"},
|
||||
{"key": "env", "value": "prod"},
|
||||
@@ -941,7 +945,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"}},
|
||||
"spec": {"display": {"name": "Beta Overview"}, "links": []},
|
||||
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
|
||||
}
|
||||
response = requests.put(
|
||||
@@ -1045,7 +1049,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"}}, "tags": created_order},
|
||||
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": created_order},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -1071,7 +1075,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"}}, "tags": reordered},
|
||||
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": reordered},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -1100,7 +1104,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"}}, "tags": new_order},
|
||||
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": new_order},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -1137,7 +1141,7 @@ def test_dashboard_v2_pin_limit(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": f"pl-{i}",
|
||||
"spec": {"display": {"name": f"Pin Limit {i}"}},
|
||||
"spec": {"display": {"name": f"Pin Limit {i}"}, "links": []},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -1235,7 +1239,7 @@ def test_dashboard_v2_like_escaping(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": name,
|
||||
"spec": {"display": {"name": display}},
|
||||
"spec": {"display": {"name": display}, "links": []},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -1326,11 +1330,13 @@ 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": [
|
||||
{
|
||||
@@ -1375,11 +1381,13 @@ 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": [
|
||||
{
|
||||
@@ -1401,6 +1409,7 @@ def test_dashboard_v2_get_by_metric_name(
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "D2 promql target"},
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
@@ -1437,11 +1446,13 @@ 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": [
|
||||
{
|
||||
@@ -1480,11 +1491,13 @@ 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": [
|
||||
{
|
||||
@@ -1513,6 +1526,7 @@ def test_dashboard_v2_get_by_metric_name(
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": f"{target_metric} clickhouse"},
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
@@ -1534,6 +1548,7 @@ def test_dashboard_v2_get_by_metric_name(
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": f"{target_metric} promql"},
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
@@ -1607,11 +1622,13 @@ 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": [
|
||||
{
|
||||
@@ -1763,6 +1780,7 @@ 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"}]},
|
||||
@@ -1785,6 +1803,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "promql"},
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
@@ -1804,6 +1823,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "clickhouse"},
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
@@ -1899,11 +1919,9 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
|
||||
for description, spec, key in absent_cases:
|
||||
assert key not in spec, description
|
||||
|
||||
# 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"
|
||||
# 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 []"
|
||||
finally:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
|
||||
@@ -1955,6 +1973,7 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
|
||||
},
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
},
|
||||
},
|
||||
"num": {
|
||||
@@ -1976,9 +1995,11 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
|
||||
},
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
"links": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ def test_public_dashboard_v2(
|
||||
"spec": {
|
||||
"display": {"name": "Sample Dashboard", "description": "Used for integration tests"},
|
||||
"duration": "1h",
|
||||
"links": [],
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
@@ -77,6 +78,7 @@ def test_public_dashboard_v2(
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "total"},
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"visualization": {"fillSpans": True}}},
|
||||
"queries": [
|
||||
{
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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"
|
||||
@@ -1,211 +0,0 @@
|
||||
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
|
||||
@@ -1,35 +0,0 @@
|
||||
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,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user