Compare commits

...

4 Commits

Author SHA1 Message Date
Vinícius Lourenço
f1d4cd2b05 test(useSafeNavigate): move helpers to add testing 2026-06-29 22:29:58 -03:00
Vinícius Lourenço
c6fa4ac548 feat(compositeQuery): drop query param in favor of serializer functions 2026-06-29 22:29:58 -03:00
Vinícius Lourenço
dbb587a26b feat(compositeQuery): add base structure for serializer with json as default 2026-06-29 22:29:58 -03:00
Vinícius Lourenço
771599d27b feat(compositeQuery): add contract for serialize/deserialize query 2026-06-29 22:29:58 -03:00
43 changed files with 921 additions and 291 deletions

View File

@@ -6,6 +6,10 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
@@ -124,15 +128,13 @@ export function useNavigateToExplorer(): (
});
}
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(preparedQuery));
applySerializedParams(serialize(preparedQuery), urlParams);
const basePath =
dataSource === DataSource.TRACES
? ROUTES.TRACES_EXPLORER
: ROUTES.LOGS_EXPLORER;
const newExplorerPath = `${basePath}?${urlParams.toString()}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
const newExplorerPath = `${basePath}?${urlParams.toString()}`;
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},

View File

@@ -32,6 +32,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
@@ -252,7 +253,7 @@ function LogDetailInner({
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
[QueryParams.compositeQuery]: JSON.stringify(
...serializeToParams(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,

View File

@@ -18,7 +18,6 @@ export enum QueryParams {
q = 'q',
activeLogId = 'activeLogId',
timeRange = 'timeRange',
compositeQuery = 'compositeQuery',
panelTypes = 'panelTypes',
pageSize = 'pageSize',
viewMode = 'viewMode',

View File

@@ -1,5 +1,7 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getAutoContexts } from '../getAutoContexts';
@@ -51,8 +53,8 @@ describe('getAutoContexts', () => {
it('includes the query in alert edit context', () => {
const ruleId = 'rule-edit';
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.compositeQuery}=${compositeQuery}`;
const serializedParams = serialize(query as unknown as Query);
const search = `?${QueryParams.ruleId}=${ruleId}&${serializedParams.toString()}`;
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
@@ -72,8 +74,8 @@ describe('getAutoContexts', () => {
it('includes the query in alert new context (no ruleId)', () => {
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.compositeQuery}=${compositeQuery}`;
const serializedParams = serialize(query as unknown as Query);
const search = `?${serializedParams.toString()}`;
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
@@ -189,4 +191,24 @@ describe('getAutoContexts', () => {
),
).toStrictEqual([]);
});
it('decodes the serialized composite query into metadata.query', () => {
const query = { builder: { queryData: [] } } as unknown as Query;
const search = `?${serialize(query).toString()}`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata?.query).toStrictEqual(query);
});
it('omits metadata.query when no serialized query is in the URL', () => {
// Detection no longer gates on the `compositeQuery` key — it routes
// through `deserialize`/the adapter list — so non-query params (time
// range, etc.) must not be mistaken for a query.
const search = `?${QueryParams.startTime}=1700000000000&${QueryParams.endTime}=1700003600000`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata).not.toHaveProperty('query');
});
});

View File

@@ -24,7 +24,7 @@ import {
undoExecution,
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { serialize } from 'lib/compositeQuery/serializer';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -363,8 +363,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → history.push', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
const params = serialize(normalized);
deps.history.push(`${base}?${params.toString()}`);
}
/** Picks the right rollback API call for a given action kind. */

View File

@@ -8,6 +8,7 @@ import { getViewById } from 'api/saveView/getViewById';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { deserialize } from 'lib/compositeQuery/serializer';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
@@ -218,7 +219,9 @@ describe('buildExplorerNavigationUrl', () => {
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain(`${QueryParams.compositeQuery}=`);
const params = new URLSearchParams(new URL(url, 'http://x').search);
expect(deserialize(params)).not.toBeNull();
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});

View File

@@ -2,6 +2,10 @@ import { getAllViews } from 'api/saveView/getAllViews';
import { getViewById } from 'api/saveView/getViewById';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { ViewProps } from 'types/api/saveViews/types';
@@ -75,10 +79,7 @@ export function buildExplorerNavigationUrl(
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), params);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});

View File

@@ -1,6 +1,7 @@
import type { MessageContext } from 'api/ai-assistant/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { deserialize } from 'lib/compositeQuery/serializer';
import { AlertListTabs } from 'pages/AlertList/types';
import { matchPath } from 'react-router-dom';
@@ -343,15 +344,9 @@ function collectSharedMetadata(
out.timeRange = { start: startTime, end: endTime };
}
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
if (compositeQueryRaw) {
try {
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
} catch {
// Malformed JSON in the URL — drop silently rather than throw
// inside a context-collection helper.
}
const decodedQuery = deserialize(params);
if (decodedQuery) {
out.query = decodedQuery;
}
// Saved view selectors (logs / traces explorer) and dashboard variables.

View File

@@ -2,8 +2,8 @@ import { memo } from 'react';
import { Card, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
@@ -28,9 +28,7 @@ function PanelTypeSelectionModal(): JSX.Element {
const queryParams = {
graphType: name,
widgetId: id,
[QueryParams.compositeQuery]: JSON.stringify(
PANEL_TYPES_INITIAL_QUERY[name],
),
...serializeToParams(PANEL_TYPES_INITIAL_QUERY[name]),
};
history.push(

View File

@@ -62,6 +62,8 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import useErrorNotification from 'hooks/useErrorNotification';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { cloneDeep, isEqual, omit } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
@@ -174,7 +176,7 @@ function ExplorerOptions({
const handleConditionalQueryModification = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(defaultQuery: Query | null): string => {
(defaultQuery: Query | null): Record<string, string> => {
const queryToUse = defaultQuery || query;
if (!queryToUse) {
throw new Error('No query provided');
@@ -184,7 +186,7 @@ function ExplorerOptions({
StringOperators.NOOP &&
sourcepage !== DataSource.LOGS
) {
return JSON.stringify(queryToUse);
return serializeToParams(queryToUse);
}
// Convert NOOP to COUNT for alerts and strip orderBy for logs
@@ -208,14 +210,7 @@ function ExplorerOptions({
);
}
try {
return JSON.stringify(modifiedQuery);
} catch (err) {
throw new Error(
'Failed to stringify modified query: ' +
(err instanceof Error ? err.message : String(err)),
);
}
return serializeToParams(modifiedQuery);
},
[panelType, query, sourcepage],
);
@@ -238,13 +233,9 @@ function ExplorerOptions({
});
}
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
const serializedParams = handleConditionalQueryModification(defaultQuery);
history.push(
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
);
history.push(`${ROUTES.ALERTS_NEW}?${createQueryParams(serializedParams)}`);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleConditionalQueryModification, history],

View File

@@ -34,6 +34,7 @@ import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty, isEqual } from 'lodash-es';
@@ -384,7 +385,7 @@ function FormAlertRules({
const onCancelHandler = useCallback(
(e?: React.MouseEvent) => {
urlQuery.delete(QueryParams.compositeQuery);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
@@ -610,7 +611,7 @@ function FormAlertRules({
`${ruleId}`,
]);
urlQuery.delete(QueryParams.compositeQuery);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);

View File

@@ -23,6 +23,10 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
clearSerializedParams,
serializeToParams,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import {
@@ -212,9 +216,7 @@ function WidgetGraphComponent({
[QueryParams.graphType]: clonedWidget?.panelTypes,
[QueryParams.widgetId]: uuid,
...(clonedWidget?.query && {
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(clonedWidget.query),
),
...serializeToParams(clonedWidget.query),
}),
};
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
@@ -255,7 +257,7 @@ function WidgetGraphComponent({
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
existingSearchParams.delete(QueryParams.compositeQuery);
clearSerializedParams(existingSearchParams);
existingSearchParams.delete(QueryParams.graphType);
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
if (queryResponse.data?.payload) {

View File

@@ -29,6 +29,10 @@ import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { isEmpty } from 'lodash-es';
import { unparse } from 'papaparse';
@@ -86,10 +90,7 @@ function WidgetHeader({
const widgetId = widget.id;
urlQuery.set(QueryParams.widgetId, widgetId);
urlQuery.set(QueryParams.graphType, widget.panelTypes);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(widget.query)),
);
applySerializedParams(serialize(widget.query), urlQuery);
const generatedUrl = buildAbsolutePath({
relativePath: 'new',
urlQueryString: urlQuery.toString(),

View File

@@ -7,6 +7,10 @@ import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
@@ -134,10 +138,7 @@ export default function AlertRules({
const compositeQuery = mapQueryDataFromApi(
toCompositeMetricQuery(record.condition.compositeQuery),
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), params);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -28,6 +28,10 @@ import {
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
@@ -410,7 +414,7 @@ export default function K8sBaseDetails<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), urlQuery);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
@@ -435,7 +439,7 @@ export default function K8sBaseDetails<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), urlQuery);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}

View File

@@ -53,6 +53,7 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetGlobalConfig } from 'api/generated/services/global';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { serialize } from 'lib/compositeQuery/serializer';
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
import {
ArrowUpRight,
@@ -77,6 +78,7 @@ import {
UpdateLimitProps,
} from 'types/api/ingestionKeys/limits/types';
import { PaginationProps } from 'types/api/ingestionKeys/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MeterAggregateOperator } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { getDaysUntilExpiry } from 'utils/timeUtils';
@@ -896,8 +898,6 @@ function MultiIngestionSettings(): JSX.Element {
},
};
const stringifiedQuery = JSON.stringify(query);
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
thresholds[0].thresholdValue = thresholdValue;
thresholds[0].unit = thresholdUnit;
@@ -907,17 +907,12 @@ function MultiIngestionSettings(): JSX.Element {
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
const URL = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(stringifiedQuery)}&${
QueryParams.thresholds
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}`;
const params = serialize(query as Query);
params.set(QueryParams.thresholds, JSON.stringify(thresholds));
params.set(QueryParams.ruleName, ruleName);
params.set(QueryParams.yAxisUnit, yAxisUnit);
history.push(URL);
history.push(`${ROUTES.ALERTS_NEW}?${params.toString()}`);
};
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [

View File

@@ -1,5 +1,6 @@
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
@@ -132,17 +133,19 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(1000);
expect(thresholds[0].unit).toBe('{count}');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('{count}');
expect(compositeQuery.builder.queryData).toBeDefined();
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('{count}');
expect(compositeQuery?.builder.queryData).toBeDefined();
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
"signoz.workspace.key.id='k1'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe(
'signoz.meter.metric.datapoint.count',
);
@@ -213,18 +216,18 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(400);
expect(thresholds[0].unit).toBe('GiBy');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('bytes');
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('bytes');
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
"signoz.workspace.key.id='k2'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.log.size',
);
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe('signoz.meter.log.size');
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');

View File

@@ -6,6 +6,10 @@ import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTableRowClick } from 'hooks/useTableRowClick';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { isModifierKeyPressed } from 'utils/app';
@@ -31,10 +35,7 @@ export function useAlertRulesHandlers(
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
rule.alertType,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), params);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -14,6 +14,10 @@ import { FontSize } from 'container/OptionsMenu/types';
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -111,10 +115,7 @@ function ContextLogRenderer({
(logId: string): void => {
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), urlQuery);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');

View File

@@ -247,16 +247,12 @@ function Application(): JSX.Element {
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
JSONCompositeQuery,
apmToTraceQuery,
queryString,
);

View File

@@ -8,6 +8,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
@@ -60,16 +64,18 @@ export function generateExplorerPath(
urlParams: URLSearchParams,
servicename: string | undefined,
selectedTraceTags: string,
JSONCompositeQuery: string,
apmToTraceQuery: Query,
queryString: string[],
): string {
const basePath = isViewLogsClicked
? ROUTES.LOGS_EXPLORER
: ROUTES.TRACES_EXPLORER;
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}&${queryString.join('&')}`;
applySerializedParams(serialize(apmToTraceQuery), urlParams);
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${queryString.join(
'&',
)}`;
}
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
@@ -105,16 +111,12 @@ export function onViewTracePopupClick({
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
JSONCompositeQuery,
apmToTraceQuery,
queryString,
);

View File

@@ -1,5 +1,6 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { withBasePath } from 'utils/basePath';
import { TopOperationList } from './TopOperationsTable';
@@ -29,13 +30,11 @@ export const navigateToTrace = ({
);
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
const newTraceExplorerPath = `${
ROUTES.TRACES_EXPLORER
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${serialize(
apmToTraceQuery,
).toString()}`;
if (openInNewTab) {
window.open(withBasePath(newTraceExplorerPath), '_blank');

View File

@@ -33,6 +33,7 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
@@ -791,9 +792,7 @@ function NewWidget({
const queryParams = {
[QueryParams.expandedWidgetId]: widgetId,
[QueryParams.graphType]: graphType,
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery),
),
...serializeToParams(currentQuery),
[QueryParams.variables]: variables,
};

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -49,7 +50,7 @@ const useBaseDrilldownNavigate = ({
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...serializeToParams(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
@@ -94,7 +95,7 @@ export function buildDrilldownUrl(
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...serializeToParams(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),

View File

@@ -35,6 +35,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
import { normalizeTimeToMs } from 'utils/timeUtils';
import {
applySerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
import { v4 as uuid } from 'uuid';
import AutoRefresh from '../AutoRefreshV2';
@@ -278,7 +283,7 @@ function DateTimeSelection({
return `Refreshed ${secondsDiff} sec ago`;
}, [maxTime, minTime, selectedTime]);
const getUpdatedCompositeQuery = useCallback((): string => {
const getUpdatedCompositeQuery = useCallback((): URLSearchParams => {
let updatedCompositeQuery = cloneDeep(currentQuery);
updatedCompositeQuery.id = uuid();
// Remove the filters
@@ -299,7 +304,7 @@ function DateTimeSelection({
})),
},
};
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
return serialize(updatedCompositeQuery);
}, [currentQuery]);
const onSelectHandler = useCallback(
@@ -334,9 +339,9 @@ function DateTimeSelection({
// Remove Hidden Filters from URL query parameters on time change
urlQuery.delete(QueryParams.activeLogId);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
@@ -424,9 +429,9 @@ function DateTimeSelection({
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
urlQuery.delete(QueryParams.relativeTime);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;

View File

@@ -0,0 +1,170 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const BASE = 'http://localhost';
const urlFrom = (pathname: string, params?: URLSearchParams): URL => {
const search = params?.toString();
const query = search ? `?${search}` : '';
return new URL(`${pathname}${query}`, BASE);
};
/** Build params containing the serialized `compositeQuery` plus any extras. */
const withQuery = (
query: Query,
extra: Record<string, string> = {},
): URLSearchParams => {
const params = serialize(query);
Object.entries(extra).forEach(([key, value]) => params.set(key, value));
return params;
};
describe('areUrlsEffectivelySame', () => {
it('returns false when pathnames differ', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/traces'))).toBe(
false,
);
});
it('returns true for two identical param-less URLs', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/logs'))).toBe(true);
});
it('returns true when only the compositeQuery is present and identical', () => {
const params = withQuery(initialQueriesMap.logs);
expect(
areUrlsEffectivelySame(
urlFrom('/logs', params),
urlFrom('/logs', new URLSearchParams(params.toString())),
),
).toBe(true);
});
// Regression: a matching compositeQuery must NOT mask differences in other
// params. Previously every param was compared via the decoded query, so any
// two URLs sharing a compositeQuery were judged identical.
it('returns false when compositeQuery matches but another param differs', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '2000' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('returns false when compositeQuery matches but a param exists on only one URL', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('ignores the volatile id when comparing compositeQuery', () => {
const url1 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-1' }),
);
const url2 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-2' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(true);
});
it('returns false when compositeQuery is semantically different', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/metrics', withQuery(initialQueriesMap.metrics));
// Force same pathname so only the query differs.
expect(
areUrlsEffectivelySame(
url1,
urlFrom('/logs', new URLSearchParams(url2.search)),
),
).toBe(false);
});
it('returns false when compositeQuery exists on only one URL', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/logs');
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('compares non-compositeQuery params directly when no compositeQuery is present', () => {
const same1 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
const same2 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
expect(areUrlsEffectivelySame(same1, same2)).toBe(true);
const diff = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '3' }),
);
expect(areUrlsEffectivelySame(same1, diff)).toBe(false);
});
it('falls back to raw comparison when compositeQuery cannot be decoded', () => {
const corrupt1 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
const corrupt2 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt2)).toBe(true);
const corrupt3 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bother' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt3)).toBe(false);
});
});
describe('isDefaultNavigation', () => {
it('returns false for different pathnames', () => {
expect(isDefaultNavigation(urlFrom('/logs'), urlFrom('/traces'))).toBe(false);
});
it('returns true when a clean URL gains params', () => {
expect(
isDefaultNavigation(
urlFrom('/logs'),
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
),
).toBe(true);
});
it('returns true when the target introduces a new param key', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '1', endTime: '2' })),
),
).toBe(true);
});
it('returns false when the target has no new param keys', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '9' })),
),
).toBe(false);
});
});

