mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-11 22:10:42 +01:00
Compare commits
2 Commits
feat/stora
...
test/expli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f1d41a31 | ||
|
|
2134adf735 |
@@ -8,7 +8,7 @@ import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panel
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
@@ -5,8 +5,18 @@ import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
].map((option) => ({ ...option, icon: (): null => null })),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useMemo } from 'react';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { PANEL_OPTIONS } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
@@ -27,17 +27,17 @@ export function usePanelTypeSelectItems({
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
label: displayName,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
value: kind,
|
||||
label: displayName,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Input } from 'antd';
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import LegendColorRow from './LegendColorRow';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import LegendColors from '../LegendColors';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
|
||||
/** Case-insensitive substring filter over series labels. Empty query → all series. */
|
||||
export function filterLegendSeries(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelKind } from '../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
|
||||
})),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
].map((option) => ({ ...option, icon: (): null => null })),
|
||||
}));
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label.
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import { getSectionControls } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getSectionControls';
|
||||
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import {
|
||||
type LegendSeries,
|
||||
resolvePieLegendSeries,
|
||||
resolveTimeSeriesLegendSeries,
|
||||
} from '../utils/legendSeries';
|
||||
|
||||
/**
|
||||
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
|
||||
* legend-colors control can key overrides by the exact labels the chart draws. Only the
|
||||
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
|
||||
* Series from its flat series); every other kind returns none.
|
||||
* legend-colors control can key overrides by the exact labels the chart draws, using
|
||||
* the resolver the kind declares as its `colors` control.
|
||||
*/
|
||||
export function useLegendSeries(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
data: PanelQueryData,
|
||||
): LegendSeries[] {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const kind = panel.spec.plugin.kind;
|
||||
|
||||
return useMemo(() => {
|
||||
switch (panel.spec.plugin.kind) {
|
||||
case 'signoz/PieChartPanel':
|
||||
return resolvePieLegendSeries(data, isDarkMode);
|
||||
case 'signoz/TimeSeriesPanel':
|
||||
case 'signoz/BarChartPanel':
|
||||
case 'signoz/HistogramPanel':
|
||||
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
|
||||
const resolve = getSectionControls(kind, SectionKind.Legend)?.colors;
|
||||
return resolve
|
||||
? resolve({ queries: panel.spec.queries, data, isDarkMode })
|
||||
: [];
|
||||
}, [kind, panel.spec.queries, data, isDarkMode]);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BarChart } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -10,6 +12,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
displayName: 'Bar Chart',
|
||||
icon: BarChart,
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
@@ -13,7 +14,10 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BarChart } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -10,6 +12,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
displayName: 'Histogram',
|
||||
icon: BarChart,
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
@@ -9,7 +10,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: true },
|
||||
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
|
||||
// Merging all queries collapses to one distribution with no legend.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { List } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -11,6 +13,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
kind: 'signoz/ListPanel',
|
||||
displayName: 'List',
|
||||
icon: List,
|
||||
Renderer,
|
||||
// Raw records come from logs and traces; metrics don't produce row data.
|
||||
supportedSignals: [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Hash } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -10,6 +12,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
kind: 'signoz/NumberPanel',
|
||||
displayName: 'Number',
|
||||
icon: Hash,
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ChartPie } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -10,6 +12,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
displayName: 'Pie Chart',
|
||||
icon: ChartPie,
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolvePieLegendSeries } from '../../utils/legendSeries';
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
|
||||
@@ -8,6 +9,9 @@ export const sections: SectionConfig[] = [
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: resolvePieLegendSeries },
|
||||
},
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Table } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -10,6 +12,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
kind: 'signoz/TablePanel',
|
||||
displayName: 'Table',
|
||||
icon: Table,
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ChartLine } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -10,6 +12,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Time Series',
|
||||
icon: ChartLine,
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
@@ -11,7 +12,10 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
|
||||
},
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TriangleAlert } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
NO_PANEL_ACTIONS,
|
||||
@@ -18,6 +19,7 @@ import Renderer from './Renderer';
|
||||
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
|
||||
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
|
||||
displayName: 'Unsupported panel',
|
||||
icon: TriangleAlert,
|
||||
Renderer,
|
||||
sections: [],
|
||||
supportedSignals: [],
|
||||
|
||||
@@ -7,22 +7,33 @@ import { definition as Table } from './kinds/TablePanel/definition';
|
||||
import { definition as List } from './kinds/ListPanel/definition';
|
||||
import { UNSUPPORTED_PANEL } from './kinds/UnsupportedPanel/definition';
|
||||
import type {
|
||||
PanelDefinition,
|
||||
PanelRegistry,
|
||||
RenderablePanelDefinition,
|
||||
} from './types/panelDefinition';
|
||||
import { PanelKind } from './types/panelKind';
|
||||
|
||||
// Each kind owns its PanelDefinition; registering a new panel is one entry here.
|
||||
// Declaration order is the order kinds are offered in the UI.
|
||||
export const PANELS: PanelRegistry = {
|
||||
[TimeSeries.kind]: TimeSeries,
|
||||
[BarChart.kind]: BarChart,
|
||||
[Histogram.kind]: Histogram,
|
||||
[NumberValue.kind]: NumberValue,
|
||||
[PieChart.kind]: PieChart,
|
||||
[Table.kind]: Table,
|
||||
[BarChart.kind]: BarChart,
|
||||
[PieChart.kind]: PieChart,
|
||||
[Histogram.kind]: Histogram,
|
||||
[List.kind]: List,
|
||||
};
|
||||
|
||||
export type PanelOption = Pick<
|
||||
PanelDefinition,
|
||||
'kind' | 'displayName' | 'icon'
|
||||
>;
|
||||
|
||||
// Backs both the new-panel picker and the editor's kind switcher; derived from PANELS
|
||||
// so a registered kind can't end up unreachable from the UI.
|
||||
export const PANEL_OPTIONS: PanelOption[] = Object.values(PANELS);
|
||||
|
||||
/**
|
||||
* Whether this build can render the kind. `PanelKind` spans every kind the API declares,
|
||||
* but a dashboard spec written by a newer SigNoz can name one this client has never heard
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ChartLine } from '@signozhq/icons';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
@@ -60,9 +61,12 @@ export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
|
||||
drilldown: false,
|
||||
};
|
||||
|
||||
export type PanelIcon = typeof ChartLine;
|
||||
|
||||
export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
kind: K;
|
||||
displayName: string;
|
||||
icon: PanelIcon;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
sections: SectionConfig[];
|
||||
/** Signals this kind can visualize. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { LegendSeriesResolver } from '../utils/legendSeries';
|
||||
import {
|
||||
Antenna,
|
||||
BarChart,
|
||||
@@ -105,7 +106,12 @@ export interface SectionControls {
|
||||
columnUnits?: boolean;
|
||||
};
|
||||
[SectionKind.Axes]: { minMax?: boolean; logScale?: boolean }; // minMax → softMin/softMax
|
||||
[SectionKind.Legend]: { position?: boolean; colors?: boolean }; // colors → customColors
|
||||
[SectionKind.Legend]: {
|
||||
position?: boolean;
|
||||
// colors → customColors; the resolver supplies the labels overrides are keyed by,
|
||||
// so a kind can't offer color overrides with nothing to color
|
||||
colors?: LegendSeriesResolver;
|
||||
};
|
||||
[SectionKind.ChartAppearance]: {
|
||||
lineStyle?: boolean;
|
||||
lineInterpolation?: boolean;
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('buildPluginSpec', () => {
|
||||
|
||||
it('omits the key entirely when a seed produces an empty slice (never key: undefined)', () => {
|
||||
const result = buildPluginSpec([
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
{ kind: SectionKind.Legend, controls: { colors: (): [] => [] } },
|
||||
]);
|
||||
|
||||
expect(result).toStrictEqual({});
|
||||
@@ -129,7 +129,7 @@ describe('buildPluginSpec', () => {
|
||||
it('seeds neither when their defaulting controls are absent', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Visualization, controls: { switchPanelKind: true } },
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
{ kind: SectionKind.Legend, controls: { colors: (): [] => [] } },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
@@ -180,7 +180,10 @@ describe('buildPluginSpec', () => {
|
||||
|
||||
it('carries old legend position but never customColors', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: (): [] => [] },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
legend: {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SectionKind, ThresholdVariant } from '../../types/sections';
|
||||
import { getSectionControls } from '../getSectionControls';
|
||||
|
||||
describe('getSectionControls', () => {
|
||||
it('returns the controls a kind declares for a section', () => {
|
||||
expect(
|
||||
getSectionControls('signoz/TimeSeriesPanel', SectionKind.Formatting),
|
||||
).toStrictEqual({ unit: true, decimals: true });
|
||||
});
|
||||
|
||||
it('distinguishes kinds that key units per column from kinds with a panel unit', () => {
|
||||
expect(
|
||||
getSectionControls('signoz/TablePanel', SectionKind.Formatting)?.unit,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getSectionControls('signoz/TablePanel', SectionKind.Formatting)?.columnUnits,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the threshold variant each kind edits', () => {
|
||||
expect(
|
||||
getSectionControls('signoz/NumberPanel', SectionKind.Thresholds)?.variant,
|
||||
).toBe(ThresholdVariant.COMPARISON);
|
||||
expect(
|
||||
getSectionControls('signoz/BarChartPanel', SectionKind.Thresholds)?.variant,
|
||||
).toBe(ThresholdVariant.LABEL);
|
||||
});
|
||||
|
||||
it('returns undefined when the kind does not expose the section', () => {
|
||||
expect(
|
||||
getSectionControls('signoz/ListPanel', SectionKind.Formatting),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getSectionControls('signoz/HistogramPanel', SectionKind.Thresholds),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an unregistered kind', () => {
|
||||
expect(
|
||||
getSectionControls(
|
||||
'signoz/FuturePanel' as Parameters<typeof getSectionControls>[0],
|
||||
SectionKind.Formatting,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getPanelDefinition } from '../registry';
|
||||
import type { PanelKind } from '../types/panelKind';
|
||||
import type { ControlledSectionKind, SectionControls } from '../types/sections';
|
||||
|
||||
/**
|
||||
* The controls a kind declares for one section, or `undefined` when it doesn't expose
|
||||
* that section — so callers read `kinds/<Kind>/sections.ts` instead of switching on kind.
|
||||
*/
|
||||
export function getSectionControls<K extends ControlledSectionKind>(
|
||||
kind: PanelKind,
|
||||
sectionKind: K,
|
||||
): SectionControls[K] | undefined {
|
||||
const section = getPanelDefinition(kind).sections.find(
|
||||
(candidate) => candidate.kind === sectionKind,
|
||||
);
|
||||
if (!section || !('controls' in section)) {
|
||||
return undefined;
|
||||
}
|
||||
// `find` can't correlate the matched member's `controls` with `sectionKind`; the
|
||||
// SectionConfig union guarantees it.
|
||||
return section.controls as SectionControls[K];
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { themeColors } from 'constants/theme';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import { preparePieData } from '../kinds/PieChartPanel/prepareData';
|
||||
import { getBuilderQueries } from './getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from './resolveSeriesLabel';
|
||||
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
@@ -22,6 +22,15 @@ export interface LegendSeries {
|
||||
|
||||
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
|
||||
|
||||
interface LegendSeriesArgs {
|
||||
queries: PanelQueries;
|
||||
data: PanelQueryData;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/** Resolves a kind's output into the legend entries the colors control keys overrides by. */
|
||||
export type LegendSeriesResolver = (args: LegendSeriesArgs) => LegendSeries[];
|
||||
|
||||
/**
|
||||
* Dedupes `labels` (first-seen order, empties dropped) into `{ label, defaultColor }`
|
||||
* pairs, resolving each unique label's color lazily via `colorFor` — so a repeated
|
||||
@@ -48,10 +57,10 @@ function buildLegendSeries(
|
||||
* draws (without overrides, so their colors are the defaults) so the color control keys
|
||||
* overrides by the same labels the chart does.
|
||||
*/
|
||||
export function resolvePieLegendSeries(
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
export function resolvePieLegendSeries({
|
||||
data,
|
||||
isDarkMode,
|
||||
}: LegendSeriesArgs): LegendSeries[] {
|
||||
const slices = preparePieData({
|
||||
tables: prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
@@ -70,11 +79,11 @@ export function resolvePieLegendSeries(
|
||||
* Time-series kinds: resolve each flattened series' label the way the renderer does
|
||||
* (`getLabelName` → `resolveSeriesLabelV5`) and color it with `generateColor`.
|
||||
*/
|
||||
export function resolveTimeSeriesLegendSeries(
|
||||
queries: PanelQueries,
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
export function resolveTimeSeriesLegendSeries({
|
||||
queries,
|
||||
data,
|
||||
isDarkMode,
|
||||
}: LegendSeriesArgs): LegendSeries[] {
|
||||
const palette = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
@@ -4,8 +4,8 @@ import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useDashboardSections } from '../../../hooks/useDashboardSections';
|
||||
import { PANEL_OPTIONS } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from './constants';
|
||||
import PanelTypeSelectionModalFooter from './PanelTypeSelectionModalFooter';
|
||||
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
|
||||
import styles from './PanelTypeSelectionModal.module.scss';
|
||||
@@ -91,19 +91,19 @@ function PanelTypeSelectionModal({
|
||||
<span className={styles.pickerLabel}>Select panel type</span>
|
||||
)}
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
{PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => (
|
||||
<button
|
||||
key={panelKind}
|
||||
key={kind}
|
||||
type="button"
|
||||
className={cx(styles.panelTypeCard, {
|
||||
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
|
||||
[styles.panelTypeCardSelected]: kind === selectedPanelKind,
|
||||
})}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
aria-pressed={panelKind === selectedPanelKind}
|
||||
onClick={(): void => handleTileClick(panelKind)}
|
||||
data-testid={`panel-type-${kind}`}
|
||||
aria-pressed={kind === selectedPanelKind}
|
||||
onClick={(): void => handleTileClick(kind)}
|
||||
>
|
||||
<Icon size={24} color={Color.BG_ROBIN_400} />
|
||||
{label}
|
||||
{displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import {
|
||||
BarChart,
|
||||
ChartLine,
|
||||
ChartPie,
|
||||
Hash,
|
||||
List,
|
||||
Table,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import type { PanelType } from './types';
|
||||
|
||||
export const PANEL_TYPES: PanelType[] = [
|
||||
{
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
label: 'Time Series',
|
||||
Icon: ChartLine,
|
||||
},
|
||||
{ panelKind: 'signoz/NumberPanel', label: 'Number', Icon: Hash },
|
||||
{ panelKind: 'signoz/TablePanel', label: 'Table', Icon: Table },
|
||||
{ panelKind: 'signoz/BarChartPanel', label: 'Bar Chart', Icon: BarChart },
|
||||
{ panelKind: 'signoz/PieChartPanel', label: 'Pie Chart', Icon: ChartPie },
|
||||
{ panelKind: 'signoz/HistogramPanel', label: 'Histogram', Icon: BarChart },
|
||||
{ panelKind: 'signoz/ListPanel', label: 'List', Icon: List },
|
||||
];
|
||||
@@ -1,20 +1,11 @@
|
||||
import type { IconSize } from '@signozhq/icons';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
|
||||
type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
|
||||
size?: number | IconSize;
|
||||
strokeWidth?: number;
|
||||
};
|
||||
|
||||
export interface PanelType {
|
||||
panelKind: PanelKind;
|
||||
label: string;
|
||||
/** Icon component — the consumer renders it and controls size/color/etc. */
|
||||
Icon: ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
export interface SectionOption {
|
||||
/** The section's `layoutIndex`, stringified for the Select value. */
|
||||
value: string;
|
||||
|
||||
@@ -8,24 +8,24 @@ import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
SectionKind,
|
||||
type PanelFormattingSlice,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getSectionControls } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getSectionControls';
|
||||
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { deriveAlertPrefill, PanelAlertPrefill } from './deriveAlertPrefill';
|
||||
|
||||
/** The panel's configured y-axis unit, for the kinds that carry one. */
|
||||
/** The panel's configured y-axis unit, for the kinds that declare one. */
|
||||
export function readPanelUnit(
|
||||
plugin: DashboardtypesPanelPluginDTO,
|
||||
): string | undefined {
|
||||
switch (plugin.kind) {
|
||||
case 'signoz/TimeSeriesPanel':
|
||||
case 'signoz/BarChartPanel':
|
||||
case 'signoz/NumberPanel':
|
||||
case 'signoz/PieChartPanel':
|
||||
return plugin.spec.formatting?.unit;
|
||||
default:
|
||||
return undefined;
|
||||
if (!getSectionControls(plugin.kind, SectionKind.Formatting)?.unit) {
|
||||
return undefined;
|
||||
}
|
||||
return (plugin.spec as { formatting?: PanelFormattingSlice }).formatting?.unit;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,15 @@ import {
|
||||
AlertThresholdOperator,
|
||||
Threshold,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { THRESHOLD_COLOR_DANGER_ORDER } from 'pages/DashboardPage/DashboardContainer/Panels/types/threshold';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import {
|
||||
THRESHOLD_COLOR_DANGER_ORDER,
|
||||
type ComparisonThresholdShape,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/threshold';
|
||||
import { getSectionControls } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getSectionControls';
|
||||
import type { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -64,27 +72,35 @@ export function uniformReduceTo(query: Query): ReduceOperators | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's thresholds, normalized for alert prefill, read through the variant the
|
||||
* kind declares. A `table` variant contributes nothing: per-column thresholds have no
|
||||
* meaning for a panel-wide alert condition.
|
||||
*/
|
||||
function readPanelThresholds(
|
||||
plugin: DashboardtypesPanelPluginDTO,
|
||||
): NormalizedPanelThreshold[] {
|
||||
switch (plugin.kind) {
|
||||
case 'signoz/TimeSeriesPanel':
|
||||
case 'signoz/BarChartPanel':
|
||||
return (plugin.spec.thresholds ?? []).map((t) => ({
|
||||
color: t.color,
|
||||
value: t.value,
|
||||
unit: t.unit,
|
||||
}));
|
||||
case 'signoz/NumberPanel':
|
||||
return (plugin.spec.thresholds ?? []).map((t) => ({
|
||||
color: t.color,
|
||||
value: t.value,
|
||||
unit: t.unit,
|
||||
operator: t.operator,
|
||||
}));
|
||||
default:
|
||||
return [];
|
||||
const variant = getSectionControls(
|
||||
plugin.kind,
|
||||
SectionKind.Thresholds,
|
||||
)?.variant;
|
||||
if (
|
||||
variant !== ThresholdVariant.LABEL &&
|
||||
variant !== ThresholdVariant.COMPARISON
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const thresholds =
|
||||
(plugin.spec as { thresholds?: ComparisonThresholdShape[] }).thresholds ?? [];
|
||||
return thresholds.map((threshold) => ({
|
||||
color: threshold.color,
|
||||
value: threshold.value,
|
||||
unit: threshold.unit,
|
||||
// Only comparison thresholds carry an operator.
|
||||
...(variant === ThresholdVariant.COMPARISON && {
|
||||
operator: threshold.operator,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// Match case-insensitively (picker emits lowercase hex); unknown colors sort last.
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
buildPluginSpec,
|
||||
type SeededPluginSpec,
|
||||
} from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { getSectionControls } from '../DashboardContainer/Panels/utils/getSectionControls';
|
||||
import { toPerses } from '../DashboardContainer/queryV5/persesQueryAdapters';
|
||||
|
||||
interface NewPanelSeed {
|
||||
@@ -21,15 +22,6 @@ interface NewPanelSeed {
|
||||
pluginSpec: SeededPluginSpec;
|
||||
}
|
||||
|
||||
function kindSupportsUnit(kind: PanelKind): boolean {
|
||||
return getPanelDefinition(kind).sections.some(
|
||||
(section) =>
|
||||
section.kind === SectionKind.Formatting &&
|
||||
'controls' in section &&
|
||||
section.controls.unit === true,
|
||||
);
|
||||
}
|
||||
|
||||
/** Kind to fall back to for a query language a builder-only kind (List) can't hold. */
|
||||
const FALLBACK_KIND_BY_QUERY_TYPE: Partial<Record<EQueryType, PanelKind>> = {
|
||||
[EQueryType.PROM]: 'signoz/TimeSeriesPanel',
|
||||
@@ -74,7 +66,10 @@ export function buildNewPanelSeed(
|
||||
const queries = converted.length > 0 ? converted : buildDefaultQueries(kind);
|
||||
|
||||
// Explorers put the single `unit` on the query itself, not the panel spec.
|
||||
if (compositeQuery.unit && kindSupportsUnit(kind)) {
|
||||
if (
|
||||
compositeQuery.unit &&
|
||||
getSectionControls(kind, SectionKind.Formatting)?.unit
|
||||
) {
|
||||
return {
|
||||
kind,
|
||||
queries,
|
||||
|
||||
103
tests/fixtures/queriercommon.py
vendored
103
tests/fixtures/queriercommon.py
vendored
@@ -1,4 +1,4 @@
|
||||
"""Seed data for the queriercommon keyless-semantics tests.
|
||||
"""Seed data for the queriercommon keyless-semantics and explicit-context tests.
|
||||
|
||||
Three identities exist in every signal. GOLD and SILVER carry the test keys.
|
||||
NONE carries no key at all. The tests assert which identities a filter
|
||||
@@ -8,6 +8,7 @@ The attribute names are outside every semantic-convention family, so the
|
||||
seeded data pins base behavior with any semconv overlay state.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Generator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -122,3 +123,103 @@ def keyless_series(insert_metrics: Callable[[list[Metrics]], None]) -> Generator
|
||||
]
|
||||
)
|
||||
yield start, start + points * 60
|
||||
|
||||
|
||||
EXPLICIT_PREFIX = "explicit-ctx"
|
||||
# String attribute that identifies the row. It has one context only. Each
|
||||
# assertion reads it back.
|
||||
IDENTITY_KEY = "probe.id"
|
||||
# Attribute with no column of the same name. It tests a key under the
|
||||
# signal's own context that metadata does not know. On logs, the rows without
|
||||
# the attribute have the value nested in the body JSON.
|
||||
ATTRIBUTE_ONLY_KEY = "route.tag"
|
||||
CONTESTED_VALUE = "checkout"
|
||||
|
||||
# Row identities. Each row shows where the contested value is:
|
||||
# - COLUMN_ONLY: in the column (`name` on spans, `severity_text` on logs).
|
||||
# - ATTRIBUTE_ONLY: in the string attribute with the same name.
|
||||
# - BOTH: in the column and in the string attribute.
|
||||
# - NEITHER: in none of them.
|
||||
# - NUMBER_ATTRIBUTE: in a number attribute with the same name. Its data
|
||||
# type is different from the column.
|
||||
COLUMN_ONLY = f"{EXPLICIT_PREFIX}-column"
|
||||
ATTRIBUTE_ONLY = f"{EXPLICIT_PREFIX}-attribute"
|
||||
BOTH = f"{EXPLICIT_PREFIX}-both"
|
||||
NEITHER = f"{EXPLICIT_PREFIX}-neither"
|
||||
NUMBER_ATTRIBUTE = f"{EXPLICIT_PREFIX}-number"
|
||||
NUMBER_VALUE = 42
|
||||
|
||||
# (identity, value in the column, value in the string attribute, value in
|
||||
# the number attribute, resource service.name, attribute service.name,
|
||||
# has route.tag, insert offset in seconds)
|
||||
ROWS = [
|
||||
(COLUMN_ONLY, True, False, False, "svc-a", None, True, 1),
|
||||
(ATTRIBUTE_ONLY, False, True, False, "svc-b", "svc-a", False, 2),
|
||||
(BOTH, True, True, False, "svc-a", "svc-a", True, 3),
|
||||
(NEITHER, False, False, False, "svc-b", "svc-b", False, 4),
|
||||
(NUMBER_ATTRIBUTE, False, False, True, "svc-b", None, False, 5),
|
||||
]
|
||||
|
||||
# Logs only. The scope name is a declared path. A scope attribute also has
|
||||
# the name `name`. A second scope attribute has a plain name.
|
||||
SCOPE_NAME = "scope-a"
|
||||
SCOPE_ATTRIBUTE_KEY = "env"
|
||||
SCOPE_ATTRIBUTE_VALUE = "prod"
|
||||
|
||||
|
||||
@pytest.fixture(name="ambiguous_rows", scope="function")
|
||||
def ambiguous_rows(
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> Generator[datetime]:
|
||||
"""Inserts one span and one log for each identity. Every row has a
|
||||
resource `service.name`. Some rows also have a span or log attribute
|
||||
`service.name` with a different value. On logs, the rows without the
|
||||
`route.tag` attribute have the value in the body JSON. Logs with the
|
||||
column value have the scope name. Logs with the attribute value have the
|
||||
scope attributes. Yields the base timestamp."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0) - timedelta(minutes=1)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=offset),
|
||||
duration=timedelta(milliseconds=10),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name=CONTESTED_VALUE if column else "other",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": resource_service},
|
||||
attributes={
|
||||
IDENTITY_KEY: identity,
|
||||
**({"name": CONTESTED_VALUE} if attribute else {}),
|
||||
**({"name": NUMBER_VALUE} if number else {}),
|
||||
**({"service.name": attribute_service} if attribute_service else {}),
|
||||
**({ATTRIBUTE_ONLY_KEY: CONTESTED_VALUE} if tagged else {}),
|
||||
},
|
||||
)
|
||||
for identity, column, attribute, number, resource_service, attribute_service, tagged, offset in ROWS
|
||||
]
|
||||
)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=offset),
|
||||
body=json.dumps({} if tagged else {"route": {"tag": CONTESTED_VALUE}}),
|
||||
severity_text="ERROR" if column else "INFO",
|
||||
scope_name=SCOPE_NAME if column else "",
|
||||
scope_attributes={"name": CONTESTED_VALUE, SCOPE_ATTRIBUTE_KEY: SCOPE_ATTRIBUTE_VALUE} if attribute else {},
|
||||
resources={"service.name": resource_service},
|
||||
attributes={
|
||||
IDENTITY_KEY: identity,
|
||||
**({"severity_text": "ERROR"} if attribute else {}),
|
||||
**({"severity_text": NUMBER_VALUE} if number else {}),
|
||||
**({"service.name": attribute_service} if attribute_service else {}),
|
||||
**({ATTRIBUTE_ONLY_KEY: CONTESTED_VALUE} if tagged else {}),
|
||||
},
|
||||
)
|
||||
for identity, column, attribute, number, resource_service, attribute_service, tagged, offset in ROWS
|
||||
]
|
||||
)
|
||||
yield now
|
||||
|
||||
400
tests/integration/tests/queriercommon/07_explicit_context.py
Normal file
400
tests/integration/tests/queriercommon/07_explicit_context.py
Normal file
@@ -0,0 +1,400 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
assert_scalar_value,
|
||||
build_aggregation,
|
||||
build_group_by_field,
|
||||
build_order_by,
|
||||
build_raw_query,
|
||||
build_scalar_query,
|
||||
get_all_warnings,
|
||||
get_column_data_from_response,
|
||||
get_scalar_table_data,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.queriercommon import (
|
||||
ATTRIBUTE_ONLY,
|
||||
BOTH,
|
||||
COLUMN_ONLY,
|
||||
EXPLICIT_PREFIX,
|
||||
IDENTITY_KEY,
|
||||
NEITHER,
|
||||
NUMBER_ATTRIBUTE,
|
||||
)
|
||||
|
||||
# One name can exist in more than one place. `name` is a span column and a
|
||||
# span attribute. `severity_text` is a log column and a log attribute.
|
||||
# `service.name` is a resource attribute and a span or log attribute.
|
||||
#
|
||||
# Rules for a filter:
|
||||
# - A key with an explicit context reads that context only.
|
||||
# - A bare key that is a column and an attribute reads both. The query
|
||||
# returns an ambiguity warning.
|
||||
# - A bare key that is a resource attribute and an attribute reads the
|
||||
# resource attribute. The query returns an ambiguity warning.
|
||||
# - An `attribute.` key returns the warning when the attribute has two data
|
||||
# types.
|
||||
# - A string operand matches a number attribute through a text cast.
|
||||
# - A key under the signal's own context (`span.`, `log.`) that exists only
|
||||
# as an attribute reads the attribute. On logs it also reads the body JSON
|
||||
# path.
|
||||
FILTER_MATRIX = [
|
||||
pytest.param("{contested} = '{value}'", {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH}, True, id="bare_column_and_attribute"),
|
||||
pytest.param("{own}.{contested} = '{value}'", {COLUMN_ONLY, BOTH}, False, id="own_context_column_only"),
|
||||
pytest.param("attribute.{contested} = '{value}'", {ATTRIBUTE_ONLY, BOTH}, True, id="attribute_context_warns_about_two_types"),
|
||||
pytest.param("{contested} != '{value}'", {NEITHER, NUMBER_ATTRIBUTE}, True, id="bare_negative_excludes_every_carrier"),
|
||||
pytest.param("{contested} EXISTS", {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH, NEITHER, NUMBER_ATTRIBUTE}, True, id="bare_exists_is_the_column"),
|
||||
pytest.param("{contested} NOT EXISTS", set(), True, id="bare_not_exists_is_never"),
|
||||
pytest.param("attribute.{contested} EXISTS", {ATTRIBUTE_ONLY, BOTH, NUMBER_ATTRIBUTE}, True, id="attribute_exists_spans_both_types"),
|
||||
pytest.param("attribute.{contested} NOT EXISTS", {COLUMN_ONLY, NEITHER}, True, id="attribute_not_exists"),
|
||||
pytest.param("{contested} = '42'", {NUMBER_ATTRIBUTE}, True, id="bare_string_operand_reaches_the_number_attribute"),
|
||||
pytest.param("attribute.{contested}:string = '{value}'", {ATTRIBUTE_ONLY, BOTH}, False, id="type_suffix_selects_the_string_attribute"),
|
||||
pytest.param("attribute.{contested}:float64 = 42", {NUMBER_ATTRIBUTE}, False, id="type_suffix_selects_the_number_attribute"),
|
||||
pytest.param("service.name = 'svc-a'", {COLUMN_ONLY, BOTH}, True, id="bare_resource_wins_with_warning"),
|
||||
pytest.param("service.name != 'svc-a'", {ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE}, True, id="bare_resource_negative"),
|
||||
pytest.param("resource.service.name = 'svc-a'", {COLUMN_ONLY, BOTH}, False, id="resource_context_no_warning"),
|
||||
pytest.param("resource.service.name != 'svc-a'", {ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE}, False, id="resource_context_negative"),
|
||||
pytest.param("attribute.service.name = 'svc-a'", {ATTRIBUTE_ONLY, BOTH}, False, id="attribute_context_no_warning"),
|
||||
pytest.param(
|
||||
"{own}.route.tag = 'checkout'",
|
||||
{"traces": {COLUMN_ONLY, BOTH}, "logs": {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH, NEITHER, NUMBER_ATTRIBUTE}},
|
||||
False,
|
||||
id="own_context_miss_corrects_to_attribute_and_on_logs_to_body",
|
||||
),
|
||||
pytest.param("route.tag = 'checkout'", {COLUMN_ONLY, BOTH}, False, id="bare_attribute_only_key"),
|
||||
]
|
||||
|
||||
# Traces run under both physical attribute layouts. `insert_traces` writes the
|
||||
# legacy maps and the `attributes` JSON column; the backend decides which one
|
||||
# the query builder reads. Logs have the maps only.
|
||||
SIGNALS = [
|
||||
pytest.param("traces", "map", "span", "name", "checkout", "other", id="traces_map"),
|
||||
pytest.param("traces", "json", "span", "name", "checkout", "other", id="traces_json"),
|
||||
pytest.param("logs", "map", "log", "severity_text", "ERROR", "INFO", id="logs"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression_template,expected,expects_ambiguity_warning", FILTER_MATRIX)
|
||||
@pytest.mark.parametrize("signal,attribute_backend,own_context,contested,value,other_value", SIGNALS)
|
||||
def test_filter_resolution(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
ambiguous_rows: datetime,
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
signal: str,
|
||||
attribute_backend: str,
|
||||
own_context: str,
|
||||
contested: str,
|
||||
value: str,
|
||||
other_value: str, # pylint: disable=unused-argument
|
||||
expression_template: str,
|
||||
expected: set[str] | dict[str, set[str]],
|
||||
expects_ambiguity_warning: bool,
|
||||
) -> None:
|
||||
use_attribute_backend(attribute_backend)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
expression = expression_template.format(own=own_context, contested=contested, value=value)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
signal,
|
||||
limit=100,
|
||||
filter_expression=expression,
|
||||
order=[build_order_by("timestamp", "asc")],
|
||||
select_fields=[{"name": IDENTITY_KEY}],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
matched = {row for row in get_column_data_from_response(response.json(), IDENTITY_KEY) if row.startswith(EXPLICIT_PREFIX)}
|
||||
assert matched == (expected[f"{signal}_{attribute_backend}" if f"{signal}_{attribute_backend}" in expected else signal] if isinstance(expected, dict) else expected), expression
|
||||
|
||||
warnings = [w["message"] for w in get_all_warnings(response.json())]
|
||||
assert any("ambiguous" in w for w in warnings) == expects_ambiguity_warning, warnings
|
||||
|
||||
|
||||
# Rules for a group by:
|
||||
# - A bare key that is a column and an attribute groups by the column only.
|
||||
# - A key with an explicit context groups by that context only.
|
||||
GROUP_BY_MATRIX = [
|
||||
pytest.param(None, {"{value}": 2, "{other}": 3}, id="bare_groups_by_the_column"),
|
||||
pytest.param("own", {"{value}": 2, "{other}": 3}, id="own_context_groups_by_the_column"),
|
||||
pytest.param("attribute", {"{value}": 2}, id="attribute_context_groups_by_the_attribute"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("context,expected_template", GROUP_BY_MATRIX)
|
||||
@pytest.mark.parametrize("signal,attribute_backend,own_context,contested,value,other_value", SIGNALS)
|
||||
def test_group_by_resolution(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
ambiguous_rows: datetime,
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
signal: str,
|
||||
attribute_backend: str,
|
||||
own_context: str,
|
||||
contested: str,
|
||||
value: str,
|
||||
other_value: str,
|
||||
context: str | None,
|
||||
expected_template: dict[str, int],
|
||||
) -> None:
|
||||
use_attribute_backend(attribute_backend)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
field_context = own_context if context == "own" else context
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.SCALAR,
|
||||
queries=[
|
||||
build_scalar_query(
|
||||
"A",
|
||||
signal,
|
||||
[build_aggregation("count()", "rows")],
|
||||
group_by=[build_group_by_field(contested, "string", field_context) if field_context else {"name": contested}],
|
||||
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
expected = {key.format(value=value, other=other_value): count for key, count in expected_template.items()}
|
||||
groups = {row[0]: row[1] for row in get_scalar_table_data(response.json()) if row[0] in expected}
|
||||
assert groups == expected, get_scalar_table_data(response.json())
|
||||
|
||||
|
||||
# Rule for a raw select of a bare key that is a resource attribute and an
|
||||
# attribute: each row shows the resource value. This is also true for a row
|
||||
# where the attribute has a different value.
|
||||
@pytest.mark.parametrize("signal,attribute_backend", [("traces", "map"), ("traces", "json"), ("logs", "map")], ids=["traces_map", "traces_json", "logs"])
|
||||
def test_select_of_ambiguous_name(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
ambiguous_rows: datetime,
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
signal: str,
|
||||
attribute_backend: str,
|
||||
) -> None:
|
||||
use_attribute_backend(attribute_backend)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
signal,
|
||||
limit=100,
|
||||
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
|
||||
order=[build_order_by("timestamp", "asc")],
|
||||
select_fields=[{"name": IDENTITY_KEY}, {"name": "service.name"}],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
rows = response.json()["data"]["data"]["results"][0]["rows"] or []
|
||||
by_identity = {row["data"][IDENTITY_KEY]: row["data"]["service.name"] for row in rows if row["data"].get(IDENTITY_KEY, "").startswith(EXPLICIT_PREFIX)}
|
||||
assert by_identity == {
|
||||
COLUMN_ONLY: "svc-a",
|
||||
ATTRIBUTE_ONLY: "svc-b",
|
||||
BOTH: "svc-a",
|
||||
NEITHER: "svc-b",
|
||||
NUMBER_ATTRIBUTE: "svc-b",
|
||||
}
|
||||
|
||||
|
||||
# Rules for an order by, descending, with the timestamp descending as the
|
||||
# second key:
|
||||
# - A bare key or a key under the signal's own context sorts by the column
|
||||
# only.
|
||||
# - An `attribute.` key sorts by the attribute on traces. The number
|
||||
# attribute sorts as text. Rows without the attribute come last.
|
||||
# - An `attribute.` key sorts by the column on logs.
|
||||
BY_COLUMN = [ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE, COLUMN_ONLY, BOTH]
|
||||
ORDER_BY_MATRIX = [
|
||||
pytest.param(None, {"traces": BY_COLUMN, "logs": BY_COLUMN}, id="bare_orders_by_the_column"),
|
||||
pytest.param("own", {"traces": BY_COLUMN, "logs": BY_COLUMN}, id="own_context_orders_by_the_column"),
|
||||
pytest.param(
|
||||
"attribute",
|
||||
{"traces": [ATTRIBUTE_ONLY, BOTH, NUMBER_ATTRIBUTE, COLUMN_ONLY, NEITHER], "logs": BY_COLUMN},
|
||||
id="attribute_context_orders_by_the_attribute_on_traces_only",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("context,expected", ORDER_BY_MATRIX)
|
||||
@pytest.mark.parametrize("signal,attribute_backend,own_context,contested,value,other_value", SIGNALS)
|
||||
def test_order_by_resolution(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
ambiguous_rows: datetime,
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
signal: str,
|
||||
attribute_backend: str,
|
||||
own_context: str,
|
||||
contested: str,
|
||||
value: str, # pylint: disable=unused-argument
|
||||
other_value: str, # pylint: disable=unused-argument
|
||||
context: str | None,
|
||||
expected: dict[str, list[str]],
|
||||
) -> None:
|
||||
use_attribute_backend(attribute_backend)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
prefix = f"{own_context}." if context == "own" else f"{context}." if context else ""
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
signal,
|
||||
limit=100,
|
||||
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
|
||||
order=[build_order_by(f"{prefix}{contested}", "desc"), build_order_by("timestamp", "desc")],
|
||||
select_fields=[{"name": IDENTITY_KEY}],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
ordered = [row for row in get_column_data_from_response(response.json(), IDENTITY_KEY) if row.startswith(EXPLICIT_PREFIX)]
|
||||
assert ordered == expected.get(f"{signal}_{attribute_backend}", expected[signal])
|
||||
|
||||
|
||||
# Rules for an aggregation argument:
|
||||
# - A bare key counts the values of the column only.
|
||||
# - An `attribute.` key counts the attribute in both data types. The number
|
||||
# attribute adds one distinct value.
|
||||
AGGREGATION_MATRIX = [
|
||||
pytest.param(None, 2, id="bare_counts_the_column"),
|
||||
pytest.param("own", 2, id="own_context_counts_the_column"),
|
||||
pytest.param("attribute", 2, id="attribute_context_counts_both_attribute_types"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("context,expected", AGGREGATION_MATRIX)
|
||||
@pytest.mark.parametrize("signal,attribute_backend,own_context,contested,value,other_value", SIGNALS)
|
||||
def test_aggregation_argument_resolution(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
ambiguous_rows: datetime,
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
signal: str,
|
||||
attribute_backend: str,
|
||||
own_context: str,
|
||||
contested: str,
|
||||
value: str, # pylint: disable=unused-argument
|
||||
other_value: str, # pylint: disable=unused-argument
|
||||
context: str | None,
|
||||
expected: int,
|
||||
) -> None:
|
||||
use_attribute_backend(attribute_backend)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
prefix = f"{own_context}." if context == "own" else f"{context}." if context else ""
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.SCALAR,
|
||||
queries=[
|
||||
build_scalar_query(
|
||||
"A",
|
||||
signal,
|
||||
[build_aggregation(f"count_distinct({prefix}{contested})", "distinct")],
|
||||
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_scalar_value(response, "A", expected)
|
||||
|
||||
|
||||
# Rules for logs only:
|
||||
# - A `body.` key reads the body JSON path. It does not read the attribute
|
||||
# with the same name.
|
||||
# - A `log.` key reads the attribute and the body JSON path together. This
|
||||
# is also true when metadata reports the attribute.
|
||||
# - A `scope.` key resolves through metadata only. When metadata does not
|
||||
# report the key, the query fails with "key not found". This is also true
|
||||
# for the declared path `scope.name` and for rows that have the scope
|
||||
# data.
|
||||
LOGS_ONLY_MATRIX = [
|
||||
pytest.param("body.route.tag = 'checkout'", {ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE}, id="body_context_reads_the_body_json"),
|
||||
pytest.param("log.route.tag = 'checkout'", {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH, NEITHER, NUMBER_ATTRIBUTE}, id="log_context_reads_attribute_and_body"),
|
||||
pytest.param("scope.name = 'scope-a'", "key `name` not found", id="scope_name_needs_metadata"),
|
||||
pytest.param("scope.env = 'prod'", "key `env` not found", id="scope_attribute_needs_metadata"),
|
||||
pytest.param("scope.env EXISTS", "key `env` not found", id="scope_attribute_exists_needs_metadata"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression,expected", LOGS_ONLY_MATRIX)
|
||||
def test_logs_body_and_scope_contexts(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
ambiguous_rows: datetime,
|
||||
expression: str,
|
||||
expected: set[str] | str,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
"logs",
|
||||
limit=100,
|
||||
filter_expression=expression,
|
||||
order=[build_order_by("timestamp", "asc")],
|
||||
select_fields=[{"name": IDENTITY_KEY}],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if isinstance(expected, str):
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert expected in response.text, response.text
|
||||
return
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
matched = {row for row in get_column_data_from_response(response.json(), IDENTITY_KEY) if row.startswith(EXPLICIT_PREFIX)}
|
||||
assert matched == expected, expression
|
||||
Reference in New Issue
Block a user