Compare commits

...

3 Commits

Author SHA1 Message Date
Vinícius Lourenço
2c76d68393 feat(flat-leaf): add serializer 2026-06-12 17:29:53 -03:00
Vinícius Lourenço
ac1321b7dd feat(flat-keys): add serializer 2026-06-12 17:29:33 -03:00
Vinícius Lourenço
75f410b5ad feat(compositeQuery): add support to custom deserializers 2026-06-12 17:29:33 -03:00
27 changed files with 1812 additions and 79 deletions

View File

@@ -6,6 +6,10 @@ import {
import { SelectOption } from 'types/common/select';
export const metricAggregateOperatorOptions: SelectOption<string, string>[] = [
{
value: MetricAggregateOperator.NOOP,
label: 'No aggregation',
},
{
value: MetricAggregateOperator.COUNT,
label: 'Count',

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 { matchPath } from 'react-router-dom';
/**
@@ -294,11 +295,11 @@ function collectSharedMetadata(
// 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.
// Decode through the serializer seam (handles every tier + malformed
// input → null); never JSON.parse the raw URL value.
const decodedQuery = deserialize(compositeQueryRaw);
if (decodedQuery) {
out.query = decodedQuery;
}
}

View File

@@ -35,6 +35,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
import { normalizeTimeToMs } from 'utils/timeUtils';
import { serialize } from 'lib/compositeQuery/serializer';
import { v4 as uuid } from 'uuid';
import AutoRefresh from '../AutoRefreshV2';
@@ -299,7 +300,7 @@ function DateTimeSelection({
})),
},
};
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
return serialize(updatedCompositeQuery);
}, [currentQuery]);
const onSelectHandler = useCallback(

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

@@ -1,12 +1,7 @@
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 => {
@@ -14,59 +9,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
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;
if (!compositeQuery) {
return null;
}
return parsedCompositeQuery;
return deserialize(compositeQuery);
}, [urlQuery]);
};

View File

@@ -1,6 +1,7 @@
import { deserialize } from 'lib/compositeQuery/serializer';
import { isEqual } from 'lodash-es';
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 {
@@ -38,16 +39,20 @@ const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
return false;
}
const decoded1 = JSON.parse(decodeURIComponent(query1));
const decoded2 = JSON.parse(decodeURIComponent(query2));
// Decode through the serializer seam: the URL value may be in any
// tier (legacy JSON or a tagged format), so never JSON.parse it raw.
const decoded1 = deserialize(query1);
const decoded2 = deserialize(query2);
const filtered1 = cloneDeep(decoded1);
const filtered2 = cloneDeep(decoded2);
if (!decoded1 || !decoded2) {
return false;
}
delete filtered1.id;
delete filtered2.id;
// Ignore the volatile `id` when comparing queries.
const { id: _id1, ...rest1 } = decoded1;
const { id: _id2, ...rest2 } = decoded2;
return isEqual(filtered1, filtered2);
return isEqual(rest1, rest2);
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
return false;

View File

@@ -0,0 +1,213 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
//
// ╔════════════════════════════════════════════════════════════════════════════╗
// ║ ⚠️ NEVER UPDATE THESE ⚠️ ║
// ╠════════════════════════════════════════════════════════════════════════════╣
// ║ These snapshots guard URL backward compatibility. Every emitted URL ║
// ║ encodes a diff against these exact baselines. ║
// ║ ║
// ║ If a test fails: REVERT baseline.ts, do NOT update snapshots. ║
// ║ If you need a new schema: create BASELINE_V2 + new adapter prefix. ║
// ╚════════════════════════════════════════════════════════════════════════════╝
exports[`baseline immutability snapshots LOGS_BASELINE must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots METRICS_BASELINE must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "noop",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots TRACES_BASELINE must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;

View File

@@ -0,0 +1,123 @@
/**
* ╔════════════════════════════════════════════════════════════════════════════╗
* ║ ⚠️ CRITICAL WARNING ⚠️ ║
* ╠════════════════════════════════════════════════════════════════════════════╣
* ║ These baselines are FROZEN FOREVER. They must NEVER be modified. ║
* ║ ║
* ║ WHY: Every URL ever emitted by the compositeQuery serializer encodes a ║
* ║ diff against these exact baselines. Changing a single byte here silently ║
* ║ BREAKS ALL EXISTING URLs — dashboards, saved views, shared links, etc. ║
* ║ ║
* ║ If these snapshot tests fail: ║
* ║ 1. DO NOT update the snapshots ║
* ║ 2. REVERT your changes to baseline.ts immediately ║
* ║ 3. If you need a new schema, create a NEW versioned baseline: ║
* ║ - METRICS_BASELINE_V2, LOGS_BASELINE_V2, TRACES_BASELINE_V2 ║
* ║ - Create a new adapter (e.g., V2~) that uses the new baselines ║
* ║ - Keep the old baselines untouched for backwards compatibility ║
* ╚════════════════════════════════════════════════════════════════════════════╝
*/
import getBaselineByTag, {
LOGS_BASELINE,
METRICS_BASELINE,
pickBaseline,
TRACES_BASELINE,
} from '../baseline';
describe('baseline immutability snapshots', () => {
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('METRICS_BASELINE must never change', () => {
expect(METRICS_BASELINE).toMatchSnapshot();
});
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('LOGS_BASELINE must never change', () => {
expect(LOGS_BASELINE).toMatchSnapshot();
});
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('TRACES_BASELINE must never change', () => {
expect(TRACES_BASELINE).toMatchSnapshot();
});
});
describe('pickBaseline', () => {
it('returns metrics baseline for metrics dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'metrics' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE);
expect(result.tag).toBe('m');
});
it('returns logs baseline for logs dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'logs' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(LOGS_BASELINE);
expect(result.tag).toBe('l');
});
it('returns traces baseline for traces dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'traces' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(TRACES_BASELINE);
expect(result.tag).toBe('t');
});
it('defaults to metrics baseline for unknown dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'unknown' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE);
expect(result.tag).toBe('m');
});
it('defaults to metrics baseline when queryData is empty', () => {
const query = {
builder: { queryData: [] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE);
expect(result.tag).toBe('m');
});
});
describe('getBaselineByTag', () => {
it('returns LOGS_BASELINE for tag "l"', () => {
expect(getBaselineByTag('l')).toBe(LOGS_BASELINE);
});
it('returns TRACES_BASELINE for tag "t"', () => {
expect(getBaselineByTag('t')).toBe(TRACES_BASELINE);
});
it('returns METRICS_BASELINE for tag "m"', () => {
expect(getBaselineByTag('m')).toBe(METRICS_BASELINE);
});
});