View File

@@ -5,7 +5,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
@@ -13,6 +12,7 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { deserialize } from 'lib/compositeQuery/serializer';
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { AppState } from 'store/reducers';
@@ -58,9 +58,14 @@ export const useActiveLog = (): UseActiveLog => {
const [activeLog, setActiveLog] = useState<ILog | null>(null);
// Close drawer/clear active log when query in URL changes
// Close drawer/clear active log when query in URL changes. Track the decoded
// query (not a single raw param) so it stays correct across serializer tiers
// that explode the query into many keys.
const urlQuery = useUrlQuery();
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
const compositeQuery = useMemo(() => {
const decoded = deserialize(urlQuery);
return decoded ? JSON.stringify(decoded) : '';
}, [urlQuery]);
const prevQueryRef = useRef<string | null>(null);
useEffect(() => {
if (

View File

@@ -2,9 +2,10 @@ import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Widgets } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import useCreateAlerts from '../useCreateAlerts';
@@ -79,14 +80,14 @@ const buildWidget = (queryType: EQueryType | undefined): Widgets =>
},
}) as unknown as Widgets;
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
const getCompositeQueryFromLastOpen = (): Query => {
const [url] = (window.open as jest.Mock).mock.calls[0];
const query = new URLSearchParams((url as string).split('?')[1]);
const raw = query.get(QueryParams.compositeQuery);
if (!raw) {
const composite = deserialize(query);
if (!composite) {
throw new Error('compositeQuery not found in URL');
}
return JSON.parse(decodeURIComponent(raw));
return composite;
};
describe('useCreateAlerts', () => {

View File

@@ -0,0 +1,26 @@
import { renderHook } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
let mockUrlQuery = new URLSearchParams();
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => mockUrlQuery,
}));
describe('useGetCompositeQueryParam', () => {
it('decodes a legacy compositeQuery param', () => {
mockUrlQuery = new URLSearchParams({
compositeQuery: encodeURIComponent(JSON.stringify(initialQueriesMap.logs)),
});
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null when the param is absent', () => {
mockUrlQuery = new URLSearchParams();
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current).toBeNull();
});
});

