Compare commits

..

4 Commits

Author SHA1 Message Date
Nityananda Gohain
6a7afa71be Merge branch 'main' into issue_8965 2026-05-22 21:43:07 +05:30
nityanandagohain
3a8a9eaef3 fix: cleanup query from tests 2026-05-22 21:40:33 +05:30
nityanandagohain
ad34f2c620 fix: update comments 2026-05-22 13:10:59 +05:30
nityanandagohain
aa140b3456 feat: trace based filters for logs, supporting aggregations as well 2026-05-21 18:47:25 +05:30
23 changed files with 856 additions and 334 deletions

View File

@@ -69,8 +69,6 @@ export function useLogsTableColumns({
id: 'timestamp',
header: 'Timestamp',
accessorFn: (log): unknown => log.timestamp,
canBeHidden: false,
enableRemove: false,
width: { default: 170, min: 170 },
cell: ({ value }): ReactElement => {
const ts = value as string | number;
@@ -94,7 +92,6 @@ export function useLogsTableColumns({
header: 'Body',
accessorFn: (log): string => getBodyDisplayString(log.body),
canBeHidden: false,
enableRemove: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (
<TanStackTable.Text

View File

@@ -62,7 +62,6 @@ describe('LogsFormatOptionsMenu (unit)', () => {
onSearch: jest.fn(),
onSelect: jest.fn(),
onRemove: jest.fn(),
onReorder: jest.fn(),
},
}}
/>,

View File

@@ -308,7 +308,9 @@ function TanStackTableInner<TData>(
});
const hasSingleColumn = useMemo(
() => effectiveColumns.filter((c) => !c.pin).length <= 1,
() =>
effectiveColumns.filter((c) => !c.pin && c.enableRemove !== false).length <=
1,
[effectiveColumns],
);

View File

@@ -16,6 +16,7 @@ import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColum
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import type { TanStackTableHandle } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
@@ -23,11 +24,13 @@ import { QueryParams } from 'constants/query';
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useEventSource } from 'providers/EventSource';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
// interfaces
import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -51,6 +54,9 @@ function LiveLogsList({
const { isConnectionLoading } = useEventSource();
const { activeLogId } = useCopyLogLink();
const { logs: logsPreferences } = usePreferenceContext();
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const hasReconciledHiddenColumnsRef = useRef(false);
const {
activeLog,
@@ -66,7 +72,7 @@ function LiveLogsList({
[logs],
);
const { options, config } = useOptionsMenu({
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator: StringOperators.NOOP,
@@ -77,7 +83,16 @@ function LiveLogsList({
[formattedLogs, activeLogId],
);
const selectedFields = convertKeysToColumnFields(options.selectColumns);
const selectedFields = convertKeysToColumnFields([
...defaultLogsSelectedColumns,
...options.selectColumns,
]);
const syncedSelectedColumns = useMemo(
() =>
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
[options.selectColumns, hiddenColumnIds],
);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
@@ -85,6 +100,30 @@ function LiveLogsList({
appendTo: 'end',
});
useEffect(() => {
if (hasReconciledHiddenColumnsRef.current) {
return;
}
hasReconciledHiddenColumnsRef.current = true;
if (syncedSelectedColumns.length === options.selectColumns.length) {
return;
}
logsPreferences.updateColumns(syncedSelectedColumns);
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
const handleColumnRemove = useCallback(
(columnId: string) => {
const updatedColumns = options.selectColumns.filter(
({ name }) => name !== columnId,
);
logsPreferences.updateColumns(updatedColumns);
},
[options.selectColumns, logsPreferences],
);
const makeOnLogCopy = useCallback(
(log: ILog) =>
(event: MouseEvent<HTMLElement>): void => {
@@ -198,7 +237,7 @@ function LiveLogsList({
ref={ref as React.Ref<TanStackTableHandle>}
columns={logsColumns}
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
onColumnRemove={config?.addColumn?.onRemove}
onColumnRemove={handleColumnRemove}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
data={formattedLogs}

View File

@@ -18,19 +18,21 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import Spinner from 'components/Spinner';
import type { TanStackTableHandle } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import APIError from 'types/api/error';
// interfaces
import { ILog } from 'types/api/logs/log';
@@ -67,6 +69,10 @@ function LogsExplorerList({
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const { activeLogId } = useCopyLogLink();
const { logs: logsPreferences } = usePreferenceContext();
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const hasReconciledHiddenColumnsRef = useRef(false);
const {
activeLog,
onAddToQuery,
@@ -75,7 +81,7 @@ function LogsExplorerList({
handleCloseLogDetail,
} = useLogDetailHandlers();
const { options, config } = useOptionsMenu({
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator:
@@ -91,15 +97,28 @@ function LogsExplorerList({
);
const selectedFields = useMemo(
() => convertKeysToColumnFields(options.selectColumns),
[options.selectColumns],
() =>
convertKeysToColumnFields([
...defaultLogsSelectedColumns,
...options.selectColumns,
]),
[options],
);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<ILog>[]): void => {
config?.addColumn?.onReorder(cols.map((c) => c.id));
const syncedSelectedColumns = useMemo(
() =>
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
[options.selectColumns, hiddenColumnIds],
);
const handleColumnRemove = useCallback(
(columnId: string) => {
const updatedColumns = options.selectColumns.filter(
({ name }) => name !== columnId,
);
logsPreferences.updateColumns(updatedColumns);
},
[config],
[options.selectColumns, logsPreferences],
);
const logsColumns = useLogsTableColumns({
@@ -142,6 +161,20 @@ function LogsExplorerList({
}
}, [isLoading, isFetching, isError, logs.length]);
useEffect(() => {
if (hasReconciledHiddenColumnsRef.current) {
return;
}
hasReconciledHiddenColumnsRef.current = true;
if (syncedSelectedColumns.length === options.selectColumns.length) {
return;
}
logsPreferences.updateColumns(syncedSelectedColumns);
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
const getItemContent = useCallback(
(_: number, log: ILog): JSX.Element => {
if (options.format === 'raw') {
@@ -204,8 +237,7 @@ function LogsExplorerList({
ref={ref as React.Ref<TanStackTableHandle>}
columns={logsColumns}
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
onColumnRemove={config?.addColumn?.onRemove}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={handleColumnRemove}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
data={logs}

View File

@@ -1,79 +0,0 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
ensureLogsRequiredColumns,
} from '../constants';
const TIMESTAMP = defaultLogsSelectedColumns.find(
(c) => c.name === 'timestamp',
);
const BODY = defaultLogsSelectedColumns.find((c) => c.name === 'body');
if (!TIMESTAMP || !BODY) {
throw new Error('defaults missing timestamp/body — test fixture invalid');
}
const ATTR_A: TelemetryFieldKey = {
name: 'service.name',
signal: 'logs',
fieldContext: 'resource',
fieldDataType: 'string',
};
const ATTR_B: TelemetryFieldKey = {
name: 'severity_text',
signal: 'logs',
fieldContext: 'log',
fieldDataType: 'string',
};
describe('ensureLogsRequiredColumns', () => {
it('prepends both timestamp + body to an empty list', () => {
expect(ensureLogsRequiredColumns([])).toStrictEqual([TIMESTAMP, BODY]);
});
it('prepends only `body` when `timestamp` is already present', () => {
expect(ensureLogsRequiredColumns([TIMESTAMP, ATTR_A])).toStrictEqual([
BODY,
TIMESTAMP,
ATTR_A,
]);
});
it('prepends only `timestamp` when `body` is already present', () => {
expect(ensureLogsRequiredColumns([BODY, ATTR_A])).toStrictEqual([
TIMESTAMP,
BODY,
ATTR_A,
]);
});
it('returns the same array when both are present (no duplicates, original order preserved)', () => {
const input = [TIMESTAMP, BODY, ATTR_A, ATTR_B];
expect(ensureLogsRequiredColumns(input)).toBe(input);
});
it('preserves a non-default order when both are present', () => {
const input = [ATTR_A, BODY, ATTR_B, TIMESTAMP];
expect(ensureLogsRequiredColumns(input)).toStrictEqual(input);
});
it('prepends both when neither is present in a list of user attributes', () => {
expect(ensureLogsRequiredColumns([ATTR_A, ATTR_B])).toStrictEqual([
TIMESTAMP,
BODY,
ATTR_A,
ATTR_B,
]);
});
it('does not duplicate if a required column appears twice in the input', () => {
// Tolerant of malformed input — invariant only adds *missing* required
// columns; it does not deduplicate existing entries (that's a separate
// concern, not its job).
const input = [BODY, BODY, ATTR_A];
const result = ensureLogsRequiredColumns(input);
expect(result.filter((c) => c.name === 'timestamp')).toHaveLength(1);
expect(result[0]).toStrictEqual(TIMESTAMP);
});
});

View File

@@ -35,32 +35,6 @@ export const defaultLogsSelectedColumns: TelemetryFieldKey[] = [
},
];
const LOGS_REQUIRED_COLUMNS = ['timestamp', 'body'] as const;
/**
* Always-on invariant: every logs selectColumns array must contain `body` and
* `timestamp`. Applied at both loader and writer boundaries so the picker, the
* table, and persisted state can never diverge into a "missing required
* column" state.
*/
export function ensureLogsRequiredColumns(
columns: TelemetryFieldKey[],
): TelemetryFieldKey[] {
const missing = LOGS_REQUIRED_COLUMNS.filter(
(name) => !columns.some((c) => c.name === name),
);
if (missing.length === 0) {
return columns;
}
const defaultsByName = new Map(
defaultLogsSelectedColumns.map((c) => [c.name, c]),
);
const prepended = missing
.map((name) => defaultsByName.get(name))
.filter((c): c is TelemetryFieldKey => c !== undefined);
return [...prepended, ...columns];
}
export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [
{
name: 'service.name',

View File

@@ -40,6 +40,5 @@ export type OptionsMenuConfig = {
isFetching: boolean;
value: TelemetryFieldKey[];
onRemove: (key: string) => void;
onReorder: (orderedIds: string[]) => void;
};
};

View File

@@ -187,6 +187,30 @@ const useOptionsMenu = ({
searchedAttributesDataV5?.data.data.keys || {},
).flat();
if (searchedAttributesDataList.length) {
if (dataSource === DataSource.LOGS) {
const logsSelectedColumns: TelemetryFieldKey[] =
defaultLogsSelectedColumns.map((e) => ({
...e,
name: e.name,
signal: e.signal as SignalType,
fieldContext: e.fieldContext as FieldContext,
fieldDataType: e.fieldDataType as FieldDataType,
}));
return [
...logsSelectedColumns,
...searchedAttributesDataList
.filter((attribute) => attribute.name !== 'body')
// eslint-disable-next-line sonarjs/no-identical-functions
.map((e) => ({
...e,
name: e.name,
signal: e.signal as SignalType,
fieldContext: e.fieldContext as FieldContext,
fieldDataType: e.fieldDataType as FieldDataType,
})),
];
}
// eslint-disable-next-line sonarjs/no-identical-functions
return searchedAttributesDataList.map((e) => ({
...e,
name: e.name,
@@ -273,9 +297,24 @@ const useOptionsMenu = ({
return [...acc, column];
}, [] as TelemetryFieldKey[]);
const optionsData: OptionsQuery = {
...defaultOptionsQuery,
selectColumns: newSelectedColumns,
format: preferences?.formatting?.format || defaultOptionsQuery.format,
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
};
updateColumns(newSelectedColumns);
handleRedirectWithOptionsData(optionsData);
},
[searchedAttributeKeys, selectedColumnKeys, preferences, updateColumns],
[
searchedAttributeKeys,
selectedColumnKeys,
preferences,
handleRedirectWithOptionsData,
updateColumns,
],
);
const handleRemoveSelectedColumn = useCallback(
@@ -288,12 +327,27 @@ const useOptionsMenu = ({
notifications.error({
message: 'There must be at least one selected column',
});
return;
} else {
const optionsData: OptionsQuery = {
...defaultOptionsQuery,
selectColumns: newSelectedColumns || [],
format: preferences?.formatting?.format || defaultOptionsQuery.format,
maxLines:
preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
fontSize:
preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
};
updateColumns(newSelectedColumns || []);
handleRedirectWithOptionsData(optionsData);
}
updateColumns(newSelectedColumns || []);
},
[dataSource, notifications, preferences, updateColumns],
[
dataSource,
notifications,
preferences,
handleRedirectWithOptionsData,
updateColumns,
],
);
const handleFormatChange = useCallback(
@@ -360,18 +414,6 @@ const useOptionsMenu = ({
setSearchText(value);
}, []);
const reorderSelectColumns = useCallback(
(orderedIds: string[]): void => {
const current = preferences?.columns ?? [];
const byName = new Map(current.map((f) => [f.name, f]));
const reordered = orderedIds
.map((id) => byName.get(id))
.filter((f): f is TelemetryFieldKey => f !== undefined);
updateColumns(reordered);
},
[preferences, updateColumns],
);
const handleFocus = (): void => {
setIsFocused(true);
};
@@ -394,7 +436,6 @@ const useOptionsMenu = ({
onSelect: handleSelectColumns,
onRemove: handleRemoveSelectedColumn,
onSearch: handleSearchAttribute,
onReorder: reorderSelectColumns,
},
format: {
value: preferences?.formatting?.format || defaultOptionsQuery.format,
@@ -416,7 +457,6 @@ const useOptionsMenu = ({
handleSelectColumns,
handleRemoveSelectedColumn,
handleSearchAttribute,
reorderSelectColumns,
handleFormatChange,
handleMaxLinesChange,
handleFontSizeChange,

View File

@@ -30,7 +30,10 @@ import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useDragColumns from 'hooks/useDragColumns';
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { AppState } from 'store/reducers';
@@ -82,6 +85,10 @@ function ListView({
},
});
const { draggedColumns, onDragColumns } = useDragColumns<RowData>(
LOCALSTORAGE.TRACES_LIST_COLUMNS,
);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
@@ -93,19 +100,6 @@ function ListView({
[stagedQuery, orderBy],
);
// TEMP — remove after traces moves to TanStack table.
// - 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],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
@@ -115,7 +109,7 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
options?.selectColumns,
orderBy,
],
[
@@ -123,7 +117,7 @@ function ListView({
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
options?.selectColumns,
maxTime,
minTime,
orderBy,
@@ -188,14 +182,13 @@ function ListView({
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns = useMemo(
() =>
getListColumns(
options?.selectColumns || [],
formatTimezoneAdjustedTimestamp,
),
[options?.selectColumns, formatTimezoneAdjustedTimestamp],
);
const columns = useMemo(() => {
const updatedColumns = getListColumns(
options?.selectColumns || [],
formatTimezoneAdjustedTimestamp,
);
return getDraggedColumns(updatedColumns, draggedColumns);
}, [options?.selectColumns, formatTimezoneAdjustedTimestamp, draggedColumns]);
const transformedQueryTableData = useMemo(
() => transformDataWithDate(queryTableData) || [],
@@ -203,16 +196,9 @@ function ListView({
);
const handleDragColumn = useCallback(
(fromIndex: number, toIndex: number): void => {
const reordered = [...columns];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
const orderedIds = reordered
.map((c) => String(('dataIndex' in c && c.dataIndex) || c.key || ''))
.filter(Boolean);
config?.addColumn?.onReorder(orderedIds);
},
[columns, config],
(fromIndex: number, toIndex: number) =>
onDragColumns(columns, fromIndex, toIndex),
[columns, onDragColumns],
);
const handleOrderChange = useCallback((value: string) => {

View File

@@ -1,15 +1,4 @@
import { createBrowserHistory } from 'history';
import { getBasePath } from 'utils/basePath';
const history = createBrowserHistory({ basename: getBasePath() });
let inAppPushCount = 0;
history.listen((_, action) => {
if (action === 'PUSH') {
inAppPushCount += 1;
}
});
export const hasInAppHistory = (): boolean => inAppPushCount > 0;
export default history;
export default createBrowserHistory({ basename: getBasePath() });

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
import * as Sentry from '@sentry/react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { TelemetryFieldKey } from 'api/v5/v5';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
@@ -14,6 +15,12 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import LogExplorerQuerySection from 'container/LogExplorerQuerySection';
import LogsExplorerViewsContainer from 'container/LogsExplorerViews';
import {
defaultLogsSelectedColumns,
defaultOptionsQuery,
URL_OPTIONS,
} from 'container/OptionsMenu/constants';
import { OptionsQuery } from 'container/OptionsMenu/types';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
@@ -24,9 +31,11 @@ import {
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { defaultTo, isEmpty, isNull } from 'lodash-es';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { defaultTo, isEmpty, isEqual, isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { EventSourceProvider } from 'providers/EventSource';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import {
@@ -53,6 +62,8 @@ function LogsExplorer(): JSX.Element {
const [selectedView, setSelectedView] = useState<ExplorerViews>(
() => panelTypeToExplorerView[panelTypesFromUrl],
);
const { logs } = usePreferenceContext();
const { preferences } = logs;
const [showFilters, setShowFilters] = useState<boolean>(() => {
const localStorageValue = getLocalStorageKey(
@@ -171,6 +182,116 @@ function LogsExplorer(): JSX.Element {
setShowFilters((prev) => !prev);
};
const { redirectWithQuery: redirectWithOptionsData } =
useUrlQueryData<OptionsQuery>(URL_OPTIONS, defaultOptionsQuery);
// Get and parse stored columns from localStorage
const logListOptionsFromLocalStorage = useMemo(() => {
const data = getLocalStorageKey(LOCALSTORAGE.LOGS_LIST_OPTIONS);
if (!data) {
return null;
}
try {
return JSON.parse(data);
} catch {
return null;
}
}, []);
// Check if the columns have the required columns (timestamp, body)
const hasRequiredColumns = useCallback(
(columns?: TelemetryFieldKey[] | null): boolean => {
if (!columns?.length) {
return false;
}
const hasTimestamp = columns.some((col) => col.name === 'timestamp');
const hasBody = columns.some((col) => col.name === 'body');
return hasTimestamp && hasBody;
},
[],
);
// Merge the columns with the required columns (timestamp, body) if missing
const mergeWithRequiredColumns = useCallback(
(columns: TelemetryFieldKey[]): TelemetryFieldKey[] => [
// Add required columns (timestamp, body) if missing
...(!hasRequiredColumns(columns) ? defaultLogsSelectedColumns : []),
...columns,
],
[hasRequiredColumns],
);
// Migrate the options query to the new format
const migrateOptionsQuery = useCallback(
(query: OptionsQuery): OptionsQuery => {
// Skip if already migrated
if (query.version) {
return query;
}
if (logListOptionsFromLocalStorage?.version) {
return logListOptionsFromLocalStorage;
}
// Case 1: we have localStorage columns
if (logListOptionsFromLocalStorage?.selectColumns?.length > 0) {
return {
...query,
version: 1,
selectColumns: mergeWithRequiredColumns(
logListOptionsFromLocalStorage.selectColumns,
),
};
}
// Case 2: No query columns in localStorage in but query has columns
if (query.selectColumns.length > 0) {
return {
...query,
version: 1,
selectColumns: mergeWithRequiredColumns(query.selectColumns),
};
}
// Case 3: No columns anywhere, use defaults
return {
...query,
version: 1,
selectColumns: defaultLogsSelectedColumns,
};
},
[mergeWithRequiredColumns, logListOptionsFromLocalStorage],
);
useEffect(() => {
if (!preferences) {
return;
}
const migratedQuery = migrateOptionsQuery({
selectColumns: preferences.columns || defaultLogsSelectedColumns,
maxLines: preferences.formatting?.maxLines || defaultOptionsQuery.maxLines,
format: preferences.formatting?.format || defaultOptionsQuery.format,
fontSize: preferences.formatting?.fontSize || defaultOptionsQuery.fontSize,
version: preferences.formatting?.version,
});
// Only redirect if the query was actually modified
if (
!isEqual(migratedQuery, {
selectColumns: preferences?.columns,
maxLines: preferences?.formatting?.maxLines,
format: preferences?.formatting?.format,
fontSize: preferences?.formatting?.fontSize,
version: preferences?.formatting?.version,
})
) {
redirectWithOptionsData(migratedQuery);
}
}, [migrateOptionsQuery, preferences, redirectWithOptionsData]);
const toolbarViews = useMemo(
() => ({
list: {

View File

@@ -16,7 +16,7 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import history, { hasInAppHistory } from 'lib/history';
import history from 'lib/history';
import {
ArrowLeft,
CalendarClock,
@@ -96,7 +96,13 @@ function TraceDetailsHeader({
}, [traceID]);
const handlePreviousBtnClick = useCallback((): void => {
if (hasInAppHistory()) {
const isSpaNavigate =
document.referrer &&
// oxlint-disable-next-line signoz/no-raw-absolute-path
new URL(document.referrer).origin === window.location.origin;
const hasBackHistory = window.history.length > 1;
if (isSpaNavigate && hasBackHistory) {
history.goBack();
} else {
history.push(ROUTES.TRACES_EXPLORER);
@@ -124,7 +130,6 @@ function TraceDetailsHeader({
size="md"
className={styles.backBtn}
onClick={handlePreviousBtnClick}
aria-label="Back"
>
<ArrowLeft size={14} />
</Button>

View File

@@ -1,60 +0,0 @@
import { fireEvent, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
import TraceDetailsHeader from '../TraceDetailsHeader';
const mockGoBack = jest.fn();
const mockPush = jest.fn();
const mockHasInAppHistory = jest.fn();
jest.mock('lib/history', () => ({
__esModule: true,
default: {
goBack: (): void => mockGoBack(),
push: (path: string): void => mockPush(path),
replace: jest.fn(),
location: { pathname: '/', search: '' },
listen: (): (() => void) => (): void => undefined,
},
hasInAppHistory: (): boolean => mockHasInAppHistory(),
}));
const baseProps = {
filterMetadata: {
startTime: 0,
endTime: 1,
traceId: 'trace-123',
},
onFilteredSpansChange: jest.fn(),
isDataLoaded: false,
};
describe('TraceDetailsHeader back button', () => {
beforeEach(() => {
mockGoBack.mockClear();
mockPush.mockClear();
mockHasInAppHistory.mockReset();
});
it('calls history.goBack() when there is in-app SPA history', () => {
mockHasInAppHistory.mockReturnValue(true);
render(<TraceDetailsHeader {...baseProps} />);
fireEvent.click(screen.getByRole('button', { name: /back/i }));
expect(mockGoBack).toHaveBeenCalledTimes(1);
expect(mockPush).not.toHaveBeenCalled();
});
it('pushes to the traces explorer route when there is no in-app SPA history', () => {
mockHasInAppHistory.mockReturnValue(false);
render(<TraceDetailsHeader {...baseProps} />);
fireEvent.click(screen.getByRole('button', { name: /back/i }));
expect(mockPush).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith(ROUTES.TRACES_EXPLORER);
expect(mockGoBack).not.toHaveBeenCalled();
});
});

View File

@@ -108,9 +108,7 @@ describe('PreferencesProvider integration', () => {
},
);
// Loader's ensureLogsRequiredColumns prepends timestamp + body, so the
// 1 column in localStorage becomes 3 in preferences.
expect(Number(screen.getByTestId('logs-columns-len').textContent)).toBe(3);
expect(Number(screen.getByTestId('logs-columns-len').textContent)).toBe(1);
});
it('direct mode updateColumns persists to localStorage', async () => {
@@ -128,11 +126,8 @@ describe('PreferencesProvider integration', () => {
const stored = getLocalStorageJSON<LogsLocalOptions>(
LOCALSTORAGE.LOGS_LIST_OPTIONS,
);
// Writer's ensureLogsRequiredColumns prepends `body` when only
// `timestamp` was passed in (defaults.slice(0,1) is just timestamp).
expect(stored?.selectColumns).toStrictEqual([
defaultLogsSelectedColumns[1] as TelemetryFieldKey, // body
defaultLogsSelectedColumns[0] as TelemetryFieldKey, // timestamp
defaultLogsSelectedColumns[0] as TelemetryFieldKey,
]);
});
@@ -188,9 +183,7 @@ describe('PreferencesProvider integration', () => {
value: originalLocation,
});
// Loader's ensureLogsRequiredColumns prepends timestamp + body, so the
// URL's 1 column becomes 3 in preferences.
expect(Number(screen.getByTestId('logs-columns-len').textContent)).toBe(3);
expect(Number(screen.getByTestId('logs-columns-len').textContent)).toBe(1);
});
it('updateFormatting persists to localStorage in direct mode', async () => {

View File

@@ -1,10 +1,7 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { LogViewMode } from 'container/LogsTable';
import {
defaultLogsSelectedColumns,
defaultOptionsQuery,
} from 'container/OptionsMenu/constants';
import { defaultOptionsQuery } from 'container/OptionsMenu/constants';
import { FontSize } from 'container/OptionsMenu/types';
import {
FormattingOptions,
@@ -88,21 +85,18 @@ describe('logsUpdaterConfig', () => {
logsUpdater.updateColumns(newColumns, PreferenceMode.DIRECT);
// Writer guards body+timestamp via ensureLogsRequiredColumns invariant
const guardedColumns = [...defaultLogsSelectedColumns, ...newColumns];
// Should update URL
expect(redirectWithOptionsData).toHaveBeenCalledWith({
...defaultOptionsQuery,
...mockPreferences.formatting,
selectColumns: guardedColumns,
selectColumns: newColumns,
});
// Should update localStorage with the guarded shape
// Should update localStorage
const storedData = JSON.parse(
mockLocalStorage[LOCALSTORAGE.LOGS_LIST_OPTIONS],
);
expect(storedData.selectColumns).toStrictEqual(guardedColumns);
expect(storedData.selectColumns).toStrictEqual(newColumns);
expect(storedData.maxLines).toBe(1); // Should preserve other fields
// Should not update saved view preferences

View File

@@ -1,5 +1,4 @@
import { renderHook, waitFor } from '@testing-library/react';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { DataSource } from 'types/common/queryBuilder';
import logsLoaderConfig from '../configs/logsLoaderConfig';
@@ -61,9 +60,9 @@ describe('usePreferenceLoader', () => {
expect(result.current.loading).toBe(false);
});
// Loader wraps with ensureLogsRequiredColumns — body+timestamp always prepended
// Should have loaded from local storage (highest priority)
expect(result.current.preferences).toStrictEqual({
columns: [...defaultLogsSelectedColumns, { name: 'local-column' }],
columns: [{ name: 'local-column' }],
formatting: { maxLines: 5, format: 'table', fontSize: 'medium', version: 1 },
});
expect(result.current.error).toBeNull();

View File

@@ -3,10 +3,7 @@ import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
defaultOptionsQuery,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { defaultOptionsQuery } from 'container/OptionsMenu/constants';
import { FontSize, OptionsQuery } from 'container/OptionsMenu/types';
import { FormattingOptions, PreferenceMode, Preferences } from '../types';
@@ -21,12 +18,11 @@ const getLogsUpdaterConfig = (
updateFormatting: (newFormatting: FormattingOptions, mode: string) => void;
} => ({
updateColumns: (newColumns: TelemetryFieldKey[], mode: string): void => {
const guardedColumns = ensureLogsRequiredColumns(newColumns);
if (mode === PreferenceMode.SAVED_VIEW) {
setSavedViewPreferences((prev) => {
if (!prev) {
return {
columns: guardedColumns,
columns: newColumns,
formatting: {
maxLines: 1,
format: 'table',
@@ -38,7 +34,7 @@ const getLogsUpdaterConfig = (
return {
...prev,
columns: guardedColumns,
columns: newColumns,
};
});
}
@@ -48,14 +44,14 @@ const getLogsUpdaterConfig = (
redirectWithOptionsData({
...defaultOptionsQuery,
...preferences?.formatting,
selectColumns: guardedColumns,
selectColumns: newColumns,
});
// Also update local storage
const local = JSON.parse(
getLocalStorageKey(LOCALSTORAGE.LOGS_LIST_OPTIONS) || '{}',
);
local.selectColumns = guardedColumns;
local.selectColumns = newColumns;
setLocalStorageKey(LOCALSTORAGE.LOGS_LIST_OPTIONS, JSON.stringify(local));
}
},

View File

@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { TelemetryFieldKey } from 'api/v5/v5';
import { ensureLogsRequiredColumns } from 'container/OptionsMenu/constants';
import { has } from 'lodash-es';
import { DataSource } from 'types/common/queryBuilder';
@@ -52,11 +51,7 @@ function logsPreferencesLoader(): {
columns: TelemetryFieldKey[];
formatting: FormattingOptions;
} {
const result = preferencesLoader<{
columns: TelemetryFieldKey[];
formatting: FormattingOptions;
}>(logsLoaderConfig);
return { ...result, columns: ensureLogsRequiredColumns(result.columns) };
return preferencesLoader(logsLoaderConfig);
}
function tracesPreferencesLoader(): {

View File

@@ -1,10 +1,7 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useEffect, useState } from 'react';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { defaultSelectedColumns as defaultTracesSelectedColumns } from 'container/TracesExplorer/ListView/configs';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
import { DataSource } from 'types/common/queryBuilder';
@@ -57,10 +54,9 @@ export function usePreferenceSync({
let columns: TelemetryFieldKey[] = [];
let formatting: FormattingOptions | undefined;
if (dataSource === DataSource.LOGS) {
columns = ensureLogsRequiredColumns(
columns =
updateExtraDataSelectColumns(parsedExtraData?.selectColumns) ||
defaultLogsSelectedColumns,
);
defaultLogsSelectedColumns;
formatting = {
maxLines: parsedExtraData?.maxLines ?? 1,
format: parsedExtraData?.format ?? 'table',

View File

@@ -199,7 +199,16 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return q.executeWindowList(ctx)
}
stmt, err := q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
fromMS, toMS := q.fromMS, q.toMS
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
fromMS, toMS, overlap = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
return emptyResultFor(q.kind, q.spec.Name), nil
}
}
stmt, err := q.stmtBuilder.Build(ctx, fromMS, toMS, q.kind, q.spec, q.variables)
if err != nil {
return nil, err
}
@@ -215,6 +224,81 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return result, nil
}
// narrowWindowByTraceID inspects the filter for trace_id predicates and clamps
// [fromMS,toMS] to the time range stored in signoz_traces.distributed_trace_summary.
// Returns the (possibly narrowed) window and overlap=false when the trace lies
// completely outside the query window — callers should short-circuit in that case.
//
// When the trace_id is not present in trace_summary the behaviour differs by
// signal:
// - traces: trace_summary is derived from the spans table, so a missing row
// means no spans exist for that trace_id; we short-circuit to empty.
// - logs: logs can carry a trace_id even when traces are not ingested at all
// (e.g. traces disabled). We must not short-circuit; instead leave the
// window untouched and let the query run.
func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toMS uint64) (uint64, uint64, bool) {
if q.spec.Filter == nil || q.spec.Filter.Expression == "" {
return fromMS, toMS, true
}
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if !found || len(traceIDs) == 0 {
return fromMS, toMS, true
}
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
if !ok {
if q.spec.Signal == telemetrytypes.SignalTraces {
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; short-circuiting traces query to empty",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, false
}
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; leaving time range untouched for logs",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, true
}
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if traceStartMS == 0 || traceEndMS == 0 {
return fromMS, toMS, true
}
if traceStartMS > toMS || traceEndMS < fromMS {
return fromMS, toMS, false
}
if traceStartMS > fromMS {
fromMS = traceStartMS
}
if traceEndMS < toMS {
toMS = traceEndMS
}
q.logger.DebugContext(ctx, "optimized time range using trace_id lookup",
slog.String("signal", q.spec.Signal.StringValue()),
slog.Any("trace_ids", traceIDs),
slog.Uint64("start", fromMS),
slog.Uint64("end", toMS))
return fromMS, toMS, true
}
// emptyResultFor returns an empty result payload appropriate for the given kind.
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
var value any
switch kind {
case qbtypes.RequestTypeTimeSeries:
value = &qbtypes.TimeSeriesData{QueryName: queryName}
case qbtypes.RequestTypeScalar:
value = &qbtypes.ScalarData{QueryName: queryName}
default:
value = &qbtypes.RawData{QueryName: queryName}
}
return &qbtypes.Result{
Type: kind,
Value: value,
}
}
// executeWithContext executes the query with query window and step context for partial value detection.
func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
@@ -310,42 +394,22 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
totalBytes := uint64(0)
start := time.Now()
// Check if filter contains trace_id(s) and optimize time range if needed
if q.spec.Signal == telemetrytypes.SignalTraces &&
q.spec.Filter != nil && q.spec.Filter.Expression != "" {
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if found && len(traceIDs) > 0 {
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if !ok {
q.logger.DebugContext(ctx, "failed to get trace time range", slog.Any("trace_ids", traceIDs))
} else if traceStartMS > 0 && traceEndMS > 0 {
// no overlap — nothing to return
if uint64(traceStartMS) > toMS || uint64(traceEndMS) < fromMS {
return &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}, nil
}
// clamp window to trace time range before bucketing
if uint64(traceStartMS) > fromMS {
fromMS = uint64(traceStartMS)
}
if uint64(traceEndMS) < toMS {
toMS = uint64(traceEndMS)
}
q.logger.DebugContext(ctx, "optimized time range for traces", slog.Any("trace_ids", traceIDs), slog.Uint64("start", fromMS), slog.Uint64("end", toMS))
}
// Check if filter contains trace_id(s) and optimize time range if needed.
// Applies to both traces (the listing this branch was built for) and logs
// (which carry trace_id and benefit from the same clamp before bucketing).
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
fromMS, toMS, overlap = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
return &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}, nil
}
}

View File

@@ -20,6 +20,7 @@ from fixtures.querier import (
index_series_by_label,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def test_logs_list(
@@ -2293,3 +2294,331 @@ def test_logs_formula_orderby_and_limit(
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"
def test_logs_list_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that filtering logs by trace_id uses the trace_summary lookup to
narrow the query window before scanning the logs table:
1. Returns the matching log (narrow window, single bucket).
2. Does not return duplicate logs when the query window should span multiple
exponential buckets (>1 h). But is clamped to the timerange of trace.
3. Returns no results when the query window does not contain the trace.
4. Logs carrying a trace_id whose trace is NOT in trace_summary (e.g.
traces disabled) are still returned — the lookup miss must not
short-circuit logs queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
other_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
other_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-filter-service",
"cloud.provider": "integration",
}
# Populate signoz_traces.distributed_trace_summary by inserting spans for
# the target trace_id. trace_summary records min/max of span timestamps
# (it ignores span duration), so two spans are inserted to give the trace
# a non-trivial recorded window of [now-10s, now-5s].
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Insert logs:
# - one with the target trace_id, at a timestamp within the trace's
# recorded window (now-10s..now-5s, padded ±1s).
# - one with a different trace_id; must never appear in target_trace_id
# results.
# - one with an orphan trace_id whose trace was never ingested — used to
# verify the lookup miss does NOT short-circuit logs queries.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=7),
resources=common_resources,
attributes={"http.method": "GET"},
body="log inside the target trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=3),
resources=common_resources,
attributes={"http.method": "POST"},
body="log with a different trace_id",
severity_text="INFO",
trace_id=other_trace_id,
span_id=other_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={"http.method": "PUT"},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _query(start_ms: int, end_ms: int, trace_id: str) -> list:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
return response.json()["data"]["data"]["results"][0]["rows"] or []
now_ms = int(now.timestamp() * 1000)
# --- Test 1: narrow window (single bucket, <1 h) ---
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
narrow_rows = _query(narrow_start_ms, now_ms, target_trace_id)
assert len(narrow_rows) == 1, f"Expected 1 log for trace_id filter (narrow window), got {len(narrow_rows)}"
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
assert narrow_rows[0]["data"]["span_id"] == target_root_span_id
# --- Test 2: wide window (>1 h, camp to the timerange from trace_summary) ---
# Should still return exactly one log — no duplicates from multi-bucket scan.
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_rows = _query(wide_start_ms, now_ms, target_trace_id)
assert len(wide_rows) == 1, f"Expected 1 log for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-log regression"
assert wide_rows[0]["data"]["trace_id"] == target_trace_id
assert wide_rows[0]["data"]["span_id"] == target_root_span_id
# --- Test 3: window that does not contain the trace returns no results ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_rows = _query(past_start_ms, past_end_ms, target_trace_id)
assert len(past_rows) == 0, f"Expected 0 logs for trace_id filter outside time window, got {len(past_rows)}"
# --- Test 4: trace_id not present in trace_summary still returns logs ---
orphan_rows = _query(narrow_start_ms, now_ms, orphan_trace_id)
assert len(orphan_rows) == 1, f"Expected 1 log for orphan trace_id (no trace_summary entry), got {len(orphan_rows)} — logs query may have been incorrectly short-circuited"
assert orphan_rows[0]["data"]["trace_id"] == orphan_trace_id
def test_logs_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) logs queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to an
empty result.
3. A trace_id with no row in trace_summary (e.g. traces disabled) still
returns the matching logs — the lookup miss must not short-circuit
logs aggregation queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-agg-service",
"cloud.provider": "integration",
}
# trace_summary records min/max of span timestamps (it ignores duration),
# so insert two spans to give the trace a recorded window wide enough to
# comfortably contain the log timestamps below.
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Two logs for the target trace_id, both inside the recorded trace window.
# One additional log carries an orphan trace_id with no row in
# trace_summary — used to verify that the lookup miss does not
# short-circuit logs aggregations.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=9),
resources=common_resources,
attributes={},
body="log A inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=6),
resources=common_resources,
attributes={},
body="log B inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> float:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0
series = aggregations[0].get("series") or []
if not series:
return 0
return sum(v["value"] for v in series[0]["values"])
now_ms = int(now.timestamp() * 1000)
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns 2 logs ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
# --- Test 2: window outside the trace short-circuits to empty ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
# --- Test 3: trace_id not present in trace_summary still returns logs ---
orphan_count = _count(narrow_start_ms, now_ms, orphan_trace_id)
assert orphan_count == 1, f"Expected count=1 for orphan trace_id aggregation, got {orphan_count} — query may have been incorrectly short-circuited"

View File

@@ -2123,3 +2123,115 @@ def test_traces_list_filter_by_trace_id(
past_rows = _query(past_start_ms, past_end_ms)
assert len(past_rows) == 0, f"Expected 0 spans for trace_id filter outside time window, got {len(past_rows)}"
def test_traces_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) traces queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to empty.
3. Filter referencing a trace_id with no row in trace_summary
short-circuits to empty (trace_summary is authoritative for traces).
"""
target_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
missing_trace_id = TraceIdGenerator.trace_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "traces-agg-filter-service",
"cloud.provider": "integration",
}
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={"http.request.method": "GET"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> float:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0
series = aggregations[0].get("series") or []
if not series:
return 0
return sum(v["value"] for v in series[0]["values"])
now_ms = int(now.timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns both spans ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
# --- Test 2: window outside the trace short-circuits to empty ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
# --- Test 3: trace_id with no entry in trace_summary short-circuits ---
missing_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
missing_count = _count(missing_start_ms, now_ms, missing_trace_id)
assert missing_count == 0, f"Expected count=0 for trace_id absent from trace_summary, got {missing_count}"