View File

@@ -0,0 +1,40 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { deserialize, serialize } from 'lib/compositeQuery/serializer';
describe('composite query serializer', () => {
it('serialize picks shortest format', () => {
const query = initialQueriesMap.metrics;
const serialized = serialize(query);
const jsonSerialized = encodeURIComponent(JSON.stringify(query));
// Serializer should pick a format shorter than or equal to raw JSON
expect(serialized.length).toBeLessThanOrEqual(jsonSerialized.length);
// Should use a tagged format with baseline indicator (m/l/t)
const usesTaggedFormat =
/^V1[mlt]~/.test(serialized) ||
/^TV[mlt]~/.test(serialized) ||
/^FV[mlt]~/.test(serialized) ||
/^FK[mlt]~/.test(serialized);
expect(usesTaggedFormat).toBe(true);
});
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', () => {
expect(deserialize('%7Bnot-json')).toBeNull();
});
it('returns null for empty/missing value', () => {
expect(deserialize('')).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');
});
});

View File

@@ -0,0 +1,166 @@
/**
* Flat-keys codec: a baseline-diff serializer that shortens keys but keeps
* values as raw JSON. Simpler than flat-leaf — no field-aware enum compression.
*
* Wire grammar (tokens joined by `*`):
* set: <shortPath>_<escapedJSON> e.g. b.qd.0.ds_"logs"
* delete: -<shortPath> e.g. -b.qd.0.ag.0.mn
*/
import getBaselineByTag, {
BaselineTag,
pickBaseline,
} from 'lib/compositeQuery/baseline';
import { INVERSE_KEY_MAP, KEY_MAP } from './maps';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
type Json = unknown;
type PathSeg = string | number;
const PAIR = '*';
const KV = '_';
const DEL = '-';
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
const isContainer = (value: Json): value is Record<string, Json> | Json[] =>
typeof value === 'object' && value !== null;
const isEmptyContainer = (value: Json): boolean =>
isContainer(value) &&
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
const isLeaf = (value: Json): boolean =>
!isContainer(value) || isEmptyContainer(value);
const shortenSeg = (seg: PathSeg): PathSeg =>
typeof seg === 'number' ? seg : (KEY_MAP[seg] ?? seg);
function leafMap(obj: Json): Record<string, Json> {
const out: Record<string, Json> = {};
const walk = (node: Json, segs: PathSeg[]): void => {
if (isLeaf(node)) {
out[segs.map(shortenSeg).join('.')] = node;
return;
}
if (Array.isArray(node)) {
node.forEach((value, index) => walk(value, [...segs, index]));
return;
}
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
walk(value, [...segs, key]),
);
};
walk(obj, []);
return out;
}
function escapeValue(str: string): string {
let escaped = str.replace(/_/g, '__').replace(/\*/g, '_s');
if (escaped[0] === '!') {
escaped = `_i${escaped.slice(1)}`;
}
return escaped;
}
const UNESCAPE: Record<string, string> = { _: '_', s: '*', i: '!' };
function unescapeValue(str: string): string {
let out = '';
for (let i = 0; i < str.length; i += 1) {
if (str[i] === '_') {
const next = str[i + 1];
out += UNESCAPE[next] ?? next;
i += 1;
} else {
out += str[i];
}
}
return out;
}
function encodeLeaf(value: Json): string {
if (value === undefined) {
return escapeValue('null');
}
return escapeValue(JSON.stringify(value));
}
function decodeLeaf(token: string): Json {
return JSON.parse(unescapeValue(token));
}
function parsePath(pathStr: string): PathSeg[] {
return pathStr
.split('.')
.map((seg) => (isIndex(seg) ? Number(seg) : (INVERSE_KEY_MAP[seg] ?? seg)));
}
function setPath(
root: Record<string, Json>,
segs: PathSeg[],
value: Json,
): void {
let node: Json = root;
for (let i = 0; i < segs.length - 1; i += 1) {
const seg = segs[i];
const container = node as Record<string | number, Json>;
if (!isContainer(container[seg])) {
container[seg] = typeof segs[i + 1] === 'number' ? [] : {};
}
node = container[seg];
}
(node as Record<string | number, Json>)[segs[segs.length - 1]] = value;
}
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
const root: Record<string, Json> = {};
Object.entries(map).forEach(([path, value]) => {
setPath(root, parsePath(path), value);
});
return root;
}
export function encode(query: Query): { payload: string; tag: BaselineTag } {
const { baseline, tag } = pickBaseline(query);
const base = leafMap(baseline);
const next = leafMap(query);
const tokens: string[] = [];
Object.entries(next).forEach(([path, value]) => {
const baseVal = base[path];
const normalizedBase = baseVal === undefined ? null : baseVal;
const normalizedNext = value === undefined ? null : value;
if (
path in base &&
JSON.stringify(normalizedBase) === JSON.stringify(normalizedNext)
) {
return;
}
tokens.push(`${path}${KV}${encodeLeaf(value)}`);
});
Object.keys(base).forEach((path) => {
if (!(path in next)) {
tokens.push(`${DEL}${path}`);
}
});
tokens.sort();
return { payload: tokens.join(PAIR), tag };
}
export function decode(payload: string, tag: BaselineTag): Query {
const map = leafMap(getBaselineByTag(tag));
if (payload) {
payload.split(PAIR).forEach((token) => {
if (!token) {
return;
}
if (token[0] === DEL) {
delete map[token.slice(1)];
return;
}
const sep = token.indexOf(KV);
const path = token.slice(0, sep);
map[path] = decodeLeaf(token.slice(sep + 1));
});
}
return rebuildFromLeaves(map) as unknown as Query;
}