View File

@@ -14,6 +14,10 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useNotifications } from 'hooks/useNotifications';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
@@ -86,10 +90,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
}
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
applySerializedParams(serialize(updatedQuery), params);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -1,72 +1,10 @@
import { useMemo } from 'react';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { deserialize } from 'lib/compositeQuery/serializer';
import { useMemo } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const useGetCompositeQueryParam = (): Query | null => {
const urlQuery = useUrlQuery();
return useMemo(() => {
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
let parsedCompositeQuery: Query | null = null;
try {
if (!compositeQuery) {
return null;
}
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
parsedCompositeQuery = JSON.parse(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
parsedCompositeQuery.builder.queryData =
parsedCompositeQuery.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
// Convert having if needed
if (Array.isArray(query.having)) {
const convertedHaving = convertHavingToExpression(query.having);
convertedQuery.having = convertedHaving;
}
// Convert aggregation if needed
if (!query.aggregations && query.aggregateOperator) {
const convertedAggregation = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
}) as any; // Type assertion to handle union type
convertedQuery.aggregations = convertedAggregation;
}
return convertedQuery;
});
}
} catch (e) {
parsedCompositeQuery = null;
}
return parsedCompositeQuery;
}, [urlQuery]);
return useMemo(() => deserialize(urlQuery), [urlQuery]);
};

