Compare commits

...

6 Commits

Author SHA1 Message Date
aks07
674f878008 test(traces-explorer): type the captured list request 2026-10-27 16:19:05 +05:30
aks07
7d258c17ac fix(trace-details): offer color-by fields with 0 or false values 2026-10-27 14:17:12 +05:30
aks07
8cd159fc55 chore(traces-explorer): nest resource fields in list story rows 2026-10-27 13:49:03 +05:30
aks07
50b323bcb2 fix(trace-details): widen getSpanAttribute return type
Color-by now groups false and 0 values on their own, not under unknown.
2026-10-27 13:49:00 +05:30
aks07
56806ede49 feat(traces-explorer): fetch all span fields in the list view
Empty selectFields makes the API return every span field, which the span
details drawer needs. Column cells read attribute and resource values from
the returned maps.
2026-10-26 18:47:36 +05:30
aks07
3ed283a473 fix(trace-details): treat false span attributes as present 2026-10-26 18:30:29 +05:30
10 changed files with 125 additions and 48 deletions

View File

@@ -3,7 +3,6 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
@@ -591,7 +590,6 @@ export const listViewInitialTraceQuery: Query = {
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 10,
selectColumns: defaultTraceSelectedColumns,
},
],
},

View File

@@ -1,9 +1,15 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialQueriesMap,
initialQueryBuilderFormTraceOperatorValues,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import ListView from './index';
@@ -24,7 +30,7 @@ jest.mock('react-redux', () => {
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the query fires and the expected columns render, independent of that API.
// so the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
@@ -35,6 +41,8 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
{ name: 'duration_nano', fieldContext: 'span' },
{ name: 'http_method', fieldContext: 'span' },
{ name: 'response_status_code', fieldContext: 'span' },
{ name: 'http.route', fieldContext: 'attribute' },
{ name: 'cache.hit', fieldContext: 'attribute' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
@@ -48,19 +56,19 @@ const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'frontend',
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
resource: { 'service.name': 'frontend' },
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
'service.name': 'demo-app',
name: 'authenticate_check_db',
duration_nano: 790949390,
// empty status fields to assert the "-" cell
@@ -68,6 +76,8 @@ const listRows = [
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
@@ -84,7 +94,9 @@ const mockSuccess = (rows: unknown[] = listRows): void => {
);
};
const renderListView = (): ReturnType<typeof render> =>
const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
@@ -98,8 +110,8 @@ const renderListView = (): ReturnType<typeof render> =>
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
stagedQuery,
currentQuery: stagedQuery,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
@@ -118,6 +130,10 @@ describe('Traces ListView - Data Loaded', () => {
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
// attribute column reads from the attributes map
expect(screen.getByText('/api/cart')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument();
// duration_nano renders in milliseconds
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
@@ -130,4 +146,37 @@ describe('Traces ListView - Data Loaded', () => {
// empty status fields render "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
});
it('requests every span field instead of the selected columns', async () => {
let requestBody: QueryRangeRequestV5 | undefined;
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
requestBody = await req.json<QueryRangeRequestV5>();
return res(ctx.status(200), ctx.json(listResponse(listRows)));
}),
);
const selectColumns = [{ name: 'http.route', fieldContext: 'attribute' }];
const { builder } = initialQueriesMap.traces;
renderListView({
...initialQueriesMap.traces,
builder: {
...builder,
queryData: [{ ...builder.queryData[0], selectColumns }],
queryTraceOperator: [
{
...initialQueryBuilderFormTraceOperatorValues,
expression: 'A',
selectColumns,
},
],
},
} as Query);
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
const queries = requestBody?.compositeQuery.queries ?? [];
expect(queries).toHaveLength(2);
queries.forEach(({ spec }) => {
expect(spec).not.toHaveProperty('selectFields');
});
});
});

View File

@@ -99,23 +99,20 @@ function ListView({
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
// Empty selectFields makes the API return every span field.
const requestQuery = useMemo(() => {
const query = getListViewQuery(
stagedQuery || initialQueriesMap.traces,
orderBy,
);
query.builder.queryData.forEach((queryData) => {
queryData.selectColumns = [];
});
query.builder.queryTraceOperator?.forEach((queryTraceOperator) => {
queryTraceOperator.selectColumns = [];
});
return query;
}, [stagedQuery, orderBy]);
const queryKey = useMemo(
() => [
@@ -126,14 +123,12 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
],
@@ -154,7 +149,6 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
},
},
// ENTITY_VERSION_V4,
@@ -163,10 +157,7 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
},
);

View File