View File

@@ -0,0 +1,100 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { roundTripScenarios } from '../testing/scenarios';
import { decodeFlatKeys, encodeFlatKeys, flatKeysAdapter } from './index';
const roundTrip = (query: Query): Query =>
flatKeysAdapter.decode(flatKeysAdapter.encode(query));
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
describe('flatKeysAdapter', () => {
describe('round-trip scenarios', () => {
it.each(roundTripScenarios)('$name', ({ query }) => {
expect(roundTrip(query)).toStrictEqual(query);
});
});
describe('tag matching', () => {
it('tags output with FKm~/FKl~/FKt~', () => {
const metricsEncoded = flatKeysAdapter.encode(initialQueriesMap.metrics);
expect(metricsEncoded.startsWith('FKm~')).toBe(true);
const logsEncoded = flatKeysAdapter.encode(initialQueriesMap.logs);
expect(logsEncoded.startsWith('FKl~')).toBe(true);
const tracesEncoded = flatKeysAdapter.encode(initialQueriesMap.traces);
expect(tracesEncoded.startsWith('FKt~')).toBe(true);
});
it('matches only own tags', () => {
const encoded = flatKeysAdapter.encode(initialQueriesMap.metrics);
expect(flatKeysAdapter.matches(encoded)).toBe(true);
expect(flatKeysAdapter.matches('FVm~')).toBe(false);
expect(flatKeysAdapter.matches('%7Bnot-mine')).toBe(false);
});
});
describe('baseline behavior', () => {
it('uses correct baseline tag per dataSource', () => {
expect(encodeFlatKeys(initialQueriesMap.logs).tag).toBe('l');
expect(encodeFlatKeys(initialQueriesMap.traces).tag).toBe('t');
expect(encodeFlatKeys(initialQueriesMap.metrics).tag).toBe('m');
});
it('decodeFlatKeys on empty payload returns baseline', () => {
const decodedMetrics = decodeFlatKeys('', 'm');
expect(decodedMetrics.queryType).toBe('builder');
expect(decodedMetrics.builder.queryData[0].dataSource).toBe('metrics');
const decodedLogs = decodeFlatKeys('', 'l');
expect(decodedLogs.builder.queryData[0].dataSource).toBe('logs');
});
});
describe('encoding stability', () => {
it('identical encoding after roundtrip', () => {
const query = initialQueriesMap.metrics;
const encoded1 = flatKeysAdapter.encode(query);
const decoded = flatKeysAdapter.decode(encoded1);
const encoded2 = flatKeysAdapter.encode(decoded);
expect(encoded2).toBe(encoded1);
});
it('key order independent', () => {
const query1 = initialQueriesMap.metrics;
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
const reordered = {
unit: query2.unit,
id: query2.id,
queryType: query2.queryType,
clickhouse_sql: query2.clickhouse_sql,
promql: query2.promql,
builder: query2.builder,
} as Query;
const encoded1 = flatKeysAdapter.encode(query1);
const encoded2 = flatKeysAdapter.encode(reordered);
expect(encoded2).toBe(encoded1);
});
});
describe('undefined handling', () => {
it('handles undefined values without breaking decode', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).aggregateOperator = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).source = undefined;
const encoded = flatKeysAdapter.encode(query);
const decoded = flatKeysAdapter.decode(encoded);
expect(decoded).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,32 @@
import { BaselineTag } from 'lib/compositeQuery/baseline';
import { decode, encode } from './codec';
import { CompositeQueryAdapter } from './types';
const TAG_PREFIX = 'FK';
const TAG_SUFFIX = '~';
/**
* Flat-keys (FK~): stores a query as a keys-only, leaf-flattened diff from the
* frozen baseline. Uses short keys but keeps values as raw JSON (no field-aware
* enum compression).
*
* Useful for benchmarking: isolates contribution of key shortening from value compression.
*/
export const flatKeysAdapter: CompositeQueryAdapter = {
name: 'flat-keys',
encode: (query) => {
const { payload, tag } = encode(query);
return `${TAG_PREFIX}${tag}${TAG_SUFFIX}${payload}`;
},
matches: (raw) =>
raw.startsWith(`${TAG_PREFIX}m${TAG_SUFFIX}`) ||
raw.startsWith(`${TAG_PREFIX}l${TAG_SUFFIX}`) ||
raw.startsWith(`${TAG_PREFIX}t${TAG_SUFFIX}`),
decode: (raw) => {
const tag = raw[2] as BaselineTag;
const payload = raw.slice(4);
return decode(payload, tag);
},
};
export { encode as encodeFlatKeys, decode as decodeFlatKeys } from './codec';

View File

@@ -0,0 +1,51 @@
/**
* Key shortening map for flatKeys adapter.
* No value compression - only key shortening.
*/
export const KEY_MAP: Record<string, string> = {
queryType: 'qt',
builder: 'b',
queryData: 'qd',
queryFormulas: 'qf',
queryTraceOperator: 'qo',
dataSource: 'ds',
queryName: 'qn',
aggregateOperator: 'ao',
aggregateAttribute: 'aa',
timeAggregation: 'ta',
spaceAggregation: 'sa',
filter: 'f',
expression: 'e',
aggregations: 'ag',
functions: 'fn',
filters: 'fl',
items: 'i',
disabled: 'd',
stepInterval: 'si',
having: 'h',
limit: 'l',
orderBy: 'ob',
groupBy: 'gb',
legend: 'lg',
reduceTo: 'rt',
source: 's',
promql: 'pq',
clickhouse_sql: 'cs',
name: 'n',
query: 'q',
key: 'k',
dataType: 'dt',
type: 't',
metricName: 'mn',
temporality: 'tp',
columnName: 'cn',
order: 'o',
value: 'v',
op: 'op',
isColumn: 'ic',
isJSON: 'ij',
};
export const INVERSE_KEY_MAP: Record<string, string> = Object.fromEntries(
Object.entries(KEY_MAP).map(([long, short]) => [short, long]),
);

View File

@@ -0,0 +1,8 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): string;
matches(raw: string): boolean;
decode(raw: string): Query;
}

View File

@@ -0,0 +1,197 @@
/**
* Flat-leaf codec: a baseline-diff serializer that emits one token per changed
* scalar leaf, with no JSON wrapper characters on the wire.
*
* Both the baseline and the query are flattened to maps of
* `{ shortDotPath: scalarLeaf }` (arrays walked element-wise, so `queryData[0]`
* becomes `qd.0`). Encode diffs the two maps and emits a token per changed or
* removed leaf; decode replays those tokens onto the baseline map and rebuilds
* the nested object. Because arrays are walked element-wise, adding one filter
* costs a few scalar tokens instead of a whole-array JSON blob.
*
* Wire grammar (tokens joined by `*`):
* set: <shortPath>_<encodedLeaf> e.g. b.qd.0.ds_0
* delete: -<shortPath> e.g. -b.qd.0.ag.0.mn
*/
import getBaselineByTag, {
BaselineTag,
pickBaseline,
} from 'lib/compositeQuery/baseline';
import {
FIELD_DOMAINS,
INVERSE_FIELD_DOMAINS,
INVERSE_KEY_MAP,
KEY_MAP,
} from './maps';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
type Json = unknown;
type PathSeg = string | number;
const PAIR = '*';
const KV = '_';
const DEL = '-';
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
const isContainer = (value: Json): value is Record<string, Json> | Json[] =>
typeof value === 'object' && value !== null;
const isEmptyContainer = (value: Json): boolean =>
isContainer(value) &&
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
const isLeaf = (value: Json): boolean =>
!isContainer(value) || isEmptyContainer(value);
const shortenSeg = (seg: PathSeg): PathSeg =>
typeof seg === 'number' ? seg : (KEY_MAP[seg] ?? seg);
const lastSeg = (path: string): string => path.slice(path.lastIndexOf('.') + 1);
function leafMap(obj: Json): Record<string, Json> {
const out: Record<string, Json> = {};
const walk = (node: Json, segs: PathSeg[]): void => {
if (isLeaf(node)) {
out[segs.map(shortenSeg).join('.')] = node;
return;
}
if (Array.isArray(node)) {
node.forEach((value, index) => walk(value, [...segs, index]));
return;
}
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
walk(value, [...segs, key]),
);
};
walk(obj, []);
return out;
}
function escapeValue(str: string): string {
let escaped = str.replace(/_/g, '__').replace(/\*/g, '_s');
if (escaped[0] === '!') {
escaped = `_i${escaped.slice(1)}`;
}
return escaped;
}
const UNESCAPE: Record<string, string> = { _: '_', s: '*', i: '!' };
function unescapeValue(str: string): string {
let out = '';
for (let i = 0; i < str.length; i += 1) {
if (str[i] === '_') {
const next = str[i + 1];
out += UNESCAPE[next] ?? next;
i += 1;
} else {
out += str[i];
}
}
return out;
}
function encodeLeaf(value: Json, shortKey: string): string {
if (typeof value === 'string') {
const domain = FIELD_DOMAINS[shortKey];
if (domain?.[value] !== undefined) {
return String(domain[value]);
}
return escapeValue(value);
}
if (value === undefined) {
return '!null';
}
return `!${JSON.stringify(value)}`;
}
function decodeLeaf(token: string, shortKey: string): Json {
if (token[0] === '!') {
return JSON.parse(token.slice(1));
}
const inverse = INVERSE_FIELD_DOMAINS[shortKey];
if (inverse && isIndex(token)) {
const mapped = inverse[Number(token)];
if (mapped !== undefined) {
return mapped;
}
}
return unescapeValue(token);
}
function parsePath(pathStr: string): PathSeg[] {
return pathStr
.split('.')
.map((seg) => (isIndex(seg) ? Number(seg) : (INVERSE_KEY_MAP[seg] ?? seg)));
}
function setPath(
root: Record<string, Json>,
segs: PathSeg[],
value: Json,
): void {
let node: Json = root;
for (let i = 0; i < segs.length - 1; i += 1) {
const seg = segs[i];
const container = node as Record<string | number, Json>;
if (!isContainer(container[seg])) {
container[seg] = typeof segs[i + 1] === 'number' ? [] : {};
}
node = container[seg];
}
(node as Record<string | number, Json>)[segs[segs.length - 1]] = value;
}
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
const root: Record<string, Json> = {};
Object.entries(map).forEach(([path, value]) => {
setPath(root, parsePath(path), value);
});
return root;
}
export function encode(query: Query): { payload: string; tag: BaselineTag } {
const { baseline, tag } = pickBaseline(query);
const base = leafMap(baseline);
const next = leafMap(query);
const tokens: string[] = [];
Object.entries(next).forEach(([path, value]) => {
const baseVal = base[path];
const normalizedBase = baseVal === undefined ? null : baseVal;
const normalizedNext = value === undefined ? null : value;
if (
path in base &&
JSON.stringify(normalizedBase) === JSON.stringify(normalizedNext)
) {
return;
}
tokens.push(`${path}${KV}${encodeLeaf(value, lastSeg(path))}`);
});
Object.keys(base).forEach((path) => {
if (!(path in next)) {
tokens.push(`${DEL}${path}`);
}
});
tokens.sort();
return { payload: tokens.join(PAIR), tag };
}
export function decode(payload: string, tag: BaselineTag): Query {
const map = leafMap(getBaselineByTag(tag));
if (payload) {
payload.split(PAIR).forEach((token) => {
if (!token) {
return;
}
if (token[0] === DEL) {
delete map[token.slice(1)];
return;
}
const sep = token.indexOf(KV);
const path = token.slice(0, sep);
map[path] = decodeLeaf(token.slice(sep + 1), lastSeg(path));
});
}
return rebuildFromLeaves(map) as unknown as Query;
}