View File

@@ -1,6 +1,9 @@
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
@@ -18,77 +21,6 @@ interface UseSafeNavigateProps {
preventSameUrlNavigation?: boolean;
}
const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
const allParams = new Set([...params1.keys(), ...params2.keys()]);
return [...allParams].every((param) => {
if (param === 'compositeQuery') {
try {
const query1 = params1.get('compositeQuery');
const query2 = params2.get('compositeQuery');
if (!query1 || !query2) {
return false;
}
const decoded1 = JSON.parse(decodeURIComponent(query1));
const decoded2 = JSON.parse(decodeURIComponent(query2));
const filtered1 = cloneDeep(decoded1);
const filtered2 = cloneDeep(decoded2);
delete filtered1.id;
delete filtered2.id;
return isEqual(filtered1, filtered2);
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
return false;
}
}
return params1.get(param) === params2.get(param);
});
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};
export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true,

View File

@@ -0,0 +1,103 @@
import { deserialize } from 'lib/compositeQuery/serializer';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { isEqual } from 'lodash-es';
/**
* Compare the (optional) `compositeQuery` param of two URLSearchParams
* semantically. Its serialized form is not byte-stable — the volatile `id` and
* the adapter choice both vary — so we decode and deep-compare, ignoring `id`.
*
* compositeQuery is not guaranteed to be present: absent on both sides counts
* as equal, present on only one side counts as different. When either side is
* present but can't be decoded, we fall back to comparing the raw values.
*/
const compositeQueriesEqual = (
params1: URLSearchParams,
params2: URLSearchParams,
): boolean => {
const raw1 = params1.get(COMPOSITE_QUERY_KEY);
const raw2 = params2.get(COMPOSITE_QUERY_KEY);
if (!raw1 && !raw2) {
return true;
}
if (!raw1 || !raw2) {
return false;
}
try {
const decoded1 = deserialize(params1);
const decoded2 = deserialize(params2);
if (decoded1 && decoded2) {
// Ignore the volatile `id` when comparing queries.
const { id: _id1, ...rest1 } = decoded1;
const { id: _id2, ...rest2 } = decoded2;
return isEqual(rest1, rest2);
}
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
}
// One or both could not be decoded — compare the raw encoded values.
return raw1 === raw2;
};
export const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
// The compositeQuery is compared semantically (it round-trips through a
// non-stable serialized form); every other param is compared by raw value.
if (!compositeQueriesEqual(params1, params2)) {
return false;
}
const otherKeys = new Set(
[...params1.keys(), ...params2.keys()].filter(
(key) => key !== COMPOSITE_QUERY_KEY,
),
);
return [...otherKeys].every((key) => params1.get(key) === params2.get(key));
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
export const isDefaultNavigation = (
currentUrl: URL,
targetUrl: URL,
): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};

