Compare commits

...

3 Commits

Author SHA1 Message Date
aks07
f21c252fc9 fix: minor comments 2025-12-29 02:40:20 +05:30
aks07
53bf3cb50f fix: add metrics to logs/traces exp transformation 2025-12-29 02:33:44 +05:30
aks07
f4b74eef11 fix: update limit input to be number type 2025-12-24 14:09:59 +05:30
7 changed files with 389 additions and 189 deletions

View File

@@ -50,6 +50,13 @@
color: var(--bg-vanilla-400) !important;
font-size: 12px !important;
}
&[type='number']::-webkit-inner-spin-button,
&[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
margin: 0;
}
}
.close-btn {

View File

@@ -375,6 +375,7 @@ function QueryAddOns({
<div className="add-on-content" data-testid="limit-content">
<InputWithLabel
label="Limit"
type="number"
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder="Enter limit"

View File

@@ -0,0 +1,118 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable react/display-name */
import '@testing-library/jest-dom';
import { jest } from '@jest/globals';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { render, screen } from 'tests/test-utils';
import { Having, IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { UseQueryOperations } from 'types/common/operations.types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import { QueryV2 } from '../QueryV2';
// Local mocks for domain-specific heavy child components
jest.mock(
'../QueryAggregation/QueryAggregation',
() =>
function () {
return <div>QueryAggregation</div>;
},
);
jest.mock(
'../MerticsAggregateSection/MetricsAggregateSection',
() =>
function () {
return <div>MetricsAggregateSection</div>;
},
);
// Mock hooks
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('hooks/queryBuilder/useQueryBuilderOperations');
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const mockedUseQueryOperations = jest.mocked(
useQueryOperations,
) as jest.MockedFunction<UseQueryOperations>;
describe('QueryV2 - base render', () => {
beforeEach(() => {
const mockCloneQuery = jest.fn() as jest.MockedFunction<
(type: string, q: IBuilderQuery) => void
>;
mockedUseQueryBuilder.mockReturnValue(({
// Only fields used by QueryV2
cloneQuery: mockCloneQuery,
panelType: null,
} as unknown) as QueryBuilderContextType);
mockedUseQueryOperations.mockReturnValue({
isTracePanelType: false,
isMetricsDataSource: false,
operators: [],
spaceAggregationOptions: [],
listOfAdditionalFilters: [],
handleChangeOperator: jest.fn(),
handleSpaceAggregationChange: jest.fn(),
handleChangeAggregatorAttribute: jest.fn(),
handleChangeDataSource: jest.fn(),
handleDeleteQuery: jest.fn(),
handleChangeQueryData: (jest.fn() as unknown) as ReturnType<UseQueryOperations>['handleChangeQueryData'],
handleChangeFormulaData: jest.fn(),
handleQueryFunctionsUpdates: jest.fn(),
listOfAdditionalFormulaFilters: [],
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('renders limit input when dataSource is logs', () => {
const baseQuery: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: '',
aggregations: [],
timeAggregation: '',
spaceAggregation: '',
temporality: '',
functions: [],
filter: undefined,
filters: { items: [], op: 'AND' },
groupBy: [],
expression: '',
disabled: false,
having: [] as Having[],
limit: 10,
stepInterval: null,
orderBy: [],
legend: 'A',
};
render(
<QueryV2
index={0}
isAvailableToDisable
query={baseQuery}
version="v4"
onSignalSourceChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
signalSourceChangeEnabled={false}
queriesCount={1}
showTraceOperator={false}
hasTraceOperator={false}
/>,
);
// Ensure the Limit add-on input is present and is of type number
const limitInput = screen.getByPlaceholderText(
'Enter limit',
) as HTMLInputElement;
expect(limitInput).toBeInTheDocument();
expect(limitInput).toHaveAttribute('type', 'number');
expect(limitInput).toHaveAttribute('name', 'limit');
expect(limitInput).toHaveAttribute('data-testid', 'input-Limit');
});
});

View File

@@ -242,6 +242,7 @@ export function Formula({
</div>
<InputWithLabel
label="Limit"
type="number"
onChange={(value): void => handleChangeLimit(Number(value))}
initialValue={formula?.limit ?? undefined}
placeholder="Enter limit"

View File

@@ -8,6 +8,7 @@ import {
getViewQuery,
isValidQueryName,
} from '../drilldownUtils';
import { METRIC_TO_LOGS_TRACES_MAPPINGS } from '../metricsCorrelationUtils';
// Mock the transformMetricsToLogsTraces function since it's not exported
// We'll test it indirectly through getViewQuery
@@ -161,16 +162,16 @@ describe('drilldownUtils', () => {
// Verify transformations were applied
if (filterExpression) {
// Rule 2: operation → name
expect(filterExpression).toContain('name = "GET"');
expect(filterExpression).not.toContain('operation = "GET"');
expect(filterExpression).toContain(`name = 'GET'`);
expect(filterExpression).not.toContain(`operation = 'GET'`);
// Rule 3: span.kind → kind
expect(filterExpression).toContain('kind = 2');
expect(filterExpression).not.toContain('span.kind = SPAN_KIND_SERVER');
expect(filterExpression).toContain(`kind = '2'`);
expect(filterExpression).not.toContain(`span.kind = SPAN_KIND_SERVER`);
// Rule 4: status.code → status_code_string with value mapping
expect(filterExpression).toContain('status_code_string = Ok');
expect(filterExpression).not.toContain('status.code = STATUS_CODE_OK');
expect(filterExpression).toContain(`status_code_string = 'Ok'`);
expect(filterExpression).not.toContain(`status.code = STATUS_CODE_OK`);
}
});
@@ -192,40 +193,16 @@ describe('drilldownUtils', () => {
// Verify transformations were applied
if (filterExpression) {
// Rule 2: operation → name
expect(filterExpression).toContain('name = "GET"');
expect(filterExpression).not.toContain('operation = "GET"');
expect(filterExpression).toContain(`name = 'GET'`);
expect(filterExpression).not.toContain(`operation = 'GET'`);
// Rule 3: span.kind → kind
expect(filterExpression).toContain('kind = 2');
expect(filterExpression).not.toContain('span.kind = SPAN_KIND_SERVER');
expect(filterExpression).toContain(`kind = '2'`);
expect(filterExpression).not.toContain(`span.kind = SPAN_KIND_SERVER`);
// Rule 4: status.code → status_code_string with value mapping
expect(filterExpression).toContain('status_code_string = Ok');
expect(filterExpression).not.toContain('status.code = STATUS_CODE_OK');
}
});
it('should NOT transform metrics query when drilling down to metrics', () => {
const result = getViewQuery(
mockMetricsQuery,
mockFilters,
'view_metrics',
'metrics_query',
);
expect(result).not.toBeNull();
expect(result?.builder.queryData).toHaveLength(1);
// Check that the filter expression was NOT transformed
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
expect(filterExpression).toBeDefined();
// Verify NO transformations were applied
if (filterExpression) {
// Should still contain original metric format
expect(filterExpression).toContain('operation = "GET"');
expect(filterExpression).toContain('span.kind = SPAN_KIND_SERVER');
expect(filterExpression).toContain('status.code = STATUS_CODE_OK');
expect(filterExpression).toContain(`status_code_string = 'Ok'`);
expect(filterExpression).not.toContain(`status.code = STATUS_CODE_OK`);
}
});
@@ -258,10 +235,10 @@ describe('drilldownUtils', () => {
if (filterExpression) {
// All transformations should be applied
expect(filterExpression).toContain('name = "POST"');
expect(filterExpression).toContain('kind = 3');
expect(filterExpression).toContain('status_code_string = Error');
expect(filterExpression).toContain('http.status_code = 500');
expect(filterExpression).toContain(`name = 'POST'`);
expect(filterExpression).toContain(`kind = '3'`);
expect(filterExpression).toContain(`status_code_string = 'Error'`);
expect(filterExpression).toContain(`http.status_code = 500`);
}
});
@@ -299,13 +276,12 @@ describe('drilldownUtils', () => {
});
it('should handle all status code value mappings correctly', () => {
const statusCodeTests = [
{ input: 'STATUS_CODE_UNSET', expected: 'Unset' },
{ input: 'STATUS_CODE_OK', expected: 'Ok' },
{ input: 'STATUS_CODE_ERROR', expected: 'Error' },
];
const mappingsByAttr = Object.fromEntries(
METRIC_TO_LOGS_TRACES_MAPPINGS.map((m) => [m.attribute, m]),
) as Record<string, { valueMappings: Record<string, string> }>;
const statusMap = mappingsByAttr['status.code'].valueMappings;
statusCodeTests.forEach(({ input, expected }) => {
Object.entries(statusMap).forEach(([input, expected]) => {
const testQuery: Query = {
...mockMetricsQuery,
builder: {
@@ -329,19 +305,18 @@ describe('drilldownUtils', () => {
);
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
expect(filterExpression).toContain(`status_code_string = ${expected}`);
expect(filterExpression).toContain(`status_code_string = '${expected}'`);
expect(filterExpression).not.toContain(`status.code = ${input}`);
});
});
it('should handle quoted status code values (browser scenario)', () => {
const statusCodeTests = [
{ input: '"STATUS_CODE_UNSET"', expected: '"Unset"' },
{ input: '"STATUS_CODE_OK"', expected: '"Ok"' },
{ input: '"STATUS_CODE_ERROR"', expected: '"Error"' },
];
const mappingsByAttr = Object.fromEntries(
METRIC_TO_LOGS_TRACES_MAPPINGS.map((m) => [m.attribute, m]),
) as Record<string, { valueMappings: Record<string, string> }>;
const statusMap = mappingsByAttr['status.code'].valueMappings;
statusCodeTests.forEach(({ input, expected }) => {
Object.entries(statusMap).forEach(([input, expected]) => {
const testQuery: Query = {
...mockMetricsQuery,
builder: {
@@ -350,7 +325,7 @@ describe('drilldownUtils', () => {
{
...mockMetricsQuery.builder.queryData[0],
filter: {
expression: `status.code = ${input}`,
expression: `status.code = "${input}"`,
},
},
],
@@ -366,8 +341,8 @@ describe('drilldownUtils', () => {
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
// Should preserve the quoting from the original expression
expect(filterExpression).toContain(`status_code_string = ${expected}`);
expect(filterExpression).not.toContain(`status.code = ${input}`);
expect(filterExpression).toContain(`status_code_string = '${expected}'`);
expect(filterExpression).not.toContain(`status.code = "${input}"`);
});
});
@@ -398,8 +373,8 @@ describe('drilldownUtils', () => {
if (filterExpression) {
// Transformed attributes
expect(filterExpression).toContain('name = "GET"');
expect(filterExpression).toContain('kind = 2');
expect(filterExpression).toContain(`name = 'GET'`);
expect(filterExpression).toContain(`kind = '2'`);
// Preserved non-metric attributes
expect(filterExpression).toContain('service = "test-service"');
@@ -408,15 +383,12 @@ describe('drilldownUtils', () => {
});
it('should handle all span.kind value mappings correctly', () => {
const spanKindTests = [
{ input: 'SPAN_KIND_INTERNAL', expected: '1' },
{ input: 'SPAN_KIND_CONSUMER', expected: '5' },
{ input: 'SPAN_KIND_CLIENT', expected: '3' },
{ input: 'SPAN_KIND_PRODUCER', expected: '4' },
{ input: 'SPAN_KIND_SERVER', expected: '2' },
];
const mappingsByAttr = Object.fromEntries(
METRIC_TO_LOGS_TRACES_MAPPINGS.map((m) => [m.attribute, m]),
) as Record<string, { valueMappings: Record<string, string> }>;
const spanKindMap = mappingsByAttr['span.kind'].valueMappings;
spanKindTests.forEach(({ input, expected }) => {
Object.entries(spanKindMap).forEach(([input, expected]) => {
const testQuery: Query = {
...mockMetricsQuery,
builder: {
@@ -440,9 +412,48 @@ describe('drilldownUtils', () => {
);
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
expect(filterExpression).toContain(`kind = ${expected}`);
expect(filterExpression).toContain(`kind = '${expected}'`);
expect(filterExpression).not.toContain(`span.kind = ${input}`);
});
});
it('should not transform when the source query is not metrics (logs/traces sources)', () => {
(['logs', 'traces'] as const).forEach((source) => {
const nonMetricsQuery: Query = {
...mockMetricsQuery,
builder: {
...mockMetricsQuery.builder,
queryData: [
{
...mockMetricsQuery.builder.queryData[0],
dataSource: source as any,
filter: {
expression:
'operation = "GET" AND span.kind = SPAN_KIND_SERVER AND status.code = STATUS_CODE_OK',
},
},
],
},
};
const result = getViewQuery(
nonMetricsQuery,
mockFilters,
source === 'logs' ? 'view_logs' : 'view_traces',
'metrics_query',
);
const expr = result?.builder.queryData[0]?.filter?.expression || '';
// Should remain unchanged (no metric-to-logs/traces transformations)
expect(expr).toContain('operation = "GET"');
expect(expr).toContain('span.kind = SPAN_KIND_SERVER');
expect(expr).toContain('status.code = STATUS_CODE_OK');
// And should not contain transformed counterparts
expect(expr).not.toContain(`name = 'GET'`);
expect(expr).not.toContain(`kind = '2'`);
expect(expr).not.toContain(`status_code_string = 'Ok'`);
});
});
});
});

View File

@@ -5,6 +5,10 @@ import {
OPERATORS,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
METRIC_TO_LOGS_TRACES_MAPPINGS,
replaceKeysAndValuesInExpression,
} from 'container/QueryTable/Drilldown/metricsCorrelationUtils';
import cloneDeep from 'lodash-es/cloneDeep';
import {
BaseAutocompleteData,
@@ -270,125 +274,6 @@ const VIEW_QUERY_MAP: Record<string, IBuilderQuery> = {
view_traces: initialQueryBuilderFormValuesMap.traces,
};
/**
* TEMP LOGIC - TO BE REMOVED LATER
* Transforms metric query filters to logs/traces format
* Applies the following transformations:
* - Rule 2: operation → name
* - Rule 3: span.kind → kind
* - Rule 4: status.code → status_code_string with value mapping
* - Rule 5: http.status_code type conversion
*/
const transformMetricsToLogsTraces = (
filterExpression: string | undefined,
): string | undefined => {
if (!filterExpression) return filterExpression;
// ===========================================
// MAPPING OBJECTS - ALL TRANSFORMATIONS DEFINED HERE
// ===========================================
const METRIC_TO_LOGS_TRACES_MAPPINGS = {
// Rule 2: operation → name
attributeRenames: {
operation: 'name',
},
// Rule 3: span.kind → kind with value mapping
spanKindMapping: {
attribute: 'span.kind',
newAttribute: 'kind',
valueMappings: {
SPAN_KIND_INTERNAL: '1',
SPAN_KIND_SERVER: '2',
SPAN_KIND_CLIENT: '3',
SPAN_KIND_PRODUCER: '4',
SPAN_KIND_CONSUMER: '5',
},
},
// Rule 4: status.code → status_code_string with value mapping
statusCodeMapping: {
attribute: 'status.code',
newAttribute: 'status_code_string',
valueMappings: {
// From metrics format → To logs/traces format
STATUS_CODE_UNSET: 'Unset',
STATUS_CODE_OK: 'Ok',
STATUS_CODE_ERROR: 'Error',
},
},
// Rule 5: http.status_code type conversion
typeConversions: {
'http.status_code': 'number',
},
};
// ===========================================
let transformedExpression = filterExpression;
// Apply attribute renames
Object.entries(METRIC_TO_LOGS_TRACES_MAPPINGS.attributeRenames).forEach(
([oldAttr, newAttr]) => {
const regex = new RegExp(`\\b${oldAttr}\\b`, 'g');
transformedExpression = transformedExpression.replace(regex, newAttr);
},
);
// Apply span.kind → kind transformation
const { spanKindMapping } = METRIC_TO_LOGS_TRACES_MAPPINGS;
if (spanKindMapping) {
// Replace attribute name - use word boundaries to avoid partial matches
const attrRegex = new RegExp(
`\\b${spanKindMapping.attribute.replace(/\./g, '\\.')}\\b`,
'g',
);
transformedExpression = transformedExpression.replace(
attrRegex,
spanKindMapping.newAttribute,
);
// Replace values
Object.entries(spanKindMapping.valueMappings).forEach(
([oldValue, newValue]) => {
const valueRegex = new RegExp(`\\b${oldValue}\\b`, 'g');
transformedExpression = transformedExpression.replace(valueRegex, newValue);
},
);
}
// Apply status.code → status_code_string transformation
const { statusCodeMapping } = METRIC_TO_LOGS_TRACES_MAPPINGS;
if (statusCodeMapping) {
// Replace attribute name - use word boundaries to avoid partial matches
// This prevents http.status_code from being transformed
const attrRegex = new RegExp(
`\\b${statusCodeMapping.attribute.replace(/\./g, '\\.')}\\b`,
'g',
);
transformedExpression = transformedExpression.replace(
attrRegex,
statusCodeMapping.newAttribute,
);
// Replace values
Object.entries(statusCodeMapping.valueMappings).forEach(
([oldValue, newValue]) => {
const valueRegex = new RegExp(`\\b${oldValue}\\b`, 'g');
transformedExpression = transformedExpression.replace(
valueRegex,
`${newValue}`,
);
},
);
}
// Note: Type conversions (Rule 5) would need more complex parsing
// of the filter expression to implement properly
return transformedExpression;
};
export const getViewQuery = (
query: Query,
filtersToAdd: FilterData[],
@@ -448,9 +333,12 @@ export const getViewQuery = (
// TEMP LOGIC - TO BE REMOVED LATER
// ===========================================
// Apply metric-to-logs/traces transformations
if (key === 'view_logs' || key === 'view_traces') {
const transformedExpression = transformMetricsToLogsTraces(
newFilterExpression?.expression,
const isMetricQuery =
getQueryData(query, queryName)?.dataSource === 'metrics';
if (isMetricQuery) {
const transformedExpression = replaceKeysAndValuesInExpression(
newFilterExpression?.expression || '',
METRIC_TO_LOGS_TRACES_MAPPINGS,
);
newQuery.builder.queryData[0].filter = {
expression: transformedExpression || '',

View File

@@ -0,0 +1,174 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable no-continue */
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { IQueryPair } from 'types/antlrQueryTypes';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isQuoted, unquote } from 'utils/stringUtils';
import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
type KeyValueMapping = {
attribute: string;
newAttribute: string;
valueMappings: Record<string, string>;
};
export const METRIC_TO_LOGS_TRACES_MAPPINGS: KeyValueMapping[] = [
{
attribute: 'operation',
newAttribute: 'name',
valueMappings: {},
},
{
attribute: 'span.kind',
newAttribute: 'kind',
valueMappings: {
SPAN_KIND_INTERNAL: '1',
SPAN_KIND_SERVER: '2',
SPAN_KIND_CLIENT: '3',
SPAN_KIND_PRODUCER: '4',
SPAN_KIND_CONSUMER: '5',
},
},
{
attribute: 'status.code',
newAttribute: 'status_code_string',
valueMappings: {
STATUS_CODE_UNSET: 'Unset',
STATUS_CODE_OK: 'Ok',
STATUS_CODE_ERROR: 'Error',
},
},
];
// Logic for rewriting key/values in an expression using provided mappings.
function modifyKeyVal(pair: IQueryPair, mapping: KeyValueMapping): string {
const newKey = mapping.newAttribute;
const op = pair.operator;
const operator = pair.hasNegation
? getOperatorValue(`NOT_${pair.operator}`.toUpperCase())
: getOperatorValue(pair.operator.toUpperCase());
// Map a single value token using valueMappings, skipping variables
const mapOne = (val: string | undefined): string | undefined => {
if (val == null) return val;
const t = String(val).trim();
// Skip variables for now. We will handle them later.
if (t.startsWith('$')) return t;
const raw = isQuoted(t) ? unquote(t) : t;
return mapping.valueMappings[raw] ?? raw;
};
// Function-style operator: op(newKey, value?)
if (isFunctionOperator(op)) {
let mapped: string | string[] | undefined;
if (pair.isMultiValue && Array.isArray(pair.valueList)) {
mapped = pair.valueList.map((v) => mapOne(v) as string);
} else if (typeof pair.value !== 'undefined') {
mapped = mapOne(pair.value);
}
const hasValue =
typeof mapped !== 'undefined' &&
!(Array.isArray(mapped) && mapped.length === 0);
if (!hasValue) {
return `${op}(${newKey})`;
}
const formatted = formatValueForExpression(mapped as any, op);
return `${op}(${newKey}, ${formatted})`;
}
// Non-value operator: e.g., exists / not exists
if (isNonValueOperator(op)) {
return `${newKey} ${operator}`;
}
// Standard key-operator-value
let mapped: string | string[] | undefined;
if (pair.isMultiValue && Array.isArray(pair.valueList)) {
mapped = pair.valueList.map((v) => mapOne(v) as string);
} else if (typeof pair.value !== 'undefined') {
mapped = mapOne(pair.value);
}
const formatted = formatValueForExpression(mapped as any, op);
return `${newKey} ${operator} ${formatted}`;
}
// Replace keys/values in an expression using provided mappings.
// wires parsing, ordering, and reconstruction.
export function replaceKeysAndValuesInExpression(
expression: string,
mappingList: KeyValueMapping[],
): string {
if (!expression || !mappingList || mappingList.length === 0) {
return expression;
}
const attributeToMapping = new Map<string, KeyValueMapping>(
mappingList.map((m) => [m.attribute.trim().toLowerCase(), m]),
);
const pairs: IQueryPair[] = extractQueryPairs(expression);
type PairWithBounds = {
pair: IQueryPair;
start: number;
end: number;
};
const withBounds: PairWithBounds[] = [];
for (let i = 0; i < pairs.length; i += 1) {
const pair = pairs[i];
// Require complete positions for safe slicing
if (!pair?.position) continue;
const start =
pair.position.keyStart ??
pair.position.operatorStart ??
pair.position.valueStart;
const end =
pair.position.valueEnd ?? pair.position.operatorEnd ?? pair.position.keyEnd;
if (
typeof start === 'number' &&
typeof end === 'number' &&
start >= 0 &&
end >= start
) {
withBounds.push({ pair, start, end });
}
}
// Process in source order
withBounds.sort((a, b) => a.start - b.start);
let startIdx = 0;
const resultParts: string[] = [];
for (let i = 0; i < withBounds.length; i += 1) {
const item = withBounds[i];
const sourceKey = item.pair?.key?.trim().toLowerCase();
if (!sourceKey) continue;
const mapping = attributeToMapping.get(sourceKey);
if (!mapping) {
continue;
}
// Add unchanged prefix up to the start of this pair
resultParts.push(expression.slice(startIdx, item.start));
// Replacement produced by modifyKeyVal
const replacement = modifyKeyVal(item.pair, mapping);
resultParts.push(replacement);
// Advance cursor past this pair
startIdx = item.end + 1;
}
// Append the remainder of the expression
resultParts.push(expression.slice(startIdx));
return resultParts.join('');
}