View File

@@ -0,0 +1,151 @@
import { isEqual } from 'lodash-es';
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { roundTripScenarios } from '../testing/scenarios';
import { decodeFlatLeaf, encodeFlatLeaf, flatLeafAdapter } from './index';
const roundTrip = (query: Query): Query =>
flatLeafAdapter.decode(flatLeafAdapter.encode(query));
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
describe('flatLeafAdapter', () => {
describe('round-trip scenarios', () => {
it.each(roundTripScenarios)('$name', ({ query }) => {
expect(roundTrip(query)).toStrictEqual(query);
});
});
describe('tag matching', () => {
it('tags output with FVm~/FVl~/FVt~', () => {
const metricsEncoded = flatLeafAdapter.encode(initialQueriesMap.metrics);
expect(metricsEncoded.startsWith('FVm~')).toBe(true);
const logsEncoded = flatLeafAdapter.encode(initialQueriesMap.logs);
expect(logsEncoded.startsWith('FVl~')).toBe(true);
const tracesEncoded = flatLeafAdapter.encode(initialQueriesMap.traces);
expect(tracesEncoded.startsWith('FVt~')).toBe(true);
});
it('matches only own tags', () => {
const encoded = flatLeafAdapter.encode(initialQueriesMap.metrics);
expect(flatLeafAdapter.matches(encoded)).toBe(true);
expect(flatLeafAdapter.matches('V1m~[]')).toBe(false);
expect(flatLeafAdapter.matches('%7Bnot-mine')).toBe(false);
});
});
describe('baseline behavior', () => {
it('uses correct baseline tag per dataSource', () => {
expect(encodeFlatLeaf(initialQueriesMap.logs).tag).toBe('l');
expect(encodeFlatLeaf(initialQueriesMap.traces).tag).toBe('t');
expect(encodeFlatLeaf(initialQueriesMap.metrics).tag).toBe('m');
});
it('emits no payload when query equals baseline shape', () => {
const { payload, tag } = encodeFlatLeaf(initialQueriesMap.logs);
expect(tag).toBe('l');
expect(decodeFlatLeaf(payload, tag)).toStrictEqual(initialQueriesMap.logs);
});
it('decodeFlatLeaf on empty payload returns baseline', () => {
const decodedMetrics = decodeFlatLeaf('', 'm');
expect(decodedMetrics.queryType).toBe('builder');
expect(decodedMetrics.builder.queryData[0].dataSource).toBe('metrics');
const decodedLogs = decodeFlatLeaf('', 'l');
expect(decodedLogs.builder.queryData[0].dataSource).toBe('logs');
});
});
describe('encoding stability', () => {
it('identical encoding after roundtrip', () => {
const query = initialQueriesMap.metrics;
const encoded1 = flatLeafAdapter.encode(query);
const decoded = flatLeafAdapter.decode(encoded1);
const encoded2 = flatLeafAdapter.encode(decoded);
expect(encoded2).toBe(encoded1);
});
it('key order independent', () => {
const query1 = initialQueriesMap.metrics;
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
const reordered = {
unit: query2.unit,
id: query2.id,
queryType: query2.queryType,
clickhouse_sql: query2.clickhouse_sql,
promql: query2.promql,
builder: query2.builder,
} as Query;
const encoded1 = flatLeafAdapter.encode(query1);
const encoded2 = flatLeafAdapter.encode(reordered);
expect(encoded2).toBe(encoded1);
});
it('stable after spread/reconstruct', () => {
const query = { ...initialQueriesMap.metrics };
const encoded1 = flatLeafAdapter.encode(query);
const transformed = {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({
...item,
})),
},
};
const encoded2 = flatLeafAdapter.encode(transformed);
expect(encoded2).toBe(encoded1);
});
});
describe('key validation', () => {
it('decoded query has all keys expected by replaceIncorrectObjectFields', () => {
const decoded = flatLeafAdapter.decode(
flatLeafAdapter.encode(initialQueriesMap.metrics),
);
const decodedKeys = Object.keys(decoded).sort();
const expectedKeys = Object.keys(initialQueriesMap.metrics).sort();
expect(decodedKeys).toStrictEqual(expectedKeys);
});
});
describe('isEqual compatibility', () => {
it('lodash isEqual works for decoded queries', () => {
const query = initialQueriesMap.metrics;
const encoded = flatLeafAdapter.encode(query);
const decoded = flatLeafAdapter.decode(encoded);
const { id: _id1, ...rest1 } = query;
const { id: _id2, ...rest2 } = decoded;
expect(isEqual(rest1, rest2)).toBe(true);
});
});
describe('undefined handling', () => {
it('handles undefined values without breaking decode', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).aggregateOperator = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).source = undefined;
const encoded = flatLeafAdapter.encode(query);
const decoded = flatLeafAdapter.decode(encoded);
expect(decoded).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,30 @@
import { BaselineTag } from 'lib/compositeQuery/baseline';
import { decode, encode } from './codec';
import { CompositeQueryAdapter } from './types';
const TAG_PREFIX = 'FV';
const TAG_SUFFIX = '~';
/**
* Flat-leaf (FV~): stores a query as a field-aware, leaf-flattened diff from the
* frozen baseline. Uses the smart hybrid strategy — metrics or logs baseline
* picked by dataSource — so the tag is FVm~ (metrics) or FVl~ (logs).
*/
export const flatLeafAdapter: CompositeQueryAdapter = {
name: 'flat-leaf',
encode: (query) => {
const { payload, tag } = encode(query);
return `${TAG_PREFIX}${tag}${TAG_SUFFIX}${payload}`;
},
matches: (raw) =>
raw.startsWith(`${TAG_PREFIX}m${TAG_SUFFIX}`) ||
raw.startsWith(`${TAG_PREFIX}l${TAG_SUFFIX}`) ||
raw.startsWith(`${TAG_PREFIX}t${TAG_SUFFIX}`),
decode: (raw) => {
const tag = raw[2] as BaselineTag;
const payload = raw.slice(4);
return decode(payload, tag);
},
};
export { encode as encodeFlatLeaf, decode as decodeFlatLeaf } from './codec';

View File

@@ -0,0 +1,197 @@
import {
MetrictypesSpaceAggregationDTO,
MetrictypesTemporalityDTO,
MetrictypesTimeAggregationDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AutocompleteType,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { EQueryType } from 'types/common/dashboard';
import {
DataSource,
LogsAggregatorOperator,
MetricAggregateOperator,
ReduceOperators,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
export const KEY_MAP: Record<string, string> = {
queryType: 'qt',
builder: 'b',
queryData: 'qd',
queryFormulas: 'qf',
queryTraceOperator: 'qo',
dataSource: 'ds',
queryName: 'qn',
aggregateOperator: 'ao',
aggregateAttribute: 'aa',
timeAggregation: 'ta',
spaceAggregation: 'sa',
filter: 'f',
expression: 'e',
aggregations: 'ag',
functions: 'fn',
filters: 'fl',
items: 'i',
disabled: 'd',
stepInterval: 'si',
having: 'h',
limit: 'l',
orderBy: 'ob',
groupBy: 'gb',
legend: 'lg',
reduceTo: 'rt',
source: 's',
promql: 'pq',
clickhouse_sql: 'cs',
name: 'n',
query: 'q',
key: 'k',
dataType: 'dt',
type: 't',
metricName: 'mn',
temporality: 'tp',
columnName: 'cn',
order: 'o',
value: 'v',
op: 'op',
isColumn: 'ic',
isJSON: 'ij',
};
export const INVERSE_KEY_MAP: Record<string, string> = Object.fromEntries(
Object.entries(KEY_MAP).map(([long, short]) => [short, long]),
);
const DATA_SOURCE: Record<DataSource, number> = {
[DataSource.LOGS]: 0,
[DataSource.METRICS]: 1,
[DataSource.TRACES]: 2,
};
const QUERY_TYPE: Record<EQueryType, number> = {
[EQueryType.QUERY_BUILDER]: 0,
[EQueryType.PROM]: 1,
[EQueryType.CLICKHOUSE]: 2,
};
const REDUCE_TO: Record<ReduceOperators, number> = {
[ReduceOperators.LAST]: 0,
[ReduceOperators.SUM]: 1,
[ReduceOperators.AVG]: 2,
[ReduceOperators.MAX]: 3,
[ReduceOperators.MIN]: 4,
};
const TEMPORALITY: Record<MetrictypesTemporalityDTO, number> = {
[MetrictypesTemporalityDTO.delta]: 0,
[MetrictypesTemporalityDTO.cumulative]: 1,
[MetrictypesTemporalityDTO.unspecified]: 2,
};
const TIME_AGGREGATION: Record<MetrictypesTimeAggregationDTO, number> = {
[MetrictypesTimeAggregationDTO.latest]: 0,
[MetrictypesTimeAggregationDTO.sum]: 1,
[MetrictypesTimeAggregationDTO.avg]: 2,
[MetrictypesTimeAggregationDTO.min]: 3,
[MetrictypesTimeAggregationDTO.max]: 4,
[MetrictypesTimeAggregationDTO.count]: 5,
[MetrictypesTimeAggregationDTO.count_distinct]: 6,
[MetrictypesTimeAggregationDTO.rate]: 7,
[MetrictypesTimeAggregationDTO.increase]: 8,
};
const SPACE_AGGREGATION: Record<MetrictypesSpaceAggregationDTO, number> = {
[MetrictypesSpaceAggregationDTO.sum]: 0,
[MetrictypesSpaceAggregationDTO.avg]: 1,
[MetrictypesSpaceAggregationDTO.min]: 2,
[MetrictypesSpaceAggregationDTO.max]: 3,
[MetrictypesSpaceAggregationDTO.count]: 4,
[MetrictypesSpaceAggregationDTO.p50]: 5,
[MetrictypesSpaceAggregationDTO.p75]: 6,
[MetrictypesSpaceAggregationDTO.p90]: 7,
[MetrictypesSpaceAggregationDTO.p95]: 8,
[MetrictypesSpaceAggregationDTO.p99]: 9,
};
const AGGREGATE_OPERATOR: Record<
MetricAggregateOperator | TracesAggregatorOperator | LogsAggregatorOperator,
number
> = {
[MetricAggregateOperator.EMPTY]: 0,
[MetricAggregateOperator.NOOP]: 1,
[MetricAggregateOperator.COUNT]: 2,
[MetricAggregateOperator.COUNT_DISTINCT]: 3,
[MetricAggregateOperator.SUM]: 4,
[MetricAggregateOperator.AVG]: 5,
[MetricAggregateOperator.MAX]: 6,
[MetricAggregateOperator.MIN]: 7,
[MetricAggregateOperator.P05]: 8,
[MetricAggregateOperator.P10]: 9,
[MetricAggregateOperator.P20]: 10,
[MetricAggregateOperator.P25]: 11,
[MetricAggregateOperator.P50]: 12,
[MetricAggregateOperator.P75]: 13,
[MetricAggregateOperator.P90]: 14,
[MetricAggregateOperator.P95]: 15,
[MetricAggregateOperator.P99]: 16,
[MetricAggregateOperator.RATE]: 17,
[MetricAggregateOperator.SUM_RATE]: 18,
[MetricAggregateOperator.AVG_RATE]: 19,
[MetricAggregateOperator.MAX_RATE]: 20,
[MetricAggregateOperator.MIN_RATE]: 21,
[MetricAggregateOperator.RATE_SUM]: 22,
[MetricAggregateOperator.RATE_AVG]: 23,
[MetricAggregateOperator.RATE_MIN]: 24,
[MetricAggregateOperator.RATE_MAX]: 25,
[MetricAggregateOperator.HIST_QUANTILE_50]: 26,
[MetricAggregateOperator.HIST_QUANTILE_75]: 27,
[MetricAggregateOperator.HIST_QUANTILE_90]: 28,
[MetricAggregateOperator.HIST_QUANTILE_95]: 29,
[MetricAggregateOperator.HIST_QUANTILE_99]: 30,
[MetricAggregateOperator.INCREASE]: 31,
[MetricAggregateOperator.LATEST]: 32,
};
const DATA_TYPE: Record<DataTypes, number> = {
[DataTypes.Int64]: 0,
[DataTypes.String]: 1,
[DataTypes.Float64]: 2,
[DataTypes.bool]: 3,
[DataTypes.ArrayFloat64]: 4,
[DataTypes.ArrayInt64]: 5,
[DataTypes.ArrayString]: 6,
[DataTypes.ArrayBool]: 7,
[DataTypes.EMPTY]: 8,
};
const ATTR_TYPE: Record<AutocompleteType, number> = {
tag: 0,
resource: 1,
'': 2,
};
export const FIELD_DOMAINS: Record<string, Record<string, number>> = {
ds: DATA_SOURCE,
qt: QUERY_TYPE,
rt: REDUCE_TO,
tp: TEMPORALITY,
ta: TIME_AGGREGATION,
sa: SPACE_AGGREGATION,
ao: AGGREGATE_OPERATOR,
dt: DATA_TYPE,
t: ATTR_TYPE,
};
export const INVERSE_FIELD_DOMAINS: Record<
string,
Record<number, string>
> = Object.fromEntries(
Object.entries(FIELD_DOMAINS).map(([field, domain]) => [
field,
Object.fromEntries(
Object.entries(domain).map(([str, int]) => [int, str]),
) as Record<number, string>,
]),
);

View File

@@ -0,0 +1,8 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): string;
matches(raw: string): boolean;
decode(raw: string): Query;
}

View File

@@ -0,0 +1,55 @@
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { CompositeQueryAdapter } from './types';
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) => encodeURIComponent(JSON.stringify(query)),
matches: () => true,
decode: (raw) => {
const parsed: Query = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
return migrateLegacyFormat(parsed);
},
};