View File

@@ -0,0 +1,51 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import {
clearSerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
describe('composite query serializer', () => {
it('round-trips through serialize/deserialize', () => {
const query = initialQueriesMap.logs;
const decoded = deserialize(serialize(query));
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null on corrupt input instead of throwing', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
expect(deserialize(params)).toBeNull();
});
it('returns null for empty/missing value', () => {
const params = new URLSearchParams();
expect(deserialize(params)).toBeNull();
});
it('preserves id field through roundtrip', () => {
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
const serialized = serialize(query);
const decoded = deserialize(serialized);
expect(decoded?.id).toBe('test-query-uuid-123');
});
it('clearSerializedParams purges every serialized key, leaving others intact', () => {
const params = serialize(initialQueriesMap.logs);
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(deserialize(params)).toBeNull();
expect(params.get('panelTypes')).toBe('list');
});
it('clearSerializedParams drops a corrupt legacy key via fallback', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(params.get('panelTypes')).toBe('list');
});
});

View File

@@ -0,0 +1,69 @@
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import {
CompositeQueryAdapter,
COMPOSITE_QUERY_KEY,
} from 'lib/compositeQuery/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
function migrateLegacyFormat(parsed: Query): Query {
if (!parsed?.builder?.queryData) {
return parsed;
}
const next = parsed;
next.builder.queryData = parsed.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
if (Array.isArray(query.having)) {
convertedQuery.having = convertHavingToExpression(query.having);
}
if (!query.aggregations && query.aggregateOperator) {
convertedQuery.aggregations = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
}
return convertedQuery;
});
return next;
}
export const jsonAdapter: CompositeQueryAdapter = {
name: 'json(legacy)',
encode: (query) => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(query));
return params;
},
matches: () => true,
decode: (params) => {
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
let parsed: Query;
try {
parsed = JSON.parse(raw);
} catch (e) {
parsed = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
}
return migrateLegacyFormat(parsed);
},
};

