Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ function EndPointDetails({
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
] as const;

const endPointDetailsDataQueries = useQueries(
Expand All @@ -197,7 +198,7 @@ function EndPointDetails({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
filters?.items, // Include filters.items in queryKey for better caching
...(filters?.items?.length ? filters.items : []), // Include filters.items in queryKey for better caching
version,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
Expand Down Expand Up @@ -56,6 +56,10 @@ function TopErrors({
{
items: endPointName
? [
// Remove any existing http.url filters from initialFilters to avoid duplicates
...(initialFilters?.items?.filter(
(item) => item.key?.key !== SPAN_ATTRIBUTES.URL_PATH,
) || []),
{
id: '92b8a1c1',
key: {
Expand All @@ -66,7 +70,6 @@ function TopErrors({
op: '=',
value: endPointName,
},
...(initialFilters?.items || []),
]
: [...(initialFilters?.items || [])],
op: 'AND',
Expand Down Expand Up @@ -128,12 +131,12 @@ function TopErrors({
const endPointDropDownDataQueries = useQueries(
endPointDropDownQueryPayload.map((payload) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[4],
END_POINT_DETAILS_QUERY_KEYS_ARRAY[2],
payload,
ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
staleTime: 60 * 1000,
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,13 +624,15 @@ describe('API Monitoring Utils', () => {
data: {
// eslint-disable-next-line sonarjs/no-duplicate-string
[URL_PATH_KEY]: '/api/users',
'url.full': 'http://example.com/api/users',
A: 150, // count or other metric
},
},
{
data: {
// eslint-disable-next-line sonarjs/no-duplicate-string
[URL_PATH_KEY]: '/api/orders',
'url.full': 'http://example.com/api/orders',
A: 75,
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/no-duplicate-string */
/**
* V5 Migration Tests for Endpoint Dropdown Query
*
* These tests validate the migration from V4 to V5 format for the third payload
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
* - Filter format change: filters.items[] → filter.expression
* - Domain handling: (net.peer.name OR server.address)
* - Kind filter: kind_string = 'Client'
* - Existence check: (http.url EXISTS OR url.full EXISTS)
* - Aggregation: count() expression
* - GroupBy: Both http.url AND url.full with type 'attribute'
*/
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';

describe('EndpointDropdown - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockStartTime = 1000;
const mockEndTime = 2000;
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};

describe('1. V5 Format Migration - Structure and Base Filters', () => {
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
emptyFilters,
);

// Third payload is the endpoint dropdown query (index 2)
const dropdownQuery = payload[2];
const queryA = dropdownQuery.query.builder.queryData[0];

// CRITICAL V5 MIGRATION: filter.expression (not filters.items)
expect(queryA.filter).toBeDefined();
expect(queryA.filter?.expression).toBeDefined();
expect(typeof queryA.filter?.expression).toBe('string');
expect(queryA).not.toHaveProperty('filters');

// Base filter 1: Domain (net.peer.name OR server.address)
expect(queryA.filter?.expression).toContain(
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
);

// Base filter 2: Kind
expect(queryA.filter?.expression).toContain("kind_string = 'Client'");

// Base filter 3: Existence check
expect(queryA.filter?.expression).toContain(
'(http.url EXISTS OR url.full EXISTS)',
);

// V5 Aggregation format: aggregations array (not aggregateAttribute)
expect(queryA.aggregations).toBeDefined();
expect(Array.isArray(queryA.aggregations)).toBe(true);
expect(queryA.aggregations?.[0]).toEqual({
expression: 'count()',
});
expect(queryA).not.toHaveProperty('aggregateAttribute');

// GroupBy: Both http.url and url.full
expect(queryA.groupBy).toHaveLength(2);
expect(queryA.groupBy).toContainEqual({
key: 'http.url',
dataType: 'string',
type: 'attribute',
});
expect(queryA.groupBy).toContainEqual({
key: 'url.full',
dataType: 'string',
type: 'attribute',
});
});
});

describe('2. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};

const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
customFilters,
);

const dropdownQuery = payload[2];
const expression =
dropdownQuery.query.builder.queryData[0].filter?.expression;

// Exact filter expression with custom filters merged
expect(expression).toBe(
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com') AND kind_string = 'Client' AND (http.url EXISTS OR url.full EXISTS) service.name = 'user-service' AND deployment.environment = 'production'",
);
});
});

describe('3. HTTP URL Filter Special Handling', () => {
it('converts http.url filter to (http.url OR url.full) expression', () => {
const filtersWithHttpUrl: IBuilderQuery['filters'] = {
items: [
{
id: 'http-url-filter',
key: {
key: 'http.url',
dataType: 'string' as any,
type: 'tag',
},
op: '=',
value: '/api/users',
},
{
id: 'service-filter',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
],
op: 'AND',
};

const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
filtersWithHttpUrl,
);

const dropdownQuery = payload[2];
const expression =
dropdownQuery.query.builder.queryData[0].filter?.expression;

// CRITICAL: Exact filter expression with http.url converted to OR logic
expect(expression).toBe(
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com') AND kind_string = 'Client' AND (http.url EXISTS OR url.full EXISTS) service.name = 'user-service' AND (http.url = '/api/users' OR url.full = '/api/users')",
);
});
});
});
64 changes: 17 additions & 47 deletions frontend/src/container/ApiMonitoring/__tests__/TopErrors.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { BuilderQuery } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';

import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
import { getTopErrorsQueryPayload } from '../utils';

// Mock the EndPointsDropDown component to avoid issues
jest.mock(
Expand Down Expand Up @@ -36,6 +38,7 @@ describe('TopErrors', () => {
const V5_QUERY_RANGE_API_PATH = '*/api/v5/query_range';

const mockProps = {
// eslint-disable-next-line sonarjs/no-duplicate-string
domainName: 'test-domain',
timeRange: {
startTime: 1000000000,
Expand Down Expand Up @@ -305,66 +308,33 @@ describe('TopErrors', () => {
});

it('sends query_range v5 API call with required filters including has_error', async () => {
let capturedRequest: any;

// Override the v5 API mock to capture the request
server.use(
rest.post(V5_QUERY_RANGE_API_PATH, async (req, res, ctx) => {
capturedRequest = await req.json();
return res(
ctx.status(200),
ctx.json({
data: {
data: {
results: [
{
columns: [
{
name: 'http.url',
fieldDataType: 'string',
fieldContext: 'attribute',
},
{
name: 'response_status_code',
fieldDataType: 'string',
fieldContext: 'span',
},
{
name: 'status_message',
fieldDataType: 'string',
fieldContext: 'span',
},
{ name: 'count()', fieldDataType: 'int64', fieldContext: '' },
],
data: [['/api/test', '500', 'Internal Server Error', 10]],
},
],
},
},
}),
);
}),
// let capturedRequest: any;

const topErrorsPayload = getTopErrorsQueryPayload(
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
{ items: [], op: 'AND' },
false,
);

// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);

// Wait for the API call to be made
await waitFor(() => {
expect(capturedRequest).toBeDefined();
expect(topErrorsPayload).toBeDefined();
});

// Extract the filter expression from the captured request
const filterExpression =
capturedRequest.compositeQuery.queries[0].spec.filter.expression;
// getTopErrorsQueryPayload returns a builder_query with TraceBuilderQuery spec
const builderQuery = topErrorsPayload.compositeQuery.queries[0]
.spec as BuilderQuery;
const filterExpression = builderQuery.filter?.expression;

// Verify all required filters are present
expect(filterExpression).toContain(`kind_string = 'Client'`);
expect(filterExpression).toContain(`(http.url EXISTS OR url.full EXISTS)`);
expect(filterExpression).toContain(
`(net.peer.name = 'test-domain' OR server.address = 'test-domain')`,
`kind_string = 'Client' AND (http.url EXISTS OR url.full EXISTS) AND (net.peer.name = 'test-domain' OR server.address = 'test-domain') AND has_error = true`,
);
expect(filterExpression).toContain(`has_error = true`);
expect(filterExpression).toContain(`status_message EXISTS`); // toggle is on by default
});
});
Loading
Loading