View File

@@ -0,0 +1,68 @@
import { initialQueriesMap } from 'constants/queryBuilder';
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('legacy format compatibility', () => {
it('encodes to legacy format (encodeURIComponent + JSON)', () => {
const query = initialQueriesMap.logs;
const encoded = jsonAdapter.encode(query);
expect(encoded).toBe(encodeURIComponent(JSON.stringify(query)));
expect(encoded.startsWith('%7B')).toBe(true);
});
});
describe('tag matching', () => {
it('matches any value (catch-all fallback)', () => {
expect(jsonAdapter.matches('%7B%22queryType%22%3A%22builder%22%7D')).toBe(
true,
);
expect(jsonAdapter.matches('z1~abc')).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 raw = encodeURIComponent(JSON.stringify(legacy));
const decoded = jsonAdapter.decode(raw);
expect(decoded.builder.queryData[0].filter).toBeDefined();
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
});
});
});

View File

@@ -0,0 +1,8 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): string;
matches(raw: string): boolean;
decode(raw: string): Query;
}

View File

@@ -0,0 +1,85 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export interface RoundTripScenario {
name: string;
query: Query;
}
const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T;
const makePromqlQuery = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'rate(http_requests_total[5m])';
return query;
};
const makeClickhouseQuery = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query = 'SELECT count() FROM signoz_logs';
return query;
};
const makeModifiedBuilderQuery = (): Query => {
const query = clone(initialQueriesMap.logs);
const qd = query.builder.queryData[0];
qd.aggregateOperator = 'p95';
qd.disabled = true;
qd.stepInterval = 60;
qd.legend = 'error rate';
qd.filter = { expression: "severity_text = 'ERROR'" };
qd.filters = {
op: 'AND',
items: [
{
key: {
key: 'severity_text',
dataType: 'string',
type: 'tag',
isColumn: false,
isJSON: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
id: 'item-1',
op: '=',
value: 'ERROR',
},
],
};
qd.orderBy = [{ columnName: 'timestamp', order: 'desc' }];
return query;
};
const makeQueryWithCustomId = (): Query => ({
...initialQueriesMap.metrics,
id: 'test-query-uuid-123',
});
const makeQueryWithEnumLikeLegend = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData[0].legend = 'sum';
query.id = 'my-query-id';
return query;
};
const makeQueryWithWireDelimiters = (): Query => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '_a*b_*c';
query.builder.queryData[0].filter = { expression: '!weird = "x_y*z"' };
return query;
};
export const roundTripScenarios: RoundTripScenario[] = [
{ name: 'metrics baseline', query: initialQueriesMap.metrics },
{ name: 'logs baseline', query: initialQueriesMap.logs },
{ name: 'traces baseline', query: initialQueriesMap.traces },
{ name: 'promql query', query: makePromqlQuery() },
{ name: 'clickhouse query', query: makeClickhouseQuery() },
{ name: 'modified builder query', query: makeModifiedBuilderQuery() },
{ name: 'custom id', query: makeQueryWithCustomId() },
{ name: 'enum-like legend preserved', query: makeQueryWithEnumLikeLegend() },
{ name: 'wire delimiters in values', query: makeQueryWithWireDelimiters() },
];

