Compare commits

..

99 Commits

Author SHA1 Message Date
Naman Verma
2161ea9ef0 test: use appendf instead of sprintf 2026-07-21 00:53:30 +05:30
Naman Verma
61700665f6 test: remove test for floating pt value 2026-07-21 00:52:26 +05:30
Naman Verma
8ad07d758c fix: fix build errors post merge conflicts 2026-07-21 00:39:34 +05:30
Naman Verma
c7cfc58a36 Merge branch 'main' into nv/dashboard-migration 2026-07-21 00:30:11 +05:30
Naman Verma
9bfd293774 chore: final set of error fixes/hacks 2026-07-17 16:23:04 +05:30
Naman Verma
f26681091d Merge branch 'main' into nv/dashboard-migration 2026-07-17 13:00:05 +05:30
Naman Verma
d798164da9 chore: undo schema change in threshold value 2026-07-17 00:49:43 +05:30
Naman Verma
4f8c555eef test: fix span gaps test 2026-07-16 18:47:33 +05:30
Naman Verma
e5eb47e360 chore: handle no string value for variable sort 2026-07-16 18:13:03 +05:30
Naman Verma
474ecba392 chore: add handling for trace operators around wrapinv5env 2026-07-16 18:12:43 +05:30
Naman Verma
c7d116b130 chore: pass threshold format raw without typecasting 2026-07-16 16:53:44 +05:30
Naman Verma
b611e3adc7 chore: skip empty type variables 2026-07-16 16:42:46 +05:30
Naman Verma
598f466682 chore: drop widgets with >1 and all aggregations as malformed 2026-07-16 16:15:07 +05:30
Naman Verma
7a29fdbcaf chore: skip unknown panel kind 2026-07-16 16:14:32 +05:30
Naman Verma
8fe094a2a4 Merge branch 'nv/dashboard-migration' of https://github.com/SigNoz/signoz into nv/dashboard-migration 2026-07-16 11:45:50 +05:30
Srikanth Chekuri
527cc5b002 Merge branch 'main' into nv/dashboard-migration 2026-07-15 16:50:24 +05:30
Naman Verma
dbbaa4d037 chore: drop invalid formulas 2026-07-14 23:10:06 +05:30
Naman Verma
e818e667a2 chore: drop trace operators with no expression 2026-07-14 22:10:40 +05:30
Naman Verma
e390eef74b chore: remove bad aggregations that accompany valid ones 2026-07-14 18:45:38 +05:30
Naman Verma
a6490661d1 chore: add a.count to malformed order by keys list 2026-07-14 17:53:11 +05:30
Naman Verma
e8883c4f65 chore: add wdiget id type check in layout migration 2026-07-14 17:34:14 +05:30
Naman Verma
aed3d096f0 chore: skip variables in wrong type 2026-07-14 17:18:45 +05:30
Naman Verma
5ffe4ec1f8 chore: remove invalid functions 2026-07-14 16:16:16 +05:30
Naman Verma
3e095b710b chore: restore function args malformed by v4->v5 migration 2026-07-14 15:37:13 +05:30
Naman Verma
c11061b0ae chore: remove limits that cross max limit 2026-07-14 14:46:21 +05:30
Naman Verma
c413d17594 chore: remove repeat occurences of widgets in layouts 2026-07-14 14:31:44 +05:30
Naman Verma
9f8b1be83b chore: handle misformed threshold operators 2026-07-14 13:19:31 +05:30
Naman Verma
f0d54cedd4 chore: fill valid span gaps 2026-07-14 11:53:06 +05:30
Naman Verma
be56b929b4 chore: replace faulty order by keys with aggregation expression 2026-07-14 11:52:43 +05:30
Naman Verma
20019d835c chore: add more panel id sanitization 2026-07-14 11:05:51 +05:30
Naman Verma
c49396944b chore: replace em dash with hyphen in widget id 2026-07-14 11:01:46 +05:30
Naman Verma
39d6880cf7 chore: add more error notes 2026-07-14 10:44:47 +05:30
Naman Verma
3f20c04c97 chore: remove expressions from metric aggregations 2026-07-14 10:44:33 +05:30
Naman Verma
70e556bd38 chore: normalize group by 2026-07-13 19:58:54 +05:30
Naman Verma
0a59a8eb04 chore: make migration drop widgets that have no aggregations for metrics 2026-07-13 19:34:24 +05:30
Naman Verma
6fee60833a Merge branch 'main' into nv/dashboard-migration 2026-07-13 19:12:01 +05:30
Naman Verma
b81bb42be6 chore: drop legacy filter fields that ui also ignores 2026-07-11 19:06:25 +05:30
Naman Verma
d8717ee466 chore: assign query names to queries if missing 2026-07-11 18:06:33 +05:30
Naman Verma
d0d81b778f chore: assign missing formula names like ui does 2026-07-11 17:49:41 +05:30
Naman Verma
b6a0431049 chore: remove widgets that have a single query that has no metric name 2026-07-11 17:10:16 +05:30
Naman Verma
54560b6f63 chore: skip over unreferenced widgets 2026-07-11 16:43:22 +05:30
Naman Verma
41a68cd17e fix: add fixes based on backup migration testing 2026-07-11 15:48:56 +05:30
Naman Verma
af96aef5e1 Merge branch 'main' into nv/dashboard-migration 2026-07-11 15:00:49 +05:30
Naman Verma
171f95ac25 Merge branch 'main' into nv/dashboard-migration 2026-07-10 15:07:21 +05:30
Naman Verma
93387b38d2 fix: add empty list tolerance to v1 variables 2026-07-09 09:46:24 +05:30
Srikanth Chekuri
d4384eba1b Merge branch 'main' into nv/dashboard-migration 2026-07-08 20:04:56 +05:30
Naman Verma
6151467b3e fix: add default aggregation count() if none is present in logs/traces 2026-07-07 11:18:49 +05:30
Naman Verma
daa999d1d6 Merge branch 'main' into nv/dashboard-migration 2026-07-07 10:10:53 +05:30
Naman Verma
b400846193 fix: ditch dynamic vars with no attr, rearrange overlapping layouts 2026-07-04 01:43:13 +05:30
Naman Verma
f5220d078f chore: add explanation comment for continuing on empty id widget 2026-07-04 00:16:12 +05:30
Naman Verma
ab750ffffd chore: better comments and names in layout migrator 2026-07-04 00:06:04 +05:30
Naman Verma
4a2b508636 chore: improve variable names 2026-07-03 23:52:07 +05:30
Naman Verma
33795999cf fix: match shape safe version to existing version 2026-07-03 23:38:59 +05:30
Naman Verma
9aac37eb94 fix: remove unused method 2026-07-03 22:48:31 +05:30
Naman Verma
12642e27da fix: validate dashboard spec after migration 2026-07-03 21:21:51 +05:30
Naman Verma
82f8bf88fc fix: add nil check on limit value when normalizing page size 2026-07-03 20:23:01 +05:30
Naman Verma
3befc9e8c6 fix: make none the default fill mode during migration 2026-07-03 20:22:36 +05:30
Naman Verma
ee5b85f7a4 revert: remove md file 2026-07-03 20:22:10 +05:30
Naman Verma
a05164f225 fix: fix go lint issue 2026-07-03 17:56:33 +05:30
Naman Verma
35e4013826 chore: reuse transition package (adds dependency, need to see what to do) 2026-07-03 17:40:50 +05:30
Naman Verma
5884c6aa90 Merge branch 'main' into nv/dashboard-migration 2026-07-03 16:38:22 +05:30
Naman Verma
533a430714 fix: add more malformation handling 2026-07-03 15:25:09 +05:30
Naman Verma
ba6af34714 fix: add more malformed query handling 2026-07-02 13:49:49 +05:30
Naman Verma
851c7b0ad7 chore: add temporary doc for other malformation handlers needed 2026-07-02 13:19:51 +05:30
Naman Verma
ef5a67495c fix: normalize telemetry field keys in query 2026-07-02 12:58:14 +05:30
Naman Verma
9f540ca84b fix: remove metric aggregation from non metric queries (malformed json) 2026-07-02 12:25:57 +05:30
Naman Verma
40a6b22aed fix: normalize having array from v4 schema 2026-07-02 11:38:37 +05:30
Naman Verma
6f16416f27 fix: add better error note down for failed conversions 2026-07-02 11:10:02 +05:30
Naman Verma
f8aa1c1c34 Merge branch 'main' into nv/dashboard-migration 2026-07-02 10:52:00 +05:30
Naman Verma
65835394c0 Merge branch 'main' into nv/dashboard-migration 2026-06-30 18:39:53 +05:30
Naman Verma
f132b7e53a fix: handle list of widget IDs in panel maps 2026-06-29 23:51:55 +05:30
Naman Verma
d4ae156dc4 fix: make threshold value non-nullable 2026-06-29 23:18:17 +05:30
Naman Verma
d6bdf9c2b2 fix: catch error from decodeTelemetryFields 2026-06-29 23:02:40 +05:30
Naman Verma
7ea654f1aa test: fix unit tests 2026-06-29 22:57:40 +05:30
Naman Verma
3fd7d013a1 fix: adjust to schema changes in variables 2026-06-29 22:56:36 +05:30
Naman Verma
fb921dd381 fix: read threshold value properly during migration 2026-06-29 22:56:12 +05:30
Naman Verma
58020d9e00 fix: note error in query parsing 2026-06-29 22:53:40 +05:30
Naman Verma
7a5933e822 fix: ignore react placeholders in layout 2026-06-29 22:52:48 +05:30
Naman Verma
2533683de6 fix: allow zero value for threshold values 2026-06-29 22:51:56 +05:30
Naman Verma
2670d53170 Merge branch 'main' into nv/dashboard-migration 2026-06-29 20:28:39 +05:30
Naman Verma
8943a9454b Merge branch 'main' into nv/dashboard-migration 2026-06-26 02:15:22 +05:30
Naman Verma
9a7ed5b711 feat: note down all errors in migration 2026-06-26 02:07:06 +05:30
Naman Verma
2d75e3d32d chore: separate error type for migration 2026-06-26 01:07:59 +05:30
Naman Verma
1d6eabf927 chore: remove spec md file 2026-06-25 22:37:52 +05:30
Naman Verma
082d7b1b77 test: fix ut to check for v2 internal name 2026-06-25 22:34:39 +05:30
Naman Verma
5019dee2d7 Merge branch 'main' into nv/dashboard-migration 2026-06-25 22:30:55 +05:30
Naman Verma
216de973fb fix: remove nil nil return 2026-06-25 03:07:58 +05:30
Naman Verma
18c0eec5e2 chore: catch typecast errors 2026-06-19 15:49:01 +05:30
Naman Verma
2ccdeb3631 chore: add a catch all panic check to log migration error 2026-06-19 15:15:17 +05:30
Naman Verma
ad12e50bbc fix: extract row and widget positions to build expanded sections 2026-06-19 15:00:32 +05:30
Naman Verma
e247bf3864 fix: sanitize tags instead of throwing error 2026-06-19 14:17:36 +05:30
Naman Verma
f4651ea134 fix: match with lower case signal for variables 2026-06-19 12:17:42 +05:30
Naman Verma
d449a2dbf2 fix: generate internal name from title 2026-06-19 11:24:40 +05:30
Naman Verma
d4b9f91062 Merge branch 'main' into nv/dashboard-migration 2026-06-18 12:28:22 +05:30
Naman Verma
530710b7bc Merge branch 'nv/dashboard-migration' of https://github.com/SigNoz/signoz into nv/dashboard-migration 2026-06-17 12:42:24 +05:30
Naman Verma
4fb5eec08d fix: move WrapInV5Envelope to types package 2026-06-17 12:42:20 +05:30
Naman Verma
f889d36f0f Merge branch 'main' into nv/dashboard-migration 2026-06-17 12:32:08 +05:30
Naman Verma
db12d44523 Merge branch 'main' into nv/dashboard-migration 2026-06-17 07:30:34 +05:30
Naman Verma
86fc0e81ba chore: add migration script from current to perses dashboard 2026-06-14 22:58:08 +05:30
90 changed files with 5075 additions and 2210 deletions

View File

@@ -223,12 +223,17 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return err
}
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, updatedRole.TransactionGroups)
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
}
additionTuples, deletionTuples := authtypes.DiffTuples(existingTuples, desiredTuples)
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {

View File

@@ -240,17 +240,17 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
mock := mockStore.Mock()
// Mock the fingerprint query (for Prometheus label matching)
// args: $1=metric_name (the __name__ matcher maps onto the column)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
// args: metric_name IN (discovered names), subquery metric_name, start, end
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).

View File

@@ -380,88 +380,4 @@ describe('convertV5ResponseToLegacy', () => {
},
});
});
describe('raw logs body: extract lone `message` field', () => {
function makeRawResult(
rows: Array<{ timestamp: string; data: Record<string, any> }>,
type: 'raw' | 'trace' = 'raw',
): ReturnType<typeof convertV5ResponseToLegacy> {
const v5Data = {
type,
data: { results: [{ queryName: 'A', rows }] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
} as unknown as QueryRangeResponseV5;
const params = makeBaseParams(type as RequestType, [
{
type: 'builder_query',
spec: {
name: 'A',
signal: type === 'trace' ? 'traces' : 'logs',
stepInterval: 60,
disabled: false,
aggregations: [],
},
},
]);
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
makeBaseSuccess({ data: v5Data }, params);
return convertV5ResponseToLegacy(input, { A: 'A' }, false);
}
it('unwraps body when it is an object with only a message field', () => {
const result = makeRawResult([
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: 'hello' } } },
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe('hello');
});
it('leaves body unchanged when the object has keys besides message', () => {
const body = { message: 'hello', level: 'INFO' };
const result = makeRawResult([
{ timestamp: '2026-07-21T00:00:00Z', data: { body } },
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toStrictEqual(
body,
);
});
it('leaves a string body unchanged (use_json_body off)', () => {
const result = makeRawResult([
{
timestamp: '2026-07-21T00:00:00Z',
data: { body: '{"message":"hello"}' },
},
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
'{"message":"hello"}',
);
});
it('stringifies the nested object when message is an object', () => {
const nested = { a: 1, b: 2 };
const result = makeRawResult([
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: nested } } },
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
JSON.stringify(nested),
);
});
it('does not add a body key to rows without a body (traces)', () => {
const result = makeRawResult(
[{ timestamp: '2026-07-21T00:00:00Z', data: { name: 'span-1' } }],
'trace',
);
const data = (result.payload.data.result[0].list?.[0] as any)?.data ?? {};
expect('body' in data).toBe(false);
});
});
});

View File

@@ -273,19 +273,6 @@ function convertScalarWithFormatForWeb(
});
}
function extractOnlyMessageBody(body: unknown): unknown {
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
if (isJsonBody) {
const keys = Object.keys(body);
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
if (hasOnlyMessageKey) {
const { message } = body as { message: unknown };
return typeof message === 'string' ? message : JSON.stringify(message);
}
}
return body;
}
/**
* Converts V5 RawData to legacy format
*/
@@ -298,22 +285,14 @@ function convertRawData(
queryName: rawData.queryName,
legend: legendMap[rawData.queryName] || rawData.queryName,
series: null,
list: rawData.rows?.map((row) => {
const data = {
list: rawData.rows?.map((row) => ({
timestamp: row.timestamp,
data: {
// Map raw data to ILog structure - spread row.data first to include all properties
...row.data,
date: row.timestamp,
} as any;
if ('body' in row.data) {
data.body = extractOnlyMessageBody(row.data.body);
}
return {
timestamp: row.timestamp,
data,
};
}),
} as any,
})),
nextCursor: rawData.nextCursor,
};
}

View File

@@ -20,7 +20,7 @@ import { Globe, Inbox, SquarePen } from '@signozhq/icons';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';
import ShareURLModal, { type ShareURLExtraOption } from './ShareURLModal';
import ShareURLModal from './ShareURLModal';
import './HeaderRightSection.styles.scss';
import { Typography } from '@signozhq/ui/typography';
@@ -29,15 +29,12 @@ interface HeaderRightSectionProps {
enableAnnouncements: boolean;
enableShare: boolean;
enableFeedback: boolean;
/** Optional page-specific toggle for the share dialog (e.g. "Include variables"). */
shareModalExtraOption?: ShareURLExtraOption;
}
function HeaderRightSection({
enableAnnouncements,
enableShare,
enableFeedback,
shareModalExtraOption,
}: HeaderRightSectionProps): JSX.Element | null {
const location = useLocation();
@@ -188,7 +185,7 @@ function HeaderRightSection({
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<ShareURLModal extraOption={shareModalExtraOption} />}
content={<ShareURLModal />}
open={openShareURLModal}
destroyTooltipOnHide
arrow={false}

View File

@@ -24,22 +24,7 @@ const routesToBeSharedWithTime = [
ROUTES.METER_EXPLORER,
];
/**
* An optional, page-specific toggle in the share dialog (e.g. a dashboard's
* "Include variables"). When enabled, `apply` mutates the URL params that go into
* the shared link. Keeps this shared modal generic — the page owns what it adds.
*/
export interface ShareURLExtraOption {
label: string;
defaultEnabled?: boolean;
apply: (params: URLSearchParams) => void;
}
interface ShareURLModalProps {
extraOption?: ShareURLExtraOption;
}
function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
function ShareURLModal(): JSX.Element {
const urlQuery = useUrlQuery();
const location = useLocation();
const { selectedTime } = useSelector<AppState, GlobalReducer>(
@@ -49,9 +34,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
selectedTime !== 'custom',
);
const [enableExtraOption, setEnableExtraOption] = useState(
extraOption?.defaultEnabled ?? false,
);
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
@@ -111,11 +93,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
}
}
if (extraOption && enableExtraOption) {
extraOption.apply(urlQuery);
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
}
return currentUrl;
};
@@ -166,20 +143,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
</>
)}
{extraOption && (
<div className="absolute-relative-time-toggler-container">
<Typography.Text className="absolute-relative-time-toggler-label">
{extraOption.label}
</Typography.Text>
<div className="absolute-relative-time-toggler">
<Switch
value={enableExtraOption}
onChange={(): void => setEnableExtraOption((prev) => !prev)}
/>
</div>
</div>
)}
<div className="share-link">
<div className="url-share-container">
<div className="url-share-container-header">

View File