View File

@@ -0,0 +1,211 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { jsonAdapter } from './index';
const roundTrip = (query: Query): Query =>
jsonAdapter.decode(jsonAdapter.encode(query));
describe('jsonAdapter', () => {
describe('round-trip', () => {
it.each(['metrics', 'logs', 'traces'] as const)(
'round-trips %s baseline preserving dataSource',
(source) => {
const query = initialQueriesMap[source];
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].dataSource).toBe(source);
},
);
});
describe('encoding', () => {
it('encodes using single URL encoding via URLSearchParams', () => {
const query = initialQueriesMap.logs;
const params = jsonAdapter.encode(query);
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
// URLSearchParams.get() returns decoded value, so raw === JSON string
expect(raw).toBe(JSON.stringify(query));
expect(raw.startsWith('{')).toBe(true);
// Full URL shows single encoding
const fullUrl = params.toString();
expect(fullUrl).toContain('%7B'); // encoded {
expect(fullUrl).not.toContain('%257B'); // NOT double-encoded
});
it('decode handles single-encoded format (current)', () => {
const query = initialQueriesMap.logs;
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(query));
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].dataSource).toBe('logs');
});
});
describe('legacy double-encoded fallback', () => {
it('decode handles double-encoded format (legacy URLs)', () => {
const query = initialQueriesMap.logs;
// Simulate legacy: JSON -> encodeURIComponent -> set as raw param
const doubleEncoded = encodeURIComponent(JSON.stringify(query));
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].dataSource).toBe('logs');
});
it('double-encoded with special chars decodes correctly', () => {
const queryWithSpecialChars = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
filters: {
op: 'AND',
items: [
{
key: { key: 'message', dataType: 'string', type: 'tag' },
op: '=',
value: 'hello world & foo=bar',
},
],
},
},
],
},
};
const doubleEncoded = encodeURIComponent(
JSON.stringify(queryWithSpecialChars),
);
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
const decoded = jsonAdapter.decode(params);
const filter = decoded.builder.queryData[0].filters?.items[0];
expect(filter?.value).toBe('hello world & foo=bar');
});
});
describe('plus-sign handling', () => {
it('plus signs in double-encoded URLs decode as spaces', () => {
// In URL encoding, + represents space. Legacy URLs may have this.
const query = { queryType: 'builder', test: 'hello world' };
// Manually create double-encoded with + for space
const jsonStr = JSON.stringify(query);
const encoded = encodeURIComponent(jsonStr).replace(/%20/g, '+');
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, encoded);
const decoded = jsonAdapter.decode(params) as any;
expect(decoded.test).toBe('hello world');
});
it('plus signs in filter values preserved after decode', () => {
// Value literally contains + (not space)
const queryWithPlus = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
filters: {
op: 'AND',
items: [
{
key: { key: 'expr', dataType: 'string', type: 'tag' },
op: '=',
value: '1+2=3',
},
],
},
},
],
},
};
// Current format (single encode) - + becomes %2B
const params = jsonAdapter.encode(queryWithPlus as Query);
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].filters?.items[0]?.value).toBe('1+2=3');
});
it('legacy double-encoded + in values preserved', () => {
const queryWithPlus = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: {
op: 'AND',
items: [{ key: { key: 'x' }, op: '=', value: 'a+b' }],
},
},
],
queryFormulas: [],
},
promql: [],
clickhouse_sql: [],
id: 'x',
unit: '',
};
// Double encode: + in JSON becomes %2B, then %252B
const doubleEncoded = encodeURIComponent(JSON.stringify(queryWithPlus));
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, doubleEncoded);
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].filters?.items[0]?.value).toBe('a+b');
});
});
describe('tag matching', () => {
it('matches any value (catch-all fallback)', () => {
const params1 = new URLSearchParams();
params1.set(COMPOSITE_QUERY_KEY, '%7B%22queryType%22%3A%22builder%22%7D');
expect(jsonAdapter.matches(params1)).toBe(true);
const params2 = new URLSearchParams();
params2.set(COMPOSITE_QUERY_KEY, 'z1~abc');
expect(jsonAdapter.matches(params2)).toBe(true);
});
});
describe('migration', () => {
it('migrates old format (filters -> filter.expression)', () => {
const legacy = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { op: 'AND', items: [] },
aggregateOperator: 'count',
aggregateAttribute: { key: '', dataType: '', type: '' },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
id: 'x',
unit: '',
};
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, JSON.stringify(legacy));
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].filter).toBeDefined();
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
});
});
});