View File

@@ -0,0 +1,179 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
* only the diff from the chosen baseline; decode replays it onto a clone. These
* MUST stay byte-stable forever — changing them silently invalidates every URL
* already emitted against the old baseline. To evolve the schema, add NEW
* tagged adapters (V2~) with their own baselines rather than editing these.
*
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
* entries (nothing is stripped before diffing).
*/
/** Baseline for metrics queries — uses metric-style aggregations object. */
export const METRICS_BASELINE = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'metrics',
queryName: 'A',
aggregateOperator: 'noop',
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: 'avg',
},
],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;
/** Baseline for logs/traces queries — uses expression-style aggregations. */
export const LOGS_BASELINE = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: null,
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [{ expression: 'count() ' }],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;
/** Baseline for traces queries — same as logs but with dataSource: traces. */
export const TRACES_BASELINE = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'traces',
queryName: 'A',
aggregateOperator: null,
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [{ expression: 'count() ' }],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;
/** Baseline tag indicators for URL encoding. */
export type BaselineTag = 'm' | 'l' | 't';
/** Pick optimal baseline based on query's primary dataSource. */
export function pickBaseline(query: Query): {
baseline: Query;
tag: BaselineTag;
} {
const ds = query.builder?.queryData?.[0]?.dataSource;
if (ds === 'logs') {
return { baseline: LOGS_BASELINE, tag: 'l' };
}
if (ds === 'traces') {
return { baseline: TRACES_BASELINE, tag: 't' };
}
return { baseline: METRICS_BASELINE, tag: 'm' };
}
function getBaselineByTag(tag: BaselineTag): Query {
if (tag === 'l') {
return LOGS_BASELINE;
}
if (tag === 't') {
return TRACES_BASELINE;
}
return METRICS_BASELINE;
}
export default getBaselineByTag;