@@ -1,6 +1,7 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
@@ -16,7 +17,10 @@ export function getFieldColumn(
return {
id: buildCompositeKey(name, fieldContext, fieldDataType),
header: name,
accessorFn: (row): unknown => row[name],
// Span rows nest attribute/resource fields in maps; trace rows are flat.
accessorFn: (row): unknown =>
row[name] ??
getSpanAttribute(row as Parameters<typeof getSpanAttribute>[0], name),
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,

View File

@@ -190,10 +190,12 @@ function SpanDetailsContent({
return null;
}
return {
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
clusterName: String(
getSpanAttribute(selectedSpan, 'k8s.cluster.name') ?? '',
),
podName: String(getSpanAttribute(selectedSpan, 'k8s.pod.name') ?? ''),
nodeName: String(getSpanAttribute(selectedSpan, 'k8s.node.name') ?? ''),
hostName: String(getSpanAttribute(selectedSpan, 'host.name') ?? ''),
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
};
}, [selectedSpan]);

View File

@@ -88,5 +88,17 @@ describe('Presentation / Styling Utils', () => {
);
expect(value).toBe('host-1');
});
it('groups 0 and false under their own value, not "unknown"', () => {
expect(
getFlamegraphSpanGroupValue({ attributes: { 'host.name': 0 } }, HOST_FIELD),
).toBe('0');
expect(
getFlamegraphSpanGroupValue(
{ attributes: { 'host.name': false } },
HOST_FIELD,
),
).toBe('false');
});
});
});

View File

@@ -80,7 +80,7 @@ export function getFlamegraphRowMetrics(
export function getFlamegraphServiceName(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
): string {
return getSpanAttribute(span, 'service.name') || '';
return String(getSpanAttribute(span, 'service.name') ?? '');
}
/**
@@ -92,7 +92,7 @@ export function getFlamegraphSpanGroupValue(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
field: TelemetryFieldKey,
): string {
return getSpanAttribute(span, field.name) || 'unknown';
return String(getSpanAttribute(span, field.name) ?? 'unknown');
}
interface GetSpanColorArgs {

View File

@@ -27,6 +27,14 @@ describe('getAvailableColorByFieldNames', () => {
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
});
it('offers a field whose only values are 0 or false', () => {
const spans = [
span({ attributes: { 'host.name': 0 } }),
span({ attributes: { 'host.name': false } }),
];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
});
it('does not offer fields no span carries', () => {
const spans = [span({ resource: { 'service.name': 'api' } })];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['service.name']);

View File

@@ -9,14 +9,14 @@ import {
RESERVED_ERROR,
} from './utils/generateColorPair';
/** Like `||`, but keeps `0`. */
/** Like `||`, but keeps `0` and `false`. */
const valueOrUndefined = <T>(value: T): T | undefined =>
value || value === 0 ? value : undefined;
value || value === 0 || value === false ? value : undefined;
/**
* Look up an attribute from both `resource` and `attributes` on a span.
* Resources are checked first (service.name, k8s.* etc. live there).
* Falsy values count as missing, except `0`.
* Falsy values count as missing, except `0` and `false`.
*
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
* structurally — the only fields touched are `resource` and `attributes`.
@@ -30,7 +30,7 @@ export function getSpanAttribute(
tagMap?: Record<string, any>;
},
key: string,
): string | undefined {
): string | number | boolean | undefined {
return (
valueOrUndefined(span.resource?.[key]) ??
valueOrUndefined(span.attributes?.[key]) ??
@@ -117,7 +117,7 @@ export function getSpanGroupValue(
span: SpanV3,
colorByFieldName: string,
): string {
return getSpanAttribute(span, colorByFieldName) || 'unknown';
return String(getSpanAttribute(span, colorByFieldName) ?? 'unknown');
}
/**
@@ -143,6 +143,6 @@ export function resolveSpanColor(
*/
export function getAvailableColorByFieldNames(spans: SpanV3[]): string[] {
return COLOR_BY_OPTIONS.filter((opt) =>
spans.some((s) => getSpanAttribute(s, opt.field.name)),
spans.some((s) => getSpanAttribute(s, opt.field.name) !== undefined),
).map((opt) => opt.field.name);
}

View File

@@ -139,7 +139,7 @@ const timestampAt = (index: number, start: number, end: number): string => {
return new Date(end - index * step).toISOString();
};
export const traceSpanRows = ({
const flatSpanRows = ({
count,
offset,
limit,
@@ -167,9 +167,22 @@ export const traceSpanRows = ({
};
});
/** The List view returns every span field, with resource and attribute fields nested in maps. */
export const traceSpanRows = (options: SpanRowsOptions): RawRow[] =>
flatSpanRows(options).map(
({ timestamp, data: { 'service.name': serviceName, ...data } }) => ({
timestamp,
data: {
...data,
attributes: {},
resource: { 'service.name': serviceName },
},
}),
);
/** The Trace view lists root spans, with the span count of the whole trace. */
export const traceRootSpanRows = (options: SpanRowsOptions): RawRow[] =>
traceSpanRows(options).map((row, index) => ({
flatSpanRows(options).map((row, index) => ({
...row,
data: {
...row.data,