View File

@@ -0,0 +1,79 @@
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
import {
COMPOSITE_QUERY_KEY,
CompositeQueryAdapter,
} from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
// Order matters for decode: most-specific (tagged) adapters first
const ADAPTERS: CompositeQueryAdapter[] = [jsonAdapter];
// Pick the adapter that owns a given URL. json's `matches` is always true, so
// it serves as the final fallback when no tagged adapter claims the params.
function adapterFor(params: URLSearchParams): CompositeQueryAdapter {
return ADAPTERS.find((adapter) => adapter.matches(params)) ?? jsonAdapter;
}
/**
* Encode a query to the shortest available URLSearchParams.
*/
export function serialize(query: Query): URLSearchParams {
return ADAPTERS[0].encode(query);
}
/**
* Decode URLSearchParams back to a Query. Total: returns null on any failure.
*/
export function deserialize(params: URLSearchParams): Query | null {
const hasParams = Array.from(params.keys()).length > 0;
if (!hasParams) {
return null;
}
try {
return adapterFor(params).decode(params);
} catch {
return null;
}
}
/**
* Apply all params from source into target URLSearchParams.
*/
export function applySerializedParams(
source: URLSearchParams,
target: URLSearchParams,
): void {
source.forEach((value, key) => target.set(key, value));
}
/**
* Remove every serialized-query param from target URLSearchParams. Use instead
* of `target.delete('compositeQuery')` so a stale query is fully purged even
* for adapters that explode a query into many content-dependent keys (e.g.
* `query0.ds`, `query0.fl.it.0.key.key`) which can't be listed statically.
*
* Keys are discovered by round-trip: decode the current params with their
* owning adapter, re-encode, then delete exactly the keys encoding produces.
* If the params don't decode (absent/corrupt), fall back to dropping the legacy
* single key so a stale `compositeQuery` is still cleared.
*/
export function clearSerializedParams(target: URLSearchParams): void {
const adapter = adapterFor(target);
try {
adapter.encode(adapter.decode(target)).forEach((_value, key) => {
target.delete(key);
});
} catch {
target.delete(COMPOSITE_QUERY_KEY);
}
}
/**
* Serialize a query to a plain record of all URL params it produces. Use when
* building a query-param object manually (e.g. for `createQueryParams`), so the
* call site carries every param the adapter emits — not just `compositeQuery`.
* Spread it: `{ ...serializeToParams(query), startTime, endTime }`.
*/
export function serializeToParams(query: Query): Record<string, string> {
return Object.fromEntries(serialize(query));
}