View File

@@ -0,0 +1,28 @@
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
import { CompositeQueryAdapter } from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { flatKeysAdapter } from 'lib/compositeQuery/adapters/flatKeys';
// Order matters for decode: most-specific (tagged) adapters first, json last
const ADAPTERS: CompositeQueryAdapter[] = [flatKeysAdapter, jsonAdapter];
/** Encode a query to the shortest available URL value. */
export function serialize(query: Query): string {
return ADAPTERS.map((adapter) => adapter.encode(query)).reduce(
(shortest, candidate) =>
candidate.length < shortest.length ? candidate : shortest,
);
}
/** Decode a URL value back to a Query. Total: returns null on any failure. */
export function deserialize(raw: string): Query | null {
if (!raw) {
return null;
}
try {
const adapter = ADAPTERS.find((item) => item.matches(raw)) ?? jsonAdapter;
return adapter.decode(raw);
} catch {
return null;
}
}

View File

@@ -0,0 +1,14 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* A serialization tier. `encode` returns the COMPLETE URL value (tag prefix
* included for tagged tiers). `matches` decides whether a raw value belongs to
* this adapter on decode. `decode` receives the COMPLETE raw value and strips
* its own tag.
*/
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): string;
matches(raw: string): boolean;
decode(raw: string): Query;
}

View File

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