@@ -5262,24 +5262,6 @@ const onboardingConfigWithLinks = [
],
},
},
{
dataSource: 'temporal-cloud-metrics',
label: 'Temporal Cloud Metrics',
imgUrl: temporalUrl,
tags: ['metrics'],
module: 'metrics',
relatedSearchKeywords: [
'metrics',
'integrations',
'temporal',
'temporal cloud',
'temporal cloud metrics',
'temporal metrics',
'openmetrics',
'prometheus',
],
link: '/docs/integrations/temporal-cloud-metrics/',
},
{
dataSource: 'temporal',
label: 'Temporal',
@@ -5291,6 +5273,9 @@ const onboardingConfigWithLinks = [
'application performance monitoring',
'integrations',
'temporal',
'temporal cloud',
'temporal logs',
'temporal metrics',
'temporal traces',
'traces',
'tracing',
@@ -5299,6 +5284,12 @@ const onboardingConfigWithLinks = [
desc: 'What are you using ?',
type: 'select',
options: [
{
key: 'temporal-cloud',
label: 'Cloud Metrics',
imgUrl: temporalUrl,
link: '/docs/integrations/temporal-cloud-metrics/',
},
{
key: 'temporal-golang',
label: 'Go',

View File

@@ -22,7 +22,7 @@ import {
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -39,7 +39,6 @@ export const AggregatorFilter = memo(function AggregatorFilter({
signalSource,
setAttributeKeys,
}: AgregatorFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
@@ -290,7 +289,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
return (
<AutoComplete
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
placeholder={getPlaceholder()}
style={selectStyle}
filterOption={false}

View File

@@ -2,7 +2,7 @@ import { Select, SelectProps, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { getCategorySelectOptionByName } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { categoryToSupport } from './config';
import { selectStyles } from './styles';
@@ -13,7 +13,6 @@ function BuilderUnitsFilter({
onChange,
yAxisUnit,
}: IBuilderUnitsFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { currentQuery, handleOnUnitsChange } = useQueryBuilder();
const selectedValue = yAxisUnit || currentQuery?.unit;
@@ -37,7 +36,7 @@ function BuilderUnitsFilter({
Y-axis unit
</Typography.Text>
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
style={selectStyles}
onChange={onChangeHandler}
value={selectedValue}

View File

@@ -9,13 +9,12 @@ import {
} from 'lib/query/transformQueryBuilderData';
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../../utils';
import { HavingFilterProps, HavingTagRenderProps } from './types';
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = formula;
const [searchText, setSearchText] = useState<string>('');
const [localValues, setLocalValues] = useState<string[]>([]);
@@ -172,7 +171,7 @@ function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { OrderByProps } from './types';
@@ -13,7 +13,6 @@ function OrderByFilter({
onChange,
query,
}: OrderByProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
createOptions,
@@ -65,7 +64,7 @@ function OrderByFilter({
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -21,7 +21,7 @@ import { isEqual, uniqWith } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -33,7 +33,6 @@ export const GroupByFilter = memo(function GroupByFilter({
disabled,
signalSource,
}: GroupByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState<string>('');
const [optionsData, setOptionsData] = useState<
@@ -175,7 +174,7 @@ export const GroupByFilter = memo(function GroupByFilter({
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -16,7 +16,7 @@ import {
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../utils';
// ** Types
@@ -27,7 +27,6 @@ export function HavingFilter({
query,
onChange,
}: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = query;
const [searchText, setSearchText] = useState<string>('');
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
@@ -232,7 +231,7 @@ export function HavingFilter({
return (
<>
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}

View File

@@ -14,7 +14,7 @@ import {
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -85,7 +85,6 @@ export const MetricNameSelector = memo(function MetricNameSelector({
signalSource,
'data-testid': dataTestId,
}: MetricNameSelectorProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
@@ -273,7 +272,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
return (
<AutoComplete
className="metric-name-selector"
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
style={selectStyle}
filterOption={false}
placeholder={placeholder}

View File

@@ -3,7 +3,7 @@ import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
@@ -16,7 +16,6 @@ export function OrderByFilter({
entityVersion,
isNewQueryV2 = false,
}: OrderByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
selectedValue,
@@ -79,7 +78,7 @@ export function OrderByFilter({
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -50,7 +50,7 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../constants/features';
@@ -95,7 +95,6 @@ function QueryBuilderSearch({
disableNavigationShortcuts,
entity,
}: QueryBuilderSearchProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(
() => pathname === ROUTES.LOGS_EXPLORER,
@@ -398,7 +397,7 @@ function QueryBuilderSearch({
<Select
data-testid={'qb-search-select'}
ref={selectRef}
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
transitionName=""
choiceTransitionName=""
virtual={false}

View File

@@ -50,7 +50,7 @@ import {
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from '../QueryBuilderSearch/config';
@@ -157,8 +157,6 @@ function QueryBuilderSearchV2(
selectProps,
} = props;
const getPopupContainer = useSelectPopupContainer();
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const { handleRunQuery, currentQuery } = useQueryBuilder();
@@ -991,7 +989,7 @@ function QueryBuilderSearchV2(
{...selectProps}
data-testid={'qb-search-select'}
ref={selectRef}
{...(hasPopupContainer ? { getPopupContainer } : {})}
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
{...(maxTagCount ? { maxTagCount } : {})}
key={queryTags.join('.')}
virtual={false}

View File

@@ -126,15 +126,6 @@ export default function UPlotChart({
}
}, [isDataEmpty, destroyPlot]);
/**
* Destroy the plot on unmount. Without this, uPlot's window-level
* `dppxchange` listener keeps the instance (and its whole detached DOM
* subtree) alive after the component is gone.
*/
const destroyPlotRef = useRef(destroyPlot);
destroyPlotRef.current = destroyPlot;
useEffect(() => (): void => destroyPlotRef.current(), []);
/**
* Handle initialization and prop changes
*/

View File

@@ -327,32 +327,6 @@ describe('UPlotChart', () => {
expect(firstInstance.destroy).toHaveBeenCalled();
expect(instances).toHaveLength(2);
});
it('destroys the instance and notifies callbacks on unmount', () => {
const plotRef = jest.fn();
const onDestroy = jest.fn();
const { unmount } = render(
<UPlotChart
config={createMockConfig()}
data={validData}
width={600}
height={400}
plotRef={plotRef}
onDestroy={onDestroy}
/>,
{ wrapper: Wrapper },
);
const firstInstance = instances[0];
plotRef.mockClear();
unmount();
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
expect(firstInstance.destroy).toHaveBeenCalledTimes(1);
expect(plotRef).toHaveBeenCalledWith(null);
});
});
describe('spanGaps data transformation', () => {

View File

@@ -1,6 +1,5 @@
import { ChevronDown } from '@signozhq/icons';
import { ColorPicker } from 'antd';
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
import styles from './ThresholdsSection.module.scss';
@@ -12,11 +11,11 @@ interface ThresholdColorSelectProps {
// Named presets from the SigNoz palette (cherry / amber / forest / robin). They surface
// as quick swatches in the picker; the full picker below covers any custom color.
const PRESETS: { label: string; value: ThresholdColor }[] = [
{ label: 'Red', value: ThresholdColor.RED },
{ label: 'Orange', value: ThresholdColor.ORANGE },
{ label: 'Green', value: ThresholdColor.GREEN },
{ label: 'Blue', value: ThresholdColor.BLUE },
const PRESETS: { label: string; value: string }[] = [
{ label: 'Red', value: '#F1575F' },
{ label: 'Orange', value: '#F5B225' },
{ label: 'Green', value: '#2BB673' },
{ label: 'Blue', value: '#4E74F8' },
];
/**

View File

@@ -12,7 +12,6 @@ import {
AnyThreshold,
ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import type { SectionEditorContext } from '../../sectionContext';
@@ -23,7 +22,7 @@ import TableThresholdRow from './rows/TableThresholdRow';
import styles from './ThresholdsSection.module.scss';
// New thresholds default to red (the first palette preset); the user recolors per rule.
const DEFAULT_THRESHOLD_COLOR = ThresholdColor.RED;
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
// Add-button testId per variant — kept stable so existing E2E/unit selectors hold.
const ADD_TESTID: Record<ThresholdVariant, string> = {

View File

@@ -73,25 +73,6 @@ describe('usePanelEditorDraft', () => {
expect(result.current.isSpecDirty).toBe(false);
});
it('flags spec-dirty when the seed differs from the saved baseline (View handoff)', () => {
// The editor opens on a handed-off, already-edited spec (`seed`) but compares
// against the persisted panel (`saved`) — so it starts dirty, not clean.
const seed = panel('Memory', 'usage');
const saved = panel('CPU', 'usage');
const { result } = renderHook(() => usePanelEditorDraft(seed, saved));
expect(result.current.isSpecDirty).toBe(true);
});
it('is clean when the seed matches the saved baseline', () => {
const { result } = renderHook(() =>
usePanelEditorDraft(panel('CPU', 'usage'), panel('CPU', 'usage')),
);
expect(result.current.isSpecDirty).toBe(false);
});
it('reset restores the spec and clears dirty after an edit', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));

View File

@@ -1,201 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { AllTheProviders } from 'tests/test-utils';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
// Exercises the REAL query builder provider (not mocks) so the dirty check is
// verified against the builder's actual re-serialization — the "always dirty"
// regression only reproduces with the real normalization in the loop.
const panelType = PANEL_TYPES.TIME_SERIES;
function makeSavedQueries(): DashboardtypesQueryDTO[] {
const base: Query = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu',
},
],
},
};
return toPerses(base, panelType);
}
function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries,
},
} as unknown as DashboardtypesPanelDTO;
}
describe('usePanelEditorQuerySync (real query builder)', () => {
it('an untouched existing panel is NOT query-dirty on mount', async () => {
const saved = makeSavedQueries();
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel(saved),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
savedQueries: saved,
}),
{ wrapper: AllTheProviders },
);
// The builder force-resets to the saved query asynchronously; once settled the
// live query must serialize back to the saved queries → clean.
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
// And stays clean (no late re-stage flips it dirty).
expect(result.current.isQueryDirty).toBe(false);
});
it('an untouched panel with a minimal/older stored query is NOT dirty (drift fix)', async () => {
// An older saved query carries only a few fields; the builder re-emits many more
// (source, stepInterval, filter, spaceAggregation, …). Comparing raw would read
// this as always-dirty; the round-tripped baseline normalizes both sides.
const minimalSaved: DashboardtypesQueryDTO[] = [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
aggregations: [
{ metricName: 'system_cpu_time', timeAggregation: 'avg' },
],
},
},
],
},
},
},
},
] as unknown as DashboardtypesQueryDTO[];
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel(minimalSaved),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
savedQueries: minimalSaved,
}),
{ wrapper: AllTheProviders },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
expect(result.current.isQueryDirty).toBe(false);
});
it('retains an in-editor query edit carried in the URL across a refresh (and reads dirty)', async () => {
// Simulate a refresh mid-edit: the saved panel is unchanged, but the URL still
// carries the last-run (edited) query. The builder must hydrate from the URL —
// not discard it — so the edit survives, and it must read dirty against saved.
const saved = makeSavedQueries();
const editedInUrl: Query = {
...initialQueriesMap[DataSource.METRICS],
id: 'edited-in-url',
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu-edited-in-url',
},
],
},
};
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(editedInUrl)),
);
const setSpec = jest.fn();
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<AllTheProviders initialRoute={`/?${params.toString()}`}>
{children}
</AllTheProviders>
);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
// The draft/preview open on the saved query…
draft: makePanel(saved),
panelType,
setSpec,
refetch: jest.fn(),
savedQueries: saved,
}),
{ wrapper },
);
// The URL edit is retained → dirty, and it's synced into the draft so the
// preview follows (setSpec called with the edited query).
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
expect(setSpec).toHaveBeenCalled();
});
it('is query-dirty when the draft carries an edit the saved panel does not (View handoff)', async () => {
const saved = makeSavedQueries();
const editedBase: Query = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu-edited',
},
],
},
};
const editedQueries = toPerses(editedBase, panelType);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
// The builder seeds from the draft (the handed-off edit)…
draft: makePanel(editedQueries),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
// …but the baseline is the persisted panel.
savedQueries: saved,
}),
{ wrapper: AllTheProviders },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
});
});

View File

@@ -95,7 +95,6 @@ describe('usePanelEditorQuerySync', () => {
draft?: DashboardtypesPanelDTO;
setSpec?: jest.Mock;
refetch?: jest.Mock;
savedQueries?: DashboardtypesPanelSpecDTO['queries'];
} = {},
): {
result: {
@@ -120,22 +119,20 @@ describe('usePanelEditorQuerySync', () => {
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
refetch,
savedQueries: opts.savedQueries,
}),
);
return { result, setSpec, refetch, rerender };
}
it('seeds the builder from the draft queries on mount (URL query, when present, wins)', () => {
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
setup();
expect(mockFromPerses).toHaveBeenCalledWith(
SAVED_QUERIES,
PANEL_TYPES.TIME_SERIES,
);
// No forceReset: useShareBuilderUrl resets to the seed only when the URL carries
// no query, so an in-editor edit in the URL survives a refresh.
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
defaultValue: SEED_V1,
forceReset: true,
});
});
@@ -345,127 +342,44 @@ describe('usePanelEditorQuerySync', () => {
});
});
describe('staged-query re-sync (browser back/forward)', () => {
it('commits the staged query into the draft when it re-stages', () => {
const state = builderState();
mockUseQueryBuilder.mockImplementation(() => state);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Browser Back re-stages a different query via initQueryBuilderData; the
// preview must follow it instead of keeping the last Run's result.
mockGetIsQueryModified.mockReturnValue(true);
state.stagedQuery = {
id: 'restaged',
queryType: 'builder',
} as unknown as Query;
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when only the live query changes (no re-stage)', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Live edit: currentQuery changes, staged query + structure unchanged.
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
describe('query dirty + save', () => {
// isQueryDirty compares the live query to the SAVED queries at the V5 envelope
// level (toQueryEnvelopes is mocked identity). Drive it via an input-sensitive
// toPerses so the envelope comparison — not getIsQueryModified — decides.
const SAVED_BASELINE = [{ id: 'saved-baseline' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const EDITED_ENVELOPES = [
{ id: 'edited-envelopes' },
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
beforeEach(() => {
mockToPerses.mockImplementation((query: Query) =>
query?.id === 'edited' ? EDITED_ENVELOPES : SAVED_BASELINE,
// Baseline is the builder's own normalized staged query — immune to the
// raw-seed vs builder-normalized serialization drift.
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
expect.anything(),
STAGED_V1,
);
});
it('is query-dirty when the live query no longer serializes to the saved queries', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
expect(result.current.isQueryDirty).toBe(true);
});
it('is not query-dirty when the live query still serializes to the saved queries', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
it('is not query-dirty when the live query matches the baseline', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
expect(result.current.isQueryDirty).toBe(false);
});
it('buildSaveSpec bakes the live query in when dirty', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: EDITED_ENVELOPES,
queries: CONVERTED_QUERIES,
});
});
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toBe(spec);
});
it('anchors the baseline to savedQueries, not the draft the builder seeds from (View handoff / refresh)', () => {
// The draft carries the View-mode edit (the builder seeds from it), but the
// baseline is the persisted panel: a live query equal to the edited draft
// still reads dirty against the saved queries.
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const draft = makeDraft(EDITED_ENVELOPES);
const { result } = setup({ draft, savedQueries: SAVED_BASELINE });
expect(result.current.isQueryDirty).toBe(true);
});
it('falls back to the seed query as the baseline when there are no saved queries (new panel)', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup();
expect(result.current.isQueryDirty).toBe(false);
});
});
});

View File

@@ -23,12 +23,6 @@ import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new
* panel or the drilldown modal, where the seed is the baseline.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
time?: PanelQueryTimeOverride;
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
@@ -73,15 +67,12 @@ export interface UsePanelEditSessionReturn {
export function usePanelEditSession({
panel,
panelId,
savedPanel,
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
panel,
savedPanel,
);
const { draft, spec, setSpec, isSpecDirty, reset } =
usePanelEditorDraft(panel);
const panelKind = draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
@@ -102,7 +93,6 @@ export function usePanelEditSession({
refetch: query.refetch,
alwaysSerializeQuery,
signal: seedQuerySignal ? defaultSignal : undefined,
savedQueries: savedPanel?.spec.queries,
});
const { onChangePanelKind } = usePanelTypeSwitch({

View File

@@ -13,14 +13,9 @@ import type { PanelEditorDraftApi } from '../types';
* preview renders it through the dashboard's renderer registry and the save hook
* patches it without conversion. Everything the config pane edits flows through the
* single `spec`/`setSpec` pair.
*
* `savedPanel` is the persisted panel the dirty check compares against — distinct from
* `initialPanel` (the seed), which may carry unsaved edits handed off from View mode.
* Defaults to the seed when there's no separate saved baseline (a new panel).
*/
export function usePanelEditorDraft(
initialPanel: DashboardtypesPanelDTO,
savedPanel: DashboardtypesPanelDTO = initialPanel,
): PanelEditorDraftApi {
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
@@ -40,9 +35,9 @@ export function usePanelEditorDraft(
() =>
!isEqual(
{ ...draft, spec: { ...draft.spec, queries: null } },
{ ...savedPanel, spec: { ...savedPanel.spec, queries: null } },
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
),
[draft, savedPanel],
[draft, initialPanel],
);
return {

View File

@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
@@ -28,12 +27,6 @@ interface UsePanelEditorQuerySyncArgs {
alwaysSerializeQuery?: boolean;
/** Signal to seed a new panel's builder with — the kind's first supported signal. */
signal?: TelemetrytypesSignalDTO;
/**
* The persisted panel's queries — the dirty baseline. Distinct from `draft.spec.queries`,
* which the builder seeds from and may carry unsaved edits handed off from View mode. Omit
* for a new panel, where the seed query is the baseline.
*/
savedQueries?: DashboardtypesQueryDTO[];
}
interface UsePanelEditorQuerySyncApi {
@@ -60,31 +53,43 @@ export function usePanelEditorQuerySync({
refetch,
alwaysSerializeQuery = false,
signal,
savedQueries,
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
const draftQueries = draft.spec.queries;
// Saved queries, captured once: seed the builder and serve as the restore target.
const savedQueries = draft.spec.queries;
// A new panel has no saved query: seed from the kind's first supported signal rather
// than `fromPerses`'s metrics default (which List doesn't support).
// A new panel has no saved query: seed from the kind's first supported signal
// instead of letting `fromPerses` fall back to the metrics default (which List
// doesn't support).
const seedQuery = useMemo(
() =>
draftQueries.length === 0 && signal
savedQueries.length === 0 && signal
? initialQueriesMap[signal]
: fromPerses(draftQueries, panelType),
[draftQueries, panelType, signal],
: fromPerses(savedQueries, panelType),
[savedQueries, panelType, signal],
);
// No forceReset: seed the builder only when the URL carries no query, so an
// in-editor edit in the URL survives a refresh / browser Back-Forward.
useShareBuilderUrl({ defaultValue: seedQuery });
// Force-reset the builder to the SAVED panel on first render only, discarding a
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
// baseline is captured from the URL). After mount the URL syncs normally.
const isInitialRenderRef = useRef(true);
useShareBuilderUrl({
defaultValue: seedQuery,
forceReset: isInitialRenderRef.current,
});
useEffect(() => {
isInitialRenderRef.current = false;
}, []);
// Commit the live query into the draft (what the preview fetches).
// Commit the live query into the draft (what the preview fetches). The dirty
// check compares against the SAVED query (`seedQuery`), not the URL-synced
// staged query, which can carry stale state across a refresh and read a real
// switch as "unchanged". Returns whether the draft changed.
const commitQuery = useCallback(
(query: Query): boolean => {
const next = getIsQueryModified(query, seedQuery)
? toPerses(query, panelType)
: draftQueries;
: savedQueries;
// No-op guard at the V5 envelope level: equivalent wrappers (bare
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
// envelopes, so a structural compare would falsely dirty the draft.
@@ -95,7 +100,7 @@ export function usePanelEditorQuerySync({
setSpec({ ...draft.spec, queries: next });
return true;
},
[seedQuery, panelType, draftQueries, draft.spec, setSpec],
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
);
// Latest query/commit, read by the structural-change effect without re-subscribing.
@@ -105,7 +110,7 @@ export function usePanelEditorQuerySync({
queryRef.current = currentQuery;
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
// mount: the initial query is synced into the draft by the staged-query effect below.
// mount: the draft already holds the saved queries the builder is reset to.
const dataSources = useMemo(
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
[currentQuery.builder],
@@ -131,15 +136,6 @@ export function usePanelEditorQuerySync({
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
}, [currentQuery.queryType, dataSourceSignature]);
// Follow the staged (executed) query into the draft on a URL re-stage (mount
// hydration, browser Back/Forward) so the preview matches. Live edits touch only
// currentQuery, so they still wait for Run; commitQuery no-ops when unchanged.
useEffect(() => {
if (stagedQuery) {
commitRef.current(stagedQuery);
}
}, [stagedQuery]);
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so it can be re-run.
const runQuery = useCallback((): void => {
handleRunQuery();
@@ -148,29 +144,20 @@ export function usePanelEditorQuerySync({
}
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
// Dirty = the live query no longer serializes to the SAVED panel's query, compared at
// the V5 envelope level. Anchoring to `savedQueries` (not the builder-seed) keeps a
// handed-off / URL-restored edit reading as dirty; routing both sides through the same
// `fromPerses → toPerses` round-trip stops builder-added defaults (absent from an older
// stored query) reading an untouched panel as modified. New panel: fall back to seed.
const baselineEnvelopes = useMemo(
() =>
toQueryEnvelopes(
toPerses(
savedQueries ? fromPerses(savedQueries, panelType) : seedQuery,
panelType,
),
),
[savedQueries, seedQuery, panelType],
);
const isQueryDirty = useMemo(
() =>
!isEqual(
toQueryEnvelopes(toPerses(currentQuery, panelType)),
baselineEnvelopes,
),
[currentQuery, panelType, baselineEnvelopes],
);
// Dirty baseline: the builder's OWN normalized saved query (first non-null
// `stagedQuery` after the mount reset) — comparing builder-normalized to
// builder-normalized avoids serialization drift reading an untouched query as
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
// once and never moved by Stage & Run, so it stays anchored to saved.
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
useEffect(() => {
if (queryBaseline === null && stagedQuery) {
setQueryBaseline(stagedQuery);
}
}, [queryBaseline, stagedQuery]);
const isQueryDirty =
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
const buildSaveSpec = useCallback(
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>

View File

@@ -6,7 +6,6 @@ import {
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
@@ -42,22 +41,10 @@ import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
@@ -81,7 +68,6 @@ function PanelEditorContainer({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
isEditable,
@@ -105,7 +91,6 @@ function PanelEditorContainer({
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
@@ -303,24 +288,22 @@ function PanelEditorContainer({
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ConfigProvider>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>

View File

@@ -22,22 +22,6 @@ export interface ComparisonThresholdShape {
format?: DashboardtypesThresholdFormatDTO;
}
/** SigNoz threshold palette; single source of truth for the hex values. */
export enum ThresholdColor {
RED = '#F1575F',
ORANGE = '#F5B225',
GREEN = '#2BB673',
BLUE = '#4E74F8',
}
/** Palette ordered most-dangerous first (preset order + alert-severity ranking). */
export const THRESHOLD_COLOR_DANGER_ORDER: ThresholdColor[] = [
ThresholdColor.RED,
ThresholdColor.ORANGE,
ThresholdColor.GREEN,
ThresholdColor.BLUE,
];
/** Comparison operators a threshold can use, as evaluable symbols. */
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';

View File

@@ -104,9 +104,7 @@ function ViewPanelModalContent({
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
handoffState: { editSpec: buildSaveSpec(draft.spec) },
});
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
};
return (

View File

@@ -5,13 +5,11 @@ import type {
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import { normalizeOperator } from 'container/CreateAlertV2/context/conditionNormalizers';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
Threshold,
} from 'container/CreateAlertV2/context/types';
import { THRESHOLD_COLOR_DANGER_ORDER } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
import type { MetricAggregation } from 'types/api/v5/queryRange';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ReduceOperators } from 'types/common/queryBuilder';
@@ -23,6 +21,14 @@ export interface PanelAlertPrefill {
threshold?: Threshold;
}
// Most-dangerous first, matching the panel editor palette; unknown colors sort last.
const THRESHOLD_COLOR_DANGER_ORDER = [
'#f1575f',
'#f5b225',
'#2bb673',
'#4e74f8',
];
interface NormalizedPanelThreshold {
color: string;
value: number;
@@ -87,12 +93,8 @@ function readPanelThresholds(
}
}
// Match case-insensitively (picker emits lowercase hex); unknown colors sort last.
function colorRank(color: string): number {
const target = color.toLowerCase();
const index = THRESHOLD_COLOR_DANGER_ORDER.findIndex(
(paletteColor) => paletteColor.toLowerCase() === target,
);
const index = THRESHOLD_COLOR_DANGER_ORDER.indexOf(color.toLowerCase());
return index === -1 ? THRESHOLD_COLOR_DANGER_ORDER.length : index;
}
@@ -104,17 +106,22 @@ function pickHighestDanger(
)[0];
}
// The alert UI has no inclusive operator; collapse "or equal" onto its strict variant.
function panelOperatorToAlertOperator(
operator: DashboardtypesComparisonOperatorDTO | undefined,
): AlertThresholdOperator | undefined {
switch (operator) {
case 'above':
case 'above_or_equal':
return normalizeOperator('above');
return AlertThresholdOperator.IS_ABOVE;
case 'below':
case 'below_or_equal':
return normalizeOperator('below');
return AlertThresholdOperator.IS_BELOW;
case 'equal':
return AlertThresholdOperator.IS_EQUAL_TO;
case 'not_equal':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
default:
return normalizeOperator(operator);
return undefined;
}
}

View File

@@ -2,7 +2,6 @@ import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
import { useShareVariablesOption } from './useShareVariablesOption';
import styles from './DashboardPageHeader.module.scss';
@@ -15,16 +14,10 @@ function DashboardPageHeader({
title,
image,
}: DashboardPageHeaderProps): JSX.Element {
const shareVariablesOption = useShareVariablesOption();
return (
<div className={styles.dashboardPageHeader}>
<DashboardPageBreadcrumbs title={title} image={image} />
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
shareModalExtraOption={shareVariablesOption}
/>
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}

View File

@@ -1,41 +0,0 @@
import { useMemo } from 'react';
import type { ShareURLExtraOption } from 'components/HeaderRightSection/ShareURLModal';
import type { SelectedVariableValue } from '../../VariablesBar/selectionTypes';
import {
ALL_SELECTED,
variablesUrlParser,
} from '../../VariablesBar/utils/variablesUrlState';
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
/**
* The share-dialog "Include variables" option: serializes the current variable
* selection into the `?variables=` param (ALL encoded as the sentinel) so a shared
* link reproduces it for the recipient — who hydrates it into local storage on load,
* after which the param is cleared (see useSeedVariableSelection). Returns undefined
* when there is nothing selected to share.
*/
export function useShareVariablesOption(): ShareURLExtraOption | undefined {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const selections = useDashboardStore(selectVariableValues(dashboardId ?? ''));
return useMemo(() => {
const names = Object.keys(selections);
if (names.length === 0) {
return undefined;
}
const urlShape: Record<string, SelectedVariableValue> = {};
names.forEach((name) => {
const selection = selections[name];
urlShape[name] = selection.allSelected ? ALL_SELECTED : selection.value;
});
const serialized = variablesUrlParser.serialize(urlShape);
return {
label: 'Include variables',
apply: (params): void => {
params.set('variables', serialized);
},
};
}, [selections]);
}

View File

@@ -1,65 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useCreatePanel } from '../useCreatePanel';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}));
describe('useCreatePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
});
it('carries the relative time window onto the new-panel route', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
});
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('/dashboard/dash-1/panel/new');
expect(url).toContain('panelKind=timeSeries');
expect(url).toContain('layoutIndex=2');
expect(url).toContain('relativeTime=6h');
});
it('carries a custom absolute window and never a stray relativeTime', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
});
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('startTime=1000');
expect(url).toContain('endTime=2000');
expect(url).not.toContain('relativeTime');
expect(url).not.toContain('&&');
});
});

View File

@@ -1,95 +0,0 @@
import { renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useOpenPanelEditor } from '../useOpenPanelEditor';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}));
describe('useOpenPanelEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
});
it('carries the relative time window into the editor route', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9?relativeTime=6h',
undefined,
);
});
it('carries a custom absolute window as a start/end ms pair', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('startTime=1000');
expect(url).toContain('endTime=2000');
// A custom range must not also carry relativeTime (it would win on the editor).
expect(url).not.toContain('relativeTime');
});
it('omits the query string for an uninitialized custom window', () => {
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9',
undefined,
);
});
it('forwards handoff state as router location state', () => {
mockGlobalTime = { selectedTime: '1h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
const handoffState = { editSpec: { title: 'x' } } as never;
result.current('panel-9', { handoffState });
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9?relativeTime=1h',
{ state: handoffState },
);
});
it('merges search with the time window (leading ? tolerated)', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('new', { search: '?panelKind=timeSeries&layoutIndex=2' });
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('/dashboard/dash-1/panel/new?');
expect(url).toContain('panelKind=timeSeries');
expect(url).toContain('layoutIndex=2');
expect(url).toContain('relativeTime=6h');
});
});

View File

@@ -1,43 +0,0 @@
import { renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useTimeSearchParams } from '../useTimeSearchParams';
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
describe('useTimeSearchParams', () => {
it('returns a relativeTime query string for a relative selection', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toBe('relativeTime=6h');
});
it('returns an absolute ms pair for a custom selection', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toContain('startTime=1000');
expect(result.current).toContain('endTime=2000');
expect(result.current).not.toContain('relativeTime');
});
it('returns an empty string for an uninitialized custom window', () => {
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toBe('');
});
});

View File

@@ -1,8 +1,11 @@
import { useCallback, useState } from 'react';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { PanelKind } from '../Panels/types/panelKind';
import { useOpenPanelEditor } from './useOpenPanelEditor';
import { useDashboardStore } from '../store/useDashboardStore';
interface UseCreatePanelResult {
isPickerOpen: boolean;
@@ -21,7 +24,8 @@ interface UseCreatePanelResult {
* until save.
*/
export function useCreatePanel(): UseCreatePanelResult {
const openPanelEditor = useOpenPanelEditor();
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const [isPickerOpen, setIsPickerOpen] = useState(false);
// Captured on open, consumed on select.
@@ -39,12 +43,15 @@ export function useCreatePanel(): UseCreatePanelResult {
const createPanel = useCallback(
(panelKind: PanelKind, targetIndex?: number): void => {
setIsPickerOpen(false);
const target = targetIndex ?? layoutIndex;
openPanelEditor(NEW_PANEL_ID, {
search: newPanelSearch(panelKind, target),
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
// Variable selection is read from the persisted store, not the URL.
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
},
[openPanelEditor, layoutIndex],
[safeNavigate, dashboardId, layoutIndex],
);
return {

View File

@@ -5,39 +5,30 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { useDashboardStore } from '../store/useDashboardStore';
import { useTimeSearchParams } from './useTimeSearchParams';
interface OpenPanelEditorOptions {
handoffState?: PanelEditorHandoffState;
/** Extra query merged into the editor URL (leading `?` optional). */
search?: string;
}
/** Opens the V2 panel editor, carrying the active time window in the URL. */
/**
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
* caller can open the editor with just the panel id. Variable selection is read from the
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
* panel.
*/
export function useOpenPanelEditor(): (
panelId: string,
options?: OpenPanelEditorOptions,
handoffState?: PanelEditorHandoffState,
) => void {
const { safeNavigate } = useSafeNavigate();
const timeSearch = useTimeSearchParams();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panelId: string, options?: OpenPanelEditorOptions): void => {
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId,
});
const params = new URLSearchParams(options?.search);
new URLSearchParams(timeSearch).forEach((value, key) => {
params.set(key, value);
});
const search = params.toString();
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
safeNavigate(
search ? `${path}?${search}` : path,
options?.handoffState ? { state: options.handoffState } : undefined,
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
handoffState ? { state: handoffState } : undefined,
);
},
[safeNavigate, dashboardId, timeSearch],
[safeNavigate, dashboardId],
);
}

View File

@@ -1,20 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
/** Active time window as a query string (no leading `?`), or `''` when unset. */
export function useTimeSearchParams(): string {
const { selectedTime, minTime, maxTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
return useMemo(
() => timeParamsFromGlobalTime({ selectedTime, minTime, maxTime }).toString(),
[selectedTime, minTime, maxTime],
);
}

View File

@@ -1,51 +0,0 @@
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { timeParamsFromGlobalTime } from '../timeUrlParams';
describe('timeParamsFromGlobalTime', () => {
it('emits relativeTime for a relative selection', () => {
const params = timeParamsFromGlobalTime({
selectedTime: '6h',
minTime: 0,
maxTime: 0,
});
expect(params.get('relativeTime')).toBe('6h');
// Mutually exclusive: no absolute pair alongside a relative range.
expect(params.has('startTime')).toBe(false);
expect(params.has('endTime')).toBe(false);
});
it('emits an absolute ms pair for a custom selection (converting from ns)', () => {
const params = timeParamsFromGlobalTime({
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
});
expect(params.get('startTime')).toBe('1000');
expect(params.get('endTime')).toBe('2000');
// A custom range must not carry a relativeTime that would win on the editor.
expect(params.has('relativeTime')).toBe(false);
});
it('carries a custom shorthand relative selection verbatim', () => {
const params = timeParamsFromGlobalTime({
selectedTime: '13m',
minTime: 0,
maxTime: 0,
});
expect(params.get('relativeTime')).toBe('13m');
});
it('emits nothing for an uninitialized custom window', () => {
const params = timeParamsFromGlobalTime({
selectedTime: 'custom',
minTime: 0,
maxTime: 0,
});
expect(params.toString()).toBe('');
});
});

View File

@@ -1,39 +0,0 @@
import { QueryParams } from 'constants/query';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import type { GlobalReducer } from 'types/reducer/globalTime';
type GlobalTimeSelection = Pick<
GlobalReducer,
'selectedTime' | 'minTime' | 'maxTime'
>;
/**
* Time-window URL params for the active selection. Derived from Redux (what the picker and
* panel queries read), not the URL: the legacy react-router and newer nuqs time writers fall
* out of sync, leaving a stale `relativeTime` that `DateTimeSelectionV2` prefers over an
* absolute range. Redux keeps them mutually exclusive (custom → start/end ms; else relativeTime).
*/
export function timeParamsFromGlobalTime({
selectedTime,
minTime,
maxTime,
}: GlobalTimeSelection): URLSearchParams {
const params = new URLSearchParams();
if (selectedTime === 'custom') {
if (minTime > 0 && maxTime > 0) {
params.set(
QueryParams.startTime,
String(Math.floor(minTime / NANO_SECOND_MULTIPLIER)),
);
params.set(
QueryParams.endTime,
String(Math.floor(maxTime / NANO_SECOND_MULTIPLIER)),
);
}
} else {
params.set(QueryParams.relativeTime, selectedTime);
}
return params;
}

View File

@@ -21,7 +21,6 @@ import {
parseNewPanelLayoutIndex,
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
@@ -39,7 +38,6 @@ function PanelEditorPage(): JSX.Element {
}>();
const { search, state } = useLocation();
const { safeNavigate } = useSafeNavigate();
const timeSearch = useTimeSearchParams();
// Edits handed off from the View modal's drilldown — open the editor on these
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
@@ -107,11 +105,10 @@ function PanelEditorPage(): JSX.Element {
const layoutIndex = parseNewPanelLayoutIndex(search);
const backToDashboard = useCallback((): void => {
// Drop editor-only URL state (variables come from the persisted store), but carry
// time so a custom range picked in the editor isn't reset to the dashboard default.
const path = generatePath(ROUTES.DASHBOARD, { dashboardId });
safeNavigate(timeSearch ? `${path}?${timeSearch}` : path);
}, [safeNavigate, dashboardId, timeSearch]);
// Drop editor-only URL state (chiefly `compositeQuery`); the dashboard reads its
// variable selection from the persisted store, and time lives in Redux.
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [safeNavigate, dashboardId]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
@@ -140,7 +137,6 @@ function PanelEditorPage(): JSX.Element {
dashboardId={dashboardId}
panelId={panelId}
panel={panel}
savedPanel={existingPanel}
isNew={!!newKind}
layoutIndex={layoutIndex}
isEditable={isEditable}

View File

@@ -1,20 +1,5 @@
import { ConfigProvider, SelectProps } from 'antd';
// eslint-disable-next-line no-restricted-imports
import { useContext } from 'react';
import { SelectProps } from 'antd';
export const popupContainer: SelectProps['getPopupContainer'] = (
trigger,
): HTMLElement => trigger.parentNode;
/**
* Popup container for query-builder Selects. Prefers a container supplied by an
* ancestor antd `ConfigProvider` (set by hosts that render the builder inside a
* clipped/portaled surface — e.g. the panel editor's `overflow:hidden` resizable
* pane, or the View modal's focus-trapped dialog) and otherwise falls back to
* `trigger.parentNode`, the app-wide default. No `ConfigProvider` container is set
* app-wide, so surfaces that don't opt in keep the legacy behavior unchanged.
*/
export function useSelectPopupContainer(): SelectProps['getPopupContainer'] {
const { getPopupContainer } = useContext(ConfigProvider.ConfigContext);
return getPopupContainer ?? popupContainer;
}

View File

@@ -56,21 +56,19 @@ func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (
}
}
// Without executing the series lookup, only an exact-name selector's
// metric name is known.
var metricNames []string
var metricName string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" && matcher.Type == prompb.LabelMatcher_EQ {
metricNames = []string{matcher.Value}
if matcher.Name == "__name__" {
metricName = matcher.Value
}
}
// Build the executing path's queries, but only record them.
sub, err := seriesLookupQuery(query, true)
subQuery, args, err := c.queryToClickhouseQuery(ctx, query, metricName, true)
if err != nil {
return nil, err
}
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
c.recorder.record(samplesQuery, samplesArgs)
return storage.EmptySeriesSet(), nil

View File

@@ -4,7 +4,8 @@ import (
"context"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
"github.com/SigNoz/signoz/pkg/errors"
@@ -14,7 +15,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/cespare/xxhash/v2"
"github.com/huandu/go-sqlbuilder"
promValue "github.com/prometheus/prometheus/model/value"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage"
@@ -56,13 +56,19 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries
}
}
lookup, err := seriesLookupQuery(query, false)
var metricName string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" {
metricName = matcher.Value
}
}
clickhouseQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, false)
if err != nil {
return nil, err
}
lookupSQL, lookupArgs := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
fingerprints, metricNames, err := client.getFingerprintsFromClickhouseQuery(ctx, lookupSQL, lookupArgs)
fingerprints, err := client.getFingerprintsFromClickhouseQuery(ctx, clickhouseQuery, args)
if err != nil {
return nil, err
}
@@ -70,14 +76,13 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries
return remote.FromQueryResult(sortSeries, new(prompb.QueryResult)), nil
}
sub, err := seriesLookupQuery(query, true)
clickhouseSubQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, true)
if err != nil {
return nil, err
}
samplesSQL, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
res := new(prompb.QueryResult)
timeseries, err := client.querySamples(ctx, samplesSQL, samplesArgs, fingerprints)
timeseries, err := client.querySamples(ctx, int64(query.StartTimestampMs), int64(query.EndTimestampMs), fingerprints, metricName, clickhouseSubQuery, args)
if err != nil {
return nil, err
}
@@ -121,115 +126,86 @@ func (c *client) ReadMultiple(ctx context.Context, queries []*prompb.Query, sort
return storage.NewMergeSeriesSet(sets, 0, storage.ChainedSeriesMerge), nil
}
// anchorRegex makes a pattern fully anchored, the way Prometheus compiles
// matcher regexes; ClickHouse's match() would otherwise substring-match.
func anchorRegex(pattern string) string {
return "^(?:" + pattern + ")$"
}
// seriesLookupQuery builds the time-series lookup. It returns a builder so
// the samples query can embed it as a subquery with the args merged in
// render order by the builder instead of hand-numbered placeholders.
func seriesLookupQuery(query *prompb.Query, subQuery bool) (*sqlbuilder.SelectBuilder, error) {
sb := sqlbuilder.NewSelectBuilder()
func (client *client) queryToClickhouseQuery(_ context.Context, query *prompb.Query, metricName string, subQuery bool) (string, []any, error) {
var clickHouseQuery string
var conditions []string
var argCount = 0
var selectString = "fingerprint, any(labels)"
if subQuery {
sb.Select("fingerprint")
} else {
sb.Select("fingerprint", "any(labels)")
argCount = 1
selectString = "fingerprint"
}
start, end, tableName := getStartAndEndAndTableName(query.StartTimestampMs, query.EndTimestampMs)
sb.From(databaseName + "." + tableName)
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
var args []any
conditions = append(conditions, fmt.Sprintf("metric_name = $%d", argCount+1))
conditions = append(conditions, "temporality IN ['Cumulative', 'Unspecified']")
// Inclusive upper bound: registration rows are hour-floored by the
// exporter, so a series first registered in the hour starting exactly at
// `end` would otherwise be invisible while its samples (<= end) are in
// range.
sb.Where(fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
conditions = append(conditions, fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
args = append(args, metricName)
for _, m := range query.Matchers {
if m.Name == "__name__" {
// __name__ maps onto the metric_name column per matcher type;
// reducing regex/negated/absent name matchers to one equality
// made such selectors silently return empty.
switch m.Type {
case prompb.LabelMatcher_EQ:
sb.Where(sb.E("metric_name", m.Value))
case prompb.LabelMatcher_NEQ:
sb.Where(sb.NE("metric_name", m.Value))
case prompb.LabelMatcher_RE:
sb.Where(fmt.Sprintf("match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
case prompb.LabelMatcher_NRE:
sb.Where(fmt.Sprintf("not match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
}
continue
}
switch m.Type {
case prompb.LabelMatcher_EQ:
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) = %s", sb.Var(m.Name), sb.Var(m.Value)))
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) = $%d", argCount+2, argCount+3))
case prompb.LabelMatcher_NEQ:
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) != %s", sb.Var(m.Name), sb.Var(m.Value)))
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) != $%d", argCount+2, argCount+3))
case prompb.LabelMatcher_RE:
sb.Where(fmt.Sprintf("match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
conditions = append(conditions, fmt.Sprintf("match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3))
case prompb.LabelMatcher_NRE:
sb.Where(fmt.Sprintf("not match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
conditions = append(conditions, fmt.Sprintf("not match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3))
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
}
args = append(args, m.Name, m.Value)
argCount += 2
}
sb.GroupBy("fingerprint")
return sb, nil
whereClause := strings.Join(conditions, " AND ")
clickHouseQuery = fmt.Sprintf(`SELECT %s FROM %s.%s WHERE %s GROUP BY fingerprint`, selectString, databaseName, tableName, whereClause)
return clickHouseQuery, args, nil
}
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, []string, error) {
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, error) {
ctx = client.withClickhousePrometheusContext(ctx, "getFingerprintsFromClickhouseQuery")
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, nil, err
return nil, err
}
defer rows.Close()
fingerprints := make(map[uint64][]prompb.Label)
nameSet := make(map[string]struct{})
var fingerprint uint64
var labelString string
for rows.Next() {
if err = rows.Scan(&fingerprint, &labelString); err != nil {
return nil, nil, err
return nil, err
}
labels, metricName, err := unmarshalLabels(labelString)
labels, _, err := unmarshalLabels(labelString)
if err != nil {
return nil, nil, err
return nil, err
}
fingerprints[fingerprint] = labels
if metricName != "" {
nameSet[metricName] = struct{}{}
}
}
if err := rows.Err(); err != nil {
return nil, nil, err
return nil, err
}
metricNames := make([]string, 0, len(nameSet))
for name := range nameSet {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
return fingerprints, metricNames, nil
return fingerprints, nil
}
// buildSamplesQuery renders the samples SQL for the series selected by
// subQuery. The metric_name condition exists only for primary-key pruning;
// the fingerprint filter already selects the right rows.
// buildSamplesQuery renders the samples SQL (and args) that fetches data
// points for the series selected by subQuery.
//
// Time bounds are inclusive on both ends because that is Prometheus's
// storage contract: Select(mint, maxt) returns [start, end] and the engine
@@ -239,29 +215,27 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
// its own model — toStartOfInterval buckets covering [t, t+step), where a
// sample at `end` falls in an unrendered bucket and end-exclusive ranges
// tile exactly across cached time slices.
func buildSamplesQuery(start int64, end int64, metricNames []string, sub *sqlbuilder.SelectBuilder) (string, []any) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "fingerprint", "unix_milli", "value", "flags")
sb.From(databaseName + "." + distributedSamplesV4)
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
argCount := len(args)
if len(metricNames) > 0 {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb.Where(sb.In("metric_name", names...))
}
sb.Where(fmt.Sprintf("fingerprint GLOBAL IN (%s)", sb.Var(sub)))
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
sb.OrderBy("fingerprint", "unix_milli")
query := fmt.Sprintf(`
SELECT metric_name, fingerprint, unix_milli, value, flags
FROM %s.%s
WHERE metric_name = $1 AND fingerprint GLOBAL IN (%s) AND unix_milli >= $%s AND unix_milli <= $%s ORDER BY fingerprint, unix_milli;`,
databaseName, distributedSamplesV4, subQuery, strconv.Itoa(argCount+2), strconv.Itoa(argCount+3))
query = strings.TrimSpace(query)
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
allArgs := append([]any{metricName}, args...)
allArgs = append(allArgs, start, end)
return query, allArgs
}
func (client *client) querySamples(ctx context.Context, query string, args []any, fingerprints map[uint64][]prompb.Label) ([]*prompb.TimeSeries, error) {
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
if err != nil {
return nil, err
}
@@ -269,7 +243,6 @@ func (client *client) querySamples(ctx context.Context, query string, args []any
var res []*prompb.TimeSeries
var ts *prompb.TimeSeries
var metricName string
var fingerprint, prevFingerprint uint64
var timestampMs, prevTimestamp int64
var value float64

View File

@@ -11,7 +11,6 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/huandu/go-sqlbuilder"
"github.com/prometheus/prometheus/prompb"
"github.com/stretchr/testify/assert"
)
@@ -30,7 +29,7 @@ func TestClient_QuerySamples(t *testing.T) {
start int64
end int64
fingerprints map[uint64][]prompb.Label
metricNames []string
metricName string
subQuery string
args []any
setupMock func(mock cmock.ClickConnMockCommon, args ...any)
@@ -53,7 +52,7 @@ func TestClient_QuerySamples(t *testing.T) {
{Name: "instance", Value: "localhost:9091"},
},
},
metricNames: []string{"cpu_usage"},
metricName: "cpu_usage",
subQuery: "SELECT metric_name, fingerprint, unix_milli, value, flags",
expectedTimeSeries: 2,
expectError: false,
@@ -98,10 +97,10 @@ func TestClient_QuerySamples(t *testing.T) {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp)
readClient := client{telemetryStore: telemetryStore}
if tt.setupMock != nil {
tt.setupMock(telemetryStore.Mock(), "cpu_usage", tt.start, tt.end)
tt.setupMock(telemetryStore.Mock(), tt.metricName, tt.start, tt.end)
}
result, err := readClient.querySamples(ctx, tt.subQuery, []any{"cpu_usage", tt.start, tt.end}, tt.fingerprints)
result, err := readClient.querySamples(ctx, tt.start, tt.end, tt.fingerprints, tt.metricName, tt.subQuery, tt.args)
if tt.expectError {
assert.Error(t, err)
@@ -116,6 +115,101 @@ func TestClient_QuerySamples(t *testing.T) {
}
}
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
cols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "labels", Type: "String"},
}
sortLabels := func(ls []prompb.Label) {
sort.Slice(ls, func(i, j int) bool {
if ls[i].Name == ls[j].Name {
return ls[i].Value < ls[j].Value
}
return ls[i].Name < ls[j].Name
})
}
tests := []struct {
name string
start, end int64
metricName string
subQuery string
args []any
setupMock func(m cmock.ClickConnMockCommon, args ...any)
want map[uint64][]prompb.Label
wantErr bool
}{
{
name: "happy-path - two fingerprints",
start: 1000,
end: 2000,
metricName: "cpu_usage",
subQuery: `SELECT fingerprint,labels`,
// args slice is empty here, but testcase still owns it
args: []any{},
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
rows := [][]any{
{uint64(123), `{"t1":"s1","t2":"s2"}`},
{uint64(234), `{"t1":"s1","t2":"s2","empty":""}`},
}
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
cmock.NewRows(cols, rows),
)
},
// No synthetic fingerprint label (#8563), empty-valued labels
// dropped: both fingerprints present one labelset for
// querySamples to merge.
want: map[uint64][]prompb.Label{
123: {
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
234: {
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := telemetrystoretest.New(
telemetrystore.Config{Provider: "clickhouse"},
sqlmock.QueryMatcherRegexp,
)
if tc.setupMock != nil {
tc.setupMock(store.Mock(), tc.args...)
}
c := client{telemetryStore: store}
got, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
if tc.wantErr {
require.Error(t, err)
require.Nil(t, got)
return
}
require.NoError(t, err)
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
for fp, expLabels := range tc.want {
gotLabels, ok := got[fp]
require.Truef(t, ok, "missing fingerprint %d", fp)
sortLabels(expLabels)
sortLabels(gotLabels)
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
}
})
}
}
// Regression for the duplicate-series class behind #8563: fingerprints
// sharing one labelset must come back as one merged series, the higher
// fingerprint winning equal timestamps.
@@ -157,7 +251,7 @@ func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
WillReturnRows(cmock.NewRows(cols, values))
readClient := client{telemetryStore: telemetryStore}
result, err := readClient.querySamples(ctx, "SELECT metric_name, fingerprint, unix_milli, value, flags", []any{"requests", int64(1000), int64(3000)}, fingerprints)
result, err := readClient.querySamples(ctx, 1000, 3000, fingerprints, "requests", "SELECT metric_name, fingerprint, unix_milli, value, flags", nil)
require.NoError(t, err)
assert.Equal(t, []*prompb.TimeSeries{
@@ -178,188 +272,6 @@ func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
}, result)
}
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
cols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "labels", Type: "String"},
}
sortLabels := func(ls []prompb.Label) {
sort.Slice(ls, func(i, j int) bool {
if ls[i].Name == ls[j].Name {
return ls[i].Value < ls[j].Value
}
return ls[i].Name < ls[j].Name
})
}
tests := []struct {
name string
start, end int64
metricName string
subQuery string
args []any
setupMock func(m cmock.ClickConnMockCommon, args ...any)
want map[uint64][]prompb.Label
wantNames []string
wantErr bool
}{
{
name: "happy-path - two fingerprints",
start: 1000,
end: 2000,
metricName: "cpu_usage",
subQuery: `SELECT fingerprint,labels`,
// args slice is empty here, but testcase still owns it
args: []any{},
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
rows := [][]any{
{uint64(123), `{"__name__":"cpu_usage","t1":"s1","t2":"s2"}`},
{uint64(234), `{"__name__":"cpu_usage","t1":"s1","t2":"s2","empty":""}`},
}
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
cmock.NewRows(cols, rows),
)
},
// No synthetic fingerprint label (#8563), empty-valued labels
// dropped: both fingerprints present one labelset for
// querySamples to merge.
want: map[uint64][]prompb.Label{
123: {
{Name: "__name__", Value: "cpu_usage"},
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
234: {
{Name: "__name__", Value: "cpu_usage"},
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
},
wantNames: []string{"cpu_usage"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := telemetrystoretest.New(
telemetrystore.Config{Provider: "clickhouse"},
sqlmock.QueryMatcherRegexp,
)
if tc.setupMock != nil {
tc.setupMock(store.Mock(), tc.args...)
}
c := client{telemetryStore: store}
got, gotNames, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
if tc.wantErr {
require.Error(t, err)
require.Nil(t, got)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantNames, gotNames, "discovered metric names mismatch")
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
for fp, expLabels := range tc.want {
gotLabels, ok := got[fp]
require.Truef(t, ok, "missing fingerprint %d", fp)
sortLabels(expLabels)
sortLabels(gotLabels)
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
}
})
}
}
// Regression for nameless/regex-name selectors silently returning empty:
// the old code reduced every __name__ matcher to `metric_name = <value>`
// (empty string when absent). Regexes must come out anchored — Prometheus
// matcher semantics, while ClickHouse match() substring-matches.
func TestQueryToClickhouseQueryNameMatchers(t *testing.T) {
query := func(matchers ...*prompb.LabelMatcher) *prompb.Query {
return &prompb.Query{StartTimestampMs: 0, EndTimestampMs: 1000, Matchers: matchers}
}
tests := []struct {
name string
query *prompb.Query
contains []string
absent []string
args []any
}{
{
name: "exact name",
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "__name__", Value: "cpu_usage"}),
contains: []string{"metric_name = ?"},
args: []any{"cpu_usage"},
},
{
name: "regex name is anchored",
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_RE, Name: "__name__", Value: ".+"}),
contains: []string{"match(metric_name, ?)"},
args: []any{"^(?:.+)$"},
},
{
name: "nameless selector has no metric_name condition",
query: query(
&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "job", Value: "api"},
&prompb.LabelMatcher{Type: prompb.LabelMatcher_NRE, Name: "group", Value: "can.*"},
),
contains: []string{
"JSONExtractString(labels, ?) = ?",
"not match(JSONExtractString(labels, ?), ?)",
},
absent: []string{"metric_name"},
args: []any{"job", "api", "group", "^(?:can.*)$"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lookup, err := seriesLookupQuery(tt.query, false)
require.NoError(t, err)
sql, args := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
for _, want := range tt.contains {
assert.Contains(t, sql, want)
}
for _, notWant := range tt.absent {
assert.NotContains(t, sql, notWant)
}
assert.Equal(t, tt.args, args)
})
}
}
// The samples query narrows by the metric names the lookup discovered and
// embeds the series lookup as a subquery, the builder merging its args in
// render order.
func TestBuildSamplesQueryMetricNames(t *testing.T) {
sub := sqlbuilder.NewSelectBuilder()
sub.Select("fingerprint")
sub.From("t")
sub.Where(sub.E("k", "v"))
sql, args := buildSamplesQuery(5, 9, []string{"a_total", "b_total"}, sub)
assert.Contains(t, sql, "metric_name IN (?, ?)")
assert.Contains(t, sql, "fingerprint GLOBAL IN (SELECT fingerprint FROM t WHERE k = ?)")
assert.Contains(t, sql, "unix_milli >= ? AND unix_milli <= ?")
assert.Equal(t, []any{"a_total", "b_total", "v", int64(5), int64(9)}, args)
sub2 := sqlbuilder.NewSelectBuilder()
sub2.Select("fingerprint")
sub2.From("t")
sql, args = buildSamplesQuery(5, 9, nil, sub2)
assert.NotContains(t, sql, "metric_name IN")
assert.Equal(t, []any{int64(5), int64(9)}, args)
}
// Hash grouping must stay order-insensitive (stored JSON key order is not
// canonical across fingerprints), and a 64-bit hash collision between
// distinct labelsets must not merge them — splitByLabelSet is that guard.

View File

@@ -237,7 +237,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
// Mock the fingerprint query (for Prometheus label matching)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
@@ -245,6 +245,8 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).

View File

@@ -925,18 +925,20 @@ func TestPromRuleUnitCombinations(t *testing.T) {
}
samplesRows := cmock.NewRows(samplesCols, samplesData)
// args: $1=metric_name (the __name__ matcher maps onto the column)
// args: $1=metric_name, $2=label_name, $3=label_value
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// args: $1=metric_name IN (discovered names), $2=metric_name (subquery), $3=start, $4=end
// args: $1=metric_name (outer), $2=metric_name (subquery), $3=label_name, $4=label_value, $5=start, $6=end
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).
@@ -1061,7 +1063,7 @@ func TestPromRuleNoData(t *testing.T) {
// no rows == no data
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
@@ -1271,7 +1273,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
telemetryStore.Mock().
@@ -1279,6 +1281,8 @@ func TestMultipleThresholdPromRule(t *testing.T) {
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).
@@ -1435,12 +1439,12 @@ func TestPromRule_NoData(t *testing.T) {
labelsJSON := `{"__name__":"test_metric"}`
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{}))
promProvider := prometheustest.New(
@@ -1571,11 +1575,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
queryStart1, queryEnd1 := calcQueryRange(t1)
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart1, queryEnd1).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart1, queryEnd1).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{
// Data points in the past relative to t1
{"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, uint32(0)},
@@ -1587,11 +1591,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
queryStart2, queryEnd2 := calcQueryRange(t2)
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart2, queryEnd2).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart2, queryEnd2).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{})) // empty - no data
promProvider := prometheustest.New(
@@ -1748,11 +1752,11 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, fingerprintData))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
WillReturnRows(cmock.NewRows(samplesCols, samplesData))
promProvider := prometheustest.New(
context.Background(),

View File

@@ -3,6 +3,7 @@ package querybuilder
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -13,8 +14,38 @@ import (
"github.com/tidwall/gjson"
)
var telemetryGrantKeys = map[string]struct{}{
"service.name": {},
}
const telemetryValueSafeBytes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
func EscapeTelemetryValue(value string) string {
var escaped strings.Builder
for _, character := range []byte(value) {
if strings.IndexByte(telemetryValueSafeBytes, character) >= 0 {
escaped.WriteByte(character)
continue
}
escaped.WriteString(fmt.Sprintf("%%%02X", character))
}
return escaped.String()
}
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
values := telemetrytypes.NewTelemetryGrantSelectors(id)
values := []string{id}
segments := strings.Split(id, "/")
for level := len(segments) - 1; level >= 1; level-- {
value := strings.Join(segments[:level], "/") + "/" + coretypes.WildCardSelectorString
if value == id {
continue
}
values = append(values, value)
}
if id != coretypes.WildCardSelectorString {
values = append(values, coretypes.WildCardSelectorString)
}
selectors := make([]coretypes.Selector, 0, len(values))
for _, value := range values {
@@ -156,14 +187,14 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
continue
}
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
key, ok := canonicalTelemetryGrantKey(condition.Key)
if !ok {
continue
}
if condition.Operator == "=" || condition.Operator == "IN" {
for _, value := range condition.Values {
ids = append(ids, queryType+"/"+key+"/"+value)
ids = append(ids, queryType+"/"+key+"/"+EscapeTelemetryValue(value))
}
}
}
@@ -174,3 +205,16 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
return ids, nil
}
func canonicalTelemetryGrantKey(keyText string) (string, bool) {
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
if fieldKey.FieldContext != telemetrytypes.FieldContextUnspecified && fieldKey.FieldContext != telemetrytypes.FieldContextResource {
return "", false
}
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
return "", false
}
return fieldKey.Name, true
}

View File

@@ -2,6 +2,7 @@ package querybuilder
import (
"context"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -21,33 +22,33 @@ func TestQueryRangeResources(t *testing.T) {
expected []coretypes.ResourceWithID
}{
{
name: "top level key equality",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'checkout' AND status = 500"),
name: "top level service equality",
body: builderQueryBody("logs", "service.name = 'checkout' AND status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
},
},
{
name: "resource prefixed key",
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
name: "resource prefixed service key",
body: builderQueryBody("traces", "resource.service.name = 'checkout'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
},
},
{
name: "in atom requires every value",
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('b', 'a')"),
body: builderQueryBody("logs", "service.name IN ('b', 'a')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
},
},
{
name: "multiple equality atoms each require a grant",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'b' AND signoz.workspace.key.id = 'a'"),
body: builderQueryBody("logs", "service.name = 'b' AND service.name = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
},
},
{
@@ -58,38 +59,38 @@ func TestQueryRangeResources(t *testing.T) {
},
},
{
name: "key atom under or does not qualify",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
name: "service atom under or does not qualify",
body: builderQueryBody("logs", "service.name = 'a' OR status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "negated key atom does not qualify",
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
name: "negated service atom does not qualify",
body: builderQueryBody("logs", "NOT service.name = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "key inequality does not qualify",
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
name: "service inequality does not qualify",
body: builderQueryBody("logs", "service.name != 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "value with spaces and slashes stays plaintext in the id",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'check out/2'"),
name: "unsafe value bytes are escaped",
body: builderQueryBody("logs", "service.name = 'check out/2'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/check out/2"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/check%20out%2F2"},
},
},
{
name: "audit source maps to audit logs resource",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"service.name = 'a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/service.name/a"},
},
},
{
@@ -116,23 +117,23 @@ func TestQueryRangeResources(t *testing.T) {
},
{
name: "trace operator rides on its referenced queries",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
},
},
{
name: "variable substitution qualifies",
body: `{"variables":{"key":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = $key"}}}]}}`,
body: `{"variables":{"svc":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = $svc"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
},
},
{
name: "duplicate queries dedupe",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name='a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
},
},
}
@@ -150,7 +151,7 @@ func TestQueryRangeResourcesErrors(t *testing.T) {
bodies := []string{
`{"compositeQuery":{"queries":[]}}`,
`{}`,
builderQueryBody("logs", "signoz.workspace.key.id = "),
builderQueryBody("logs", "service.name = "),
`{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"unknown"}}]}}`,
`{"compositeQuery":{"queries":[{"type":"unknown_type"}]}}`,
}
@@ -174,10 +175,10 @@ func TestTelemetrySelector(t *testing.T) {
return values
}
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
assert.Equal(t, []string{"*"}, selectorValues("*"))
// a value containing "/" stays one logical segment (SplitN 3).
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
assert.Equal(t, []string{"builder_query/service.name/a", "builder_query/service.name/*", "builder_query/*", "*"}, selectorValues("builder_query/service.name/a"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, strings.Repeat("a", 256), orgID)
assert.Error(t, err)
}

View File

@@ -162,28 +162,18 @@ func (c *conditionBuilder) ConditionFor(
return nil, nil, err
}
keys := querybuilder.MatchingFieldKeys(key, fieldKeys)
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
if len(keys) == 0 {
if _, isColumn := timeSeriesV4Columns[key.Name]; isColumn {
keys = []*telemetrytypes.TelemetryFieldKey{key}
} else {
if len(fieldKeys[key.Name]) == 0 {
warnings = append(warnings, fmt.Sprintf("label `%s` not found in metadata; check the label name for typos", key.Name))
}
keys = []*telemetrytypes.TelemetryFieldKey{
telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType),
}
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
keys = append(keys, telemetrytypes.NewTelemetryFieldKey(
key.FieldContext.StringValue()+"."+key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType))
}
}
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, k, operator, value, sb)
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
@@ -191,3 +181,21 @@ func (c *conditionBuilder) ConditionFor(
}
return conds, warnings, nil
}
func (c *conditionBuilder) conditionForKey(
ctx context.Context,
orgID valuer.UUID,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
condition, err := c.conditionFor(ctx, orgID, startNs, endNs, key, operator, value, sb)
if err != nil {
return "", err
}
return condition, nil
}

View File

@@ -307,86 +307,3 @@ func TestConditionForMultipleKeys(t *testing.T) {
})
}
}
func TestConditionForKeyNotInMetadata(t *testing.T) {
ctx := context.Background()
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
operator qbtypes.FilterOperator
value any
expectedSQL []string
expectWarn bool
}{
{
name: "intrinsic metric_name full-text resolves without warning",
key: telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
operator: qbtypes.FilterOperatorRegexp,
value: "k8s",
expectedSQL: []string{"match(metric_name, ?)"},
expectWarn: false,
},
{
name: "unknown label resolves to labels extract with a typo warning",
key: telemetrytypes.TelemetryFieldKey{Name: "foo", FieldContext: telemetrytypes.FieldContextUnspecified},
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
operator: qbtypes.FilterOperatorEqual,
value: "bar",
expectedSQL: []string{"JSONExtractString(labels, 'foo') = ?"},
expectWarn: true,
},
{
name: "context prefix that may be part of the name tries both readings",
key: telemetrytypes.TelemetryFieldKey{Name: "a.b.c", FieldContext: telemetrytypes.FieldContextScope},
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
operator: qbtypes.FilterOperatorEqual,
value: "x",
expectedSQL: []string{"JSONExtractString(labels, 'a.b.c') = ?", "JSONExtractString(labels, 'scope.a.b.c') = ?"},
expectWarn: true,
},
{
name: "unresolved metric-context name is treated as a label prefix",
key: telemetrytypes.TelemetryFieldKey{Name: "foo", FieldContext: telemetrytypes.FieldContextMetric},
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
operator: qbtypes.FilterOperatorEqual,
value: "bar",
expectedSQL: []string{"JSONExtractString(labels, 'foo') = ?", "JSONExtractString(labels, 'metric.foo') = ?"},
expectWarn: true,
},
{
name: "known label under a mismatched context collapses without warning",
key: telemetrytypes.TelemetryFieldKey{Name: "region", FieldContext: telemetrytypes.FieldContextResource},
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
"region": {{Name: "region", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString}},
},
operator: qbtypes.FilterOperatorEqual,
value: "us",
expectedSQL: []string{"JSONExtractString(labels, 'region') = ?"},
expectWarn: false,
},
}
fm := NewFieldMapper()
conditionBuilder := NewConditionBuilder(fm)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
cond, warnings, err := conditionBuilder.ConditionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, tc.fieldKeys, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
require.NoError(t, err)
sb.Where(cond...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
for _, want := range tc.expectedSQL {
assert.Contains(t, sql, want)
}
if tc.expectWarn {
assert.NotEmpty(t, warnings)
} else {
assert.Empty(t, warnings)
}
})
}
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type migrateCommon struct {
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
}
}
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
// transform is stateless and shared with the v1→v2 dashboard conversion.
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
}
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {

View File

@@ -0,0 +1,353 @@
// nolint
package transition
import (
"context"
"log/slog"
)
// ══════════════════════════════════════════════
// Shape-safe (idempotent) migration
// ══════════════════════════════════════════════
//
// A copy of the Migrate → updateWidget → updateQueryData chain with the
// "uniformly v4 input" assumption removed, so it is safe on a dashboard whose
// `version` tag lies (a "v5"-labelled dashboard with un-upgraded, possibly mixed,
// bodies — the v1→v2 converter's case). Versus the original: no version gate, and
// each step acts only on the pre-v5 shape (leaving a v5 field alone), so it is
// idempotent. The original Migrate is left unchanged (battle-tested, no test net).
// The *ShapeSafe methods below each note the original they copy; the reused steps
// (createFilterExpression, fixGroupBy, buildAggregationExpression, orderByExpr) are
// already v5-safe.
// MigrateQueryDataShapeSafe is the per-query entry point (the core of
// updateQueryDataShapeSafe) for callers that process queries one at a time (the
// v1→v2 converter). widgetType is the v1 panelTypes (metric reduceTo on tables);
// "" is safe.
func (m *dashboardMigrateV5) MigrateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
return m.updateQueryDataShapeSafe(ctx, queryData, widgetType)
}
// updateQueryDataShapeSafe copies updateQueryData, with each destructive step
// guarded to act only on the pre-v5 shape (see the file header).
func (mc *migrateCommon) updateQueryDataShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
updated := false
aggregateOp, _ := queryData["aggregateOperator"].(string)
hasAggregation := aggregateOp != "" && aggregateOp != "noop"
if mc.createAggregationsShapeSafe(ctx, queryData, widgetType) {
updated = true
}
// createFilterExpression only touches v4 `filters`; skip if a v5 `filter` exists.
if _, hasFilter := queryData["filter"]; !hasFilter {
if mc.createFilterExpression(ctx, queryData) {
updated = true
}
}
if mc.fixGroupBy(queryData) {
updated = true
}
if mc.createHavingExpressionShapeSafe(queryData) {
updated = true
}
if hasAggregation {
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
newOrderBy := make([]any, 0)
for _, order := range orderBy {
if orderMap, ok := order.(map[string]any); ok {
columnName, _ := orderMap["columnName"].(string)
// skip timestamp, id (logs, traces), samples(metrics) ordering for aggregation queries
if columnName != "timestamp" && columnName != "samples" && columnName != "id" {
if columnName == "#SIGNOZ_VALUE" {
if expr, has := mc.orderByExpr(queryData); has {
orderMap["columnName"] = expr
}
} else {
// if the order by key is not part of the group by keys, remove it
present := false
groupBy, ok := queryData["groupBy"].([]any)
if !ok {
return false
}
for idx := range groupBy {
item, ok := groupBy[idx].(map[string]any)
if !ok {
continue
}
key, ok := item["key"].(string)
if !ok {
continue
}
if key == columnName {
present = true
}
}
if !present {
mc.logger.WarnContext(ctx, "found a order by without group by, skipping", slog.String("order_col_name", columnName))
continue
}
}
newOrderBy = append(newOrderBy, orderMap)
}
}
}
queryData["orderBy"] = newOrderBy
updated = true
}
} else {
dataSource, _ := queryData["dataSource"].(string)
if orderBy, ok := queryData["orderBy"].([]any); ok && orderByIsPreV5(orderBy) {
newOrderBy := make([]any, 0)
for _, order := range orderBy {
if orderMap, ok := order.(map[string]any); ok {
columnName, _ := orderMap["columnName"].(string)
// skip id and timestamp for (traces)
if (columnName == "id" || columnName == "timestamp") && dataSource == "traces" {
mc.logger.InfoContext(ctx, "skipping `id` order by for traces")
continue
}
// skip id for (logs)
if (columnName == "id" || columnName == "timestamp") && dataSource == "logs" {
mc.logger.InfoContext(ctx, "skipping `id`/`timestamp` order by for logs")
continue
}
newOrderBy = append(newOrderBy, orderMap)
}
}
queryData["orderBy"] = newOrderBy
updated = true
}
}
// Only the `&& functionsArePreV5(functions)` guard differs from updateQueryData.
if functions, ok := queryData["functions"].([]any); ok && functionsArePreV5(functions) {
v5Functions := make([]any, len(functions))
for i, fn := range functions {
if fnMap, ok := fn.(map[string]any); ok {
v5Function := map[string]any{
"name": fnMap["name"],
}
// Convert args from v4 format to v5 FunctionArg format
if args, ok := fnMap["args"].([]any); ok {
v5Args := make([]any, len(args))
for j, arg := range args {
// In v4, args were just values. In v5, they are FunctionArg objects
v5Args[j] = map[string]any{
"name": "", // v4 didn't have named args
"value": arg,
}
}
v5Function["args"] = v5Args
}
// Handle namedArgs if present (some functions might have used this)
if namedArgs, ok := fnMap["namedArgs"].(map[string]any); ok {
// Convert named args to the new format
existingArgs, _ := v5Function["args"].([]any)
if existingArgs == nil {
existingArgs = []any{}
}
for name, value := range namedArgs {
existingArgs = append(existingArgs, map[string]any{
"name": name,
"value": value,
})
}
v5Function["args"] = existingArgs
}
v5Functions[i] = v5Function
}
}
queryData["functions"] = v5Functions
updated = true
}
delete(queryData, "aggregateOperator")
delete(queryData, "aggregateAttribute")
delete(queryData, "temporality")
delete(queryData, "timeAggregation")
delete(queryData, "spaceAggregation")
delete(queryData, "reduceTo")
delete(queryData, "filters")
delete(queryData, "ShiftBy")
delete(queryData, "IsAnomaly")
delete(queryData, "QueriesUsedInFormula")
delete(queryData, "seriesAggregation")
return updated
}
// createHavingExpressionShapeSafe copies createHavingExpression but leaves an
// already-v5 having:{expression} alone instead of wiping it.
func (mc *migrateCommon) createHavingExpressionShapeSafe(queryData map[string]any) bool {
if _, ok := queryData["having"].(map[string]any); ok {
return false // already v5-shaped
}
having, ok := queryData["having"].([]any)
if !ok || len(having) == 0 {
queryData["having"] = map[string]any{"expression": ""}
return true
}
dataSource, _ := queryData["dataSource"].(string)
for idx := range having {
if havingItem, ok := having[idx].(map[string]any); ok {
havingCol, has := mc.orderByExpr(queryData)
if has {
havingItem["columnName"] = havingCol
havingItem["key"] = map[string]any{"key": havingCol}
}
having[idx] = havingItem
}
}
queryData["having"] = map[string]any{"expression": mc.buildExpression(context.Background(), having, "AND", dataSource)}
return true
}
// createAggregationsShapeSafe copies createAggregations but skips a query that
// already has a v5 aggregations[], and picks the metric time/space aggregation
// from the body's shape (has timeAggregation/spaceAggregation?) rather than the
// version tag.
func (mc *migrateCommon) createAggregationsShapeSafe(ctx context.Context, queryData map[string]any, widgetType string) bool {
if aggs, ok := queryData["aggregations"].([]any); ok && len(aggs) > 0 {
return false // already v5-shaped
}
aggregateOp, hasOp := queryData["aggregateOperator"].(string)
aggregateAttr, hasAttr := queryData["aggregateAttribute"].(map[string]any)
dataSource, _ := queryData["dataSource"].(string)
if aggregateOp == "noop" && dataSource != "metrics" {
return false
}
if !hasOp || !hasAttr {
return false
}
var aggregation map[string]any
switch dataSource {
case "metrics":
_, hasTime := queryData["timeAggregation"]
_, hasSpace := queryData["spaceAggregation"]
if hasTime || hasSpace { // acts as a check for v4 shape: the body carries its own time/space aggregation.
if _, ok := queryData["spaceAggregation"]; !ok {
queryData["spaceAggregation"] = aggregateOp
}
aggregation = map[string]any{
"metricName": aggregateAttr["key"],
"temporality": queryData["temporality"],
"timeAggregation": queryData["timeAggregation"],
"spaceAggregation": queryData["spaceAggregation"],
}
if reduceTo, ok := queryData["reduceTo"].(string); ok {
aggregation["reduceTo"] = reduceTo
}
} else {
// v3 shape: derive time/space from the compound operator.
var timeAgg, spaceAgg, reduceTo string
switch aggregateOp {
case "sum_rate", "rate_sum":
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
case "avg_rate", "rate_avg":
timeAgg, spaceAgg, reduceTo = "rate", "avg", "avg"
case "min_rate", "rate_min":
timeAgg, spaceAgg, reduceTo = "rate", "min", "min"
case "max_rate", "rate_max":
timeAgg, spaceAgg, reduceTo = "rate", "max", "max"
case "hist_quantile_50":
timeAgg, spaceAgg, reduceTo = "", "p50", "avg"
case "hist_quantile_75":
timeAgg, spaceAgg, reduceTo = "", "p75", "avg"
case "hist_quantile_90":
timeAgg, spaceAgg, reduceTo = "", "p90", "avg"
case "hist_quantile_95":
timeAgg, spaceAgg, reduceTo = "", "p95", "avg"
case "hist_quantile_99":
timeAgg, spaceAgg, reduceTo = "", "p99", "avg"
case "rate":
timeAgg, spaceAgg, reduceTo = "rate", "sum", "sum"
case "p99", "p90", "p75", "p50", "p25", "p20", "p10", "p05":
mc.logger.InfoContext(ctx, "found invalid config")
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
case "min":
timeAgg, spaceAgg, reduceTo = "min", "min", "min"
case "max":
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
case "avg":
timeAgg, spaceAgg, reduceTo = "avg", "avg", "avg"
case "sum":
timeAgg, spaceAgg, reduceTo = "sum", "sum", "sum"
case "count":
timeAgg, spaceAgg, reduceTo = "count", "sum", "sum"
case "count_distinct":
timeAgg, spaceAgg, reduceTo = "count_distinct", "sum", "sum"
case "noop":
mc.logger.WarnContext(ctx, "noop found in the aggregation data")
timeAgg, spaceAgg, reduceTo = "max", "max", "max"
}
aggregation = map[string]any{
"metricName": aggregateAttr["key"],
"temporality": queryData["temporality"],
"timeAggregation": timeAgg,
"spaceAggregation": spaceAgg,
}
if widgetType == "table" {
aggregation["reduceTo"] = reduceTo
} else if reduceTo, ok := queryData["reduceTo"].(string); ok {
aggregation["reduceTo"] = reduceTo
}
}
case "logs", "traces":
aggregation = map[string]any{"expression": mc.buildAggregationExpression(aggregateOp, aggregateAttr)}
default:
return false
}
queryData["aggregations"] = []any{aggregation}
return true
}
// orderByIsPreV5 reports whether an orderBy slice is still in the v4 shape (an
// entry carries "columnName"); a v5 orderBy uses {key:{name}, direction}.
func orderByIsPreV5(orderBy []any) bool {
for _, o := range orderBy {
if m, ok := o.(map[string]any); ok {
if _, has := m["columnName"]; has {
return true
}
}
}
return false
}
// functionsArePreV5 reports whether a functions slice is still in the v4 shape
// (args are raw values); a v5 function's args are {name,value} objects.
func functionsArePreV5(functions []any) bool {
for _, f := range functions {
if m, ok := f.(map[string]any); ok {
args, ok := m["args"].([]any)
if !ok || len(args) == 0 {
continue
}
_, argIsObject := args[0].(map[string]any)
return !argIsObject
}
}
return false
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -107,6 +106,10 @@ func NewGettableTransaction(results []*TransactionWithAuthorization) []*Gettable
return gettableTransactions
}
func (groups TransactionGroups) Diff(desired TransactionGroups) (additions, deletions TransactionGroups) {
return desired.subtract(groups), groups.subtract(desired)
}
func (groups TransactionGroups) Value() (driver.Value, error) {
data, err := json.Marshal(groups)
if err != nil {
@@ -165,6 +168,51 @@ func (transaction *Transaction) TransactionKey() string {
return transaction.Relation.StringValue() + ":" + transaction.Object.Resource.Type.StringValue() + ":" + transaction.Object.Resource.Kind.String()
}
func (groups TransactionGroups) subtract(other TransactionGroups) TransactionGroups {
otherSelectors := other.selectorSet()
order := make([]string, 0)
grouped := make(map[string]*TransactionGroup)
for _, group := range groups {
for _, selector := range group.ObjectGroup.Selectors {
if _, ok := otherSelectors[group.selectorKey(selector)]; ok {
continue
}
groupKey := group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String()
out, ok := grouped[groupKey]
if !ok {
out = &TransactionGroup{Relation: group.Relation, ObjectGroup: coretypes.ObjectGroup{Resource: group.ObjectGroup.Resource, Selectors: make([]coretypes.Selector, 0)}}
grouped[groupKey] = out
order = append(order, groupKey)
}
out.ObjectGroup.Selectors = append(out.ObjectGroup.Selectors, selector)
}
}
result := make(TransactionGroups, 0, len(order))
for _, key := range order {
result = append(result, grouped[key])
}
return result
}
func (groups TransactionGroups) selectorSet() map[string]struct{} {
set := make(map[string]struct{})
for _, group := range groups {
for _, selector := range group.ObjectGroup.Selectors {
set[group.selectorKey(selector)] = struct{}{}
}
}
return set
}
func (group *TransactionGroup) selectorKey(selector coretypes.Selector) string {
return group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String() + "|" + selector.String()
}
func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup, error) {
verb, err := coretypes.NewVerb(raw.Relation)
if err != nil {
@@ -188,13 +236,6 @@ func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup,
selectors := make([]coretypes.Selector, 0, len(raw.ObjectGroup.Selectors))
for selectorIndex, rawSelector := range raw.ObjectGroup.Selectors {
if resourceType.Equals(coretypes.TypeTelemetryResource) {
rawSelector, err = telemetrytypes.NewTelemetryGrantSelector(rawSelector)
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())
}
}
selector, err := resourceType.Selector(rawSelector)
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())

View File

@@ -3,7 +3,6 @@ package authtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
@@ -69,38 +68,6 @@ func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionG
return tuples, nil
}
func DiffTuples(existing, desired []*openfgav1.TupleKey) (additions, deletions []*openfgav1.TupleKey) {
key := func(tuple *openfgav1.TupleKey) string {
return tuple.GetUser() + "|" + tuple.GetRelation() + "|" + tuple.GetObject()
}
existingSet := make(map[string]struct{}, len(existing))
for _, tuple := range existing {
existingSet[key(tuple)] = struct{}{}
}
desiredSet := make(map[string]struct{}, len(desired))
for _, tuple := range desired {
desiredSet[key(tuple)] = struct{}{}
}
additions = make([]*openfgav1.TupleKey, 0)
for _, tuple := range desired {
if _, ok := existingSet[key(tuple)]; !ok {
additions = append(additions, tuple)
}
}
deletions = make([]*openfgav1.TupleKey, 0)
for _, tuple := range existing {
if _, ok := desiredSet[key(tuple)]; !ok {
deletions = append(deletions, tuple)
}
}
return additions, deletions
}
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) TransactionGroups {
objectsByRelation := make(map[string][]*coretypes.Object)
@@ -142,29 +109,17 @@ func NewTuplesFromTransactionsWithCorrelations(transactions []*Transaction, subj
return nil, nil, err
}
selectorStrings, err := newCheckSelectors(txn.Object.Resource.Type, txn.Object.Selector)
if err != nil {
return nil, nil, err
}
selectors := make([]coretypes.Selector, 0, len(selectorStrings))
for _, selectorString := range selectorStrings {
selector, err := txn.Object.Resource.Type.Selector(selectorString)
if err != nil {
return nil, nil, err
}
selectors = append(selectors, selector)
}
txnID := txn.ID.StringValue()
for index, tuple := range NewTuples(resource, subject, txn.Relation, selectors, orgID) {
if index == 0 {
tuples[txnID] = tuple
continue
}
txnTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{txn.Object.Selector}, orgID)
tuples[txnID] = txnTuples[0]
if txn.Object.Selector.String() != coretypes.WildCardSelectorString {
wildcardSelector := txn.Object.Resource.Type.MustSelector(coretypes.WildCardSelectorString)
wildcardTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{wildcardSelector}, orgID)
correlationID := valuer.GenerateUUID().StringValue()
tuples[correlationID] = tuple
tuples[correlationID] = wildcardTuples[0]
correlations[txnID] = append(correlations[txnID], correlationID)
}
}
@@ -259,21 +214,3 @@ func NewTransactionWithAuthorizationFromBatchResults(
return output
}
func newCheckSelectors(resourceType coretypes.Type, selector coretypes.Selector) ([]string, error) {
if resourceType.Equals(coretypes.TypeTelemetryResource) {
canonical, err := telemetrytypes.NewTelemetryGrantSelector(selector.String())
if err != nil {
return nil, err
}
return telemetrytypes.NewTelemetryGrantSelectors(canonical), nil
}
selectorStrings := []string{selector.String()}
if selector.String() != coretypes.WildCardSelectorString {
selectorStrings = append(selectorStrings, coretypes.WildCardSelectorString)
}
return selectorStrings, nil
}

View File

@@ -23,5 +23,5 @@ var (
TypeRole = Type{valuer.NewString("role"), regexp.MustCompile(`^([a-z-]{1,50}|\*)$`), []Verb{VerbAssignee, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
TypeOrganization = Type{valuer.NewString("organization"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbRead, VerbUpdate}}
TypeMetaResource = Type{valuer.NewString("metaresource"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^.{1,512}$`), []Verb{VerbRead}}
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^(\*|[a-z_]{1,32}(/(\*|[A-Za-z0-9._%-]{1,128})){0,2})$`), []Verb{VerbRead}}
)

View File

@@ -1,9 +1,6 @@
package coretypes
import (
"crypto/sha256"
"encoding/hex"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -29,18 +26,7 @@ func (resourceTelemetryResource *resourceTelemetryResource) Prefix(orgID valuer.
}
func (resourceTelemetryResource *resourceTelemetryResource) Object(orgID valuer.UUID, selector string) string {
if selector == WildCardSelectorString {
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
}
return resourceTelemetryResource.Prefix(orgID) + "/" + telemetrySelectorHash(selector)
}
// Must stay stable: grant-time and check-time tuple objects both hash the selector
// here, so changing this invalidates every stored telemetry grant tuple.
func telemetrySelectorHash(selector string) string {
sum := sha256.Sum256([]byte(selector))
return hex.EncodeToString(sum[:16])
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
}
func (resourceTelemetryResource *resourceTelemetryResource) Scope(verb Verb) string {

View File

@@ -7,7 +7,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -22,6 +21,7 @@ var (
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
ErrCodeDashboardMigrationFailed = errors.MustNewCode("dashboard_migration_failed")
)
type StorableDashboard struct {
@@ -413,27 +413,26 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
widgetData := data.Widgets[widgetIndex]
switch widgetData.Query.QueryType {
case "builder":
migrate := transition.NewMigrateCommon(logger)
for _, query := range widgetData.Query.Builder.QueryData {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
}
for _, query := range widgetData.Query.Builder.QueryFormulas {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
}
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
queryName, ok := query["queryName"].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
}
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
}
case "clickhouse_sql":
for _, query := range widgetData.Query.ClickhouseSQL {

View File

@@ -106,7 +106,7 @@ func (d *DashboardSpec) validatePanels() error {
}
panelKind := panel.Spec.Plugin.Kind
if len(panel.Spec.Queries) != 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(panel.Spec.Queries))
}
allowed := allowedQueryKinds[panelKind]
for qi, q := range panel.Spec.Queries {
@@ -269,8 +269,8 @@ func (d *DashboardSpec) validateLayouts() error {
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
}
if grid.Display != nil {
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxDisplayNameLen {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxDisplayNameLen, n)
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxLayoutTitleLen {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxLayoutTitleLen, n)
}
}
if err := validateGridLayoutGeometry(grid, li); err != nil {

View File

@@ -1634,55 +1634,61 @@ func TestInvalidateDuplicatePanelReference(t *testing.T) {
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
}
// Every display name — dashboard, panel, variable — and the grid layout title is
// bounded at MaxDisplayNameLen. The name is one over the limit in each case, and
// the message reads "<json path>: <field> name must be at most ...", pairing the
// locatable path (like the other spec errors) with a human field label.
// Every display name — dashboard, panel, variable — is bounded at MaxDisplayNameLen,
// while the grid layout title has its own, larger bound (MaxLayoutTitleLen). The name
// is one over the relevant limit in each case, and the message reads "<json path>:
// <field> name must be at most ...", pairing the locatable path (like the other spec
// errors) with a human field label.
func TestInvalidateDisplayNameTooLong(t *testing.T) {
tooLong := strings.Repeat("x", MaxDisplayNameLen+1)
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", MaxDisplayNameLen, MaxDisplayNameLen+1)
testCases := []struct {
scenario string
dashboardJSON string
expectedPath string
expectedLabel string
scenario string
limit int
dashboardJSONFmt string
expectedPath string
expectedLabel string
}{
{
scenario: "dashboard display name",
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
scenario: "dashboard display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"display": {"name": "%s"}, "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": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
scenario: "panel display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
},
{
scenario: "list variable display name",
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
scenario: "list variable display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "text variable display name",
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
scenario: "text variable display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "layout title",
dashboardJSON: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
scenario: "layout title",
limit: MaxLayoutTitleLen,
dashboardJSONFmt: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(testCase.dashboardJSON))
tooLong := strings.Repeat("x", testCase.limit+1)
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", testCase.limit, testCase.limit+1)
_, err := unmarshalDashboard(fmt.Appendf(nil, testCase.dashboardJSONFmt, tooLong))
require.Error(t, err)
// Message is "<path>: <label> name must be at most N characters, got M".
want := testCase.expectedPath + ": " + testCase.expectedLabel + " name " + lengthMsg

View File

@@ -16,10 +16,14 @@ import (
"github.com/swaggest/jsonschema-go"
)
// MaxDisplayNameLen bounds every human-readable display name — dashboard, panel,
// and variable display names, plus the grid layout title.
// MaxDisplayNameLen bounds the human-readable display names — dashboard, panel,
// and variable. The grid layout title has its own, larger bound (MaxLayoutTitleLen).
const MaxDisplayNameLen = 128
// MaxLayoutTitleLen bounds a grid layout title. It is larger than MaxDisplayNameLen
// because v1 section (row) titles ran longer.
const MaxLayoutTitleLen = 256
type Display struct {
Name string `json:"name" required:"true"`
// Description always serializes ("" included) so a create -> GET round-trip

View File

@@ -0,0 +1,95 @@
package dashboardtypes
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
)
// V1 → V2 migration. The v1 storable shape is the frontend's `DashboardData`
// (see frontend/src/types/api/dashboard/getAll.ts); v2 is DashboardV2 /
// DashboardSpec.
//
// Assumes the v1 widget query data has already been migrated to v5 shape
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
// invalid v2 envelopes — run the v4→v5 migration first.
//
// The conversion is split across sibling files by concern:
// - perses_v1_to_v2_tags.go tags
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)
// - perses_v1_to_v2_queries.go widget queries
// - perses_v1_to_v2_layouts.go grid layouts and sections
// - perses_v1_to_v2_variables.go variables
// - perses_v1_to_v2_decoder.go v1Decoder: typed field reads + malformed-field detection
// ══════════════════════════════════════════════
// Entry point
// ══════════════════════════════════════════════
func (storable StorableDashboard) IsV2() bool {
metadata, _ := storable.Data["metadata"].(map[string]any)
if metadata == nil {
return false
}
version, _ := metadata["schemaVersion"].(string)
return version == SchemaVersion
}
func (storable StorableDashboard) ConvertV1ToV2() (result *DashboardV2, err error) {
// Legacy v1 data can be arbitrarily malformed. The accessors degrade
// gracefully, but recover from any unforeseen panic so one bad dashboard
// surfaces as an error (to be logged and skipped) rather than crashing the run.
defer func() {
if r := recover(); r != nil {
result, err = nil, errors.Newf(errors.TypeInternal, ErrCodeDashboardMigrationFailed, "panic converting dashboard %s: %v", storable.ID, r)
}
}()
if storable.IsV2() {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardMigrationFailed, "dashboard %s is already in %s schema", storable.ID, SchemaVersion)
}
d := &v1Decoder{}
title := d.readString(storable.Data, "title")
description := d.readString(storable.Data, "description")
image := d.readString(storable.Data, "image")
sanitizeWidgetIDs(storable.Data)
panels := d.convertV1Panels(retainPlacedWidgets(storable.Data))
spec := DashboardSpec{
Display: Display{Name: clipName(title, MaxDisplayNameLen), Description: description},
Variables: d.convertV1Variables(storable.Data["variables"]),
Panels: panels,
Layouts: d.convertV1Layouts(storable.Data, panels),
}
// marshal and unmarshal cycle to confirm full validation
raw, marshalErr := json.Marshal(spec)
if marshalErr != nil {
return nil, errors.WrapInternalf(marshalErr, errors.CodeInternal, "marshal converted dashboard %s", storable.ID)
}
if err := json.Unmarshal(raw, new(DashboardSpec)); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardMigrationFailed, "converted dashboard %s is invalid", storable.ID)
}
tags := d.convertV1TagsForOrg(storable.OrgID, storable.Data["tags"])
if err := d.errIfHasMalformedFields(); err != nil {
return nil, err
}
return &DashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Source: storable.Source,
DashboardV2MetadataBase: DashboardV2MetadataBase{
SchemaVersion: SchemaVersion,
Image: image,
},
Name: generateDashboardName(title),
Tags: tags,
Spec: spec,
}, nil
}

View File

@@ -0,0 +1,214 @@
package dashboardtypes
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
)
// ══════════════════════════════════════════════
// v1 decoder
// ══════════════════════════════════════════════
// v1Decoder reads fields out of the untyped v1 dashboard blob. Every read*
// method follows the same contract: a field that is absent or null yields the
// zero value; a field present with the wrong type yields zero AND records a
// malformed-field error. Conversion proceeds (so one bad field doesn't abort
// the rest) and ConvertV1ToV2 returns d.malformedFieldsErr() at the end so the
// dashboard is logged and skipped.
//
// Polymorphic v1 fields (spanGaps bool|number, selectedValue string|array, …)
// are read with a type switch on the already-extracted value, never through
// these accessors, so they stay lenient by construction.
type v1Decoder struct {
bad []string
seen map[string]struct{}
}
// note records a decoding problem (malformed field, unknown value, swallowed
// sub-parse error), deduping identical messages. ConvertV1ToV2 surfaces these
// via errIfHasMalformedFields.
func (d *v1Decoder) note(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if _, dup := d.seen[msg]; dup {
return
}
if d.seen == nil {
d.seen = make(map[string]struct{})
}
d.seen[msg] = struct{}{}
d.bad = append(d.bad, msg)
}
// noteMalformedField records a v1 field present with the wrong Go type.
func (d *v1Decoder) noteMalformedField(field string, raw any) {
d.note("%q has unexpected type %T", field, raw)
}
// detailErr renders an error for a diagnostic note, unfolding the structured
// detail our JSON binding attaches via WithAdditional. A plain %v on these
// errors prints only the innermost message ("request body contains invalid
// field value") and drops the field/type context that says which field was
// wrong — the part that actually tells you what to fix.
func detailErr(err error) string {
if err == nil {
return ""
}
j := errors.AsJSON(err)
if len(j.Errors) == 0 {
return err.Error()
}
details := make([]string, 0, len(j.Errors))
for _, e := range j.Errors {
details = append(details, e.Message)
}
return j.Message + ": " + strings.Join(details, "; ")
}
func (d *v1Decoder) errIfHasMalformedFields() error {
if len(d.bad) == 0 {
return nil
}
// One field per line: these lists run long (a bad widget query is reported
// once per widget), and a single "; "-joined line is an unscannable wall.
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidData, "malformed v1 dashboard fields:\n %s", strings.Join(d.bad, "\n "))
}
func readField[T any](d *v1Decoder, m map[string]any, key string) T {
var zero T
v, present := m[key]
if !present || v == nil {
return zero
}
t, ok := v.(T)
if !ok {
d.noteMalformedField(key, v)
return zero
}
return t
}
func (d *v1Decoder) readString(m map[string]any, key string) string {
return readField[string](d, m, key)
}
func (d *v1Decoder) readFloat(m map[string]any, key string) float64 {
v, present := m[key]
if !present || v == nil {
return 0
}
f, ok := coerceFloat(v)
if !ok {
d.noteMalformedField(key, v)
return 0
}
return f
}
// coerceFloat accepts a JSON number or a numeric string (v1 sometimes stores
// numbers like softMin as quoted strings). A blank string is "unset", not a
// number, so it fails to coerce.
func coerceFloat(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
if err != nil {
return 0, false
}
return f, true
}
return 0, false
}
func (d *v1Decoder) readBool(m map[string]any, key string) bool { return readField[bool](d, m, key) }
func (d *v1Decoder) readArray(m map[string]any, key string) []any { return readField[[]any](d, m, key) }
func (d *v1Decoder) readObject(m map[string]any, key string) map[string]any {
return readField[map[string]any](d, m, key)
}
// readInt narrows a numeric field to int (JSON numbers decode as float64).
func (d *v1Decoder) readInt(m map[string]any, key string) int { return int(d.readFloat(m, key)) }
func (d *v1Decoder) readFloatPtr(m map[string]any, key string) *float64 {
v, present := m[key]
if !present || v == nil {
return nil
}
// A blank string means "unset" (v1's empty softMin/softMax), not malformed.
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
return nil
}
f, ok := coerceFloat(v)
if !ok {
d.noteMalformedField(key, v)
return nil
}
return &f
}
// clipName truncates s to at most limit runes so a v1 name over a v2 length bound
// (MaxDisplayNameLen / MaxLayoutTitleLen) is shortened rather than failing migration.
func clipName(s string, limit int) string {
r := []rune(s)
if len(r) <= limit {
return s
}
return string(r[:limit])
}
func (d *v1Decoder) readStringMap(m map[string]any, key string) map[string]string {
// An empty list is a stand-in for an empty map here; tolerate it silently
// rather than flagging the wrong-type as malformed.
if s, ok := m[key].([]any); ok && len(s) == 0 {
return nil
}
raw := d.readObject(m, key)
if len(raw) == 0 {
return nil
}
out := make(map[string]string, len(raw))
for k, v := range raw {
s, ok := v.(string)
if !ok {
d.noteMalformedField(key+"."+k, v)
continue
}
out[k] = s
}
return out
}
func (d *v1Decoder) readObjects(m map[string]any, key string) []map[string]any {
raw := d.readArray(m, key)
if len(raw) == 0 {
return nil
}
out := make([]map[string]any, 0, len(raw))
for i, item := range raw {
obj, ok := item.(map[string]any)
if !ok {
d.noteMalformedField(fmt.Sprintf("%s[%d]", key, i), item)
continue
}
out = append(out, obj)
}
return out
}
// decodeMapInto converts an untyped map[string]any into a typed T by
// round-tripping through JSON, letting encoding/json (struct tags, custom
// UnmarshalJSON) do the field mapping instead of hand-copying out of the map.
func decodeMapInto[T any](src map[string]any) (T, error) {
var dst T
bytes, err := json.Marshal(src)
if err != nil {
return dst, err
}
if err := json.Unmarshal(bytes, &dst); err != nil {
return dst, err
}
return dst, nil
}

View File

@@ -0,0 +1,349 @@
package dashboardtypes
import (
"sort"
"strings"
"github.com/perses/spec/go/common"
"github.com/perses/spec/go/dashboard"
)
// panelRefPrefix is the JSON-ref prefix a grid item uses to point at a panel:
// "#/spec/panels/<id>".
const panelRefPrefix = "#/spec/panels/"
// sanitizePanelID rewrites a widget id to something valid in a panel $ref. Perses
// accepts only [a-zA-Z0-9_-] per ref segment (common.jsonRefMatching), so every
// other rune (em dash, spaces, dots, unicode, …) is mapped to a hyphen.
func sanitizePanelID(id string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
return r
default:
return '-'
}
}, id)
}
// sanitizeWidgetIDs rewrites every widget id in the raw v1 data — widgets[].id,
// layout[].i, panelMap keys and their widgets[].i — through sanitizePanelID, so a
// panel's map key and the layout $ref pointing at it stay identical (an illegal char
// in one but not the other would dangle the ref). Runs before panels/layouts build.
func sanitizeWidgetIDs(data StorableDashboardData) {
sanitizeField := func(raw any, field string) {
items, _ := raw.([]any)
for _, it := range items {
if m, ok := it.(map[string]any); ok {
if s, ok := m[field].(string); ok {
m[field] = sanitizePanelID(s)
}
}
}
}
sanitizeField(data["widgets"], "id")
sanitizeField(data["layout"], "i")
if panelMap, ok := data["panelMap"].(map[string]any); ok {
for key, v := range panelMap {
if s := sanitizePanelID(key); s != key {
panelMap[s] = v
delete(panelMap, key)
}
if m, ok := v.(map[string]any); ok {
sanitizeField(m["widgets"], "i")
}
}
}
}
// ══════════════════════════════════════════════
// Layouts (data.layout + data.panelMap)
// ══════════════════════════════════════════════
// convertV1Layouts groups v1 react-grid-layout entries into v2 grid layouts.
// Membership is positional (as the frontend renders): each row widget owns the
// panels below it until the next row; panels above the first row form an unnamed
// grid with no section header. Collapsed rows are the exception — their children
// live in panelMap[rowID].widgets, not `layout`.
func (d *v1Decoder) convertV1Layouts(data StorableDashboardData, panels map[string]*Panel) []Layout {
layout := d.readObjects(data, "layout")
if len(layout) == 0 {
return nil
}
// react-grid-layout can persist the same widget id more than once. Keep the first
// occurrence in stored order (mirroring getUpdatedLayout — the losing entry's
// geometry is discarded, not merged) and drop the rest. Dedupe before sortByPosition
// so "first" means first-in-stored-order, not topmost. Entries with no id are left
// for the main loop to drop.
seenWidgetIds := make(map[string]bool, len(layout))
dedupedLayouts := layout[:0]
for _, item := range layout {
if id := d.readString(item, "i"); id != "" {
if seenWidgetIds[id] {
continue
}
seenWidgetIds[id] = true
}
dedupedLayouts = append(dedupedLayouts, item)
}
layout = dedupedLayouts
rows := d.extractRowsAndCollapsedWidgets(data)
// ids placed directly in `layout`. A collapsed child also listed here is rendered from
// layout (the open section), so it's dropped from its collapsed section below.
placedInLayout := make(map[string]bool, len(layout))
for _, item := range layout {
if id := d.readString(item, "i"); id != "" {
placedInLayout[id] = true
}
}
d.sortByPosition(layout)
type section struct {
row *rowInfo // nil for the unnamed grid of ungrouped panels
items []map[string]any
}
topSectionWithoutHeader := &section{}
sectionsWithHeader := make([]*section, 0, len(rows))
currentRowHeader := topSectionWithoutHeader
for _, item := range layout {
id := d.readString(item, "i")
if id == "" {
continue
}
if row, ok := rows[id]; ok {
newRowHeader := &section{row: row, items: d.extractValidLayoutItemsForCollapsedSection(row.collapsedWidgets, panels, placedInLayout)}
sectionsWithHeader = append(sectionsWithHeader, newRowHeader)
// A collapsed row owns only its stashed children; later panels → ungrouped.
if row.collapsed {
currentRowHeader = topSectionWithoutHeader
} else {
currentRowHeader = newRowHeader
}
continue
}
// Keep a layout entry only if its widget became a panel; otherwise (skipped
// widget, deleted id, or the "__dropping-elem__" drag placeholder) it would
// reference a panel that does not exist. Rows are handled above.
if _, ok := panels[id]; !ok {
continue
}
currentRowHeader.items = append(currentRowHeader.items, item)
}
out := make([]Layout, 0, len(sectionsWithHeader)+1)
if len(topSectionWithoutHeader.items) > 0 {
out = append(out, d.buildV2GridLayout(nil, topSectionWithoutHeader.items))
}
for _, sec := range sectionsWithHeader {
out = append(out, d.buildV2GridLayout(sec.row, sec.items))
}
return out
}
// retainPlacedWidgets drops widgets the v1 layout never places, returning the
// filtered widgets. v1 doesn't render an unplaced widget, so converting it — and
// noting any problems it has — is pure noise; filter before conversion so only
// rendered widgets reach convertV1Panels. A non-array widgets value is returned
// untouched for convertV1Panels to flag; non-map entries are kept so it still
// flags them as malformed.
func retainPlacedWidgets(data StorableDashboardData) any {
widgets, ok := data["widgets"].([]any)
if !ok {
return data["widgets"]
}
placed := placedWidgetIDs(data)
kept := make([]any, 0, len(widgets))
for _, w := range widgets {
wm, ok := w.(map[string]any)
if !ok {
kept = append(kept, w) // malformed entry — leave it for convertV1Panels to note
continue
}
if id, _ := wm["id"].(string); placed[id] {
kept = append(kept, w)
}
}
return kept
}
// placedWidgetIDs returns the set of widget ids the v1 layout actually renders:
// every id in `layout`, plus the collapsed-row children stashed in panelMap.
// Read leniently (no malformed notes) — convertV1Layouts re-reads these and
// reports any genuine problems.
func placedWidgetIDs(data StorableDashboardData) map[string]bool {
ids := make(map[string]bool)
if layout, ok := data["layout"].([]any); ok {
for _, e := range layout {
if m, ok := e.(map[string]any); ok {
if i, ok := m["i"].(string); ok && i != "" {
ids[i] = true
}
}
}
}
if panelMap, ok := data["panelMap"].(map[string]any); ok {
for _, v := range panelMap {
m, ok := v.(map[string]any)
if !ok {
continue
}
widgets, ok := m["widgets"].([]any)
if !ok {
continue
}
for _, w := range widgets {
if wm, ok := w.(map[string]any); ok {
if i, ok := wm["i"].(string); ok && i != "" {
ids[i] = true
}
}
}
}
}
return ids
}
// extractValidLayoutItemsForCollapsedSection keeps only the collapsed-row children
// backed by a real panel and not already placed in `layout`, dropping ghosts and any
// child the open layout renders instead. These come from panelMap and skip the main
// loop's per-item panel check, so a grid never references a missing or twice-placed panel.
func (d *v1Decoder) extractValidLayoutItemsForCollapsedSection(items []map[string]any, panels map[string]*Panel, placedInLayout map[string]bool) []map[string]any {
out := make([]map[string]any, 0, len(items))
seen := make(map[string]bool, len(items))
for _, item := range items {
id := d.readString(item, "i")
if id == "" {
continue
}
if _, ok := panels[id]; !ok {
continue
}
if placedInLayout[id] || seen[id] {
continue
}
seen[id] = true
out = append(out, item)
}
return out
}
type rowInfo struct {
title string
collapsed bool
collapsedWidgets []map[string]any
}
// extractRowsAndCollapsedWidgets returns the row widgets keyed by id; collapsed
// rows also carry their children stashed under panelMap[id].widgets.
func (d *v1Decoder) extractRowsAndCollapsedWidgets(data StorableDashboardData) map[string]*rowInfo {
panelMap := d.readObject(data, "panelMap")
rows := make(map[string]*rowInfo)
for _, w := range d.readObjects(data, "widgets") {
// Read id directly (not via readString): a non-string id is skipped silently by
// convertV1Panels, so flagging it malformed here would fail the migration for a
// widget that's already been dropped.
id, _ := w["id"].(string)
if d.readString(w, "panelTypes") != "row" || id == "" {
continue
}
row := &rowInfo{title: d.readString(w, "title")}
// Some templates store panelMap[id] as a bare []widgetID instead of the
// canonical {widgets, collapsed}. The frontend treats such a non-object
// entry as "not collapsed" (see GridCardLayout), so read it leniently: a
// non-map yields nil, which reads as not collapsed.
pm, _ := panelMap[id].(map[string]any)
if d.readBool(pm, "collapsed") {
row.collapsed = true
row.collapsedWidgets = d.readObjects(pm, "widgets")
}
rows[id] = row
}
return rows
}
// buildV2GridLayout builds one v2 grid. row is nil for the unnamed grid (no
// display); otherwise the grid takes the row's title and collapse state. Items are
// sorted by (y, x) then vertically compacted (see compactGridItemsVertically).
func (d *v1Decoder) buildV2GridLayout(row *rowInfo, items []map[string]any) Layout {
d.sortByPosition(items)
spec := dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, 0, len(items))}
if row != nil {
spec.Display = &dashboard.GridLayoutDisplay{
Title: clipName(row.title, MaxLayoutTitleLen),
Collapse: &dashboard.GridLayoutCollapse{Open: !row.collapsed},
}
}
for _, item := range items {
spec.Items = append(spec.Items, dashboard.GridItem{
X: d.readInt(item, "x"),
Y: d.readInt(item, "y"),
Width: d.readInt(item, "w"),
Height: d.readInt(item, "h"),
Content: &common.JSONRef{Ref: panelRefPrefix + d.readString(item, "i")},
})
}
compactGridItemsVertically(spec.Items)
return Layout{Kind: dashboard.KindGridLayout, Spec: &spec}
}
// compactGridItemsVertically mirrors react-grid-layout's correctBounds+compact
// (compactType "vertical", allowOverlap false): clamp each item into the grid (x,y>=0;
// x+width<=cols by shifting left), then move sorted-first items up to fill space and
// down past collisions. Fixes overlaps, gaps, and out-of-bounds coords so the migrated
// grid matches the v1 UI and passes v2 validation.
func compactGridItemsVertically(items []dashboard.GridItem) {
collides := func(a, b dashboard.GridItem) bool {
return a.X < b.X+b.Width && b.X < a.X+a.Width && a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
}
firstCollision := func(l dashboard.GridItem, placed []dashboard.GridItem) (dashboard.GridItem, bool) {
for _, p := range placed {
if collides(l, p) {
return p, true
}
}
return dashboard.GridItem{}, false
}
for i := range items {
l := items[i]
if l.X+l.Width > gridColumnCount { // overflows right → shift left to fit
l.X = gridColumnCount - l.Width
}
if l.X < 0 {
l.X = 0
}
if l.Y < 0 {
l.Y = 0
}
for l.Y > 0 { // move up to fill space above
up := l
up.Y--
if _, hit := firstCollision(up, items[:i]); hit {
break
}
l.Y--
}
for { // then down past any collision with an already-placed item
c, hit := firstCollision(l, items[:i])
if !hit {
break
}
l.Y = c.Y + c.Height
}
items[i] = l
}
}
func (d *v1Decoder) sortByPosition(items []map[string]any) {
sort.SliceStable(items, func(i, j int) bool {
if yi, yj := d.readInt(items[i], "y"), d.readInt(items[j], "y"); yi != yj {
return yi < yj
}
return d.readInt(items[i], "x") < d.readInt(items[j], "x")
})
}

View File

@@ -0,0 +1,484 @@
package dashboardtypes
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// Widgets → Panels
// ══════════════════════════════════════════════
// convertV1Panels walks the v1 `widgets` array and produces v2 panels keyed by
// the v1 widget id. WidgetRow entries (panelTypes == "row") are dropped here
// and consumed by convertV1Layouts as section headers.
func (d *v1Decoder) convertV1Panels(raw any) map[string]*Panel {
if raw == nil {
return nil
}
widgetsRaw, ok := raw.([]any)
if !ok {
d.noteMalformedField("widgets", raw)
return nil
}
panels := make(map[string]*Panel, len(widgetsRaw))
for i, widgetRaw := range widgetsRaw {
widget, ok := widgetRaw.(map[string]any)
if !ok {
d.noteMalformedField(fmt.Sprintf("widgets[%d]", i), widgetRaw)
continue
}
// A non-string (or missing) id can't be referenced by any layout entry, and
// v1 doesn't render such widgets either — skip silently, don't flag it as
// malformed. Read directly (not via readString) to avoid a malformed note.
id, ok := widget["id"].(string)
if !ok || id == "" {
continue
}
var panel *Panel
panelType := d.readString(widget, "panelTypes")
switch panelType {
case "graph":
panel = d.convertGraphWidget(widget)
case "time_series", "TIME_SERIES":
// Malformed panelTypes: the canonical v1 value is "graph". Some dashboards
// stored the v2/enum-style name instead; accept it as a time-series graph.
panel = d.convertGraphWidget(widget)
case "bar":
panel = d.convertBarWidget(widget)
case "value":
panel = d.convertValueWidget(widget)
case "pie":
panel = d.convertPieWidget(widget)
case "table":
panel = d.convertTableWidget(widget)
case "histogram":
panel = d.convertHistogramWidget(widget)
case "list":
panel = d.convertListWidget(widget)
case "row":
// "row" (section header) is handled by the layout pass;
continue
default:
// Unknown/unsupported panel type — v1 can't render it either, so skip the
// widget silently rather than failing the whole migration.
continue
}
if panel == nil {
continue
}
if len(panel.Spec.Queries) == 0 {
// No renderable queries — every query was dropped as unrenderable, or none
// were defined. v1 renders nothing, so skip the widget silently.
continue
}
panels[id] = panel
}
return panels
}
func (d *v1Decoder) convertGraphWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindTimeSeries,
Spec: &TimeSeriesPanelSpec{
Visualization: TimeSeriesVisualization{
BasicVisualization: d.basicVisualization(w),
FillSpans: d.readBool(w, "fillSpans"),
},
Formatting: d.panelFormatting(w),
ChartAppearance: TimeSeriesChartAppearance{
LineInterpolation: mapV1Enum(d.readString(w, "lineInterpolation"), LineInterpolationSpline,
LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore),
ShowPoints: d.readBool(w, "showPoints"),
LineStyle: mapV1Enum(d.readString(w, "lineStyle"), LineStyleSolid, LineStyleSolid, LineStyleDashed),
FillMode: mapV1Enum(d.readString(w, "fillMode"), FillModeNone, FillModeSolid, FillModeGradient, FillModeNone),
SpanGaps: mapV1SpanGaps(w["spanGaps"]),
},
Axes: d.axesFromWidget(w),
Legend: d.legendFromWidget(w),
Thresholds: d.mapV1ThresholdsWithLabel(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindTimeSeries),
},
}
}
func (d *v1Decoder) convertBarWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindBarChart,
Spec: &BarChartPanelSpec{
Visualization: BarChartVisualization{
BasicVisualization: d.basicVisualization(w),
FillSpans: d.readBool(w, "fillSpans"),
StackedBarChart: d.readBool(w, "stackedBarChart"),
},
Formatting: d.panelFormatting(w),
Axes: d.axesFromWidget(w),
Legend: d.legendFromWidget(w),
Thresholds: d.mapV1ThresholdsWithLabel(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindBarChart),
},
}
}
func (d *v1Decoder) convertValueWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindNumber,
Spec: &NumberPanelSpec{
Visualization: d.basicVisualization(w),
Formatting: d.panelFormatting(w),
Thresholds: d.mapV1ComparisonThresholds(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindNumber),
},
}
}
func (d *v1Decoder) convertPieWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindPieChart,
Spec: &PieChartPanelSpec{
Visualization: d.basicVisualization(w),
Formatting: d.panelFormatting(w),
Legend: d.legendFromWidget(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindPieChart),
},
}
}
func (d *v1Decoder) convertTableWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindTable,
Spec: &TablePanelSpec{
Visualization: d.basicVisualization(w),
Formatting: TableFormatting{
ColumnUnits: d.readStringMap(w, "columnUnits"),
DecimalPrecision: mapV1Precision(w["decimalPrecision"]),
},
Thresholds: d.mapV1TableThresholds(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindTable),
},
}
}
func (d *v1Decoder) convertHistogramWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindHistogram,
Spec: &HistogramPanelSpec{
HistogramBuckets: HistogramBuckets{
BucketCount: d.readFloatPtr(w, "bucketCount"),
BucketWidth: d.readFloatPtr(w, "bucketWidth"),
MergeAllActiveQueries: d.readBool(w, "mergeAllActiveQueries"),
},
Legend: d.legendFromWidget(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindHistogram),
},
}
}
func (d *v1Decoder) convertListWidget(w map[string]any) *Panel {
return &Panel{
Kind: "Panel",
Spec: PanelSpec{
Display: d.widgetDisplay(w),
Plugin: PanelPlugin{
Kind: PanelKindList,
Spec: &ListPanelSpec{
SelectFields: d.mapV1SelectFields(w),
},
},
Queries: d.convertV1WidgetQuery(w, PanelKindList),
},
}
}
// ══════════════════════════════════════════════
// Panel-spec shared helpers
// ══════════════════════════════════════════════
func (d *v1Decoder) widgetDisplay(w map[string]any) Display {
return Display{Name: clipName(d.readString(w, "title"), MaxDisplayNameLen), Description: d.readString(w, "description")}
}
func (d *v1Decoder) basicVisualization(w map[string]any) BasicVisualization {
return BasicVisualization{TimePreference: mapV1TimePreference(d.readString(w, "timePreferance"))}
}
func (d *v1Decoder) panelFormatting(w map[string]any) PanelFormatting {
return PanelFormatting{Unit: d.readString(w, "yAxisUnit"), DecimalPrecision: mapV1Precision(w["decimalPrecision"])}
}
func (d *v1Decoder) axesFromWidget(w map[string]any) Axes {
return Axes{
SoftMin: d.readFloatPtr(w, "softMin"),
SoftMax: d.readFloatPtr(w, "softMax"),
IsLogScale: d.readBool(w, "isLogScale"),
}
}
func (d *v1Decoder) legendFromWidget(w map[string]any) Legend {
return Legend{
Position: mapV1Enum(d.readString(w, "legendPosition"), LegendPositionBottom, LegendPositionBottom, LegendPositionRight),
CustomColors: d.readStringMap(w, "customLegendColors"),
}
}
func (d *v1Decoder) mapV1SelectFields(w map[string]any) []telemetrytypes.TelemetryFieldKey {
field := "selectedLogFields"
raw := d.readArray(w, field)
if len(raw) == 0 {
field = "selectedTracesFields"
raw = d.readArray(w, field)
}
if len(raw) == 0 {
return nil
}
normalizePreV5FieldKeys(raw)
fields, err := decodeTelemetryFields(raw)
if err != nil {
d.note("widget %q has malformed %s: %v", d.readString(w, "id"), field, err)
return nil
}
// Drop nameless entries (blank column rows) — v2 requires a name, and the v1
// UI renders nothing for them anyway.
out := fields[:0]
for _, f := range fields {
if f.Name != "" {
out = append(out, f)
}
}
return out
}
func decodeTelemetryFields(raw []any) ([]telemetrytypes.TelemetryFieldKey, error) {
bytes, err := json.Marshal(raw)
if err != nil {
return nil, err
}
var fields []telemetrytypes.TelemetryFieldKey
if err := json.Unmarshal(bytes, &fields); err != nil {
return nil, err
}
return fields, nil
}
// ══════════════════════════════════════════════
// Panel field mappers
// ══════════════════════════════════════════════
// v1 stores timePreferance as `GLOBAL_TIME`, `LAST_5_MIN`, … (see
// frontend/src/container/NewWidget/RightContainer/timeItems.ts). v2 uses the
// lowercase form, so the translation is just downcase.
func mapV1TimePreference(s string) TimePreference {
if s == "" {
return TimePreferenceGlobalTime
}
candidate := TimePreference{valuer.NewString(strings.ToLower(s))}
for _, allowed := range candidate.Enum() {
if allowed == candidate {
return candidate
}
}
return TimePreferenceGlobalTime
}
// mapV1Precision is polymorphic (string|number), so it type-switches the raw
// value rather than reading through a typed accessor.
func mapV1Precision(raw any) PrecisionOption {
switch v := raw.(type) {
case string:
candidate := PrecisionOption{valuer.NewString(v)}
for _, allowed := range candidate.Enum() {
if allowed == candidate {
return candidate
}
}
case float64:
n := int(v)
if n >= 0 && n <= 4 {
return PrecisionOption{valuer.NewString(strconv.Itoa(n))}
}
}
return PrecisionOption2
}
// mapV1Enum picks the v1 string value if it matches one of the allowed v2
// values, otherwise returns the fallback. v1 frontend enums (lineInterpolation,
// lineStyle, fillMode, legendPosition) already use the v2 lowercase form.
func mapV1Enum[T interface{ StringValue() string }](s string, fallback T, allowed ...T) T {
if s == "" {
return fallback
}
for _, a := range allowed {
if a.StringValue() == s {
return a
}
}
return fallback
}
// v1 spanGaps is `boolean | number`. true → span every gap; false → never span;
// a number is interpreted (per frontend SeriesProps.spanGaps docs) as an
// X-axis threshold in seconds. Polymorphic, so it type-switches the raw value.
func mapV1SpanGaps(raw any) SpanGaps {
switch v := raw.(type) {
case bool:
return SpanGaps{FillOnlyBelow: false}
case float64:
return SpanGaps{FillOnlyBelow: true, FillLessThan: time.Duration(v * float64(time.Second)).String()}
}
return SpanGaps{FillOnlyBelow: false}
}
func (d *v1Decoder) mapV1ThresholdsWithLabel(w map[string]any) []ThresholdWithLabel {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]ThresholdWithLabel, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
label := d.readString(t, "thresholdLabel")
if color == "" || label == "" {
// v2 ThresholdWithLabel requires both; drop entries that wouldn't validate.
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, ThresholdWithLabel{Value: value, Unit: d.readString(t, "thresholdUnit"), Color: color, Label: label})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1ComparisonThresholds(w map[string]any) []ComparisonThreshold {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]ComparisonThreshold, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
if color == "" {
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, ComparisonThreshold{
Value: value,
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
Unit: d.readString(t, "thresholdUnit"),
Color: color,
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1TableThresholds(w map[string]any) []TableThreshold {
rawSlice := d.readObjects(w, "thresholds")
if len(rawSlice) == 0 {
return nil
}
out := make([]TableThreshold, 0, len(rawSlice))
for _, t := range rawSlice {
color := d.readString(t, "thresholdColor")
columnName := d.readString(t, "thresholdTableOptions")
if color == "" || columnName == "" {
continue
}
value := d.readFloat(t, "thresholdValue")
out = append(out, TableThreshold{
ComparisonThreshold: ComparisonThreshold{
Value: value,
Operator: d.mapV1ComparisonOperator(d.readString(t, "thresholdOperator")),
Unit: d.readString(t, "thresholdUnit"),
Color: color,
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
},
ColumnName: columnName,
})
}
if len(out) == 0 {
return nil
}
return out
}
func (d *v1Decoder) mapV1ComparisonOperator(s string) ComparisonOperator {
switch s {
case ">", "gt":
return ComparisonOperatorAbove
case ">=", "gte":
return ComparisonOperatorAboveOrEqual
case "<", "lt":
return ComparisonOperatorBelow
case "<=", "lte":
return ComparisonOperatorBelowOrEqual
case "=", "==", "eq":
return ComparisonOperatorEqual
case "!=", "neq":
return ComparisonOperatorNotEqual
default:
// v1 often leaves the operator empty or carries an unknown value; default to
// "above" without flagging.
return ComparisonOperatorAbove
}
}
// mapV1ThresholdFormat reads the raw value (not via readString) so a non-string
// thresholdFormat — some v1 dashboards store it as a number — defaults to text
// silently instead of being flagged malformed.
func mapV1ThresholdFormat(raw any) ThresholdFormat {
s, _ := raw.(string)
switch strings.ToLower(s) {
case "background":
return ThresholdFormatBackground
case "text":
return ThresholdFormatText
}
return ThresholdFormatText
}

View File

@@ -0,0 +1,443 @@
package dashboardtypes
import (
"encoding/json"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// ══════════════════════════════════════════════
// Queries
// ══════════════════════════════════════════════
// convertV1WidgetQuery returns exactly one Query (per Spec.Validate). The kind
// chosen depends on the v1 widget query shape:
// - a single query (promql / clickhouse_sql / builder) → its native kind
// - multiple queries → signoz/CompositeQuery
//
// A single query is never wrapped in a CompositeQuery; in particular List
// panels accept only a bare signoz/BuilderQuery. Builder queries are routed
// through qb.WrapInV5Envelope (in collectV1QueryEnvelopes), which translates v4
// builder-field names (orderBy/selectColumns/dataSource) into their v5
// equivalents and adds the `signal` field required by BuilderQuerySpec's
// per-signal dispatch.
func (d *v1Decoder) convertV1WidgetQuery(widget map[string]any, panelKind PanelPluginKind) []Query {
envelopes, signal := d.collectV1QueryEnvelopes(widget, panelKind)
if len(envelopes) == 0 {
return nil
}
// List panels accept only a bare BuilderQuery — never a CompositeQuery. Keep the
// first query and drop the rest so a multi-query v1 list widget still migrates.
if panelKind == PanelKindList && len(envelopes) > 1 {
envelopes = envelopes[:1]
}
requestType := requestTypeForPanel(panelKind)
// A single query keeps its native kind — never wrapped in a CompositeQuery.
if len(envelopes) == 1 {
if q := singleQueryFromEnvelope(envelopes[0], requestType, signal); q != nil {
return []Query{*q}
}
}
// Default: wrap in CompositeQuery.
composite, err := parseCompositeFromEnvelopes(envelopes)
if err != nil || composite == nil {
d.note("widget %q: could not build query from %d envelope(s): %s", d.readString(widget, "id"), len(envelopes), detailErr(err))
return nil
}
return []Query{{
Kind: requestType,
Spec: QuerySpec{
Plugin: QueryPlugin{Kind: QueryKindComposite, Spec: composite},
},
}}
}
// dropUnrenderableQueries removes queries whose aggregation can't render (see
// queryIsUnrenderable). If every query is unrenderable the result is empty, so the
// widget produces no panel and is skipped silently (convertV1Panels) — matching v1,
// which renders nothing.
func dropUnrenderableQueries(queries []map[string]any) []map[string]any {
renderable := make([]map[string]any, 0, len(queries))
for _, q := range queries {
if !queryIsUnrenderable(q) {
renderable = append(renderable, q)
}
}
return renderable
}
// queryIsUnrenderable reports whether a builder query can't render because of its
// aggregations: a metrics query with none or an empty metric name, or a logs/traces
// query with an empty aggregation expression. No aggregations is valid for a raw
// logs/traces query, so that isn't flagged.
func queryIsUnrenderable(q map[string]any) bool {
aggs, _ := q["aggregations"].([]any)
switch signalFromDataSource(q["dataSource"]) {
case telemetrytypes.SignalMetrics:
if len(aggs) == 0 {
return true
}
for _, a := range aggs {
if agg, ok := a.(map[string]any); ok {
if mn, _ := agg["metricName"].(string); mn == "" {
return true
}
}
}
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
for _, a := range aggs {
if agg, ok := a.(map[string]any); ok {
if expr, _ := agg["expression"].(string); expr == "" {
return true
}
}
}
}
return false
}
// requestTypeForPanel maps a v2 panel plugin kind to the request type (result
// shape) its queries produce. Mirrors the frontend's panelTypeToRequestType
// (buildQueryRangeRequest.ts): time series for line/bar/histogram (histogram
// bins client-side from raw time series, V1 parity), scalar for
// number/pie/table, raw rows for list.
func requestTypeForPanel(panelKind PanelPluginKind) qb.RequestType {
switch panelKind {
case PanelKindTimeSeries, PanelKindBarChart, PanelKindHistogram:
return qb.RequestTypeTimeSeries
case PanelKindNumber, PanelKindPieChart, PanelKindTable:
return qb.RequestTypeScalar
case PanelKindList:
return qb.RequestTypeRaw
}
return qb.RequestTypeTimeSeries
}
// collectV1QueryEnvelopes inspects widget.query.queryType and produces a
// flattened list of v5-shaped envelopes. The returned signal is the dominant
// builder signal (if any), used for typed builder-query dispatch.
func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind PanelPluginKind) ([]map[string]any, telemetrytypes.Signal) {
queryMap := d.readObject(widget, "query")
if queryMap == nil {
d.note("widget %q has no query map", d.readString(widget, "id"))
return nil, telemetrytypes.Signal{}
}
rowLimitPanel := panelKind == PanelKindList || panelKind == PanelKindTable
// Raw (list) panels legitimately have no aggregation; every other panel needs one.
needsAggregation := requestTypeForPanel(panelKind) != qb.RequestTypeRaw
queryType := d.readString(queryMap, "queryType")
switch queryType {
case "promql":
promQueries := d.readObjects(queryMap, "promql")
var out []map[string]any
for _, q := range promQueries {
// Drop empty queries; if none remain the widget produces no queries and is
// skipped silently (convertV1Panels), as v1 renders nothing.
if d.readString(q, "query") == "" {
continue
}
out = append(out, promQLEnvelope(q))
}
return out, telemetrytypes.Signal{}
case "clickhouse_sql":
chQueries := d.readObjects(queryMap, "clickhouse_sql")
var out []map[string]any
for _, q := range chQueries {
// Drop empty queries; if none remain the widget produces no queries and is
// skipped silently (convertV1Panels), as v1 renders nothing.
if d.readString(q, "query") == "" {
continue
}
out = append(out, clickhouseEnvelope(q))
}
return out, telemetrytypes.Signal{}
// "builder" plus a blank queryType: v1 defaults an unset type to builder, so a
// missing value still carries a real builder query — try the builder path.
case "builder", "":
builder := d.readObject(queryMap, "builder")
if builder == nil {
d.note("widget %q has no builder data in the query map", d.readString(widget, "id"))
return nil, telemetrytypes.Signal{}
}
var out []map[string]any
var signal telemetrytypes.Signal
widgetType := d.readString(widget, "panelTypes")
queries := d.readObjects(builder, "queryData")
assignQueryDataNames(queries)
for _, q := range queries {
normalizePreV5QueryData(q, widgetType)
normalizePreV5SelectColumns(q)
normalizePreV5GroupBy(q)
normalizePreV5PageSize(q, rowLimitPanel)
normalizeQueryLimit(q)
if needsAggregation {
ensureDefaultAggregation(q)
}
}
queries = dropUnrenderableQueries(queries)
for _, q := range queries {
name := d.readString(q, "queryName")
out = append(out, qb.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue())))
if signal.IsZero() {
signal = signalFromDataSource(q["dataSource"])
}
}
formulas := d.readObjects(builder, "queryFormulas")
assignMissingFormulaNames(formulas)
for _, f := range formulas {
normalizePreV5QueryData(f, widgetType)
name := d.readString(f, "queryName")
env := qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue()))
// Drop a formula whose expression the validator rejects (blank/unparseable);
// v1 tolerated it but v2 fails the whole query. Reuse the real validator
// rather than reimplement it, as we do for functions.
if !formulaEnvelopeIsValid(env) {
continue
}
out = append(out, env)
}
for _, op := range d.readObjects(builder, "queryTraceOperator") {
// A trace operator's expression is the operation itself ("A=>B->C") and is
// required (ParseExpression rejects a blank one); drop it if empty.
expression := d.readString(op, "expression")
if expression == "" {
continue
}
normalizePreV5QueryData(op, widgetType)
normalizePreV5GroupBy(op)
name := d.readString(op, "queryName")
out = append(out, traceOperatorEnvelope(name, expression, op))
}
return out, signal
default:
d.note("widget %q has unknown queryType %q", d.readString(widget, "id"), queryType)
}
return nil, telemetrytypes.Signal{}
}
// traceOperatorEnvelope builds a v5 builder_trace_operator envelope. WrapInV5Envelope
// would misclassify a trace operator as a formula (its name differs from its
// expression, e.g. "A=>B->C"), so route the map through the builder-query path —
// temporarily aligning expression with name to dodge that heuristic — then restore the
// real expression, drop the builder-only signal (a trace operator's spec has no signal
// field), and set the trace-operator type.
func traceOperatorEnvelope(name, expression string, op map[string]any) map[string]any {
op["expression"] = name
env := qb.WrapInV5Envelope(name, op, string(qb.QueryTypeBuilder.StringValue()))
if spec, ok := env["spec"].(map[string]any); ok {
delete(spec, "signal")
spec["expression"] = expression
}
env["type"] = string(qb.QueryTypeTraceOperator.StringValue())
return env
}
// maxQueries mirrors the frontend MAX_QUERIES; builder query names run A..Z.
const maxQueries = 26
// assignQueryDataNames names builder data queries the way the frontend does: each
// unnamed query takes the first unused A..Z, deduped against existing names. It also
// forces expression == queryName, since a data query's expression is always its own
// name and WrapInV5Envelope's name != expression heuristic would otherwise
// misclassify the query as a formula.
func assignQueryDataNames(queries []map[string]any) {
taken := make(map[string]bool, len(queries))
for _, q := range queries {
if name, _ := q["queryName"].(string); name != "" {
taken[name] = true
}
}
for _, q := range queries {
name, _ := q["queryName"].(string)
if name == "" {
for i := 0; i < maxQueries; i++ {
candidate := string(rune('A' + i))
if !taken[candidate] {
name = candidate
taken[candidate] = true
break
}
}
q["queryName"] = name
}
q["expression"] = name
}
}
// formulaEnvelopeIsValid reports whether a builder_formula envelope's spec passes
// QueryBuilderFormula.Validate (blank/unparseable expression, blank name, invalid
// functions). Reuses the real validator rather than reimplementing it.
func formulaEnvelopeIsValid(env map[string]any) bool {
spec, ok := env["spec"].(map[string]any)
if !ok {
return false
}
raw, err := json.Marshal(spec)
if err != nil {
return false
}
var f qb.QueryBuilderFormula
if err := json.Unmarshal(raw, &f); err != nil {
return false
}
return f.Validate() == nil
}
// maxFormulas mirrors the frontend MAX_FORMULAS; formula names run F1..F20.
const maxFormulas = 20
// assignMissingFormulaNames fills queryName for unnamed formulas, mirroring the
// frontend: pick the first F{n} (n in 1..20) not already used by another formula.
// Formulas that already have a name keep it.
func assignMissingFormulaNames(formulas []map[string]any) {
taken := make(map[string]bool, len(formulas))
for _, f := range formulas {
if name, _ := f["queryName"].(string); name != "" {
taken[name] = true
}
}
for _, f := range formulas {
if name, _ := f["queryName"].(string); name != "" {
continue
}
for i := 1; i <= maxFormulas; i++ {
candidate := "F" + strconv.Itoa(i)
if !taken[candidate] {
f["queryName"] = candidate
taken[candidate] = true
break
}
}
}
}
func promQLEnvelope(q map[string]any) map[string]any {
return map[string]any{
"type": qb.QueryTypePromQL.StringValue(),
"spec": map[string]any{
"name": q["name"],
"query": q["query"],
"disabled": q["disabled"],
"legend": q["legend"],
},
}
}
func clickhouseEnvelope(q map[string]any) map[string]any {
return map[string]any{
"type": qb.QueryTypeClickHouseSQL.StringValue(),
"spec": map[string]any{
"name": q["name"],
"query": q["query"],
"disabled": q["disabled"],
"legend": q["legend"],
},
}
}
// singleQueryFromEnvelope returns a typed Query for one envelope, using its
// native query kind (promql/clickhouse_sql/builder) rather than wrapping it in
// a CompositeQuery. A bare signoz/BuilderQuery is valid for every panel kind
// and is the only kind List panels accept.
func singleQueryFromEnvelope(envelope map[string]any, requestType qb.RequestType, signal telemetrytypes.Signal) *Query {
t, _ := envelope["type"].(string)
spec, _ := envelope["spec"].(map[string]any)
switch t {
case qb.QueryTypePromQL.StringValue():
prom, err := decodeMapInto[qb.PromQuery](spec)
if err != nil {
return nil
}
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: prom.Name,
Plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &prom},
},
}
case qb.QueryTypeClickHouseSQL.StringValue():
ch, err := decodeMapInto[qb.ClickHouseQuery](spec)
if err != nil {
return nil
}
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: ch.Name,
Plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &ch},
},
}
case qb.QueryTypeBuilder.StringValue():
builderSpec := parseBuilderQuerySpec(spec, signal)
if builderSpec == nil {
return nil
}
name, _ := spec["name"].(string)
return &Query{
Kind: requestType,
Spec: QuerySpec{
Name: name,
Plugin: QueryPlugin{Kind: QueryKindBuilder, Spec: &BuilderQuerySpec{Spec: builderSpec}},
},
}
}
return nil
}
func parseCompositeFromEnvelopes(envelopes []map[string]any) (*CompositeQuerySpec, error) {
bytes, err := json.Marshal(envelopes)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v1 query envelopes")
}
var parsed []qb.QueryEnvelope
if err := json.Unmarshal(bytes, &parsed); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidWidgetQuery, "decode v5 query envelopes")
}
return &CompositeQuerySpec{Queries: parsed}, nil
}
func parseBuilderQuerySpec(rawSpec any, signal telemetrytypes.Signal) any {
spec, ok := rawSpec.(map[string]any)
if !ok {
return nil
}
if !signal.IsZero() {
spec["signal"] = signal.StringValue()
}
bytes, err := json.Marshal(spec)
if err != nil {
return nil
}
parsed, err := qb.UnmarshalBuilderQueryBySignal(bytes)
if err != nil {
return nil
}
return parsed
}
// signalFromDataSource maps a v1 data-source string to a v5 signal. Casing
// varies by source: builder queries store lowercase ("traces"), while variable
// `dynamicVariablesSource` stores capitalized ("Traces"), so match
// case-insensitively. Unknown values (e.g. "All telemetry") map to the zero
// Signal.
func signalFromDataSource(raw any) telemetrytypes.Signal {
s, _ := raw.(string)
switch strings.ToLower(s) {
case "traces":
return telemetrytypes.SignalTraces
case "logs":
return telemetrytypes.SignalLogs
case "metrics":
return telemetrytypes.SignalMetrics
}
return telemetrytypes.Signal{}
}

View File

@@ -0,0 +1,431 @@
package dashboardtypes
import (
"context"
"encoding/json"
"log/slog"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// Malformed-field normalization
// ══════════════════════════════════════════════
//
// Pre-v5 query-body reshapes for dashboards whose bodies aren't actually v5-shaped
// (e.g. stamped version:"v5" but never upgraded). The bulk of the upgrade is
// delegated to transition.MigrateQueryDataShapeSafe (see normalizePreV5QueryData);
// this file keeps only the reshapes it doesn't cover.
// preV5Migrator runs transition's shape-safe (idempotent) v4→v5 upgrade. Stateless
// after construction, so a shared instance with a discard logger / no ambiguity
// keys is fine.
var preV5Migrator = transition.NewDashboardMigrateV5(slog.New(slog.DiscardHandler), nil, nil)
// normalizePreV5QueryData upgrades one builder queryData/formula in place: the
// shared migrator, then a reshape of any existing aggregations[] it leaves alone.
func normalizePreV5QueryData(query map[string]any, widgetType string) {
dropLegacyFilter(query)
preV5Migrator.MigrateQueryDataShapeSafe(context.Background(), query, widgetType)
normalizePreV5LogTraceAggregations(query)
normalizeMetricAggregations(query)
normalizeOrderByKeys(query)
normalizeFunctionArgs(query)
dropInvalidFunctions(query)
}
// dropInvalidFunctions removes any function the v5 validator would reject — an unknown
// name, or a missing/uncastable required arg (see Function.Validate). v1 tolerated these
// but v2 fails the whole query, so we drop just the offending function. Runs after
// normalizeFunctionArgs so a merely double-wrapped (but otherwise valid) function isn't
// lost.
func dropInvalidFunctions(query map[string]any) {
fns, ok := query["functions"].([]any)
if !ok {
return
}
kept := make([]any, 0, len(fns))
for _, f := range fns {
raw, err := json.Marshal(f)
if err != nil {
continue
}
var fn qb.Function
if err := json.Unmarshal(raw, &fn); err != nil {
continue
}
if fn.Validate() != nil {
continue
}
kept = append(kept, f)
}
query["functions"] = kept
}
// normalizeFunctionArgs collapses a doubly-wrapped function arg to a scalar. The
// v4→v5 migration that runs before ConvertV1ToV2 (transition.updateQueryData) wraps every arg as
// {name, value} without checking whether it's already a v5 arg, so a body that was
// already v5 comes back as {value:{value:60}} and fails validation ("must be a floating
// value"). We can't guard it at the source — transition's Migrate is shared and left
// untouched — so unwrap one level of {value:...} nesting here.
func normalizeFunctionArgs(query map[string]any) {
fns, ok := query["functions"].([]any)
if !ok {
return
}
for _, f := range fns {
fn, ok := f.(map[string]any)
if !ok {
continue
}
args, ok := fn["args"].([]any)
if !ok {
continue
}
for _, a := range args {
arg, ok := a.(map[string]any)
if !ok {
continue
}
if inner, ok := arg["value"].(map[string]any); ok {
if v, ok := inner["value"]; ok {
arg["value"] = v
}
}
}
}
}
// malformedOrderByValueKeys are v4 order-by columnNames meaning "order by the aggregation value"
// that the v5 aggregation validator rejects (validateOrderByForAggregation). All resolve
// to the same aggregation key. Add more as they surface. The frontend passes these
// through (the query-service resolves them), but the v2 dashboard validator only accepts
// a real aggregation key.
var malformedOrderByValueKeys = map[string]bool{
"#SIGNOZ_VALUE": true,
"A": true,
"A.count()": true,
"__result": true,
"value": true,
"A.p99(duration_nano)": true,
"aws_Kafka_MessagesInPerSec_max": true,
"byte_in_count": true,
"(http_server_request_duration_ms.bucket)": true,
}
// normalizeOrderByKeys rewrites any orderBy columnName in orderByValueKeys to the
// v5-valid aggregation key. Left untouched if the key can't resolve (no aggregation to
// name).
func normalizeOrderByKeys(query map[string]any) {
orders, ok := query["orderBy"].([]any)
if !ok {
return
}
key, ok := aggregationOrderKey(query)
if !ok {
return
}
for _, o := range orders {
order, ok := o.(map[string]any)
if !ok {
continue
}
if cn, _ := order["columnName"].(string); malformedOrderByValueKeys[cn] {
order["columnName"] = key
}
}
}
// aggregationOrderKey names the first aggregation the way validateOrderByForAggregation
// expects: "space(metricName)" for metrics, the expression for logs/traces.
func aggregationOrderKey(query map[string]any) (string, bool) {
aggs, ok := query["aggregations"].([]any)
if !ok || len(aggs) == 0 {
return "", false
}
agg, ok := aggs[0].(map[string]any)
if !ok {
return "", false
}
if signalFromDataSource(query["dataSource"]) == telemetrytypes.SignalMetrics {
metricName, _ := agg["metricName"].(string)
space, _ := agg["spaceAggregation"].(string)
if metricName == "" || space == "" {
return "", false
}
return space + "(" + metricName + ")", true
}
expr, _ := agg["expression"].(string)
if expr == "" {
return "", false
}
return expr, true
}
// dropLegacyFilter removes a v4-shaped filter ({items, op}) stored under the v5
// `filter` key. The v5 filter is {expression}; the migrator only rewrites the v4
// `filters` key and skips when `filter` is present, so this stale shape would reach
// WrapInV5Envelope and fail v5 validation. The v1 UI ignores it — it types
// IBuilderQuery.filter as {expression} (frontend queryBuilderData.ts, filter?: Filter)
// and only ever reads filter.expression, so items/op go unread. We drop it before the
// migrator, which can then rebuild `filter` from `filters` if present.
func dropLegacyFilter(query map[string]any) {
filter, ok := query["filter"].(map[string]any)
if !ok {
return
}
_, hasItems := filter["items"]
_, hasOp := filter["op"]
if hasItems || hasOp {
delete(query, "filter")
}
}
// metricAggregationFields are the JSON keys a metric aggregation accepts (see
// MetricAggregation). The decoder is strict, so any other key (e.g. a logs/traces
// style `expression`) is rejected as an unknown field.
var metricAggregationFields = map[string]bool{
"metricName": true,
"temporality": true,
"timeAggregation": true,
"spaceAggregation": true,
"comparisonSpaceAggregationParam": true,
"reduceTo": true,
}
// normalizeMetricAggregations reshapes a metric query's aggregations to the shape v5
// expects. v1 bodies sometimes carry a logs/traces-style aggregation ({expression});
// the frontend ignores expression for metrics and builds from the metric fields
// (createAggregation, prepareQueryRangePayloadV5.ts), so we drop every non-metric
// key. A dropped expression leaves metricName empty and the widget is skipped later
// (isUnrenderableMetricQuery), matching what v1 renders.
//
// It also defaults an invalid spaceAggregation to "sum": v1 bodies often leave it
// empty or carry a stale value, which fails validation (SpaceAggregation.IsValid). A
// valid value (including a histogram percentile) is left alone; the metric type isn't
// in the body, so we can't prefer a percentile default for histograms.
func normalizeMetricAggregations(query map[string]any) {
if signalFromDataSource(query["dataSource"]) != telemetrytypes.SignalMetrics {
return
}
aggs, ok := query["aggregations"].([]any)
if !ok {
return
}
for _, a := range aggs {
agg, ok := a.(map[string]any)
if !ok {
continue
}
for k := range agg {
if !metricAggregationFields[k] {
delete(agg, k)
}
}
sa, _ := agg["spaceAggregation"].(string)
if !(metrictypes.SpaceAggregation{String: valuer.NewString(sa)}).IsValid() {
agg["spaceAggregation"] = metrictypes.SpaceAggregationSum.StringValue()
}
}
}
// normalizePreV5LogTraceAggregations reshapes an existing logs/traces aggregations[]
// via parseAggregations (extract func(args), lift inline "as alias", split
// multi-part, drop metric-only fields; empty → count()). Covers the case the
// migrator skips: it builds from flat fields but leaves a present-but-malformed
// aggregations[] alone. A query with none is left as-is.
func normalizePreV5LogTraceAggregations(query map[string]any) {
switch signalFromDataSource(query["dataSource"]) {
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
default:
return
}
aggs, ok := query["aggregations"].([]any)
if !ok || len(aggs) == 0 {
return
}
out := make([]any, 0, len(aggs))
for _, a := range aggs {
agg, ok := a.(map[string]any)
if !ok {
continue
}
expr, _ := agg["expression"].(string)
alias, _ := agg["alias"].(string)
parsed := parseAggregations(expr, alias)
if len(parsed) == 0 {
parsed = []any{map[string]any{"expression": "count()"}}
}
out = append(out, parsed...)
}
query["aggregations"] = out
}
// ensureDefaultAggregation defaults an empty logs/traces aggregations[] to count(),
// mirroring the frontend. Callers gate this to aggregation panels. Metrics are skipped:
// count() can't stand in for a missing metricName.
func ensureDefaultAggregation(query map[string]any) {
switch signalFromDataSource(query["dataSource"]) {
case telemetrytypes.SignalLogs, telemetrytypes.SignalTraces:
default:
return
}
if aggs, ok := query["aggregations"].([]any); ok && len(aggs) > 0 {
return
}
query["aggregations"] = []any{map[string]any{"expression": "count()"}}
}
// aggExprRe matches one "func(args)" with an optional "as alias". Mirrors the
// frontend's parseAggregations regex; matching only well-formed func(args)
// discards trailing junk ("sum(x) ) )" → "sum(x)").
var aggExprRe = regexp.MustCompile(`([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+('[^']*'|"[^"]*"|[a-zA-Z0-9_-]+))?`)
// aggExprNestedRe is a backup for aggExprRe that tolerates one level of nested
// parens in args (rate(count())). HACK: the flat aggExprRe (and the frontend it
// mirrors) truncates such exprs to an unbalanced "rate(count()"; the UI fails
// these today, so this is best-effort beyond v1. Tried only when the flat match
// comes back unbalanced.
var aggExprNestedRe = regexp.MustCompile(`([a-zA-Z0-9_]+\((?:[a-zA-Z0-9_]+\([^()]*\)|[^()])*\))(?:\s*as\s+('[^']*'|"[^"]*"|[a-zA-Z0-9_-]+))?`)
// parseAggregations pulls every func(args) (with inline or passed-through alias,
// quotes stripped) out of a v1 expression. Mirrors the frontend's
// parseAggregations; empty result if none.
func parseAggregations(expression, availableAlias string) []any {
matches := aggExprRe.FindAllStringSubmatch(expression, -1)
if hasUnbalancedParens(matches) {
matches = aggExprNestedRe.FindAllStringSubmatch(expression, -1)
}
out := make([]any, 0, len(matches))
for _, m := range matches {
alias := m[2]
if alias == "" {
alias = availableAlias
}
agg := map[string]any{"expression": m[1]}
if alias != "" {
agg["alias"] = strings.Trim(alias, `'"`)
}
out = append(out, agg)
}
return out
}
// hasUnbalancedParens reports whether any matched expression has mismatched
// parens — the signature of aggExprRe truncating a nested expr ("rate(count()").
func hasUnbalancedParens(matches [][]string) bool {
for _, m := range matches {
if strings.Count(m[1], "(") != strings.Count(m[1], ")") {
return true
}
}
return false
}
// normalizePreV5SelectColumns / normalizePreV5GroupBy let WrapInV5Envelope (which
// reads the old {key,dataType,type}) handle selectColumns/groupBy stored the v5 way
// ({name,…}) — see backfillPreV5FieldKeys. Inverse of normalizePreV5FieldKeys (the
// two consumers want opposite shapes).
func normalizePreV5SelectColumns(query map[string]any) {
if cols, ok := query["selectColumns"].([]any); ok {
query["selectColumns"] = backfillPreV5FieldKeys(cols)
}
}
func normalizePreV5GroupBy(query map[string]any) {
if gb, ok := query["groupBy"].([]any); ok {
query["groupBy"] = backfillPreV5FieldKeys(gb)
}
}
// backfillPreV5FieldKeys copies v5 field names (name/fieldDataType/fieldContext)
// down to their v4 equivalents (key/dataType/type) so WrapInV5Envelope, which reads
// the v4 names, sees a field stored the v5 way. Fields with no resolvable key are
// dropped.
func backfillPreV5FieldKeys(fields []any) []any {
out := make([]any, 0, len(fields))
for _, f := range fields {
field, ok := f.(map[string]any)
if !ok {
continue
}
if _, ok := field["key"]; !ok {
if name, ok := field["name"]; ok {
field["key"] = name
}
}
if _, ok := field["dataType"]; !ok {
if fdt, ok := field["fieldDataType"]; ok {
field["dataType"] = fdt
}
}
if _, ok := field["type"]; !ok {
if fc, ok := field["fieldContext"]; ok {
field["type"] = fc
}
}
if key, _ := field["key"].(string); key == "" {
continue
}
out = append(out, field)
}
return out
}
// normalizePreV5FieldKeys renames list-panel field keys {key,dataType,type} →
// {name,fieldDataType,fieldContext} in place (as WrapInV5Envelope does for
// groupBy/orderBy). Entries already carrying "name" are left as-is.
func normalizePreV5FieldKeys(fields []any) {
for _, f := range fields {
field, ok := f.(map[string]any)
if !ok {
continue
}
if _, hasName := field["name"]; hasName {
continue
}
if key, ok := field["key"]; ok {
field["name"] = key
}
if dataType, ok := field["dataType"]; ok {
field["fieldDataType"] = dataType
}
if typ, ok := field["type"]; ok {
field["fieldContext"] = typ
}
}
}
// normalizePreV5PageSize backfills limit from the legacy pageSize (frontend's
// `limit || pageSize`), for row-limited panels (list/table) only. Leaves a query
// that already has limit, or a non-row-limited panel, untouched.
func normalizePreV5PageSize(query map[string]any, rowLimitPanel bool) {
if !rowLimitPanel {
return
}
if limit, ok := query["limit"]; ok && limit != nil {
return
}
if ps, ok := query["pageSize"]; ok {
query["limit"] = ps
}
}
// normalizeQueryLimit drops a limit above the v5 maximum (MaxQueryLimit); v1 allowed
// larger/unbounded limits, and an over-max value fails validation. Removing it leaves
// the query unlimited (the field is optional).
func normalizeQueryLimit(query map[string]any) {
limit, ok := coerceFloat(query["limit"])
if !ok {
return
}
if limit > qb.MaxQueryLimit {
delete(query, "limit")
}
}

View File

@@ -0,0 +1,122 @@
package dashboardtypes
import (
"fmt"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// Tags
// ══════════════════════════════════════════════
// v1 carries tags as a flat []string; v2 tags are (key, value) pairs. Each v1
// string is normalized into a pair (separator split, empty-side fallback,
// reserved-key prefix, `/` scrub). Tags that normalize to the same
// (lower(key), lower(value)) within a dashboard are collapsed, first occurrence
// winning the display casing.
//
// Characters still illegal after normalization (spaces, punctuation) are molded
// to fit the tag validators: disallowed runs collapse to "_" (see moldTagField).
// defaultV1TagKey is the key assigned when a v1 tag string has no usable
// separator (or one side of the split is empty).
const defaultV1TagKey = "tag"
func (d *v1Decoder) convertV1TagsForOrg(orgID valuer.UUID, raw any) []*tagtypes.Tag {
if raw == nil {
return nil
}
rawTagsList, ok := raw.([]any)
if !ok {
d.noteMalformedField("tags", raw)
return nil
}
seen := make(map[string]struct{}, len(rawTagsList))
tagsV2 := make([]*tagtypes.Tag, 0, len(rawTagsList))
for i, rawTag := range rawTagsList {
s, ok := rawTag.(string)
if !ok {
d.noteMalformedField(fmt.Sprintf("tags[%d]", i), rawTag)
continue
}
key, value, ok := normalizeV1Tag(s)
if !ok {
continue
}
dedupKey := strings.ToLower(key) + "\x00" + strings.ToLower(value)
if _, dup := seen[dedupKey]; dup {
continue
}
seen[dedupKey] = struct{}{}
tagsV2 = append(tagsV2, tagtypes.NewTag(orgID, coretypes.KindDashboard, key, value))
}
return tagsV2
}
// normalizeV1Tag derives a (key, value) pair from one v1 tag string. After
// splitting and molding both sides, a lone survivor becomes a value under the
// default key; ok is false if neither survives.
func normalizeV1Tag(s string) (string, string, bool) {
s = strings.TrimSpace(s)
if s == "" {
return "", "", false
}
var rawKey, rawValue string
switch {
case strings.Contains(s, ":"):
rawKey, rawValue, _ = strings.Cut(s, ":")
// Only the first ":" separates key from value; collapse the rest.
rawValue = strings.ReplaceAll(rawValue, ":", "_")
case strings.Contains(s, "/"):
rawKey, rawValue, _ = strings.Cut(s, "/")
default:
rawValue = s
}
rawKey = strings.TrimSpace(rawKey)
rawValue = strings.TrimSpace(rawValue)
// Reserved-key collision: prefix "_" so the list-query DSL stays unambiguous.
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(rawKey))]; rawKey != "" && reserved {
rawKey = "_" + rawKey
}
key := moldTagField(rawKey, tagKeyDisallowed, tagKeyNotLead, tagtypes.MAX_LEN_TAG_KEY)
value := moldTagField(rawValue, tagValueDisallowed, nil, tagtypes.MAX_LEN_TAG_VALUE)
switch {
case key == "" && value == "":
return "", "", false
case key == "":
return defaultV1TagKey, value, true
case value == "":
return defaultV1TagKey, key, true
default:
return key, value, true
}
}
// Inverse of tagKeyRegex/tagValueRegex ("/" always rejected); tagKeyNotLead
// matches a bad first char for a key. TestMoldedV1TagsPassValidation guards drift.
var (
tagKeyDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:-]+`)
tagValueDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:.+=-]+`)
tagKeyNotLead = regexp.MustCompile(`^[^a-zA-Z$_@{#]`)
)
// moldTagField collapses disallowed runs to "_", prefixes "_" if notLead hits
// the first char, and caps at max. Keeps a leading "_", trims a trailing one.
func moldTagField(s string, disallowed, notLead *regexp.Regexp, max int) string {
s = strings.TrimRight(disallowed.ReplaceAllString(s, "_"), "_")
if s != "" && notLead != nil && notLead.MatchString(s) {
s = "_" + s
}
if len(s) > max {
s = strings.TrimRight(s[:max], "_")
}
return s
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
package dashboardtypes
import (
"sort"
"strconv"
"strings"
"github.com/perses/spec/go/dashboard/variable"
)
// ══════════════════════════════════════════════
// Variables
// ══════════════════════════════════════════════
// convertV1Variables walks the v1 `variables` map (UUID-keyed) and produces an
// ordered []Variable. Variables sort by `order` first, then by id for stable
// output. v1 variable types map as follows:
//
// QUERY → ListVariable + signoz/QueryVariable
// CUSTOM → ListVariable + signoz/CustomVariable
// DYNAMIC → ListVariable + signoz/DynamicVariable
// TEXTBOX → TextVariable
func (d *v1Decoder) convertV1Variables(raw any) []Variable {
if raw == nil {
return nil
}
rawVariablesMap, ok := raw.(map[string]any)
if !ok {
// v1 sometimes stores variables as a list. The frontend consumes it via
// Object.entries/keys, which for an array yields the stringified index as the
// key, so mirror that: [{...}] is treated as {"0":{...}}. An empty list is
// simply "no variables".
rawSlice, isSlice := raw.([]any)
if !isSlice {
d.noteMalformedField("variables", raw)
return nil
}
rawVariablesMap = make(map[string]any, len(rawSlice))
for i, v := range rawSlice {
rawVariablesMap[strconv.Itoa(i)] = v
}
}
type ordered struct {
variableID string
variableContent map[string]any
order float64
}
entries := make([]ordered, 0, len(rawVariablesMap))
for variableID, variableContentRaw := range rawVariablesMap {
variableContent, ok := variableContentRaw.(map[string]any)
if !ok {
// A variable whose content isn't an object (e.g. a stray "list" array) can't
// render in the current UI, so it's useless — skip it instead of failing the
// migration.
continue
}
entries = append(entries, ordered{variableID: variableID, variableContent: variableContent, order: d.readFloat(variableContent, "order")})
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].order != entries[j].order {
return entries[i].order < entries[j].order
}
return entries[i].variableID < entries[j].variableID
})
variablesV2 := make([]Variable, 0, len(entries))
for _, e := range entries {
v, ok := d.convertV1Variable(e.variableContent)
if !ok {
continue
}
variablesV2 = append(variablesV2, v)
}
return variablesV2
}
func (d *v1Decoder) convertV1Variable(v map[string]any) (Variable, bool) {
name := d.readString(v, "name")
if name == "" {
return Variable{}, false
}
description := d.readString(v, "description")
// v1 stores the type upper-cased (QUERY/CUSTOM/…); tolerate any casing.
kind := strings.ToUpper(d.readString(v, "type"))
switch kind {
case "TEXTBOX":
spec := &TextVariableSpec{
Display: Display{Name: clipName(name, MaxDisplayNameLen), Description: description},
Value: d.readString(v, "textboxValue"),
Name: name,
}
return Variable{Kind: variable.KindText, Spec: spec}, true
case "QUERY", "CUSTOM", "DYNAMIC":
// Drop (don't fail on) a dynamic variable with no attribute — it can't resolve.
if kind == "DYNAMIC" && d.readString(v, "dynamicVariablesAttribute") == "" {
return Variable{}, false
}
// Drop a custom variable with no recoverable option list — v2 requires one.
if kind == "CUSTOM" && d.readString(v, "customValue") == "" && d.readString(v, "selectedValue") == "" && d.readString(v, "defaultValue") == "" {
return Variable{}, false
}
// Drop a query variable with no query — it can't resolve.
if kind == "QUERY" && d.readString(v, "queryValue") == "" {
return Variable{}, false
}
listSpec := &ListVariableSpec{
Display: Display{Name: clipName(name, MaxDisplayNameLen), Description: description},
AllowAllValue: d.readBool(v, "showALLOption"),
AllowMultiple: d.readBool(v, "multiSelect"),
CustomAllValue: d.readString(v, "customAllValue"),
CapturingRegexp: d.readString(v, "capturingRegexp"),
Sort: mapV1Sort(v["sort"]),
Plugin: d.variablePluginFor(kind, v),
Name: name,
}
if dv := mapV1VariableDefault(v, listSpec.AllowMultiple); dv != nil {
listSpec.DefaultValue = dv
}
return Variable{Kind: variable.KindList, Spec: listSpec}, true
case "":
// v1 sometimes stores a variable with no type; it can't render, so drop it
// silently rather than flagging it malformed.
return Variable{}, false
default:
d.note("variable %q has unknown type %q", name, kind)
return Variable{}, false
}
}
func (d *v1Decoder) variablePluginFor(kind string, v map[string]any) VariablePlugin {
switch kind {
case "QUERY":
return VariablePlugin{
Kind: VariableKindQuery,
Spec: &QueryVariableSpec{QueryValue: d.readString(v, "queryValue")},
}
case "CUSTOM":
// Some v1 dashboards stored the option list in selectedValue/defaultValue
// instead of customValue; fall back so the variable survives migration.
customValue := d.readString(v, "customValue")
if customValue == "" {
customValue = d.readString(v, "selectedValue")
}
if customValue == "" {
customValue = d.readString(v, "defaultValue")
}
return VariablePlugin{
Kind: VariableKindCustom,
Spec: &CustomVariableSpec{CustomValue: customValue},
}
case "DYNAMIC":
spec := &DynamicVariableSpec{Name: d.readString(v, "dynamicVariablesAttribute")}
if signal := signalFromDataSource(v["dynamicVariablesSource"]); !signal.IsZero() {
spec.Signal = signal
}
return VariablePlugin{Kind: VariableKindDynamic, Spec: spec}
}
return VariablePlugin{}
}
// mapV1VariableDefault reads selectedValue/defaultValue, both polymorphic
// (string|array), so it indexes the raw value and lets defaultValueFromAny
// type-switch — no typed accessor, intentionally lenient.
func mapV1VariableDefault(v map[string]any, allowMultiple bool) *VariableDefaultValue {
if raw, ok := v["selectedValue"]; ok {
return defaultValueFromAny(raw, allowMultiple)
}
if raw, ok := v["defaultValue"]; ok {
return defaultValueFromAny(raw, allowMultiple)
}
return nil
}
func defaultValueFromAny(raw any, allowMultiple bool) *VariableDefaultValue {
switch v := raw.(type) {
case string:
if v == "" {
return nil
}
return &VariableDefaultValue{variable.DefaultValue{SingleValue: v}}
case []any:
if len(v) == 0 {
return nil
}
values := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
values = append(values, s)
}
}
if len(values) == 0 {
return nil
}
// A single-select variable can't carry a list default; collapse a lone value.
if !allowMultiple && len(values) == 1 {
return &VariableDefaultValue{variable.DefaultValue{SingleValue: values[0]}}
}
return &VariableDefaultValue{variable.DefaultValue{SliceValues: values}}
}
return nil
}
// mapV1Sort reads the raw value (not via readString) so a non-string sort — some v1
// dashboards store it as a number (e.g. 0) — defaults to none silently instead of
// being flagged malformed.
func mapV1Sort(raw any) ListVariableSpecSort {
s, _ := raw.(string)
switch s {
case "ASC":
return SortAlphabeticalAsc
case "DESC":
return SortAlphabeticalDesc
}
return ListVariableSpecSort{} // zero (omitzero) — SortNone is the implicit default
}

View File

@@ -0,0 +1,127 @@
package querybuildertypesv5
// WrapInV5Envelope translates a single v4 builder query/formula map into a
// v5 query envelope ({"type": ..., "spec": ...}). It is a pure shape transform
// over untyped maps: v4 builder field names (groupBy/orderBy/selectColumns/
// dataSource) are rewritten to their v5 equivalents and a `signal` is derived
// from the data source. queryType selects the envelope type, except a formula
// (detected when name != queryMap["expression"]) is always emitted as
// "builder_formula".
//
// Migration code (pkg/transition) and the v1→v2 dashboard conversion both
// produce v5 envelopes, so this lives here with the v5 query types rather than
// in an infra-level package.
func WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}

View File

@@ -1,110 +0,0 @@
package telemetrytypes
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
)
const wildcardSelector = "*"
var telemetryGrantQueryTypes = map[string]bool{
"builder_query": true,
"builder_sub_query": true,
"promql": false,
"clickhouse_sql": false,
}
var telemetryGrantKeys = map[string]struct{}{
"signoz.workspace.key.id": {},
}
func NewTelemetryGrantKey(keyText string) (string, bool) {
fieldKey := GetFieldKeyFromKeyText(keyText)
if fieldKey.FieldContext != FieldContextUnspecified && fieldKey.FieldContext != FieldContextResource {
return "", false
}
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
return "", false
}
return fieldKey.Name, true
}
func NewTelemetryGrantSelector(input string) (string, error) {
if input == wildcardSelector {
return input, nil
}
parts := strings.SplitN(input, "/", 3)
keyScoped, ok := telemetryGrantQueryTypes[parts[0]]
if !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must start with a supported query type or be %q", input, wildcardSelector)
}
queryType := parts[0]
if len(parts) < 3 {
if len(parts) == 2 && parts[1] != wildcardSelector {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must be <query_type>, <query_type>/*, <query_type>/<key>/* or <query_type>/<key>/<value>", input)
}
return queryType + "/" + wildcardSelector, nil
}
if !keyScoped {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q is invalid: query type %q supports only %q or %q", input, queryType, queryType, queryType+"/"+wildcardSelector)
}
key, ok := NewTelemetryGrantKey(parts[1])
if !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must use a supported key: %s", input, strings.Join(telemetryGrantKeyNames(), ", "))
}
value := parts[2]
if value == wildcardSelector {
return queryType + "/" + key + "/" + wildcardSelector, nil
}
if value == "" || strings.HasPrefix(value, "$") {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must use a concrete non-empty value", input)
}
return queryType + "/" + key + "/" + value, nil
}
func NewTelemetryGrantSelectors(selector string) []string {
if selector == wildcardSelector {
return []string{wildcardSelector}
}
parts := strings.SplitN(selector, "/", 3)
queryType := parts[0]
if len(parts) < 3 {
return []string{queryType + "/" + wildcardSelector, wildcardSelector}
}
key, value := parts[1], parts[2]
if value == wildcardSelector {
return []string{
queryType + "/" + key + "/" + wildcardSelector,
queryType + "/" + wildcardSelector,
wildcardSelector,
}
}
return []string{
queryType + "/" + key + "/" + value,
queryType + "/" + key + "/" + wildcardSelector,
queryType + "/" + wildcardSelector,
wildcardSelector,
}
}
func telemetryGrantKeyNames() []string {
names := make([]string, 0, len(telemetryGrantKeys))
for name := range telemetryGrantKeys {
names = append(names, name)
}
return names
}

View File

@@ -1,78 +0,0 @@
package telemetrytypes
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewTelemetryGrantSelector(t *testing.T) {
valid := map[string]string{
"*": "*",
"builder_query": "builder_query/*",
"builder_query/*": "builder_query/*",
"promql": "promql/*",
"clickhouse_sql": "clickhouse_sql/*",
"builder_query/signoz.workspace.key.id/*": "builder_query/signoz.workspace.key.id/*",
"builder_query/signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
"builder_query/resource.signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
"builder_query/signoz.workspace.key.id/key a": "builder_query/signoz.workspace.key.id/key a",
"builder_query/signoz.workspace.key.id/a/b": "builder_query/signoz.workspace.key.id/a/b",
}
for input, expected := range valid {
canonical, err := NewTelemetryGrantSelector(input)
require.NoError(t, err, "input %q", input)
assert.Equal(t, expected, canonical, "input %q", input)
}
invalid := []string{
"",
"key-a",
"signoz.workspace.key.id = 'key-a'",
"builder_trace_operator/signoz.workspace.key.id/key-a",
"builder_query/service.name/frontend",
"builder_query/signoz.workspace.key.id/",
"builder_query/signoz.workspace.key.id/$svc",
"*/signoz.workspace.key.id/key-a",
"builder_query/signoz.workspace.key.id",
"clickhouse_sql/signoz.workspace.key.id/key-a",
"clickhouse_sql/signoz.workspace.key.id/*",
"promql/signoz.workspace.key.id/key-a",
}
for _, input := range invalid {
_, err := NewTelemetryGrantSelector(input)
assert.Error(t, err, "input %q", input)
}
}
func TestNewTelemetryGrantKey(t *testing.T) {
valid := map[string]string{
"signoz.workspace.key.id": "signoz.workspace.key.id",
"resource.signoz.workspace.key.id": "signoz.workspace.key.id",
}
for keyText, expected := range valid {
key, ok := NewTelemetryGrantKey(keyText)
assert.True(t, ok, keyText)
assert.Equal(t, expected, key, keyText)
}
for _, keyText := range []string{"service.name", "attribute.signoz.workspace.key.id", "body.signoz.workspace.key.id"} {
_, ok := NewTelemetryGrantKey(keyText)
assert.False(t, ok, keyText)
}
}
func TestNewTelemetryGrantSelectors(t *testing.T) {
ladders := map[string][]string{
"*": {"*"},
"builder_query/*": {"builder_query/*", "*"},
"promql/*": {"promql/*", "*"},
"builder_query/signoz.workspace.key.id/*": {"builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
"builder_query/signoz.workspace.key.id/a": {"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
"builder_query/signoz.workspace.key.id/a/b": {"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
}
for selector, expected := range ladders {
assert.Equal(t, expected, NewTelemetryGrantSelectors(selector), "selector %q", selector)
}
}

View File

@@ -200,7 +200,6 @@ def test_hosts_warnings(
{"prod-linux-1", "dev-linux-1"},
id="in_contains",
),
pytest.param("host.namee = 'prod-linux-1'", set(), id="unresolved_key"),
],
)
def test_hosts_filter(
@@ -258,6 +257,7 @@ def test_hosts_filter(
@pytest.mark.parametrize(
"expression,err_substr",
[
pytest.param("host.namee = 'prod-linux-1'", "host.namee", id="bad_attr_name"),
pytest.param("host.name =", None, id="trailing_op"),
pytest.param("(host.name = 'prod-linux-1'", None, id="unclosed_paren"),
# Cases dropped — parser is permissive and accepts these silently:
@@ -274,8 +274,8 @@ def test_hosts_filter_invalid(
expression: str,
err_substr,
) -> None:
"""Malformed filter grammar (trailing operator, unclosed paren) returns
400 invalid_input with structured errors."""
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
400 invalid_input with structured errors; bad attribute keys are named in them."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(

View File

@@ -290,7 +290,6 @@ def test_pods_warnings(
{"web-prod-1", "web-dev-1"},
id="in_contains",
),
pytest.param("k8s.pod.namee = 'web-prod-1'", set(), id="unresolved_key"),
],
)
def test_pods_filter(
@@ -349,6 +348,7 @@ def test_pods_filter(
@pytest.mark.parametrize(
"expression,err_substr",
[
pytest.param("k8s.pod.namee = 'web-prod-1'", "k8s.pod.namee", id="bad_attr_name"),
pytest.param("k8s.pod.name =", None, id="trailing_op"),
pytest.param("(k8s.pod.name = 'web-prod-1'", None, id="unclosed_paren"),
],
@@ -361,8 +361,8 @@ def test_pods_filter_invalid(
expression: str,
err_substr,
) -> None:
"""Malformed filter grammar (trailing operator, unclosed paren) returns
400 invalid_input with structured errors."""
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
400 invalid_input with structured errors; bad attribute keys are named in them."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
_load_pods_metrics(

View File

@@ -216,7 +216,6 @@ def test_nodes_warnings(
{"web-a-us-1", "web-b-us-1"},
id="in_contains",
),
pytest.param("k8s.node.namee = 'web-a-us-1'", set(), id="unresolved_key"),
],
)
def test_nodes_filter(
@@ -273,6 +272,7 @@ def test_nodes_filter(
@pytest.mark.parametrize(
"expression,err_substr",
[
pytest.param("k8s.node.namee = 'web-a-us-1'", "k8s.node.namee", id="bad_attr_name"),
pytest.param("k8s.node.name =", None, id="trailing_op"),
pytest.param("(k8s.node.name = 'web-a-us-1'", None, id="unclosed_paren"),
],
@@ -285,8 +285,8 @@ def test_nodes_filter_invalid(
expression: str,
err_substr,
) -> None:
"""Malformed filter grammar (trailing operator, unclosed paren) returns
400 invalid_input with structured errors."""
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
400 invalid_input with structured errors; bad attribute keys are named in them."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(

View File

@@ -215,7 +215,6 @@ def test_namespaces_warnings(
{"web-a-prod", "web-b-prod"},
id="in_contains",
),
pytest.param("k8s.namespace.namee = 'web-a-prod'", set(), id="unresolved_key"),
],
)
def test_namespaces_filter(
@@ -271,6 +270,7 @@ def test_namespaces_filter(
@pytest.mark.parametrize(
"expression,err_substr",
[
pytest.param("k8s.namespace.namee = 'web-a-prod'", "k8s.namespace.namee", id="bad_attr_name"),
pytest.param("k8s.namespace.name =", None, id="trailing_op"),
pytest.param("(k8s.namespace.name = 'web-a-prod'", None, id="unclosed_paren"),
],
@@ -283,8 +283,8 @@ def test_namespaces_filter_invalid(
expression: str,
err_substr,
) -> None:
"""Malformed filter grammar (trailing operator, unclosed paren) returns
400 invalid_input with structured errors."""
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
400 invalid_input with structured errors; bad attribute keys are named in them."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(

View File

@@ -233,7 +233,6 @@ def test_clusters_warnings(
{"web-gcp-prod", "web-aws-prod"},
id="in_contains",
),
pytest.param("k8s.cluster.namee = 'web-gcp-prod'", set(), id="unresolved_key"),
],
)
def test_clusters_filter(
@@ -291,6 +290,7 @@ def test_clusters_filter(
@pytest.mark.parametrize(
"expression,err_substr",
[
pytest.param("k8s.cluster.namee = 'web-gcp-prod'", "k8s.cluster.namee", id="bad_attr_name"),
pytest.param("k8s.cluster.name =", None, id="trailing_op"),
pytest.param("(k8s.cluster.name = 'web-gcp-prod'", None, id="unclosed_paren"),
],
@@ -303,8 +303,8 @@ def test_clusters_filter_invalid(
expression: str,
err_substr,
) -> None:
"""Malformed filter grammar (trailing operator, unclosed paren) returns
400 invalid_input with structured errors."""
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
400 invalid_input with structured errors; bad attribute keys are named in them."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(

View File

@@ -231,7 +231,6 @@ def test_volumes_warnings(
{"data-ns-a-prod", "data-ns-b-prod"},
id="in_contains",
),
pytest.param("k8s.persistentvolumeclaim.namee = 'data-ns-a-prod'", set(), id="unresolved_key"),
],
)
def test_volumes_filter(
@@ -290,6 +289,11 @@ def test_volumes_filter(
@pytest.mark.parametrize(
"expression,err_substr",
[
pytest.param(
"k8s.persistentvolumeclaim.namee = 'data-ns-a-prod'",
"k8s.persistentvolumeclaim.namee",
id="bad_attr_name",
),
pytest.param("k8s.persistentvolumeclaim.name =", None, id="trailing_op"),
pytest.param("(k8s.persistentvolumeclaim.name = 'data-ns-a-prod'", None, id="unclosed_paren"),
],
@@ -302,8 +306,8 @@ def test_volumes_filter_invalid(
expression: str,
err_substr,
) -> None:
"""Malformed filter grammar (trailing operator, unclosed paren) returns
400 invalid_input with structured errors."""
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
400 invalid_input with structured errors; bad attribute keys are named in them."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(

View File

@@ -242,7 +242,6 @@ def test_deployments_warnings(
{"web-a-prod", "web-b-prod"},
id="in_contains",
),
pytest.param("k8s.deployment.namee = 'web-a-prod'", set(), id="unresolved_key"),
],
)
def test_deployments_filter(
@@ -302,6 +301,7 @@ def test_deployments_filter(
@pytest.mark.parametrize(
"expression,err_substr",
[
pytest.param("k8s.deployment.namee = 'web-a-prod'", "k8s.deployment.namee", id="bad_attr_name"),
pytest.param("k8s.deployment.name =", None, id="trailing_op"),
pytest.param("(k8s.deployment.name = 'web-a-prod'", None, id="unclosed_paren"),
],
@@ -314,8 +314,8 @@ def test_deployments_filter_invalid(
expression: str,
err_substr,
) -> None:
"""Malformed filter grammar (trailing operator, unclosed paren) returns
400 invalid_input with structured errors."""
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
400 invalid_input with structured errors; bad attribute keys are named in them."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(

View File

@@ -156,7 +156,6 @@ def test_statefulsets_accuracy(
{"web-a-prod", "web-b-prod"},
id="in_contains",
),
pytest.param("k8s.statefulset.namee = 'web-a-prod'", set(), id="unresolved_key"),
],
)
def test_statefulsets_filter(
@@ -216,6 +215,7 @@ def test_statefulsets_filter(
@pytest.mark.parametrize(
"expression,err_substr",
[
pytest.param("k8s.statefulset.namee = 'web-a-prod'", "k8s.statefulset.namee", id="bad_attr_name"),
pytest.param("k8s.statefulset.name =", None, id="trailing_op"),
pytest.param("(k8s.statefulset.name = 'web-a-prod'", None, id="unclosed_paren"),
],
@@ -228,8 +228,8 @@ def test_statefulsets_filter_invalid(
expression: str,
err_substr,
) -> None:
"""Malformed filter grammar (trailing operator, unclosed paren) returns
400 invalid_input with structured errors."""
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
400 invalid_input with structured errors; bad attribute keys are named in them."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(

View File

@@ -246,7 +246,6 @@ def test_jobs_warnings(
{"etl-a-prod", "etl-b-prod"},
id="in_contains",
),
pytest.param("k8s.job.namee = 'etl-a-prod'", set(), id="unresolved_key"),
],
)
def test_jobs_filter(
@@ -305,6 +304,7 @@ def test_jobs_filter(
@pytest.mark.parametrize(
"expression,err_substr",
[
pytest.param("k8s.job.namee = 'etl-a-prod'", "k8s.job.namee", id="bad_attr_name"),
pytest.param("k8s.job.name =", None, id="trailing_op"),
pytest.param("(k8s.job.name = 'etl-a-prod'", None, id="unclosed_paren"),
],
@@ -317,8 +317,8 @@ def test_jobs_filter_invalid(
expression: str,
err_substr,
) -> None:
"""Malformed filter grammar (trailing operator, unclosed paren) returns
400 invalid_input with structured errors."""
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
400 invalid_input with structured errors; bad attribute keys are named in them."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(

View File

@@ -162,7 +162,6 @@ def test_daemonsets_accuracy(
{"logs-a-prod", "logs-b-prod"},
id="in_contains",
),
pytest.param("k8s.daemonset.namee = 'logs-a-prod'", set(), id="unresolved_key"),
],
)
def test_daemonsets_filter(
@@ -222,6 +221,7 @@ def test_daemonsets_filter(
@pytest.mark.parametrize(
"expression,err_substr",
[
pytest.param("k8s.daemonset.namee = 'logs-a-prod'", "k8s.daemonset.namee", id="bad_attr_name"),
pytest.param("k8s.daemonset.name =", None, id="trailing_op"),
pytest.param("(k8s.daemonset.name = 'logs-a-prod'", None, id="unclosed_paren"),
],
@@ -234,8 +234,8 @@ def test_daemonsets_filter_invalid(
expression: str,
err_substr,
) -> None:
"""Malformed filter grammar (trailing operator, unclosed paren) returns
400 invalid_input with structured errors."""
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
400 invalid_input with structured errors; bad attribute keys are named in them."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(

View File

@@ -19,8 +19,8 @@ from fixtures.querier import build_raw_query, get_column_data_from_response, mak
from fixtures.role import transaction_group
user_password = "password123Z$"
scoped_role = "telemetry-scope-key-a"
scoped_email = "scope-key-a@telemetry.test"
scoped_role = "telemetry-scope-svc-a"
scoped_email = "scope-svc-a@telemetry.test"
def test_setup(
@@ -37,8 +37,8 @@ def test_setup(
admin_token,
scoped_role,
[
transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/key-a"]),
transaction_group("read", "telemetryresource", "traces", ["builder_query/signoz.workspace.key.id/key-a"]),
transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/service-a"]),
transaction_group("read", "telemetryresource", "traces", ["builder_query/service.name/service-a"]),
],
)
user_id = create_active_user(signoz, admin_token, email=scoped_email, role="VIEWER", password=user_password)
@@ -48,13 +48,10 @@ def test_setup(
@pytest.mark.parametrize(
"selector",
[
"signoz.workspace.key.id = 'key-a'", # expression form, not the wire form
"unknown_query_type/signoz.workspace.key.id/key-a", # unsupported query type
"builder_query/service.name/frontend", # service.name is not a supported grant key
"*/signoz.workspace.key.id/key-a", # non-prefix wildcard
"builder_query/signoz.workspace.key.id/", # empty value
"builder_query/signoz.workspace.key.id", # missing value, not a wildcard
"clickhouse_sql/signoz.workspace.key.id/key-a", # clickhouse_sql does not support key-scoped selectors
"service.name = 'service-a'", # expression form, not the wire form
"builder_query/service.name/check out", # raw space
"builder_query/service.name/'quoted'", # quote
"builder_query/service.name/a/b/c", # too deep
],
)
def test_invalid_telemetry_selector_rejected(
@@ -78,10 +75,10 @@ def test_invalid_telemetry_selector_rejected(
@pytest.mark.parametrize(
"expression",
[
"signoz.workspace.key.id = 'key-a'",
"signoz.workspace.key.id IN ('key-a')",
"resource.signoz.workspace.key.id = 'key-a'",
"signoz.workspace.key.id = 'key-a' AND severity_text = 'ERROR'",
"service.name = 'service-a'",
"service.name IN ('service-a')",
"resource.service.name = 'service-a'",
"service.name = 'service-a' AND severity_text = 'ERROR'",
],
)
def test_allowed(
@@ -91,9 +88,9 @@ def test_allowed(
expression: str,
) -> None:
now = datetime.now(tz=UTC)
# Seed a key-a log so the resource-attribute key resolves; without any
# Seed a service-a log so the resource-attribute key resolves; without any
# ingested data the querier rejects the filter with "key not found".
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0")])
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0")])
response = make_query_request(
signoz,
@@ -110,15 +107,15 @@ def test_allowed(
"expression",
[
None, # no filter
"signoz.workspace.key.id = 'key-b'",
"signoz.workspace.key.id IN ('key-a', 'key-b')",
"signoz.workspace.key.id = 'key-a' OR severity_text = 'ERROR'",
"NOT signoz.workspace.key.id = 'key-a'",
"signoz.workspace.key.id != 'key-b'",
# Same result set as IN ('key-a','key-b'), but the OR spelling is not
"service.name = 'service-b'",
"service.name IN ('service-a', 'service-b')",
"service.name = 'service-a' OR severity_text = 'ERROR'",
"NOT service.name = 'service-a'",
"service.name != 'service-b'",
# Same result set as IN ('service-a','service-b'), but the OR spelling is not
# yet recognized as a bounded set, so it is denied today. This flips to
# allowed-with-both-grants once the where-clause bound evaluation lands.
"signoz.workspace.key.id = 'key-a' OR signoz.workspace.key.id = 'key-b'",
"service.name = 'service-a' OR service.name = 'service-b'",
],
)
def test_denied(
@@ -149,11 +146,11 @@ def test_denied_message_names_resource(
get_token(scoped_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-b'")],
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-b'")],
request_type="raw",
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
assert "builder_query/signoz.workspace.key.id/key-b" in response.text
assert "builder_query/service.name/service-b" in response.text
def test_variables_resolve_into_gate(
@@ -162,7 +159,7 @@ def test_variables_resolve_into_gate(
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0")])
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0")])
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(scoped_email, user_password)
@@ -171,9 +168,9 @@ def test_variables_resolve_into_gate(
token,
start,
end,
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = $key")],
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = $svc")],
request_type="raw",
variables={"key": {"value": "key-a"}},
variables={"svc": {"value": "service-a"}},
)
assert allowed.status_code == HTTPStatus.OK, allowed.text
@@ -182,9 +179,9 @@ def test_variables_resolve_into_gate(
token,
start,
end,
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = $key")],
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = $svc")],
request_type="raw",
variables={"key": {"value": "key-b"}},
variables={"svc": {"value": "service-b"}},
)
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
@@ -195,17 +192,17 @@ def test_returns_only_scoped_rows(
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"signoz.workspace.key.id": "key-a"}, body=f"key-a-{i}") for i in range(3)] + [Logs(timestamp=now - timedelta(seconds=i + 1), resources={"signoz.workspace.key.id": "key-b"}, body=f"key-b-{i}") for i in range(3)])
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "service-a"}, body=f"service-a-{i}") for i in range(3)] + [Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "service-b"}, body=f"service-b-{i}") for i in range(3)])
response = make_query_request(
signoz,
get_token(scoped_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-a'")],
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-a'")],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
bodies = get_column_data_from_response(response.json(), "body")
assert bodies, "expected rows for key-a"
assert all(body.startswith("key-a") for body in bodies), bodies
assert bodies, "expected rows for service-a"
assert all(body.startswith("service-a") for body in bodies), bodies

View File

@@ -9,8 +9,8 @@ from fixtures.querier import build_raw_query, get_column_data_from_response, mak
from fixtures.role import transaction_group
user_password = "password123Z$"
any_key_role = "telemetry-scope-any-key"
any_key_email = "scope-any-key@telemetry.test"
any_service_role = "telemetry-scope-any-service"
any_service_email = "scope-any-service@telemetry.test"
builder_all_role = "telemetry-scope-builder-all"
builder_all_email = "scope-builder-all@telemetry.test"
@@ -23,16 +23,16 @@ def test_setup(
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, any_key_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/*"])])
any_user = create_active_user(signoz, admin_token, email=any_key_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, any_user, "signoz-viewer", any_key_role)
create_role(admin_token, any_service_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/*"])])
any_user = create_active_user(signoz, admin_token, email=any_service_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, any_user, "signoz-viewer", any_service_role)
create_role(admin_token, builder_all_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/*"])])
all_user = create_active_user(signoz, admin_token, email=builder_all_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, all_user, "signoz-viewer", builder_all_role)
def test_key_wildcard_allows_any_single_key(
def test_service_wildcard_allows_any_single_service(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
@@ -40,28 +40,28 @@ def test_key_wildcard_allows_any_single_key(
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0"),
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-b"}, body="key-b-0"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-b"}, body="service-b-0"),
]
)
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(any_key_email, user_password)
token = get_token(any_service_email, user_password)
key_a = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-a'")], request_type="raw")
assert key_a.status_code == HTTPStatus.OK, key_a.text
service_a = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-a'")], request_type="raw")
assert service_a.status_code == HTTPStatus.OK, service_a.text
key_b = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-b'")], request_type="raw")
assert key_b.status_code == HTTPStatus.OK, key_b.text
service_b = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-b'")], request_type="raw")
assert service_b.status_code == HTTPStatus.OK, service_b.text
def test_key_wildcard_denies_unfiltered(
def test_service_wildcard_denies_unfiltered(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
get_token(any_key_email, user_password),
get_token(any_service_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50)],
@@ -86,7 +86,7 @@ def test_builder_wildcard_allows_unfiltered(
assert response.status_code == HTTPStatus.OK, response.text
def test_admin_allows_unfiltered_across_keys(
def test_admin_allows_unfiltered_across_services(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
@@ -94,8 +94,8 @@ def test_admin_allows_unfiltered_across_keys(
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0"),
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-b"}, body="key-b-0"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-b"}, body="service-b-0"),
]
)
@@ -109,4 +109,4 @@ def test_admin_allows_unfiltered_across_keys(
)
assert response.status_code == HTTPStatus.OK, response.text
bodies = get_column_data_from_response(response.json(), "body")
assert any(body.startswith("key-b") for body in bodies), bodies
assert any(body.startswith("service-b") for body in bodies), bodies

View File

@@ -10,8 +10,8 @@ from fixtures.role import transaction_group
user_password = "password123Z$"
chsql_role = "telemetry-scope-chsql"
chsql_email = "scope-chsql@telemetry.test"
key_a_role = "telemetry-qt-key-a"
key_a_email = "qt-key-a@telemetry.test"
svc_a_role = "telemetry-qt-svc-a"
svc_a_email = "qt-svc-a@telemetry.test"
viewer_email = "qt-managed-viewer@telemetry.test"
clickhouse_query = [{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT toFloat64(1.5) AS `__result_0`", "disabled": False}}]
@@ -41,9 +41,9 @@ def test_setup(
chsql_user = create_active_user(signoz, admin_token, email=chsql_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, chsql_user, "signoz-viewer", chsql_role)
create_role(admin_token, key_a_role, [transaction_group("read", "telemetryresource", "traces", ["builder_query/signoz.workspace.key.id/key-a"])])
key_a_user = create_active_user(signoz, admin_token, email=key_a_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, key_a_user, "signoz-viewer", key_a_role)
create_role(admin_token, svc_a_role, [transaction_group("read", "telemetryresource", "traces", ["builder_query/service.name/service-a"])])
svc_a_user = create_active_user(signoz, admin_token, email=svc_a_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, svc_a_user, "signoz-viewer", svc_a_role)
# A plain managed viewer (signoz-viewer) — for the meter-metrics/audit-logs policy checks.
create_active_user(signoz, admin_token, email=viewer_email, role="VIEWER", password=user_password)
@@ -59,7 +59,7 @@ def test_clickhouse_sql_requires_chsql_grant(
granted = make_query_request(signoz, get_token(chsql_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
assert granted.status_code == HTTPStatus.OK, granted.text
scoped = make_query_request(signoz, get_token(key_a_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
scoped = make_query_request(signoz, get_token(svc_a_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
assert scoped.status_code == HTTPStatus.FORBIDDEN, scoped.text
admin = make_query_request(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
@@ -73,8 +73,8 @@ def test_promql_requires_promql_grant(
now = datetime.now(tz=UTC)
start, end = int((now - timedelta(hours=1)).timestamp() * 1000), int(now.timestamp() * 1000)
# Neither the chsql grant nor a builder-key grant covers promql.
scoped = make_query_request(signoz, get_token(key_a_email, user_password), start, end, promql_query, request_type=querier.RequestType.TIME_SERIES)
# Neither the chsql grant nor a builder-service grant covers promql.
scoped = make_query_request(signoz, get_token(svc_a_email, user_password), start, end, promql_query, request_type=querier.RequestType.TIME_SERIES)
assert scoped.status_code == HTTPStatus.FORBIDDEN, scoped.text
# Admin holds the wildcard; authz passes (the handler may still 2xx/4xx, never 403).
@@ -88,19 +88,19 @@ def test_trace_operator_rides_on_referenced_queries(
) -> None:
now = datetime.now(tz=UTC)
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(key_a_email, user_password)
token = get_token(svc_a_email, user_password)
def operator_queries(b_key: str) -> list[dict]:
def operator_queries(b_service: str) -> list[dict]:
return [
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "signoz.workspace.key.id = 'key-a'"}, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": True, "filter": {"expression": f"signoz.workspace.key.id = '{b_key}'"}, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "service.name = 'service-a'"}, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": True, "filter": {"expression": f"service.name = '{b_service}'"}, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_trace_operator", "spec": {"name": "T1", "expression": "A => B", "returnSpansFrom": "A", "disabled": False}},
]
allowed = make_query_request(signoz, token, start, end, operator_queries("key-a"), request_type=querier.RequestType.RAW)
allowed = make_query_request(signoz, token, start, end, operator_queries("service-a"), request_type=querier.RequestType.RAW)
assert allowed.status_code == HTTPStatus.OK, allowed.text
denied = make_query_request(signoz, token, start, end, operator_queries("key-b"), request_type=querier.RequestType.RAW)
denied = make_query_request(signoz, token, start, end, operator_queries("service-b"), request_type=querier.RequestType.RAW)
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
@@ -110,14 +110,14 @@ def test_formula_rides_on_referenced_queries(
) -> None:
now = datetime.now(tz=UTC)
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(key_a_email, user_password)
token = get_token(svc_a_email, user_password)
def formula_queries(b_filtered: bool) -> list[dict]:
b_spec = {"name": "B", "signal": "traces", "disabled": True, "aggregations": [{"expression": "count()"}]}
if b_filtered:
b_spec["filter"] = {"expression": "signoz.workspace.key.id = 'key-a'"}
b_spec["filter"] = {"expression": "service.name = 'service-a'"}
return [
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "signoz.workspace.key.id = 'key-a'"}, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "service.name = 'service-a'"}, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_query", "spec": b_spec},
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A/B", "disabled": False}},
]

View File

@@ -11,9 +11,8 @@ from fixtures.role import transaction_group
user_password = "password123Z$"
spacey_role = "telemetry-scope-spacey"
spacey_email = "scope-spacey@telemetry.test"
# The grant value has a space; it is stored plaintext in the role record and hashed
# into the tuple, so a matching query must round-trip the exact value.
spacey_value = "key with space"
# The service name has a space; its canonical selector escapes it to %20.
spacey_service = "check out"
def test_setup(
@@ -23,31 +22,31 @@ def test_setup(
create_role: Callable[..., str],
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, spacey_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/key with space"])])
create_role(admin_token, spacey_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/check%20out"])])
user_id = create_active_user(signoz, admin_token, email=spacey_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", spacey_role)
def test_escaped_value_parity_allows_matching_value(
def test_escaped_value_parity_allows_matching_service(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": spacey_value}, body="spacey-0")])
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": spacey_service}, body="spacey-0")])
response = make_query_request(
signoz,
get_token(spacey_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression=f"signoz.workspace.key.id = '{spacey_value}'")],
[build_raw_query("A", "logs", limit=50, filter_expression=f"service.name = '{spacey_service}'")],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
def test_escaped_value_denies_other_value(
def test_escaped_value_denies_other_service(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
@@ -57,7 +56,7 @@ def test_escaped_value_denies_other_value(
get_token(spacey_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'keywithspace'")],
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'checkout'")],
request_type="raw",
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text

View File

@@ -1,50 +0,0 @@
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
from fixtures.role import transaction_group
user_password = "password123Z$"
keywild_role = "telemetry-check-keywild"
keywild_email = "check-keywild@telemetry.test"
def test_setup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, keywild_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/*"])])
user_id = create_active_user(signoz, admin_token, email=keywild_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", keywild_role)
@pytest.mark.parametrize(
("selector", "authorized"),
[
("builder_query/signoz.workspace.key.id/key-a", True), # concrete value resolves up the ladder to the key wildcard grant
("builder_query/signoz.workspace.key.id/*", True), # exact grant
("builder_query/resource.signoz.workspace.key.id/key-a", True), # resource.signoz.workspace.key.id folds before laddering
("promql/*", False), # different query type never reaches the builder_query grant
],
)
def test_check_ladders_to_key_wildcard_grant(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
selector: str,
authorized: bool,
) -> None:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/authz/check"),
json=[{"relation": "read", "object": {"resource": {"type": "telemetryresource", "kind": "logs"}, "selector": selector}}],
headers={"Authorization": f"Bearer {get_token(keywild_email, user_password)}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"][0]["authorized"] is authorized

View File

@@ -66,9 +66,9 @@ def test_metrics_filter_label_context(
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
"""Metrics has no per-context storage: every label lives in the `labels` JSON, so a label
*filter* collapses every context to JSONExtractString(labels,'region') just like group-by does.
bare `region`, `attribute.region`, and `resource.region` are all equivalent and select `us`."""
"""Unlike group-by (which collapses every context to labels), a label *filter* resolves via
metadata under the label's registered (attribute) context: bare `region` and `attribute.region`
are equivalent, but an explicit mismatched context (`resource.region`) is not found (400)."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(
[
@@ -106,8 +106,7 @@ def test_metrics_filter_label_context(
data = {row[0]: row[-1] for row in querier.get_scalar_table_data(response.json())}
assert data == {"us": 30.0}, f"{expr}: {data}"
# resource. is a context the label is not registered under; metrics collapses it to the
# same labels lookup, so it resolves rather than erroring.
# resource. is a context the label is not registered under -> hard "not found".
response = querier.make_scalar_query_request(
signoz,
token,
@@ -121,7 +120,7 @@ def test_metrics_filter_label_context(
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_metrics_group_by_unknown_label(
@@ -209,50 +208,3 @@ def test_metrics_filter_unknown_label_matches_nothing(
assert response.status_code == HTTPStatus.OK, response.text
assert querier.get_scalar_table_data(response.json()) == []
assert querier.get_all_warnings(response.json()) == []
def test_metrics_full_text_filter_does_not_error(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
"""A bare/quoted term has no key=value form, so the visitor routes it through the metrics
full-text search column, which is never present in the metadata keys. The condition builder
must resolve it (not hard-error) so the query runs. Regression: a partial filter like `abc`
used to 400 with `key <full-text-column> not found` (broke the Metrics Explorer summary)."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(
[
Metrics(
metric_name=METRIC,
labels={"region": "us"},
timestamp=now - timedelta(seconds=1),
temporality="Unspecified",
type_="Gauge",
is_monotonic=False,
value=30.0,
)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# bare word and quoted term are both full-text searches; neither may 400.
for expr in ("abc", '"abc"'):
response = querier.make_scalar_query_request(
signoz,
token,
now,
[
querier.build_scalar_query(
name="A",
signal="metrics",
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified")],
filter_expression=expr,
)
],
)
assert response.status_code == HTTPStatus.OK, f"{expr}: {response.text}"
# the term matches no series, and metrics emits no key-not-found warning.
assert querier.get_scalar_table_data(response.json()) == [], f"{expr}: {response.json()}"
assert querier.get_all_warnings(response.json()) == [], f"{expr}: {querier.get_all_warnings(response.json())}"