View File

@@ -0,0 +1,15 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const COMPOSITE_QUERY_KEY = 'compositeQuery';
/**
* A serialization tier. `encode` returns URLSearchParams (default key =
* `compositeQuery`). `matches` checks if params belong to this adapter.
* `decode` receives URLSearchParams and returns Query.
*/
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): URLSearchParams;
matches(params: URLSearchParams): boolean;
decode(params: URLSearchParams): Query;
}

View File

@@ -15,6 +15,7 @@ import EditRulesContainer from 'container/EditRules';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
import history from 'lib/history';
import {
NEW_ALERT_SCHEMA_VERSION,
@@ -49,7 +50,7 @@ function EditRules(): JSX.Element {
const { notifications } = useNotifications();
const clickHandler = (): void => {
params.delete(QueryParams.compositeQuery);
clearSerializedParams(params);
params.delete(QueryParams.panelTypes);
params.delete(QueryParams.ruleId);
params.delete(QueryParams.relativeTime);

View File

@@ -28,6 +28,10 @@ import ROUTES from 'constants/routes';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import dayjs from 'dayjs';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import {
TraceDetailEventKeys,
TraceDetailEvents,
@@ -246,7 +250,7 @@ function SpanDetailsContent({
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), searchParams);
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());

View File

@@ -18,6 +18,7 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Compass } from '@signozhq/icons';
import { ILog } from 'types/api/logs/log';
@@ -138,7 +139,7 @@ function SpanLogs({
[QueryParams.activeLogId]: `"${log.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
...serializeToParams(updatedQuery),
};
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;

View File

@@ -38,6 +38,10 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
@@ -990,10 +994,7 @@ export function QueryBuilderProvider({
);
}
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
);
applySerializedParams(serialize(currentGeneratedQuery), urlQuery);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>

View File

@@ -2,6 +2,7 @@ import { generatePath } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
type GenerateExportToDashboardLinkParams = {
@@ -21,6 +22,4 @@ export const generateExportToDashboardLink = ({
dashboardId,
})}/new?${QueryParams.graphType}=${panelType}&${
QueryParams.widgetId
}=${widgetId}&${QueryParams.compositeQuery}=${encodeURIComponent(
JSON.stringify(query),
)}`;
}=${widgetId}&${serialize(query).toString()}`;