mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-06 00:10:41 +00:00
Compare commits
7 Commits
fix/query-
...
nv/exec-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe45b9dcae | ||
|
|
63ee60a40c | ||
|
|
9a541c851f | ||
|
|
e0802b8274 | ||
|
|
29a393e47e | ||
|
|
c58f76f998 | ||
|
|
e39ad9e221 |
@@ -28287,6 +28287,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28295,9 +28301,15 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
summary: Get rule history filter keys
|
||||
tags:
|
||||
- rules
|
||||
@@ -28465,6 +28477,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28473,9 +28491,15 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
summary: Get rule history filter values
|
||||
tags:
|
||||
- rules
|
||||
@@ -28548,9 +28572,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
summary: Get rule overall status timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -28620,9 +28644,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
summary: Get rule history stats
|
||||
tags:
|
||||
- rules
|
||||
@@ -28704,6 +28728,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28712,9 +28742,19 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Get rule history timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -28779,6 +28819,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28787,9 +28833,19 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Get top contributors to rule firing
|
||||
tags:
|
||||
- rules
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_PANEL_COLUMNS = 'AI_OBSERVABILITY_PANEL_COLUMNS',
|
||||
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
|
||||
import { useAppSearchParams } from 'lib/router/useAppSearchParams';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
@@ -85,8 +86,9 @@ function Explorer(): JSX.Element {
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
const selectedView = useMemo(
|
||||
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
[searchParams, panelTypesFromUrl],
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
@@ -115,8 +117,6 @@ function Explorer(): JSX.Element {
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
@@ -190,6 +190,7 @@ function Explorer(): JSX.Element {
|
||||
testId="llm-observability-explorer"
|
||||
showFilters={isOpen}
|
||||
onToggleFilters={(): void => setOpen(!isOpen)}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.ai_observability }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
quickFilterSource: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Dispatch, SetStateAction, useCallback } from 'react';
|
||||
import { useAppNavigate } from 'lib/router/useAppNavigate';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type {
|
||||
CellTypographySize,
|
||||
@@ -31,6 +32,8 @@ export type TracesTableProps = {
|
||||
isFilterApplied: boolean;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
columnSizing?: ColumnSizingState;
|
||||
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||
cellTypographySize?: CellTypographySize;
|
||||
};
|
||||
|
||||
@@ -48,6 +51,8 @@ function TracesTable({
|
||||
isFilterApplied,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
columnSizing,
|
||||
onColumnSizingChange,
|
||||
cellTypographySize = 'medium',
|
||||
}: TracesTableProps): JSX.Element {
|
||||
const navigate = useAppNavigate();
|
||||
@@ -98,6 +103,8 @@ function TracesTable({
|
||||
cellTypographySize={cellTypographySize}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
columnSizing={columnSizing}
|
||||
onColumnSizingChange={onColumnSizingChange}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
@@ -113,6 +120,8 @@ TracesTable.defaultProps = {
|
||||
respectColumnOrder: false,
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
columnSizing: undefined,
|
||||
onColumnSizingChange: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
};
|
||||
|
||||
|
||||
@@ -11,13 +11,16 @@ const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
|
||||
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
|
||||
|
||||
// An aggregate outside the default order starts hidden, so the persisted
|
||||
// defaults are observable.
|
||||
// An aggregate that starts hidden, so the persisted defaults are observable.
|
||||
const COLUMNS = buildTraceViewColumns([
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'unlisted_aggregate' },
|
||||
]);
|
||||
]).map((column) =>
|
||||
column.id === 'unlisted_aggregate'
|
||||
? { ...column, defaultVisibility: false }
|
||||
: column,
|
||||
);
|
||||
|
||||
function RaceHarness(): JSX.Element {
|
||||
const [columnsReady, setColumnsReady] = useState(false);
|
||||
|
||||
@@ -12,11 +12,19 @@ import { QueryKey } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
setColumnSizing,
|
||||
useColumnSizing,
|
||||
} from 'components/TanStackTableView/useColumnStore';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { getTraceLink } from '../ListView/utils';
|
||||
import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import TracesTable from '../TracesTable/TracesTable';
|
||||
@@ -40,8 +48,11 @@ import {
|
||||
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
|
||||
} from '../constants';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { PER_PAGE_OPTIONS } from './configs';
|
||||
import { useTraceViewColumns } from './useTraceViewColumns';
|
||||
import {
|
||||
buildTraceViewColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
REQUIRED_FIELDS,
|
||||
} from './configs';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
@@ -65,14 +76,37 @@ function TracesView({
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const {
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
requiredFields,
|
||||
isLoading: isColumnsLoading,
|
||||
canPersistColumns,
|
||||
} = useTraceViewColumns();
|
||||
const { options, config } = useOptionsMenu({
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
});
|
||||
|
||||
const tableColumns = useMemo(
|
||||
() => buildTraceViewColumns(options.selectColumns),
|
||||
[options.selectColumns],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(cols.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
// The column store keeps widths only; the picked columns live in the slice.
|
||||
const columnSizing = useColumnSizing(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS,
|
||||
);
|
||||
const handleColumnSizingChange = useCallback<
|
||||
Dispatch<SetStateAction<ColumnSizingState>>
|
||||
>(
|
||||
(updater) => {
|
||||
setColumnSizing(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS,
|
||||
typeof updater === 'function' ? updater(columnSizing) : updater,
|
||||
);
|
||||
},
|
||||
[columnSizing],
|
||||
);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
@@ -174,24 +208,6 @@ function TracesView({
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
// Without the full column set there is no pool to pick from, so the control is dropped.
|
||||
const fieldsSelectorConfig = useMemo(
|
||||
() =>
|
||||
canPersistColumns
|
||||
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
|
||||
: null,
|
||||
[canPersistColumns, selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
canPersistColumns
|
||||
? columns
|
||||
: columns.filter((column) => column.defaultVisibility !== false),
|
||||
[canPersistColumns, columns],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
@@ -215,11 +231,11 @@ function TracesView({
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
config={fieldsSelectorConfig}
|
||||
config={config}
|
||||
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
|
||||
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
|
||||
extraFields={TRACE_VIEW_COLUMN_EXTRA_FIELDS}
|
||||
requiredFields={requiredFields}
|
||||
requiredFields={REQUIRED_FIELDS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,15 +243,13 @@ function TracesView({
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={
|
||||
canPersistColumns
|
||||
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
|
||||
: undefined
|
||||
}
|
||||
respectColumnOrder
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
columnSizing={columnSizing}
|
||||
onColumnSizingChange={handleColumnSizingChange}
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading || isColumnsLoading}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { TRACE_VIEW_DEFAULT_COLUMNS } from '../../constants';
|
||||
import type { TracesTableProps } from '../../TracesTable/TracesTable';
|
||||
import TracesView from '../TracesView';
|
||||
|
||||
jest.mock('../../TracesTable/TracesTable', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
columns,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
onColumnSizingChange,
|
||||
}: TracesTableProps): JSX.Element => (
|
||||
<div>
|
||||
<div data-testid="table-columns">
|
||||
{columns.map((column) => String(column.header)).join(',')}
|
||||
</div>
|
||||
<button
|
||||
data-testid="reverse-columns"
|
||||
type="button"
|
||||
onClick={(): void => onColumnOrderChange?.([...columns].reverse())}
|
||||
>
|
||||
reverse
|
||||
</button>
|
||||
<button
|
||||
data-testid="remove-total-tokens"
|
||||
type="button"
|
||||
onClick={(): void => onColumnRemove?.('trace:total_tokens:float64')}
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
<button
|
||||
data-testid="resize-trace-id"
|
||||
type="button"
|
||||
onClick={(): void => onColumnSizingChange?.({ trace_id: 240 })}
|
||||
>
|
||||
resize
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
|
||||
const SIZING_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
|
||||
const TOTAL_TOKENS = {
|
||||
name: 'total_tokens',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
};
|
||||
|
||||
const storedColumnNames = (): string[] | undefined => {
|
||||
const raw = localStorage.getItem(PANEL_COLUMNS_KEY);
|
||||
return raw
|
||||
? (JSON.parse(raw)[PANEL_TYPES.TRACE] as { name: string }[]).map(
|
||||
(field) => field.name,
|
||||
)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const shownColumns = (): string[] =>
|
||||
(screen.getByTestId('table-columns').textContent ?? '').split(',');
|
||||
|
||||
const renderTracesView = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<TracesView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
/>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/llm-observability/traces',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
stagedQuery: initialQueryAIWithType,
|
||||
currentQuery: initialQueryAIWithType,
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
describe('TracesView columns', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.setState({ tables: {} });
|
||||
localStorage.clear();
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: { type: 'trace', data: {} } })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows the default columns while nothing is picked, storing nothing', () => {
|
||||
renderTracesView();
|
||||
|
||||
expect(shownColumns()).toStrictEqual(
|
||||
TRACE_VIEW_DEFAULT_COLUMNS.map((field) => field.name),
|
||||
);
|
||||
expect(localStorage.getItem(PANEL_COLUMNS_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows trace_id first when a stored list lacks it', () => {
|
||||
localStorage.setItem(
|
||||
PANEL_COLUMNS_KEY,
|
||||
JSON.stringify({ [PANEL_TYPES.TRACE]: [TOTAL_TOKENS] }),
|
||||
);
|
||||
|
||||
renderTracesView();
|
||||
|
||||
expect(shownColumns()).toStrictEqual(['trace_id', 'total_tokens']);
|
||||
});
|
||||
|
||||
it('stores the order a column drag leaves behind', async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem(
|
||||
PANEL_COLUMNS_KEY,
|
||||
JSON.stringify({
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'trace_id' }, TOTAL_TOKENS],
|
||||
}),
|
||||
);
|
||||
renderTracesView();
|
||||
|
||||
await user.click(screen.getByTestId('reverse-columns'));
|
||||
|
||||
expect(storedColumnNames()).toStrictEqual(['total_tokens', 'trace_id']);
|
||||
expect(shownColumns()).toStrictEqual(['total_tokens', 'trace_id']);
|
||||
});
|
||||
|
||||
it('keeps a dragged trace_id in place when the stored list lacks it', async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem(
|
||||
PANEL_COLUMNS_KEY,
|
||||
JSON.stringify({ [PANEL_TYPES.TRACE]: [TOTAL_TOKENS] }),
|
||||
);
|
||||
renderTracesView();
|
||||
|
||||
await user.click(screen.getByTestId('reverse-columns'));
|
||||
|
||||
expect(storedColumnNames()).toStrictEqual(['total_tokens', 'trace_id']);
|
||||
expect(shownColumns()).toStrictEqual(['total_tokens', 'trace_id']);
|
||||
});
|
||||
|
||||
it('drops a column removed from the table', async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem(
|
||||
PANEL_COLUMNS_KEY,
|
||||
JSON.stringify({
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'trace_id' }, TOTAL_TOKENS],
|
||||
}),
|
||||
);
|
||||
renderTracesView();
|
||||
|
||||
await user.click(screen.getByTestId('remove-total-tokens'));
|
||||
|
||||
expect(storedColumnNames()).toStrictEqual(['trace_id']);
|
||||
expect(shownColumns()).toStrictEqual(['trace_id']);
|
||||
});
|
||||
|
||||
it('keeps column widths in the column store', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
await user.click(screen.getByTestId('resize-trace-id'));
|
||||
|
||||
expect(
|
||||
useColumnStore.getState().tables[SIZING_KEY].columnSizing,
|
||||
).toStrictEqual({ trace_id: 240 });
|
||||
expect(localStorage.getItem(PANEL_COLUMNS_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,7 @@ import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import TracesView from '../TracesView';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
const PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
|
||||
|
||||
@@ -84,9 +83,9 @@ const mockFieldKeysFailure = (): void => {
|
||||
);
|
||||
};
|
||||
|
||||
const persistedState = (): { hiddenColumnIds: string[] } | null => {
|
||||
const raw = localStorage.getItem(PERSISTED_KEY);
|
||||
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
|
||||
const storedOptions = (): Record<string, { name: string }[]> | null => {
|
||||
const raw = localStorage.getItem(PANEL_COLUMNS_KEY);
|
||||
return raw ? (JSON.parse(raw) as Record<string, { name: string }[]>) : null;
|
||||
};
|
||||
|
||||
const renderTracesView = (): ReturnType<typeof render> =>
|
||||
@@ -121,16 +120,16 @@ describe('TracesView column persistence', () => {
|
||||
// Rows are virtualised, so a mounted table stands in for "rows arrived".
|
||||
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
|
||||
|
||||
it('seeds the persisted defaults once the field keys arrive', async () => {
|
||||
it('shows the default columns once the field keys arrive, storing nothing', async () => {
|
||||
mockFieldKeys(['llm_call_count', 'tool_call_count']);
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
expect(storedOptions()).toBeNull();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -140,20 +139,19 @@ describe('TracesView column persistence', () => {
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(persistedState()).toBeNull();
|
||||
expect(storedOptions()).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the column picker when the field keys fail', async () => {
|
||||
it('keeps the column picker when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the display-only columns when the field keys fail', async () => {
|
||||
it('renders the default columns when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
@@ -162,22 +160,27 @@ describe('TracesView column persistence', () => {
|
||||
expect(screen.getByText('root_span_name')).toBeInTheDocument();
|
||||
expect(screen.getByText('trace_id')).toBeInTheDocument();
|
||||
expect(screen.getByText('input')).toBeInTheDocument();
|
||||
expect(screen.queryByText('llm_call_count')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves an existing selection untouched while the field keys fail', async () => {
|
||||
it('shows a stored selection, in its order, while the field keys fail', async () => {
|
||||
const existing = {
|
||||
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
|
||||
columnOrder: ['trace_id', 'resource:service.name'],
|
||||
columnSizing: {},
|
||||
[PANEL_TYPES.TRACE]: [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'trace_id' },
|
||||
],
|
||||
};
|
||||
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
|
||||
localStorage.setItem(PANEL_COLUMNS_KEY, JSON.stringify(existing));
|
||||
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(persistedState()).toStrictEqual(existing);
|
||||
const headers = screen
|
||||
.getAllByRole('columnheader')
|
||||
.map((header) => header.textContent);
|
||||
expect(headers).toStrictEqual(['service.name', 'trace_id']);
|
||||
expect(storedOptions()).toStrictEqual(existing);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { useTraceViewColumns } from '../useTraceViewColumns';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
|
||||
const AGGREGATE_KEYS = [
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'distinct_tool_count',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'total_tokens',
|
||||
'estimated_total_cost',
|
||||
'max_llm_duration_nano',
|
||||
];
|
||||
|
||||
const fieldNames = (fields: TelemetryFieldKey[]): string[] =>
|
||||
fields.map((field) => field.name);
|
||||
|
||||
const columnNames = (columns: { header?: unknown }[]): string[] =>
|
||||
columns.map((column) => column.header as string);
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const seenAI: URLSearchParams[] = [];
|
||||
|
||||
const mockAggregateKeys = (names: string[]): void => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
|
||||
(req, res, ctx) => {
|
||||
seenAI.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
[
|
||||
{
|
||||
name,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderColumns = async (): Promise<
|
||||
ReturnType<typeof renderHook<ReturnType<typeof useTraceViewColumns>, unknown>>
|
||||
> => {
|
||||
const rendered = renderHook(() => useTraceViewColumns(), { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(rendered.result.current.isLoading).toBe(false);
|
||||
});
|
||||
return rendered;
|
||||
};
|
||||
|
||||
describe('useTraceViewColumns', () => {
|
||||
beforeEach(() => {
|
||||
seenAI.length = 0;
|
||||
useColumnStore.getState().tables = {};
|
||||
localStorage.clear();
|
||||
mockAggregateKeys(AGGREGATE_KEYS);
|
||||
});
|
||||
|
||||
it('reads the aggregates from the trace context of the keys endpoint', async () => {
|
||||
await renderColumns();
|
||||
|
||||
expect(seenAI).toHaveLength(1);
|
||||
expect(seenAI[0]?.get('searchText')).toBe('');
|
||||
expect(seenAI[0]?.get('fieldContext')).toBe(
|
||||
TelemetrytypesFieldContextDTO.trace,
|
||||
);
|
||||
});
|
||||
|
||||
it('pools the hardcoded display-only columns with the endpoint aggregates', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(columnNames(result.current.columns)).toStrictEqual([
|
||||
'trace_id',
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'estimated_total_cost',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'total_tokens',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'distinct_tool_count',
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
'max_llm_duration_nano',
|
||||
]);
|
||||
});
|
||||
|
||||
it('selects only the default-visible columns on first render', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'estimated_total_cost',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'total_tokens',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'distinct_tool_count',
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
'max_llm_duration_nano',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a newly reported aggregate hidden until it is picked', async () => {
|
||||
mockAggregateKeys(['brand_new_aggregate']);
|
||||
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
|
||||
expect(fieldNames(result.current.selectedFields)).not.toContain(
|
||||
'brand_new_aggregate',
|
||||
);
|
||||
});
|
||||
|
||||
describe('when the keys fetch fails', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
|
||||
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not persist defaults', async () => {
|
||||
await renderColumns();
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(
|
||||
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the column state as not persistable', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(result.current.canPersistColumns).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a selection change instead of persisting a partial set', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }]);
|
||||
});
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds the defaults once a later fetch succeeds', async () => {
|
||||
const { unmount } = await renderColumns();
|
||||
unmount();
|
||||
|
||||
mockAggregateKeys(AGGREGATE_KEYS);
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(result.current.canPersistColumns).toBe(true);
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'estimated_total_cost',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'total_tokens',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'distinct_tool_count',
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
'max_llm_duration_nano',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the columns dropped from the selection', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'total_tokens',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a column added back from the pool', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }]);
|
||||
});
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'trace_id',
|
||||
'input',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the trace id column even when the selection drops it', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'span_count' }]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toContain('trace_id');
|
||||
expect(result.current.requiredFields).toStrictEqual(['trace_id']);
|
||||
});
|
||||
|
||||
it('persists the selection order', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([
|
||||
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'total_tokens',
|
||||
'trace_id',
|
||||
'service.name',
|
||||
]);
|
||||
expect(
|
||||
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
|
||||
).toStrictEqual([
|
||||
'trace:total_tokens:float64',
|
||||
'trace_id',
|
||||
'resource:service.name',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,52 +3,17 @@ import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
import { TRACE_ID_COLUMN_ID } from '../constants';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
/** Always present: it is the row's link to the trace, but it can be reordered. */
|
||||
export const TRACE_ID_COLUMN_ID = 'trace_id';
|
||||
|
||||
/** Fallback order, until the user drags a column; unlisted fields keep the order the keys endpoint returns them in. */
|
||||
const DEFAULT_COLUMN_ORDER = [
|
||||
TRACE_ID_COLUMN_ID,
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'estimated_total_cost',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'total_tokens',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'distinct_tool_count',
|
||||
'llm_call_count',
|
||||
'tool_call_count',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
'max_llm_duration_nano',
|
||||
];
|
||||
|
||||
const orderRank = (field: TelemetryFieldKey): number => {
|
||||
const index = DEFAULT_COLUMN_ORDER.indexOf(field.name);
|
||||
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
||||
};
|
||||
|
||||
export const sortByDefaultOrder = (
|
||||
fields: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] =>
|
||||
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
|
||||
|
||||
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
|
||||
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
|
||||
export const REQUIRED_FIELDS: readonly string[] = [TRACE_ID_COLUMN_ID];
|
||||
|
||||
export const buildTraceViewColumns = (
|
||||
fields: TelemetryFieldKey[],
|
||||
): TableColumnDef<TracesTableRow>[] =>
|
||||
fields.map((field) => ({
|
||||
...getFieldColumn(field),
|
||||
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
|
||||
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
|
||||
enableMove: true,
|
||||
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import { mergeExtraFields } from 'utils/extraFields';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
hideColumn,
|
||||
initializeFromDefaults,
|
||||
setColumnOrder,
|
||||
showColumn,
|
||||
useColumnOrder,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
TRACE_VIEW_BUILDER_QUERY_TYPE,
|
||||
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
|
||||
TRACE_VIEW_FIELD_KEYS,
|
||||
} from '../constants';
|
||||
import {
|
||||
buildTraceViewColumns,
|
||||
sortByDefaultOrder,
|
||||
TRACE_ID_COLUMN_ID,
|
||||
} from './configs';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
|
||||
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
|
||||
const columnIdOf = (field: TelemetryFieldKey): string =>
|
||||
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
|
||||
|
||||
interface UseTraceViewColumns {
|
||||
columns: TableColumnDef<TracesTableRow>[];
|
||||
selectedFields: TelemetryFieldKey[];
|
||||
onFieldsChange: (next: TelemetryFieldKey[]) => void;
|
||||
requiredFields: readonly string[];
|
||||
isLoading: boolean;
|
||||
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
|
||||
canPersistColumns: boolean;
|
||||
}
|
||||
|
||||
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
|
||||
export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
const {
|
||||
data: fetchedFields = [],
|
||||
isFetched,
|
||||
isSuccess,
|
||||
} = useFieldKeysSuggestion(
|
||||
{
|
||||
...TRACE_VIEW_FIELD_KEYS,
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
|
||||
searchText: '',
|
||||
},
|
||||
TRACE_VIEW_BUILDER_QUERY_TYPE,
|
||||
);
|
||||
|
||||
const availableFields = useMemo(
|
||||
() =>
|
||||
sortByDefaultOrder(
|
||||
mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
|
||||
),
|
||||
[fetchedFields],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildTraceViewColumns(availableFields),
|
||||
[availableFields],
|
||||
);
|
||||
|
||||
// Defaults from a partial column set would persist as the user's own choice.
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
initializeFromDefaults(STORAGE_KEY, columns);
|
||||
}
|
||||
}, [isSuccess, columns]);
|
||||
|
||||
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
|
||||
const columnOrder = useColumnOrder(STORAGE_KEY);
|
||||
|
||||
const selectedFields = useMemo(() => {
|
||||
const hidden = new Set(hiddenColumnIds);
|
||||
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
|
||||
|
||||
return availableFields
|
||||
.filter((field) => !hidden.has(columnIdOf(field)))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
|
||||
(orderIndex.get(columnIdOf(b)) ?? Infinity),
|
||||
);
|
||||
}, [availableFields, hiddenColumnIds, columnOrder]);
|
||||
|
||||
const onFieldsChange = useCallback(
|
||||
(next: TelemetryFieldKey[]): void => {
|
||||
if (!isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keptIds = new Set(next.map(columnIdOf));
|
||||
|
||||
columns.forEach((column) => {
|
||||
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
|
||||
showColumn(STORAGE_KEY, column.id);
|
||||
} else {
|
||||
hideColumn(STORAGE_KEY, column.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Columns missing from the order sort last, so the visible ones suffice.
|
||||
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
|
||||
},
|
||||
[columns, isSuccess],
|
||||
);
|
||||
|
||||
return {
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
requiredFields: [TRACE_ID_COLUMN_ID],
|
||||
isLoading: !isFetched,
|
||||
canPersistColumns: isSuccess,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { renderWithExplorerProviders } from 'container/SavedViews/__tests__/savedViewsTestUtils';
|
||||
import { act, screen } from 'tests/test-utils';
|
||||
|
||||
import Explorer from '../Explorer';
|
||||
|
||||
jest.mock('../ListView/ListView', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="ai-list-view" />,
|
||||
}));
|
||||
jest.mock('../TableView/TableView', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="ai-table-view" />,
|
||||
}));
|
||||
|
||||
const explorerUrlWithPanel = (panelType: PANEL_TYPES): string =>
|
||||
`${ROUTES.AI_OBSERVABILITY_EXPLORER}?${QueryParams.panelTypes}=${encodeURIComponent(
|
||||
JSON.stringify(panelType),
|
||||
)}`;
|
||||
|
||||
describe('AI observability Explorer', () => {
|
||||
it('shows the view of the panel type in the url', () => {
|
||||
renderWithExplorerProviders(
|
||||
<Explorer />,
|
||||
explorerUrlWithPanel(PANEL_TYPES.LIST),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-list-view')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('follows the url when the panel type changes, as opening a saved view does', () => {
|
||||
const { history } = renderWithExplorerProviders(
|
||||
<Explorer />,
|
||||
explorerUrlWithPanel(PANEL_TYPES.LIST),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
history.push(explorerUrlWithPanel(PANEL_TYPES.TABLE));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-table-view')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('ai-list-view')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
|
||||
|
||||
/** Always present: it is the row's link to the trace, but it can be reordered. */
|
||||
export const TRACE_ID_COLUMN_ID = 'trace_id';
|
||||
|
||||
export const TOOLBAR_VIEWS = {
|
||||
trace: {
|
||||
name: 'trace',
|
||||
@@ -57,6 +60,81 @@ export const TRACE_VIEW_COLUMN_EXTRA_FIELDS: TelemetryFieldKey[] = [
|
||||
{ name: 'output' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
/**
|
||||
* Shown, in this order, until the user picks columns. The aggregates are the
|
||||
* keys the keys endpoint returns for builder_ai_query; the rest match
|
||||
* TRACE_VIEW_COLUMN_EXTRA_FIELDS, so both build the same column ids.
|
||||
*/
|
||||
export const TRACE_VIEW_DEFAULT_COLUMNS: TelemetryFieldKey[] = [
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'root_span_name' },
|
||||
{
|
||||
name: 'estimated_total_cost',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{ name: 'trace_duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{
|
||||
name: 'total_tokens',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{
|
||||
name: 'input_tokens',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{
|
||||
name: 'output_tokens',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{
|
||||
name: 'distinct_tool_count',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{
|
||||
name: 'llm_call_count',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{
|
||||
name: 'tool_call_count',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
{ name: 'start_time' },
|
||||
{ name: 'end_time' },
|
||||
{ name: 'error_count' },
|
||||
{ name: 'input' },
|
||||
{ name: 'output' },
|
||||
{
|
||||
name: 'max_llm_duration_nano',
|
||||
signal: 'traces',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
];
|
||||
|
||||
export const ensureAiRequiredColumns = (
|
||||
columns: TelemetryFieldKey[],
|
||||
panelType: PANEL_TYPES,
|
||||
): TelemetryFieldKey[] =>
|
||||
panelType !== PANEL_TYPES.TRACE ||
|
||||
columns.some((column) => column.name === TRACE_ID_COLUMN_ID)
|
||||
? columns
|
||||
: [{ name: TRACE_ID_COLUMN_ID }, ...columns];
|
||||
|
||||
export const TRACE_VIEW_FIELD_KEYS = {
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
} as const;
|
||||
|
||||
@@ -87,6 +87,7 @@ describe('useOptionsMenu', () => {
|
||||
mockUsePreferenceContext.mockReturnValue({
|
||||
[SavedviewtypesSourceDTO.traces]: slice([], rawFormatting),
|
||||
[SavedviewtypesSourceDTO.logs]: slice([], rawFormatting),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
|
||||
} satisfies PreferenceContextValue);
|
||||
|
||||
mockUseUrlQueryData.mockReturnValue({
|
||||
@@ -236,6 +237,7 @@ describe('useOptionsMenu', () => {
|
||||
mockUsePreferenceContext.mockReturnValue({
|
||||
[SavedviewtypesSourceDTO.traces]: slice([], baseFormatting),
|
||||
[SavedviewtypesSourceDTO.logs]: slice(seedColumns, baseFormatting),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
|
||||
} satisfies PreferenceContextValue);
|
||||
});
|
||||
|
||||
@@ -357,6 +359,7 @@ describe('useOptionsMenu', () => {
|
||||
},
|
||||
),
|
||||
[SavedviewtypesSourceDTO.logs]: slice([], {}),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
|
||||
} satisfies PreferenceContextValue);
|
||||
|
||||
const { result } = renderHook(
|
||||
|
||||
@@ -21,6 +21,7 @@ export const SOURCE_PAGE_SIGNAL: Record<
|
||||
> = {
|
||||
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
|
||||
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
|
||||
[SavedviewtypesSourceDTO.ai_observability]: DataSource.TRACES,
|
||||
};
|
||||
|
||||
export const EXCLUDED_COLUMNS: Record<DataSource, string[]> = {
|
||||
|
||||
@@ -8,7 +8,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { isPreferenceSourcePage } from 'providers/preferences/utils';
|
||||
import {
|
||||
hasPreferences,
|
||||
isPreferenceSourcePage,
|
||||
} from 'providers/preferences/utils';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { UseActiveSavedViewResult } from '../types';
|
||||
@@ -20,9 +23,12 @@ export function useActiveSavedView(
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const hasOptions = isPreferenceSourcePage(source);
|
||||
const viewPanelType = panelType ?? PANEL_TYPES.LIST;
|
||||
const hasOptions = hasPreferences(source, viewPanelType);
|
||||
const { options } = useOptionsMenu({
|
||||
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
|
||||
sourcePage: isPreferenceSourcePage(source)
|
||||
? source
|
||||
: SavedviewtypesSourceDTO.logs,
|
||||
});
|
||||
|
||||
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
|
||||
@@ -61,16 +67,18 @@ export function useActiveSavedView(
|
||||
return hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
panelType: viewPanelType,
|
||||
options: hasOptions ? options : undefined,
|
||||
sourcePage: source,
|
||||
});
|
||||
}, [
|
||||
view,
|
||||
isError,
|
||||
isStagedQueryCurrent,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
viewPanelType,
|
||||
hasOptions,
|
||||
source,
|
||||
options,
|
||||
]);
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { usePreferenceSlice } from 'providers/preferences/context/usePreferenceSlice';
|
||||
import { isPreferenceSourcePage } from 'providers/preferences/utils';
|
||||
import {
|
||||
hasPreferences,
|
||||
isPreferenceSourcePage,
|
||||
} from 'providers/preferences/utils';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
@@ -59,9 +62,12 @@ export function useSavedViewActions(
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
|
||||
const hasOptions = isPreferenceSourcePage(source);
|
||||
const viewPanelType = panelType ?? PANEL_TYPES.LIST;
|
||||
const hasOptions = hasPreferences(source, viewPanelType);
|
||||
const { options } = useOptionsMenu({
|
||||
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
|
||||
sourcePage: isPreferenceSourcePage(source)
|
||||
? source
|
||||
: SavedviewtypesSourceDTO.logs,
|
||||
});
|
||||
const preferenceSlice = usePreferenceSlice(source);
|
||||
|
||||
@@ -79,12 +85,12 @@ export function useSavedViewActions(
|
||||
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
|
||||
toSavedViewSpec({
|
||||
query: stagedQuery ?? currentQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
panelType: viewPanelType,
|
||||
displayName,
|
||||
options: hasOptions ? options : undefined,
|
||||
color,
|
||||
}),
|
||||
[stagedQuery, currentQuery, panelType, hasOptions, options],
|
||||
[stagedQuery, currentQuery, viewPanelType, hasOptions, options],
|
||||
);
|
||||
|
||||
const selectView = useCallback(
|
||||
@@ -101,11 +107,11 @@ export function useSavedViewActions(
|
||||
const revertView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
if (preferenceSlice) {
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
const { columns, formatting, panelType } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
source,
|
||||
);
|
||||
preferenceSlice.updateColumns(columns);
|
||||
preferenceSlice.updateColumns(columns, panelType);
|
||||
if (formatting) {
|
||||
preferenceSlice.updateFormatting(formatting);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface HasUnsavedViewChangesArgs {
|
||||
stagedQuery: Query | null;
|
||||
panelType: PANEL_TYPES;
|
||||
options?: SavedViewOptions;
|
||||
sourcePage: SavedviewtypesSourceDTO;
|
||||
}
|
||||
|
||||
export interface UseLastUsedViewResult {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
@@ -120,6 +121,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, selectColumns: [columns[0]] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -139,6 +141,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, selectColumns: defaultTraceSelectedColumns },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -163,6 +166,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: { selectColumns: shownColumns, ...formatting },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -174,6 +178,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -193,6 +198,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: {
|
||||
selectColumns: defaultLogsSelectedColumns,
|
||||
format: 'table',
|
||||
@@ -236,6 +242,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: olderView,
|
||||
stagedQuery: stagedFrom(olderView, DataSource.METRICS),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
sourcePage: SavedviewtypesSourceDTO.metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -283,6 +290,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: storedView,
|
||||
stagedQuery: stagedWithTag,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
sourcePage: SavedviewtypesSourceDTO.metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -322,6 +330,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: noOrder,
|
||||
stagedQuery: stagedFrom(noOrder, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -337,6 +346,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: noOrder,
|
||||
stagedQuery: sorted,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
@@ -346,6 +356,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: saved,
|
||||
stagedQuery: stagedFrom(saved, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -362,6 +373,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: legacy,
|
||||
stagedQuery: stagedFrom(legacy, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -373,6 +385,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
@@ -3,12 +3,18 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import {
|
||||
DEFAULT_PANEL_TYPE,
|
||||
ensureAiRequiredColumns,
|
||||
} from 'container/LLMObservability/Explorer/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import getAiObservabilityLoaderConfig from 'providers/preferences/configs/aiObservabilityLoaderConfig';
|
||||
import { Preferences } from 'providers/preferences/types';
|
||||
|
||||
function withColumnNames(
|
||||
@@ -47,5 +53,18 @@ export function getViewColumnsAndFormatting(
|
||||
if (sourcePage === SavedviewtypesSourceDTO.traces) {
|
||||
return { columns: selectedFields || defaultTraceSelectedColumns };
|
||||
}
|
||||
if (sourcePage === SavedviewtypesSourceDTO.ai_observability) {
|
||||
const panelType =
|
||||
(spec?.panelType as unknown as PANEL_TYPES | undefined) ??
|
||||
DEFAULT_PANEL_TYPE;
|
||||
return {
|
||||
columns: ensureAiRequiredColumns(
|
||||
selectedFields ||
|
||||
getAiObservabilityLoaderConfig(panelType).default().columns,
|
||||
panelType,
|
||||
),
|
||||
panelType,
|
||||
};
|
||||
}
|
||||
return { columns: [] };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SavedviewtypesSourceDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { HasUnsavedViewChangesArgs } from '../types';
|
||||
import { getSavedViewQuery } from './getSavedViewQuery';
|
||||
import { toSavedViewPanelType } from './toSavedViewSpec';
|
||||
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
|
||||
import { toSavedViewSource } from './toSavedViewSource';
|
||||
|
||||
// Legacy context names the server stores under their v5 name.
|
||||
const FIELD_CONTEXT_ALIASES: Record<string, TelemetrytypesFieldContextDTO> = {
|
||||
@@ -51,6 +52,7 @@ export function hasUnsavedViewChanges({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
options,
|
||||
sourcePage,
|
||||
}: HasUnsavedViewChangesArgs): boolean {
|
||||
if (!stagedQuery) {
|
||||
return false;
|
||||
@@ -77,10 +79,9 @@ export function hasUnsavedViewChanges({
|
||||
return false;
|
||||
}
|
||||
|
||||
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
toSavedViewSource(dataSource),
|
||||
sourcePage,
|
||||
);
|
||||
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
|
||||
fields.map((field) => field.name ?? field.key);
|
||||
@@ -88,7 +89,7 @@ export function hasUnsavedViewChanges({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dataSource !== DataSource.LOGS || !formatting) {
|
||||
if (sourcePage !== SavedviewtypesSourceDTO.logs || !formatting) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -3,8 +3,8 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewSourcePage } from '../types';
|
||||
|
||||
// The views API files each view under a source page, but callers only know the
|
||||
// signal they query: a builder query's dataSource, or the AI assistant's hint.
|
||||
// The AI assistant names a view's source by DataSource (the signal), the api
|
||||
// keys views by source page.
|
||||
// TODO: remove in a follow-up once callers pass the source page directly.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TRACE_VIEW_DEFAULT_COLUMNS } from 'container/LLMObservability/Explorer/constants';
|
||||
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
@@ -9,12 +12,15 @@ import {
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { usePreferenceContext } from '../context/PreferenceContextProvider';
|
||||
import { PreferenceSourcePage } from '../types';
|
||||
import { PanelColumns, PreferenceSourcePage } from '../types';
|
||||
|
||||
const ROUTE_LOGS = '/logs';
|
||||
const ROUTE_TRACES = '/traces';
|
||||
const TESTID_LOGS = 'logs';
|
||||
const TESTID_TRACES = 'traces';
|
||||
const ROUTE_AI = '/ai-observability';
|
||||
const AI_DEFAULT_COUNT = String(TRACE_VIEW_DEFAULT_COLUMNS.length);
|
||||
const TESTID_AI = 'ai';
|
||||
|
||||
type LogsLocalOptions = {
|
||||
selectColumns?: TelemetryFieldKey[];
|
||||
@@ -57,9 +63,9 @@ function Consumer({
|
||||
type="button"
|
||||
onClick={(): void => {
|
||||
const newCols: TelemetryFieldKey[] =
|
||||
sourcePage === SavedviewtypesSourceDTO.traces
|
||||
? (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
|
||||
: (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
|
||||
sourcePage === SavedviewtypesSourceDTO.logs
|
||||
? (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
|
||||
: (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
|
||||
slice.updateColumns(newCols);
|
||||
}}
|
||||
>
|
||||
@@ -436,4 +442,226 @@ describe('PreferencesProvider integration', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI observability traces', () => {
|
||||
const renderAiConsumer = (): void => {
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.ai_observability}
|
||||
testIdPrefix={TESTID_AI}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: ROUTE_AI },
|
||||
);
|
||||
};
|
||||
|
||||
it('starts with the default trace view columns', () => {
|
||||
renderAiConsumer();
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
|
||||
AI_DEFAULT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it('loads its own localStorage, not the traces list one', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.TRACES_LIST_OPTIONS, {
|
||||
selectColumns: [{ name: 'traces.list.col' }],
|
||||
});
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'ai.col.a' }, { name: 'ai.col.b' }],
|
||||
});
|
||||
|
||||
renderAiConsumer();
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
const URL_COLUMNS = [{ name: 'ai.url.a' }, { name: 'ai.url.b' }];
|
||||
|
||||
const renderAtUrl = (
|
||||
pathname: string,
|
||||
params: Record<string, unknown>,
|
||||
renderConsumer: () => void,
|
||||
): void => {
|
||||
const search = new URLSearchParams(
|
||||
Object.entries(params).map(([key, value]) => [key, JSON.stringify(value)]),
|
||||
).toString();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...originalLocation, pathname, search: `?${search}` },
|
||||
});
|
||||
|
||||
renderConsumer();
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
};
|
||||
|
||||
const renderAiAtUrl = (params: Record<string, unknown>): void =>
|
||||
renderAtUrl(ROUTES.AI_OBSERVABILITY_EXPLORER, params, renderAiConsumer);
|
||||
|
||||
it('ignores the url options on other explorers', () => {
|
||||
renderAtUrl(
|
||||
ROUTE_TRACES,
|
||||
{ options: { selectColumns: URL_COLUMNS } },
|
||||
renderAiConsumer,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
|
||||
AI_DEFAULT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it('loads the url columns for the panel in panelTypes', () => {
|
||||
renderAiAtUrl({
|
||||
options: { selectColumns: URL_COLUMNS },
|
||||
panelTypes: PANEL_TYPES.TRACE,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('files the url columns under the panel in panelTypes', () => {
|
||||
renderAiAtUrl({
|
||||
options: { selectColumns: URL_COLUMNS },
|
||||
panelTypes: PANEL_TYPES.LIST,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
|
||||
AI_DEFAULT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers its localStorage over the url options', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'ai.local.col' }],
|
||||
});
|
||||
|
||||
renderAiAtUrl({
|
||||
options: { selectColumns: URL_COLUMNS },
|
||||
panelTypes: PANEL_TYPES.TRACE,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('keeps its url columns out of the traces slice', () => {
|
||||
renderAtUrl(
|
||||
ROUTES.AI_OBSERVABILITY_EXPLORER,
|
||||
{
|
||||
options: { selectColumns: URL_COLUMNS },
|
||||
panelTypes: PANEL_TYPES.TRACE,
|
||||
},
|
||||
(): void => {
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.traces}
|
||||
testIdPrefix={TESTID_TRACES}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: ROUTE_AI },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('traces-columns-len')).toHaveTextContent(
|
||||
String(defaultTraceSelectedColumns.length),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops stored columns without a name or from another signal', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: [
|
||||
{ name: 'ai.col.a' },
|
||||
{ name: 'logs.col', signal: 'logs' },
|
||||
{ key: 'no.name' },
|
||||
{},
|
||||
'not-a-column',
|
||||
],
|
||||
});
|
||||
|
||||
renderAiConsumer();
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('ignores a stored panel that is not a list', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: 'ai.col.a',
|
||||
});
|
||||
|
||||
renderAiConsumer();
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
|
||||
AI_DEFAULT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the url options when every stored column is invalid', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'logs.col', signal: 'logs' }],
|
||||
});
|
||||
|
||||
renderAiAtUrl({
|
||||
options: { selectColumns: URL_COLUMNS },
|
||||
panelTypes: PANEL_TYPES.TRACE,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('loads the columns of the panel open in the url', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.TRACE]: [{ name: 'ai.trace.a' }, { name: 'ai.trace.b' }],
|
||||
[PANEL_TYPES.LIST]: [{ name: 'ai.list.col' }],
|
||||
});
|
||||
|
||||
render(
|
||||
<Consumer
|
||||
sourcePage={SavedviewtypesSourceDTO.ai_observability}
|
||||
testIdPrefix={TESTID_AI}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: `${ROUTE_AI}?panelTypes=${encodeURIComponent(
|
||||
JSON.stringify(PANEL_TYPES.LIST),
|
||||
)}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('keeps each panel apart in its localStorage', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
|
||||
[PANEL_TYPES.LIST]: [{ name: 'ai.list.col' }],
|
||||
});
|
||||
|
||||
renderAiConsumer();
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
|
||||
AI_DEFAULT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it('updateColumns writes its own localStorage key in direct mode', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderAiConsumer();
|
||||
|
||||
await user.click(screen.getByTestId('ai-update-columns'));
|
||||
|
||||
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
|
||||
expect(
|
||||
getLocalStorageJSON<PanelColumns>(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS,
|
||||
)?.[PANEL_TYPES.TRACE],
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
getLocalStorageJSON<TracesLocalOptions>(LOCALSTORAGE.TRACES_LIST_OPTIONS),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import getAiObservabilityUpdaterConfig from '../configs/aiObservabilityUpdaterConfig';
|
||||
import { PreferenceMode } from '../types';
|
||||
|
||||
const columns = [{ name: 'total_tokens' }] as TelemetryFieldKey[];
|
||||
|
||||
describe('aiObservabilityUpdaterConfig in saved view mode', () => {
|
||||
it('stores the columns under the open panel', () => {
|
||||
const setSavedViewPreferences = jest.fn();
|
||||
const { updateColumns } = getAiObservabilityUpdaterConfig(
|
||||
jest.fn(),
|
||||
setSavedViewPreferences,
|
||||
PANEL_TYPES.TRACE,
|
||||
);
|
||||
|
||||
updateColumns(columns, PreferenceMode.SAVED_VIEW);
|
||||
|
||||
expect(setSavedViewPreferences).toHaveBeenCalledWith({
|
||||
columns,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores the columns under the given panel', () => {
|
||||
const setSavedViewPreferences = jest.fn();
|
||||
const { updateColumns } = getAiObservabilityUpdaterConfig(
|
||||
jest.fn(),
|
||||
setSavedViewPreferences,
|
||||
PANEL_TYPES.LIST,
|
||||
);
|
||||
|
||||
updateColumns(columns, PreferenceMode.SAVED_VIEW, PANEL_TYPES.TRACE);
|
||||
|
||||
expect(setSavedViewPreferences).toHaveBeenCalledWith({
|
||||
columns,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import {
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { TRACE_VIEW_DEFAULT_COLUMNS } from 'container/LLMObservability/Explorer/constants';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
@@ -197,6 +200,64 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('adds trace_id to AI trace selectedFields and uses the defaults when absent', () => {
|
||||
mockViews([
|
||||
makeView('ai-with-fields', SavedviewtypesSourceDTO.ai_observability, {
|
||||
panelType: SavedviewtypesPanelTypeDTO.trace,
|
||||
selectedFields: [{ name: 'total_tokens' }],
|
||||
}),
|
||||
makeView('ai-without-fields', SavedviewtypesSourceDTO.ai_observability, {
|
||||
panelType: SavedviewtypesPanelTypeDTO.trace,
|
||||
}),
|
||||
]);
|
||||
|
||||
const withFields = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
savedViewId: 'ai-with-fields',
|
||||
}),
|
||||
);
|
||||
const withoutFields = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
savedViewId: 'ai-without-fields',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(withFields.result.current.preferences).toStrictEqual({
|
||||
columns: [{ name: 'trace_id' }, { name: 'total_tokens' }],
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
expect(withoutFields.result.current.preferences).toStrictEqual({
|
||||
columns: TRACE_VIEW_DEFAULT_COLUMNS,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives the open panel its defaults when the AI view was saved on another panel', () => {
|
||||
mockViews([
|
||||
makeView('ai-graph', SavedviewtypesSourceDTO.ai_observability, {
|
||||
panelType: SavedviewtypesPanelTypeDTO.graph,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
savedViewId: 'ai-graph',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.preferences).toStrictEqual({
|
||||
columns: TRACE_VIEW_DEFAULT_COLUMNS,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses defaults while the saved view is not loaded', () => {
|
||||
mockViews([makeView('other', SavedviewtypesSourceDTO.logs, {})]);
|
||||
|
||||
@@ -212,4 +273,21 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
ensureLogsRequiredColumns(defaultLogsSelectedColumns),
|
||||
);
|
||||
});
|
||||
it('uses the AI trace defaults while the saved view is not loaded', () => {
|
||||
mockViews([]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
savedViewId: 'missing',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.preferences).toStrictEqual({
|
||||
columns: TRACE_VIEW_DEFAULT_COLUMNS,
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,6 +114,7 @@ describe('usePreferenceUpdater', () => {
|
||||
expect(mockLogsUpdateColumns).toHaveBeenCalledWith(
|
||||
newColumns,
|
||||
PreferenceMode.DIRECT,
|
||||
undefined,
|
||||
);
|
||||
expect(mockTracesUpdateColumns).not.toHaveBeenCalled();
|
||||
expect(setReSync).toHaveBeenCalledWith(true);
|
||||
@@ -177,6 +178,7 @@ describe('usePreferenceUpdater', () => {
|
||||
expect(mockTracesUpdateColumns).toHaveBeenCalledWith(
|
||||
newColumns,
|
||||
PreferenceMode.DIRECT,
|
||||
undefined,
|
||||
);
|
||||
expect(mockLogsUpdateColumns).not.toHaveBeenCalled();
|
||||
expect(setReSync).toHaveBeenCalledWith(true);
|
||||
|
||||
@@ -1,20 +1,47 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { isPreferenceSourcePage } from '../utils';
|
||||
import { hasPreferences, isPreferenceSourcePage } from '../utils';
|
||||
|
||||
describe('isPreferenceSourcePage', () => {
|
||||
it.each([SavedviewtypesSourceDTO.logs, SavedviewtypesSourceDTO.traces])(
|
||||
'is true for %s',
|
||||
it.each([
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
SavedviewtypesSourceDTO.ai_observability,
|
||||
])('is true for %s', (source) => {
|
||||
expect(isPreferenceSourcePage(source)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([SavedviewtypesSourceDTO.metrics, SavedviewtypesSourceDTO.meter])(
|
||||
'is false for %s',
|
||||
(source) => {
|
||||
expect(isPreferenceSourcePage(source)).toBe(true);
|
||||
expect(isPreferenceSourcePage(source)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
SavedviewtypesSourceDTO.meter,
|
||||
SavedviewtypesSourceDTO.ai_observability,
|
||||
])('is false for %s', (source) => {
|
||||
expect(isPreferenceSourcePage(source)).toBe(false);
|
||||
describe('hasPreferences', () => {
|
||||
it('is true for every panel of the logs and traces explorers', () => {
|
||||
expect(
|
||||
hasPreferences(SavedviewtypesSourceDTO.traces, PANEL_TYPES.TRACE),
|
||||
).toBe(true);
|
||||
expect(hasPreferences(SavedviewtypesSourceDTO.logs, PANEL_TYPES.LIST)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is true only for the AI observability panels that keep columns', () => {
|
||||
expect(
|
||||
hasPreferences(SavedviewtypesSourceDTO.ai_observability, PANEL_TYPES.TRACE),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasPreferences(SavedviewtypesSourceDTO.ai_observability, PANEL_TYPES.LIST),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for source pages without a slice', () => {
|
||||
expect(
|
||||
hasPreferences(SavedviewtypesSourceDTO.metrics, PANEL_TYPES.LIST),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
DEFAULT_PANEL_TYPE,
|
||||
TRACE_VIEW_DEFAULT_COLUMNS,
|
||||
} from 'container/LLMObservability/Explorer/constants';
|
||||
|
||||
import { PanelColumns } from '../types';
|
||||
import { isAiObservabilityExplorerRoute } from '../utils';
|
||||
|
||||
/**
|
||||
* Validates if a column is valid for the AI observability explorer
|
||||
* Needs a name; a column from another signal would fail the trace query
|
||||
*/
|
||||
const isValidAiColumn = (column: unknown): column is TelemetryFieldKey => {
|
||||
if (!column || typeof column !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const { name, signal } = column as { name?: unknown; signal?: unknown };
|
||||
return (
|
||||
typeof name === 'string' && name !== '' && (!signal || signal === 'traces')
|
||||
);
|
||||
};
|
||||
|
||||
// Keeps the valid columns of each panel; a panel left empty falls back to the
|
||||
// defaults, like one that was never stored.
|
||||
const toPanels = (value: unknown): PanelColumns => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return Object.entries(value).reduce<PanelColumns>(
|
||||
(panels, [panelType, columns]) => {
|
||||
if (!Array.isArray(columns)) {
|
||||
return panels;
|
||||
}
|
||||
const validColumns = columns.filter(isValidAiColumn);
|
||||
return validColumns.length
|
||||
? { ...panels, [panelType as PANEL_TYPES]: validColumns }
|
||||
: panels;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
// Stored as { [panelType]: columns }.
|
||||
export const readStoredPanels = (): PanelColumns => {
|
||||
try {
|
||||
return toPanels(
|
||||
JSON.parse(
|
||||
getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS) || '{}',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
// The url keeps the open panel's columns in `options.selectColumns`, and
|
||||
// `panelTypes` says which panel they belong to.
|
||||
const readUrlPanels = (): PanelColumns => {
|
||||
if (!isAiObservabilityExplorerRoute()) {
|
||||
return {};
|
||||
}
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
try {
|
||||
const options = JSON.parse(urlParams.get('options') || '{}');
|
||||
const panelTypeParam = urlParams.get(QueryParams.panelTypes);
|
||||
const panelType: PANEL_TYPES = panelTypeParam
|
||||
? JSON.parse(panelTypeParam)
|
||||
: DEFAULT_PANEL_TYPE;
|
||||
return toPanels({ [panelType]: options?.selectColumns });
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
type AiObservabilityLoaderConfig = {
|
||||
local: () => { columns: TelemetryFieldKey[] };
|
||||
url: () => { columns: TelemetryFieldKey[] };
|
||||
default: () => { columns: TelemetryFieldKey[] };
|
||||
priority: readonly ['local', 'url', 'default'];
|
||||
};
|
||||
|
||||
// --- AI OBSERVABILITY preferences loader config ---
|
||||
// Each panel keeps its own columns, so the config is built for one panel.
|
||||
const getAiObservabilityLoaderConfig = (
|
||||
panelType: PANEL_TYPES,
|
||||
): AiObservabilityLoaderConfig => ({
|
||||
local: () => ({ columns: readStoredPanels()[panelType] ?? [] }),
|
||||
url: () => ({ columns: readUrlPanels()[panelType] ?? [] }),
|
||||
// Only the trace panel keeps columns today.
|
||||
default: () => ({
|
||||
columns: panelType === PANEL_TYPES.TRACE ? TRACE_VIEW_DEFAULT_COLUMNS : [],
|
||||
}),
|
||||
priority: ['local', 'url', 'default'] as const,
|
||||
});
|
||||
|
||||
export default getAiObservabilityLoaderConfig;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { defaultOptionsQuery } from 'container/OptionsMenu/constants';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
|
||||
import { PreferenceMode, Preferences } from '../types';
|
||||
import { readStoredPanels } from './aiObservabilityLoaderConfig';
|
||||
|
||||
// --- AI OBSERVABILITY preferences updater config ---
|
||||
const getAiObservabilityUpdaterConfig = (
|
||||
redirectWithOptionsData: (options: OptionsQuery) => void,
|
||||
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
|
||||
panelType: PANEL_TYPES | undefined,
|
||||
): {
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
mode: string,
|
||||
columnsPanelType?: PANEL_TYPES,
|
||||
) => void;
|
||||
updateFormatting: () => void;
|
||||
} => ({
|
||||
updateColumns: (newColumns, mode, columnsPanelType = panelType): void => {
|
||||
if (!columnsPanelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === PreferenceMode.SAVED_VIEW) {
|
||||
setSavedViewPreferences({
|
||||
columns: newColumns,
|
||||
panelType: columnsPanelType,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === PreferenceMode.DIRECT) {
|
||||
const panels = {
|
||||
...readStoredPanels(),
|
||||
[columnsPanelType]: newColumns,
|
||||
};
|
||||
redirectWithOptionsData({
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: newColumns,
|
||||
});
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS,
|
||||
JSON.stringify(panels),
|
||||
);
|
||||
}
|
||||
},
|
||||
updateFormatting: (): void => {}, // no-op for ai observability
|
||||
});
|
||||
|
||||
export default getAiObservabilityUpdaterConfig;
|
||||
@@ -7,6 +7,7 @@ import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { FormattingOptions } from '../types';
|
||||
import { isAiObservabilityExplorerRoute } from '../utils';
|
||||
|
||||
/**
|
||||
* Validates if a column is valid for Logs Explorer
|
||||
@@ -53,6 +54,9 @@ const logsLoaders = {
|
||||
columns: BaseAutocompleteData[];
|
||||
formatting: FormattingOptions;
|
||||
} => {
|
||||
if (isAiObservabilityExplorerRoute()) {
|
||||
return { columns: [], formatting: undefined } as any;
|
||||
}
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
try {
|
||||
const options = JSON.parse(urlParams.get('options') || '{}');
|
||||
|
||||
@@ -5,6 +5,8 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { isAiObservabilityExplorerRoute } from '../utils';
|
||||
|
||||
/**
|
||||
* Validates if a column is valid for Traces Explorer
|
||||
* Filters out Logs-specific columns that would cause query failures
|
||||
@@ -41,6 +43,9 @@ const tracesLoaders = {
|
||||
url: (): {
|
||||
columns: BaseAutocompleteData[];
|
||||
} => {
|
||||
if (isAiObservabilityExplorerRoute()) {
|
||||
return { columns: [] };
|
||||
}
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
try {
|
||||
const options = JSON.parse(urlParams.get('options') || '{}');
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
// Only the logs and traces explorers keep column and formatting preferences.
|
||||
// Source pages that keep column and formatting preferences.
|
||||
export const PREFERENCE_SOURCE_PAGES = [
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
SavedviewtypesSourceDTO.ai_observability,
|
||||
] as const;
|
||||
|
||||
// The AI observability panels that keep their own columns.
|
||||
export const AI_OBSERVABILITY_COLUMN_PANELS: PANEL_TYPES[] = [
|
||||
PANEL_TYPES.TRACE,
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PreferenceMode,
|
||||
} from 'providers/preferences/types';
|
||||
|
||||
import { useAiObservabilityPreferenceSync } from '../sync/useAiObservabilityPreferenceSync';
|
||||
import { usePreferenceSync } from '../sync/usePreferenceSync';
|
||||
|
||||
const PreferenceContext = createContext<PreferenceContextValue | undefined>(
|
||||
@@ -47,12 +48,18 @@ export function PreferenceContextProvider({
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
|
||||
const aiObservabilitySlice = useAiObservabilityPreferenceSync({
|
||||
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
|
||||
savedViewId: savedViewId || undefined,
|
||||
});
|
||||
|
||||
const value = useMemo<PreferenceContextValue>(
|
||||
() => ({
|
||||
[SavedviewtypesSourceDTO.logs]: logsSlice,
|
||||
[SavedviewtypesSourceDTO.traces]: tracesSlice,
|
||||
[SavedviewtypesSourceDTO.ai_observability]: aiObservabilitySlice,
|
||||
}),
|
||||
[logsSlice, tracesSlice],
|
||||
[logsSlice, tracesSlice, aiObservabilitySlice],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import getAiObservabilityLoaderConfig from '../configs/aiObservabilityLoaderConfig';
|
||||
import { Preferences } from '../types';
|
||||
|
||||
// The defaults of one panel, for source pages whose panels each keep their own
|
||||
// columns; undefined for the others.
|
||||
export function getPanelDefaults(
|
||||
sourcePage: SavedviewtypesSourceDTO,
|
||||
panelType: PANEL_TYPES,
|
||||
): Preferences | undefined {
|
||||
if (sourcePage !== SavedviewtypesSourceDTO.ai_observability) {
|
||||
return undefined;
|
||||
}
|
||||
return { ...getAiObservabilityLoaderConfig(panelType).default(), panelType };
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ensureAiRequiredColumns } from 'container/LLMObservability/Explorer/constants';
|
||||
import { ensureLogsRequiredColumns } from 'container/OptionsMenu/constants';
|
||||
import { has } from 'lodash-es';
|
||||
|
||||
import getAiObservabilityLoaderConfig from '../configs/aiObservabilityLoaderConfig';
|
||||
import logsLoaderConfig from '../configs/logsLoaderConfig';
|
||||
import tracesLoaderConfig from '../configs/tracesLoaderConfig';
|
||||
import { FormattingOptions, Preferences, PreferenceSourcePage } from '../types';
|
||||
@@ -66,17 +69,37 @@ function tracesPreferencesLoader(): {
|
||||
return { columns };
|
||||
}
|
||||
|
||||
const PREFERENCE_LOADERS: Record<PreferenceSourcePage, () => Preferences> = {
|
||||
// Loads the open panel only. Same priority as the other loaders: localStorage,
|
||||
// then the url, then the defaults.
|
||||
function aiObservabilityPreferencesLoader(
|
||||
panelType?: PANEL_TYPES,
|
||||
): Preferences {
|
||||
if (!panelType) {
|
||||
return { columns: [] };
|
||||
}
|
||||
const { columns } = preferencesLoader<Preferences>(
|
||||
getAiObservabilityLoaderConfig(panelType),
|
||||
);
|
||||
return { columns: ensureAiRequiredColumns(columns, panelType), panelType };
|
||||
}
|
||||
|
||||
const PREFERENCE_LOADERS: Record<
|
||||
PreferenceSourcePage,
|
||||
(panelType?: PANEL_TYPES) => Preferences
|
||||
> = {
|
||||
[SavedviewtypesSourceDTO.logs]: logsPreferencesLoader,
|
||||
[SavedviewtypesSourceDTO.traces]: tracesPreferencesLoader,
|
||||
[SavedviewtypesSourceDTO.ai_observability]: aiObservabilityPreferencesLoader,
|
||||
};
|
||||
|
||||
export function usePreferenceLoader({
|
||||
sourcePage,
|
||||
panelType,
|
||||
reSync,
|
||||
setReSync,
|
||||
}: {
|
||||
sourcePage: PreferenceSourcePage;
|
||||
panelType?: PANEL_TYPES;
|
||||
reSync: boolean;
|
||||
setReSync: (value: boolean) => void;
|
||||
}): {
|
||||
@@ -85,17 +108,24 @@ export function usePreferenceLoader({
|
||||
error: Error | null;
|
||||
} {
|
||||
const loadSlicePreferences = PREFERENCE_LOADERS[sourcePage];
|
||||
const [preferences, setPreferences] = useState<Preferences | null>(
|
||||
loadSlicePreferences,
|
||||
const [preferences, setPreferences] = useState<Preferences | null>(() =>
|
||||
loadSlicePreferences(panelType),
|
||||
);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Switching panels loads the new panel's columns in the same render.
|
||||
const [loadedPanelType, setLoadedPanelType] = useState(panelType);
|
||||
if (loadedPanelType !== panelType) {
|
||||
setLoadedPanelType(panelType);
|
||||
setPreferences(loadSlicePreferences(panelType));
|
||||
}
|
||||
|
||||
useEffect((): void => {
|
||||
function loadPreferences(): void {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setPreferences(loadSlicePreferences());
|
||||
setPreferences(loadSlicePreferences(panelType));
|
||||
} catch (e) {
|
||||
setError(e as Error);
|
||||
} finally {
|
||||
@@ -110,7 +140,7 @@ export function usePreferenceLoader({
|
||||
if (reSync) {
|
||||
loadPreferences();
|
||||
}
|
||||
}, [loadSlicePreferences, reSync, setReSync]);
|
||||
}, [loadSlicePreferences, panelType, reSync, setReSync]);
|
||||
|
||||
return { preferences, loading: false, error };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DEFAULT_PANEL_TYPE } from 'container/LLMObservability/Explorer/constants';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import { PreferenceMode, PreferenceSlice } from '../types';
|
||||
import { usePreferenceSync } from './usePreferenceSync';
|
||||
|
||||
export function useAiObservabilityPreferenceSync({
|
||||
mode,
|
||||
savedViewId,
|
||||
}: {
|
||||
mode: PreferenceMode;
|
||||
savedViewId: string | undefined;
|
||||
}): PreferenceSlice {
|
||||
const params = useUrlQuery();
|
||||
|
||||
let panelType = DEFAULT_PANEL_TYPE;
|
||||
const panelTypesParam = params.get(QueryParams.panelTypes);
|
||||
if (panelTypesParam) {
|
||||
try {
|
||||
panelType = JSON.parse(panelTypesParam);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return usePreferenceSync({
|
||||
mode,
|
||||
savedViewId,
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
|
||||
|
||||
import { getPanelDefaults } from '../loader/getPanelDefaults';
|
||||
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
|
||||
import {
|
||||
FormattingOptions,
|
||||
@@ -16,16 +18,22 @@ import { usePreferenceUpdater } from '../updater/usePreferenceUpdater';
|
||||
export function usePreferenceSync({
|
||||
mode,
|
||||
sourcePage,
|
||||
panelType,
|
||||
savedViewId,
|
||||
}: {
|
||||
mode: PreferenceMode;
|
||||
sourcePage: PreferenceSourcePage;
|
||||
// The open panel, for source pages whose panels each keep their own columns.
|
||||
panelType?: PANEL_TYPES;
|
||||
savedViewId: string | undefined;
|
||||
}): {
|
||||
preferences: Preferences | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
panelType?: PANEL_TYPES,
|
||||
) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
} {
|
||||
const isSavedViewMode = mode === PreferenceMode.SAVED_VIEW && !!savedViewId;
|
||||
@@ -58,22 +66,36 @@ export function usePreferenceSync({
|
||||
const [reSync, setReSync] = useState(false);
|
||||
const { preferences, loading, error } = usePreferenceLoader({
|
||||
sourcePage,
|
||||
panelType,
|
||||
reSync,
|
||||
setReSync,
|
||||
});
|
||||
|
||||
const { updateColumns, updateFormatting } = usePreferenceUpdater({
|
||||
sourcePage,
|
||||
panelType,
|
||||
mode,
|
||||
preferences,
|
||||
setReSync,
|
||||
setSavedViewPreferences,
|
||||
});
|
||||
|
||||
// A view saved on another panel has no columns for the open one, so that
|
||||
// panel starts from its defaults.
|
||||
const viewPreferences = useMemo(
|
||||
() =>
|
||||
savedViewPreferences?.panelType &&
|
||||
panelType &&
|
||||
savedViewPreferences.panelType !== panelType
|
||||
? (getPanelDefaults(sourcePage, panelType) ?? savedViewPreferences)
|
||||
: savedViewPreferences,
|
||||
[savedViewPreferences, panelType, sourcePage],
|
||||
);
|
||||
|
||||
return {
|
||||
preferences:
|
||||
mode === PreferenceMode.SAVED_VIEW && savedViewId
|
||||
? savedViewPreferences
|
||||
? viewPreferences
|
||||
: preferences,
|
||||
loading,
|
||||
error,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
|
||||
|
||||
import { PREFERENCE_SOURCE_PAGES } from '../constants';
|
||||
@@ -10,11 +11,17 @@ export enum PreferenceMode {
|
||||
|
||||
export type PreferenceSourcePage = (typeof PREFERENCE_SOURCE_PAGES)[number];
|
||||
|
||||
export type PanelColumns = Partial<Record<PANEL_TYPES, TelemetryFieldKey[]>>;
|
||||
|
||||
export interface PreferenceSlice {
|
||||
preferences: Preferences | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
// `panelType` defaults to the open panel.
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
panelType?: PANEL_TYPES,
|
||||
) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
}
|
||||
|
||||
@@ -33,4 +40,7 @@ export interface FormattingOptions {
|
||||
export interface Preferences {
|
||||
columns: TelemetryFieldKey[];
|
||||
formatting?: FormattingOptions;
|
||||
// Set by source pages whose panels each keep their own columns: the panel
|
||||
// these columns belong to.
|
||||
panelType?: PANEL_TYPES;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
defaultOptionsQuery,
|
||||
URL_OPTIONS,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
|
||||
import getAiObservabilityUpdaterConfig from '../configs/aiObservabilityUpdaterConfig';
|
||||
import getLogsUpdaterConfig from '../configs/logsUpdaterConfig';
|
||||
import getTracesUpdaterConfig from '../configs/tracesUpdaterConfig';
|
||||
import { FormattingOptions, Preferences, PreferenceSourcePage } from '../types';
|
||||
@@ -16,10 +18,15 @@ const getUpdaterConfig = (
|
||||
preferences: Preferences | null,
|
||||
redirectWithOptionsData: (options: OptionsQuery) => void,
|
||||
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>,
|
||||
panelType: PANEL_TYPES | undefined,
|
||||
): Record<
|
||||
PreferenceSourcePage,
|
||||
{
|
||||
updateColumns: (newColumns: TelemetryFieldKey[], mode: string) => void;
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
mode: string,
|
||||
panelType?: PANEL_TYPES,
|
||||
) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions, mode: string) => void;
|
||||
}
|
||||
> => ({
|
||||
@@ -32,22 +39,32 @@ const getUpdaterConfig = (
|
||||
redirectWithOptionsData,
|
||||
setSavedViewPreferences,
|
||||
),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: getAiObservabilityUpdaterConfig(
|
||||
redirectWithOptionsData,
|
||||
setSavedViewPreferences,
|
||||
panelType,
|
||||
),
|
||||
});
|
||||
|
||||
export function usePreferenceUpdater({
|
||||
sourcePage,
|
||||
panelType,
|
||||
mode,
|
||||
preferences,
|
||||
setReSync,
|
||||
setSavedViewPreferences,
|
||||
}: {
|
||||
sourcePage: PreferenceSourcePage;
|
||||
panelType?: PANEL_TYPES;
|
||||
mode: string;
|
||||
preferences: Preferences | null;
|
||||
setReSync: Dispatch<SetStateAction<boolean>>;
|
||||
setSavedViewPreferences: Dispatch<SetStateAction<Preferences | null>>;
|
||||
}): {
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
panelType?: PANEL_TYPES,
|
||||
) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
} {
|
||||
const { redirectWithQuery: redirectWithOptionsData } =
|
||||
@@ -56,11 +73,15 @@ export function usePreferenceUpdater({
|
||||
preferences,
|
||||
redirectWithOptionsData,
|
||||
setSavedViewPreferences,
|
||||
panelType,
|
||||
)[sourcePage];
|
||||
|
||||
return {
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]): void => {
|
||||
updater.updateColumns(newColumns, mode);
|
||||
updateColumns: (
|
||||
newColumns: TelemetryFieldKey[],
|
||||
columnsPanelType?: PANEL_TYPES,
|
||||
): void => {
|
||||
updater.updateColumns(newColumns, mode, columnsPanelType);
|
||||
setReSync(true);
|
||||
},
|
||||
updateFormatting: (newFormatting: FormattingOptions): void => {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import { PREFERENCE_SOURCE_PAGES } from './constants';
|
||||
import {
|
||||
AI_OBSERVABILITY_COLUMN_PANELS,
|
||||
PREFERENCE_SOURCE_PAGES,
|
||||
} from './constants';
|
||||
import { PreferenceSourcePage } from './types';
|
||||
|
||||
export function isPreferenceSourcePage(
|
||||
@@ -10,3 +16,21 @@ export function isPreferenceSourcePage(
|
||||
PREFERENCE_SOURCE_PAGES as readonly SavedviewtypesSourceDTO[]
|
||||
).includes(sourcePage);
|
||||
}
|
||||
|
||||
export function hasPreferences(
|
||||
sourcePage: SavedviewtypesSourceDTO,
|
||||
panelType: PANEL_TYPES,
|
||||
): boolean {
|
||||
if (sourcePage === SavedviewtypesSourceDTO.ai_observability) {
|
||||
return AI_OBSERVABILITY_COLUMN_PANELS.includes(panelType);
|
||||
}
|
||||
return isPreferenceSourcePage(sourcePage);
|
||||
}
|
||||
|
||||
// The url `options` param belongs to the explorer that is open, so a link to
|
||||
// one explorer never seeds another explorer's slice.
|
||||
export function isAiObservabilityExplorerRoute(): boolean {
|
||||
return (
|
||||
window.location.pathname === withBasePath(ROUTES.AI_OBSERVABILITY_EXPLORER)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -13,7 +16,7 @@ import (
|
||||
func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/stats", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryStats),
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryStats, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryStats",
|
||||
Tags: []string{"rules"},
|
||||
@@ -24,13 +27,21 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/timeline", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryTimeline),
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryTimeline, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryTimeline",
|
||||
Tags: []string{"rules"},
|
||||
@@ -40,14 +51,27 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
Response: new(rulestatehistorytypes.GettableRuleStateTimeline),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/top_contributors", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryContributors),
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryContributors, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryTopContributors",
|
||||
Tags: []string{"rules"},
|
||||
@@ -57,14 +81,27 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
Response: new([]rulestatehistorytypes.GettableRuleStateHistoryContributor),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/filter_keys", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryFilterKeys),
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryFilterKeys, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryFilterKeys",
|
||||
Tags: []string{"rules"},
|
||||
@@ -74,14 +111,27 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
Response: new(telemetrytypes.GettableFieldKeys),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, buildFieldScopes(coretypes.VerbList)...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
Resources: ruler.NewRuleQueryFieldResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/filter_values", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryFilterValues),
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryFilterValues, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryFilterValues",
|
||||
Tags: []string{"rules"},
|
||||
@@ -91,14 +141,27 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
Response: new(telemetrytypes.GettableFieldValues),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, buildFieldScopes(coretypes.VerbRead)...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
Resources: ruler.NewRuleQueryFieldResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/overall_status", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryOverallStatus),
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryOverallStatus, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryOverallStatus",
|
||||
Tags: []string{"rules"},
|
||||
@@ -109,10 +172,26 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFieldScopes(verb coretypes.Verb) []string {
|
||||
return []string{
|
||||
coretypes.ResourceMetaResourceLogsField.Scope(verb),
|
||||
coretypes.ResourceMetaResourceTracesField.Scope(verb),
|
||||
coretypes.ResourceMetaResourceMetricsField.Scope(verb),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,12 @@ import (
|
||||
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
|
||||
// "valid <noun> are ..." list consistently across the codebase.
|
||||
const (
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounRequestTypes = "request types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -26,8 +26,9 @@ const bucketCacheCellSteps = 60
|
||||
type BucketCache interface {
|
||||
// cached portion + list of gaps to fetch
|
||||
GetMissRanges(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step) (cached *qbtypes.Result, missing []*qbtypes.TimeRange)
|
||||
// store fresh buckets for future hits
|
||||
Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result)
|
||||
// store fresh buckets for future hits; only the ranges in the map were
|
||||
// queried for fresh, the rest of it came from the cache
|
||||
Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats)
|
||||
}
|
||||
|
||||
// bucketCache implements the BucketCache interface.
|
||||
@@ -116,7 +117,7 @@ func (bc *bucketCache) GetMissRanges(
|
||||
}
|
||||
|
||||
// Merge buckets into a single result
|
||||
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, cachedWarnings)
|
||||
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, cachedWarnings, startMs, endMs)
|
||||
|
||||
// Filter the merged result to only include values within the requested time range
|
||||
mergedResult = bc.filterResultToTimeRange(mergedResult, q, startMs, endMs, stepMs)
|
||||
@@ -125,7 +126,7 @@ func (bc *bucketCache) GetMissRanges(
|
||||
}
|
||||
|
||||
// Put stores fresh query results in the cache.
|
||||
func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result) {
|
||||
func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) {
|
||||
// Get query window
|
||||
startMs, endMs := q.Window()
|
||||
|
||||
@@ -188,7 +189,7 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
|
||||
|
||||
// Convert trimmed result to buckets, one per grid cell it spans
|
||||
cellWidthMs := bucketCacheCellSteps * stepMs
|
||||
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs, cellWidthMs)
|
||||
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs, cellWidthMs, execStatsForQueriedTimeRanges)
|
||||
|
||||
cellKeys := make([]string, len(freshBuckets))
|
||||
cellEntries := make([]cachetypes.Cacheable, len(freshBuckets))
|
||||
@@ -210,7 +211,7 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
|
||||
cellEntry = cellEntries[i].(*qbtypes.CachedData)
|
||||
}
|
||||
|
||||
cellBuckets, changed := bc.mergeFreshBucketIntoCell(ctx, cellEntry.Buckets, freshBucket)
|
||||
cellBuckets, changed := bc.mergeFreshBucketIntoCell(ctx, cellEntry.Buckets, freshBucket, execStatsForQueriedTimeRanges)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
@@ -463,7 +464,7 @@ func (bc *bucketCache) filterRelevantBuckets(buckets []*qbtypes.CachedBucket, st
|
||||
}
|
||||
|
||||
// mergeBuckets combines multiple cached buckets into a single result.
|
||||
func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.CachedBucket, warnings []string) *qbtypes.Result {
|
||||
func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.CachedBucket, warnings []string, startMs, endMs uint64) *qbtypes.Result {
|
||||
if len(buckets) == 0 {
|
||||
return &qbtypes.Result{}
|
||||
}
|
||||
@@ -471,12 +472,13 @@ func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.Cach
|
||||
// All buckets should have the same type
|
||||
resultType := buckets[0].Type
|
||||
|
||||
// Aggregate stats
|
||||
// A bucket can reach past the window; only the part inside it is served
|
||||
var totalStats qbtypes.ExecStats
|
||||
for _, bucket := range buckets {
|
||||
totalStats.RowsScanned += bucket.Stats.RowsScanned
|
||||
totalStats.BytesScanned += bucket.Stats.BytesScanned
|
||||
totalStats.DurationMS += bucket.Stats.DurationMS
|
||||
servedStats := bucket.ProrateStatsToWindow(startMs, endMs)
|
||||
totalStats.RowsScanned += servedStats.RowsScanned
|
||||
totalStats.BytesScanned += servedStats.BytesScanned
|
||||
totalStats.DurationMS += servedStats.DurationMS
|
||||
}
|
||||
|
||||
// Merge values based on type
|
||||
@@ -674,7 +676,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
|
||||
}
|
||||
|
||||
// resultToBuckets converts a query result into time-based buckets.
|
||||
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs, cellWidthMs uint64) []*qbtypes.CachedBucket {
|
||||
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs, cellWidthMs uint64, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) []*qbtypes.CachedBucket {
|
||||
// Check if result is empty
|
||||
isEmpty, isFiltered := bc.isEmptyResult(result)
|
||||
|
||||
@@ -686,7 +688,7 @@ func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Resu
|
||||
}
|
||||
|
||||
// Empty filtered results still become buckets, so the range is not re-queried
|
||||
perCellBuckets, err := result.SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs)
|
||||
perCellBuckets, err := result.SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs, execStatsForQueriedTimeRanges)
|
||||
if err != nil {
|
||||
bc.logger.ErrorContext(ctx, "failed to convert result to buckets", errors.Attr(err))
|
||||
return nil
|
||||
@@ -698,7 +700,7 @@ func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Resu
|
||||
// and reports whether the cell changed: a bucket already covering the fresh
|
||||
// one leaves it as is, buckets inside the fresh one are replaced, buckets
|
||||
// overlapping or edge to edge with it are unioned, and the rest are kept apart.
|
||||
func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets []*qbtypes.CachedBucket, freshBucket *qbtypes.CachedBucket) ([]*qbtypes.CachedBucket, bool) {
|
||||
func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets []*qbtypes.CachedBucket, freshBucket *qbtypes.CachedBucket, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) ([]*qbtypes.CachedBucket, bool) {
|
||||
keptBuckets := make([]*qbtypes.CachedBucket, 0, len(cellBuckets)+1)
|
||||
touchingBuckets := []*qbtypes.CachedBucket{}
|
||||
for _, cellBucket := range cellBuckets {
|
||||
@@ -711,7 +713,7 @@ func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets
|
||||
keptBuckets = append(keptBuckets, cellBucket)
|
||||
}
|
||||
}
|
||||
keptBuckets = append(keptBuckets, bc.unionBuckets(ctx, freshBucket, touchingBuckets))
|
||||
keptBuckets = append(keptBuckets, bc.unionBuckets(ctx, freshBucket, touchingBuckets, execStatsForQueriedTimeRanges))
|
||||
|
||||
slices.SortFunc(keptBuckets, func(a, b *qbtypes.CachedBucket) int {
|
||||
return cmp.Compare(a.StartMs, b.StartMs)
|
||||
@@ -722,7 +724,16 @@ func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets
|
||||
// unionBuckets widens the fresh bucket by the buckets it touches. Buckets
|
||||
// inside it are replaced outright, so only a genuine partial overlap decodes
|
||||
// anything.
|
||||
func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.CachedBucket, touchingBuckets []*qbtypes.CachedBucket) *qbtypes.CachedBucket {
|
||||
func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.CachedBucket, touchingBuckets []*qbtypes.CachedBucket, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) *qbtypes.CachedBucket {
|
||||
// Data queried again takes the new query's cost, which the fresh bucket
|
||||
// carries; a touching bucket keeps its cost only for what was not queried again
|
||||
for _, touchingBucket := range touchingBuckets {
|
||||
keptStats := touchingBucket.ProrateStatsToUnqueried(execStatsForQueriedTimeRanges)
|
||||
freshBucket.Stats.RowsScanned += keptStats.RowsScanned
|
||||
freshBucket.Stats.BytesScanned += keptStats.BytesScanned
|
||||
freshBucket.Stats.DurationMS += keptStats.DurationMS
|
||||
}
|
||||
|
||||
bucketsToMerge := []*qbtypes.CachedBucket{freshBucket}
|
||||
for _, touchingBucket := range touchingBuckets {
|
||||
if !freshBucket.Contains(touchingBucket) {
|
||||
@@ -743,14 +754,11 @@ func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.Ca
|
||||
EndMs: freshBucket.EndMs,
|
||||
Type: freshBucket.Type,
|
||||
Value: value,
|
||||
Stats: qbtypes.ExecStats{StepIntervals: freshBucket.Stats.StepIntervals},
|
||||
Stats: freshBucket.Stats,
|
||||
}
|
||||
for _, mergedBucket := range bucketsToMerge {
|
||||
unionBucket.StartMs = min(unionBucket.StartMs, mergedBucket.StartMs)
|
||||
unionBucket.EndMs = max(unionBucket.EndMs, mergedBucket.EndMs)
|
||||
unionBucket.Stats.RowsScanned += mergedBucket.Stats.RowsScanned
|
||||
unionBucket.Stats.BytesScanned += mergedBucket.Stats.BytesScanned
|
||||
unionBucket.Stats.DurationMS += mergedBucket.Stats.DurationMS
|
||||
}
|
||||
return unionBucket
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func BenchmarkBucketCache_GetMissRanges(b *testing.B) {
|
||||
endMs: uint64((i + 1) * 10000),
|
||||
}
|
||||
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
|
||||
}
|
||||
|
||||
// Create test queries with varying cache hit patterns
|
||||
@@ -121,7 +121,7 @@ func BenchmarkBucketCache_Put(b *testing.B) {
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for j := 0; j < tc.numQueries; j++ {
|
||||
bc.Put(ctx, orgID, queries[j], qbtypes.Step{Duration: 1000 * time.Millisecond}, results[j])
|
||||
bc.Put(ctx, orgID, queries[j], qbtypes.Step{Duration: 1000 * time.Millisecond}, results[j], map[qbtypes.TimeRange]qbtypes.ExecStats{{From: queries[j].startMs, To: queries[j].endMs}: results[j].Stats})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -259,7 +259,7 @@ func BenchmarkBucketCache_ConcurrentOperations(b *testing.B) {
|
||||
endMs: uint64((i + 1) * 10000),
|
||||
}
|
||||
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
@@ -284,7 +284,7 @@ func BenchmarkBucketCache_ConcurrentOperations(b *testing.B) {
|
||||
endMs: uint64((i + 1) * 10000),
|
||||
}
|
||||
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
|
||||
case 2: // Partial read
|
||||
query := &mockQuery{
|
||||
fingerprint: fmt.Sprintf("concurrent-query-%d", i%100),
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestBucketCacheStepAlignment(t *testing.T) {
|
||||
}
|
||||
|
||||
// Put result in cache
|
||||
bc.Put(ctx, orgID, query, step, result)
|
||||
bc.Put(ctx, orgID, query, step, result, nil)
|
||||
|
||||
// Get cached data
|
||||
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
|
||||
@@ -109,7 +109,7 @@ func TestBucketCacheNoStepInterval(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should cache the entire range when step is 0
|
||||
bc.Put(ctx, orgID, query, step, result)
|
||||
bc.Put(ctx, orgID, query, step, result, nil)
|
||||
|
||||
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
|
||||
assert.NotNil(t, cached)
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestBucketCache_Put_And_Get(t *testing.T) {
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
|
||||
|
||||
// Wait a bit for cache to be written
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -189,7 +189,7 @@ func TestBucketCache_PartialHit(t *testing.T) {
|
||||
Type: qbtypes.RequestTypeTimeSeries,
|
||||
Value: createTestTimeSeries("A", 1000, 3000, 1000),
|
||||
}
|
||||
bc.Put(context.Background(), valuer.UUID{}, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1)
|
||||
bc.Put(context.Background(), valuer.UUID{}, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1, nil)
|
||||
|
||||
// Wait for cache write
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -225,7 +225,7 @@ func TestBucketCache_MultipleBuckets(t *testing.T) {
|
||||
bc.Put(context.Background(), valuer.UUID{}, query1, qbtypes.Step{Duration: 100 * time.Millisecond}, &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeTimeSeries,
|
||||
Value: createTestTimeSeries("A", 1000, 2000, 100),
|
||||
})
|
||||
}, nil)
|
||||
|
||||
query2 := &mockQuery{
|
||||
fingerprint: "test-query",
|
||||
@@ -235,7 +235,7 @@ func TestBucketCache_MultipleBuckets(t *testing.T) {
|
||||
bc.Put(context.Background(), valuer.UUID{}, query2, qbtypes.Step{Duration: 100 * time.Millisecond}, &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeTimeSeries,
|
||||
Value: createTestTimeSeries("A", 3000, 4000, 100),
|
||||
})
|
||||
}, nil)
|
||||
|
||||
// Wait for cache writes
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -280,7 +280,7 @@ func TestBucketCache_FluxInterval(t *testing.T) {
|
||||
}
|
||||
|
||||
// This should not be cached due to flux interval
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
|
||||
|
||||
// Wait a bit
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -358,7 +358,7 @@ func TestBucketCache_MergeTimeSeriesResults(t *testing.T) {
|
||||
{Series: []*qbtypes.TimeSeries{series1}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
|
||||
// Cache second part
|
||||
query2 := &mockQuery{
|
||||
@@ -374,7 +374,7 @@ func TestBucketCache_MergeTimeSeriesResults(t *testing.T) {
|
||||
{Series: []*qbtypes.TimeSeries{series2}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
|
||||
// Wait for cache writes
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -441,7 +441,7 @@ func TestBucketCache_RawData(t *testing.T) {
|
||||
Value: rawData,
|
||||
}
|
||||
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
cached, missing := bc.GetMissRanges(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
|
||||
@@ -481,7 +481,7 @@ func TestBucketCache_ScalarData(t *testing.T) {
|
||||
Value: scalarData,
|
||||
}
|
||||
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
cached, missing := bc.GetMissRanges(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
|
||||
@@ -509,7 +509,7 @@ func TestBucketCache_EmptyFingerprint(t *testing.T) {
|
||||
Value: createTestTimeSeries("A", 1000, 5000, 1000),
|
||||
}
|
||||
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Should still be able to retrieve
|
||||
@@ -564,7 +564,7 @@ func TestBucketCache_ConcurrentAccess(t *testing.T) {
|
||||
Type: qbtypes.RequestTypeTimeSeries,
|
||||
Value: createTestTimeSeries(fmt.Sprintf("Q%d", id), query.startMs, query.endMs, 100),
|
||||
}
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 100 * time.Microsecond}, result)
|
||||
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 100 * time.Microsecond}, result, nil)
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
@@ -624,7 +624,7 @@ func TestBucketCache_GetMissRanges_FluxInterval(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, cachedResult)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, cachedResult, nil)
|
||||
|
||||
// Get miss ranges
|
||||
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
|
||||
@@ -686,7 +686,7 @@ func TestBucketCache_Put_FluxIntervalTrimming(t *testing.T) {
|
||||
}
|
||||
|
||||
// Put the result
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
|
||||
|
||||
// Retrieve cached data
|
||||
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
|
||||
@@ -756,7 +756,7 @@ func TestBucketCache_Put_EntireRangeInFluxInterval(t *testing.T) {
|
||||
}
|
||||
|
||||
// Put the result - should not cache anything
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
|
||||
|
||||
// Try to get cached data - should have no cached data
|
||||
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
|
||||
@@ -862,7 +862,7 @@ func TestBucketCache_EmptyDataHandling(t *testing.T) {
|
||||
}
|
||||
|
||||
// Put the result
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, tt.result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, tt.result, nil)
|
||||
|
||||
// Wait a bit for cache to be written
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -927,7 +927,7 @@ func TestBucketCache_PartialValues(t *testing.T) {
|
||||
}
|
||||
|
||||
// Put the result
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
|
||||
|
||||
// Wait for cache to be written
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -997,7 +997,7 @@ func TestBucketCache_AllPartialValues(t *testing.T) {
|
||||
}
|
||||
|
||||
// Put the result
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
|
||||
|
||||
// Wait for cache to be written
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -1058,7 +1058,7 @@ func TestBucketCache_FilteredCachedResults(t *testing.T) {
|
||||
}
|
||||
|
||||
// Cache the wide range
|
||||
bc.Put(ctx, orgID, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1)
|
||||
bc.Put(ctx, orgID, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1, nil)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Now query for a smaller range (2000-3500ms)
|
||||
@@ -1125,7 +1125,7 @@ func TestBucketCache_PromQLKeepsTheValueAtTheWindowEnd(t *testing.T) {
|
||||
}},
|
||||
}},
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
|
||||
@@ -1291,7 +1291,7 @@ func TestBucketCache_PartialValueDetection(t *testing.T) {
|
||||
}
|
||||
|
||||
// Put the result
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Get cached data
|
||||
@@ -1345,7 +1345,7 @@ func TestBucketCache_PartialValueDetection(t *testing.T) {
|
||||
}
|
||||
|
||||
// Put the result
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Get cached data
|
||||
@@ -1397,7 +1397,7 @@ func TestBucketCache_PartialValueDetection(t *testing.T) {
|
||||
}
|
||||
|
||||
// Put the result
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Get cached data
|
||||
@@ -1454,7 +1454,7 @@ func TestBucketCache_NoCache(t *testing.T) {
|
||||
}
|
||||
|
||||
// Put the result in cache
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
|
||||
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Verify data is cached
|
||||
@@ -1475,11 +1475,12 @@ func TestBucketCachePutSlicesAtCellBoundaries(t *testing.T) {
|
||||
|
||||
// a 1s step gives 60s cells, so 30s to 150s spans three of them
|
||||
query := &mockQuery{fingerprint: "sliced-query", startMs: 30000, endMs: 150000}
|
||||
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
|
||||
result := &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeTimeSeries,
|
||||
Value: createTestTimeSeries("A", 30000, 150000, 1000),
|
||||
Stats: qbtypes.ExecStats{RowsScanned: 120, BytesScanned: 1200, DurationMS: 12},
|
||||
})
|
||||
}
|
||||
bc.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: 30000, To: 150000}: result.Stats})
|
||||
|
||||
type bucketShape struct {
|
||||
cell, startMs, endMs, rowsScanned uint64
|
||||
@@ -1539,7 +1540,7 @@ func TestBucketCachePutAbsorbsWritesWithinACell(t *testing.T) {
|
||||
name: "OverlappingWrites_BecomeOne",
|
||||
firstWindow: qbtypes.TimeRange{From: 1000, To: 4000},
|
||||
secondWindow: qbtypes.TimeRange{From: 3000, To: 6000},
|
||||
expectedBuckets: []bucketShape{{1000, 6000, 6}},
|
||||
expectedBuckets: []bucketShape{{1000, 6000, 5}},
|
||||
expectedMissingRanges: []*qbtypes.TimeRange{},
|
||||
expectedValueCount: 5,
|
||||
},
|
||||
@@ -1570,12 +1571,13 @@ func TestBucketCachePutAbsorbsWritesWithinACell(t *testing.T) {
|
||||
|
||||
for _, window := range []qbtypes.TimeRange{testCase.firstWindow, testCase.secondWindow} {
|
||||
query := &mockQuery{fingerprint: "absorb-query", startMs: window.From, endMs: window.To}
|
||||
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
|
||||
result := &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeTimeSeries,
|
||||
Value: createTestTimeSeries("A", window.From, window.To, 1000),
|
||||
// one row per second, so a bucket's stats read as the seconds it was scanned for
|
||||
// one row per second, so a bucket's stats read as the seconds of data it holds
|
||||
Stats: qbtypes.ExecStats{RowsScanned: (window.To - window.From) / 1000},
|
||||
})
|
||||
}
|
||||
bc.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{window: result.Stats})
|
||||
}
|
||||
|
||||
query := &mockQuery{
|
||||
@@ -1609,7 +1611,7 @@ func TestBucketCachePutLeavesAnUnchangedCellAlone(t *testing.T) {
|
||||
Type: qbtypes.RequestTypeTimeSeries,
|
||||
Value: createTestTimeSeries("A", 1000, 10000, 1000),
|
||||
Warnings: []string{"from the wide write"},
|
||||
})
|
||||
}, nil)
|
||||
|
||||
// a write already covered by the cell's bucket must not rewrite the entry,
|
||||
// which shows as its warnings never arriving
|
||||
@@ -1618,7 +1620,7 @@ func TestBucketCachePutLeavesAnUnchangedCellAlone(t *testing.T) {
|
||||
Type: qbtypes.RequestTypeTimeSeries,
|
||||
Value: createTestTimeSeries("A", 3000, 5000, 1000),
|
||||
Warnings: []string{"from the inside write"},
|
||||
})
|
||||
}, nil)
|
||||
|
||||
var cellEntry qbtypes.CachedData
|
||||
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(wide, 0), &cellEntry))
|
||||
|
||||
@@ -823,7 +823,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
return nil, err
|
||||
}
|
||||
// Store in cache for future use
|
||||
q.bucketCache.Put(ctx, orgID, query, step, result)
|
||||
q.bucketCache.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: startMs, To: endMs}: result.Stats})
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
@@ -879,16 +879,19 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.bucketCache.Put(ctx, orgID, query, step, result)
|
||||
startMs, endMs := query.Window()
|
||||
q.bucketCache.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: startMs, To: endMs}: result.Stats})
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total stats and filter out nil results
|
||||
validResults := make([]*qbtypes.Result, 0, len(freshResults))
|
||||
for _, result := range freshResults {
|
||||
execStatsForQueriedTimeRanges := make(map[qbtypes.TimeRange]qbtypes.ExecStats, len(freshResults))
|
||||
for i, result := range freshResults {
|
||||
if result != nil {
|
||||
validResults = append(validResults, result)
|
||||
execStatsForQueriedTimeRanges[*missingRanges[i]] = result.Stats
|
||||
totalStats.RowsScanned += result.Stats.RowsScanned
|
||||
totalStats.BytesScanned += result.Stats.BytesScanned
|
||||
totalStats.DurationMS += result.Stats.DurationMS
|
||||
@@ -903,7 +906,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
mergedResult.Stats.DurationMS += totalStats.DurationMS
|
||||
|
||||
// Store merged result in cache
|
||||
q.bucketCache.Put(ctx, orgID, query, step, mergedResult)
|
||||
q.bucketCache.Put(ctx, orgID, query, step, mergedResult, execStatsForQueriedTimeRanges)
|
||||
|
||||
return mergedResult, nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package ruler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -159,6 +162,55 @@ func orgIDFromContext(ctx context.Context) (valuer.UUID, error) {
|
||||
return valuer.NewUUID(claims.OrgID)
|
||||
}
|
||||
|
||||
func NewRuleQueryTelemetryResources(rulerService Ruler) coretypes.ResourceExtractor {
|
||||
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
body, err := fetchStoredCompositeQuery(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return querybuilder.QueryRangeResources(coretypes.ExtractorContext{Request: ec.Request, RequestBody: body})
|
||||
}
|
||||
}
|
||||
|
||||
func NewRuleQueryFieldResources(rulerService Ruler) coretypes.ResourceExtractor {
|
||||
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
body, err := fetchStoredCompositeQuery(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mapFieldResources(body)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchStoredCompositeQuery(ec coretypes.ExtractorContext, rulerService Ruler) ([]byte, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ruleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := rulerService.GetRule(ec.Request.Context(), ruleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{"compositeQuery": stored.RuleCondition.CompositeQuery})
|
||||
}
|
||||
|
||||
func mapFieldResources(body []byte) ([]coretypes.ResourceWithID, error) {
|
||||
telemetry, err := querybuilder.QueryRangeResources(coretypes.ExtractorContext{RequestBody: body})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ruletypes.NewFieldResources(telemetry)
|
||||
}
|
||||
|
||||
func diffLinks(stored, incoming []string) (attached, detached []string) {
|
||||
storedSet := make(map[string]struct{}, len(stored))
|
||||
for _, id := range stored {
|
||||
|
||||
@@ -216,3 +216,101 @@ func TestDowntimeDiffExtractorScopesStoreByOrg(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapFieldResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantKinds []coretypes.Kind
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Builder_Logs_LogsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "Builder_Traces_TracesField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"traces"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindTracesField},
|
||||
},
|
||||
{
|
||||
name: "Builder_Metrics_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "SubQuery_Metrics_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_sub_query","spec":{"signal":"metrics"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "BuilderAI_TracesField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindTracesField},
|
||||
},
|
||||
{
|
||||
name: "PromQL_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "ClickHouseSQL_AllThreeFields",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField, coretypes.KindTracesField, coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "Builder_AuditLogs_LogsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "Builder_MeterMetrics_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics","source":"meter"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "TwoBuilderSameSignal_Deduped",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics"}},{"type":"builder_query","spec":{"signal":"metrics"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "FormulaAlongsideBuilder_FormulaSkipped",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}},{"type":"builder_formula","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "NoQueries_Errors",
|
||||
body: `{"compositeQuery":{"queries":[]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownSignal_Errors",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"events"}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownQueryType_Errors",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"graphql","spec":{}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
refs, err := mapFieldResources([]byte(testCase.body))
|
||||
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
kinds := make([]coretypes.Kind, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
kinds = append(kinds, ref.Resource.Kind())
|
||||
}
|
||||
assert.Equal(t, testCase.wantKinds, kinds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,23 @@ func (c *CachedBucket) Touches(other *CachedBucket) bool {
|
||||
return c.StartMs <= other.EndMs && other.StartMs <= c.EndMs
|
||||
}
|
||||
|
||||
// ProrateStatsToWindow returns the share of the stats for the part of the
|
||||
// bucket inside [startMs, endMs).
|
||||
func (c *CachedBucket) ProrateStatsToWindow(startMs, endMs uint64) ExecStats {
|
||||
return c.Stats.prorate(overlapMs(c.StartMs, c.EndMs, startMs, endMs), c.EndMs-c.StartMs)
|
||||
}
|
||||
|
||||
// ProrateStatsToUnqueried returns the share of the stats for the part of the
|
||||
// bucket no queried range covers.
|
||||
func (c *CachedBucket) ProrateStatsToUnqueried(execStatsForQueriedTimeRanges map[TimeRange]ExecStats) ExecStats {
|
||||
widthMs := c.EndMs - c.StartMs
|
||||
queriedMs := uint64(0)
|
||||
for queried := range execStatsForQueriedTimeRanges {
|
||||
queriedMs += overlapMs(c.StartMs, c.EndMs, queried.From, queried.To)
|
||||
}
|
||||
return c.Stats.prorate(widthMs-min(queriedMs, widthMs), widthMs)
|
||||
}
|
||||
|
||||
// CachedData represents the full cached data for a query.
|
||||
type CachedData struct {
|
||||
Buckets []*CachedBucket `json:"buckets"`
|
||||
@@ -88,7 +105,7 @@ func (c *CachedData) Cost() int64 {
|
||||
return size
|
||||
}
|
||||
|
||||
func (r *Result) SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs uint64) ([]*CachedBucket, error) {
|
||||
func (r *Result) SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs uint64, execStatsForQueriedTimeRanges map[TimeRange]ExecStats) ([]*CachedBucket, error) {
|
||||
tsData, ok := r.Value.(*TimeSeriesData)
|
||||
if !ok || tsData == nil {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "result of type %s cannot be bucketed", r.Type.StringValue())
|
||||
@@ -111,18 +128,39 @@ func (r *Result) SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs uint64) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A slice costs what was queried inside it; the rest of the result came
|
||||
// out of the cache and is costed there already
|
||||
sliceStats := ExecStats{StepIntervals: maps.Clone(r.Stats.StepIntervals)}
|
||||
for queried, stats := range execStatsForQueriedTimeRanges {
|
||||
queriedMs := overlapMs(from, to, queried.From, queried.To)
|
||||
if queriedMs == 0 {
|
||||
continue
|
||||
}
|
||||
share := stats.prorate(queriedMs, queried.To-queried.From)
|
||||
sliceStats.RowsScanned += share.RowsScanned
|
||||
sliceStats.BytesScanned += share.BytesScanned
|
||||
sliceStats.DurationMS += share.DurationMS
|
||||
}
|
||||
cellBuckets = append(cellBuckets, &CachedBucket{
|
||||
StartMs: from,
|
||||
EndMs: to,
|
||||
Type: r.Type,
|
||||
Value: value,
|
||||
Stats: r.Stats.prorate(to-from, endMs-startMs),
|
||||
Stats: sliceStats,
|
||||
})
|
||||
from = to
|
||||
}
|
||||
return cellBuckets, nil
|
||||
}
|
||||
|
||||
// overlapMs is the length of [aFromMs, aToMs) ∩ [bFromMs, bToMs).
|
||||
func overlapMs(aFromMs, aToMs, bFromMs, bToMs uint64) uint64 {
|
||||
if fromMs, toMs := max(aFromMs, bFromMs), min(aToMs, bToMs); fromMs < toMs {
|
||||
return toMs - fromMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// prorate returns the share of the stats that partMs out of wholeMs of the
|
||||
// scanned window accounts for.
|
||||
func (e ExecStats) prorate(partMs, wholeMs uint64) ExecStats {
|
||||
|
||||
@@ -104,10 +104,6 @@ func (f QueryBuilderFormula) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if f.Limit < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be non-negative, got %d", f.Limit)
|
||||
}
|
||||
|
||||
// Validate functions if present
|
||||
for i, fn := range f.Functions {
|
||||
if err := fn.Validate(); err != nil {
|
||||
|
||||
@@ -23,8 +23,7 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
|
||||
*r = v
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q", s).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -495,18 +495,6 @@ func (q *QueryBuilderQuery[T]) validateOrderBy(cfg validationConfig) error {
|
||||
return q.validateOrderByForAggregation()
|
||||
}
|
||||
|
||||
// a raw order key resolves like a select field, so only an empty one is
|
||||
// invalid before resolution
|
||||
for i, order := range q.Order {
|
||||
if order.Key.Name == "" {
|
||||
orderID := fmt.Sprintf("order by clause #%d", i+1)
|
||||
if q.Name != "" {
|
||||
orderID = fmt.Sprintf("order by clause #%d in query '%s'", i+1, q.Name)
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid empty key name for %s", orderID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -594,10 +582,19 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return err
|
||||
// Validate request type
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid request type: %s",
|
||||
r.RequestType,
|
||||
).WithAdditional(
|
||||
"Valid request types are: raw, timeseries, scalar, heatmap",
|
||||
)
|
||||
}
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return err
|
||||
@@ -638,10 +635,14 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return nil, err
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
opts = GetValidationOptions(r.RequestType)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap")
|
||||
}
|
||||
opts := GetValidationOptions(r.RequestType)
|
||||
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return nil, err
|
||||
@@ -698,20 +699,6 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
|
||||
return validateQueryEnvelope(e, opts...)
|
||||
}
|
||||
|
||||
// Enum is the one list of request types a query can use, so the spec and the
|
||||
// suggestions agree with what is accepted.
|
||||
func (r *QueryRangeRequest) validateRequestType() error {
|
||||
if slices.Contains(RequestType{}.Enum(), any(r.RequestType)) {
|
||||
return nil
|
||||
}
|
||||
if r.RequestType == RequestTypeUnknown {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "requestType is required").
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request type %q is not supported", r.RequestType.StringValue()).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateTimeRange() error {
|
||||
if r.RequestType == RequestTypeRawStream {
|
||||
return nil
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
@@ -526,28 +521,6 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "expression cannot be blank",
|
||||
},
|
||||
{
|
||||
name: "Formula_NegativeLimit_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeTimeSeries,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeFormula,
|
||||
Spec: QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A * 2",
|
||||
Limit: -5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid formula 'F1': limit must be non-negative, got -5",
|
||||
},
|
||||
{
|
||||
name: "promql with empty query should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -723,31 +696,6 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "raw request type is not supported for metric queries",
|
||||
},
|
||||
{
|
||||
name: "Raw_EmptyOrderKey_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeRaw,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeBuilder,
|
||||
Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Order: []OrderBy{
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp"}}, Direction: OrderDirectionDesc},
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ""}}, Direction: OrderDirectionAsc},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid query 'B': invalid empty key name for order by clause #2 in query 'B'",
|
||||
},
|
||||
{
|
||||
name: "timeseries request with group by timestamp should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -1459,44 +1407,6 @@ func TestRequestType_IsAggregation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRangeRequest_ValidateRequestType(t *testing.T) {
|
||||
validRequestTypes := []string{"valid request types are `scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `heatmap`"}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "Missing_Required",
|
||||
requestType: "",
|
||||
message: "requestType is required",
|
||||
},
|
||||
{
|
||||
name: "Timeseries_Unknown",
|
||||
requestType: `"requestType": "timeseries",`,
|
||||
message: `unknown request type "timeseries"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
body := fmt.Sprintf(`{"schemaVersion": "v1", "start": 1640995200000, "end": 1640998800000, %s "compositeQuery": {"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs"}}]}}`, testCase.requestType)
|
||||
|
||||
var request QueryRangeRequest
|
||||
err := json.Unmarshal([]byte(body), &request)
|
||||
if err == nil {
|
||||
err = request.Validate()
|
||||
}
|
||||
require.Error(t, err)
|
||||
|
||||
got := errors.AsJSON(err)
|
||||
assert.Equal(t, testCase.message, got.Message)
|
||||
assert.Equal(t, validRequestTypes, got.Suggestions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonAggregationFieldsSkipped(t *testing.T) {
|
||||
// Fields that only apply to aggregation queries (groupBy, having, aggregations)
|
||||
// should be silently skipped for non-aggregation request types.
|
||||
|
||||
39
pkg/types/ruletypes/resource.go
Normal file
39
pkg/types/ruletypes/resource.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
)
|
||||
|
||||
func NewFieldResources(telemetry []coretypes.ResourceWithID) ([]coretypes.ResourceWithID, error) {
|
||||
seen := make(map[string]struct{}, 3)
|
||||
resources := make([]coretypes.ResourceWithID, 0, 3)
|
||||
for _, ref := range telemetry {
|
||||
field, err := fieldResource(ref.Resource.Kind())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kind := field.Kind().String()
|
||||
if _, ok := seen[kind]; ok {
|
||||
continue
|
||||
}
|
||||
seen[kind] = struct{}{}
|
||||
resources = append(resources, coretypes.ResourceWithID{Resource: field})
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func fieldResource(kind coretypes.Kind) (coretypes.Resource, error) {
|
||||
switch kind {
|
||||
case coretypes.KindLogs, coretypes.KindAuditLogs:
|
||||
return coretypes.ResourceMetaResourceLogsField, nil
|
||||
case coretypes.KindTraces:
|
||||
return coretypes.ResourceMetaResourceTracesField, nil
|
||||
case coretypes.KindMetrics, coretypes.KindMeterMetrics:
|
||||
return coretypes.ResourceMetaResourceMetricsField, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "no field resource for telemetry kind %q", kind.String())
|
||||
}
|
||||
}
|
||||
86
pkg/types/ruletypes/resource_test.go
Normal file
86
pkg/types/ruletypes/resource_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewFieldResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
telemetry []coretypes.Resource
|
||||
wantKinds []coretypes.Kind
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Logs_LogsField",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceLogs},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "AuditLogs_LogsField",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceAuditLogs},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "Traces_TracesField",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceTraces},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindTracesField},
|
||||
},
|
||||
{
|
||||
name: "Metrics_MetricsField",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceMetrics},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "MeterMetrics_MetricsField",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceMeterMetrics},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "MetricsAndMeterMetrics_Deduped",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceMetrics, coretypes.ResourceTelemetryResourceMeterMetrics},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "AllSignals_AllThreeFields",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceTelemetryResourceLogs, coretypes.ResourceTelemetryResourceTraces, coretypes.ResourceTelemetryResourceMetrics},
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField, coretypes.KindTracesField, coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "Empty_Empty",
|
||||
wantKinds: []coretypes.Kind{},
|
||||
},
|
||||
{
|
||||
name: "FieldKind_Errors",
|
||||
telemetry: []coretypes.Resource{coretypes.ResourceMetaResourceLogsField},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
telemetry := make([]coretypes.ResourceWithID, 0, len(testCase.telemetry))
|
||||
for _, resource := range testCase.telemetry {
|
||||
telemetry = append(telemetry, coretypes.ResourceWithID{Resource: resource})
|
||||
}
|
||||
|
||||
resources, err := NewFieldResources(telemetry)
|
||||
|
||||
if testCase.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
kinds := make([]coretypes.Kind, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
kinds = append(kinds, resource.Resource.Kind())
|
||||
}
|
||||
assert.Equal(t, testCase.wantKinds, kinds)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -442,3 +442,55 @@ def test_builder_refreshing_a_sliding_time_range(
|
||||
assert uncached.status_code == HTTPStatus.OK, uncached.text
|
||||
|
||||
assert_results_equal(from_cache.json(), uncached.json(), "A", "the twentieth refresh")
|
||||
|
||||
|
||||
def test_builder_refreshing_does_not_inflate_rows_scanned(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
metric_name = f"cache_rows_scanned_{uuid4().hex[:8]}"
|
||||
|
||||
# 90 minutes back so even the last refresh closes clear of the flux
|
||||
# interval, which holds recent data out of the cache
|
||||
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=90)).timestamp()) // 60 * 60, tz=UTC)
|
||||
start_time_ms = int(start_time.timestamp() * 1000)
|
||||
|
||||
query = [build_builder_query("A", metric_name, "max", "max")]
|
||||
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=start_time + timedelta(minutes=minute),
|
||||
value=minute,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute in range(70)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# a dashboard left open on a thirty minute range, re-running a minute later
|
||||
# each time. rowsScanned is a cost indicator, so a refresh served mostly out
|
||||
# of the cache cannot claim more than every read made so far: the first
|
||||
# full read and then one new minute per refresh, measured with the cache off
|
||||
uncached_rows_scanned_so_far = 0
|
||||
for refresh in range(10):
|
||||
refresh_start_ms = start_time_ms + refresh * MINUTE_MS
|
||||
refresh_end_ms = refresh_start_ms + 30 * MINUTE_MS
|
||||
|
||||
uncached_start_ms = refresh_start_ms if refresh == 0 else refresh_end_ms - MINUTE_MS
|
||||
uncached_response = make_query_request(signoz, token, uncached_start_ms, refresh_end_ms, query, no_cache=True)
|
||||
assert uncached_response.status_code == HTTPStatus.OK, f"refresh {refresh}: uncached read failed: {uncached_response.text}"
|
||||
uncached_rows_scanned_so_far += uncached_response.json()["data"]["meta"]["rowsScanned"]
|
||||
assert uncached_rows_scanned_so_far > 0, f"refresh {refresh}: uncached read reported no rows scanned"
|
||||
|
||||
cached_response = make_query_request(signoz, token, refresh_start_ms, refresh_end_ms, query, no_cache=False)
|
||||
assert cached_response.status_code == HTTPStatus.OK, f"refresh {refresh}: cached read failed: {cached_response.text}"
|
||||
cached_rows_scanned = cached_response.json()["data"]["meta"]["rowsScanned"]
|
||||
assert cached_rows_scanned <= uncached_rows_scanned_so_far, f"refresh {refresh}: cached read reported {cached_rows_scanned} rows scanned, more than the {uncached_rows_scanned_so_far} rows all uncached reads scanned so far"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
@@ -898,6 +899,156 @@ def test_downtime_widening_to_all_rules_forbidden_without_wildcard_attach(
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_history_labels_need_telemetry_and_field_grants(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={"description": "", "transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS + [transaction_group("read", "metaresource", "rule", ["*"])]},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
|
||||
|
||||
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
|
||||
start_ms = end_ms - 30 * 60 * 1000
|
||||
history_url = f"{V2_RULES_URL}/{target_id}/history"
|
||||
rule_scoped = (
|
||||
(f"{history_url}/stats", {"start": start_ms, "end": end_ms}),
|
||||
(f"{history_url}/overall_status", {"start": start_ms, "end": end_ms}),
|
||||
)
|
||||
rule_and_telemetry_scoped = (
|
||||
(f"{history_url}/timeline", {"start": start_ms, "end": end_ms}),
|
||||
(f"{history_url}/top_contributors", {"start": start_ms, "end": end_ms}),
|
||||
)
|
||||
field_scoped = (
|
||||
(f"{history_url}/filter_keys", {"startUnixMilli": start_ms, "endUnixMilli": end_ms}),
|
||||
(f"{history_url}/filter_values", {"startUnixMilli": start_ms, "endUnixMilli": end_ms, "name": "severity"}),
|
||||
)
|
||||
label_scoped = rule_and_telemetry_scoped + field_scoped
|
||||
|
||||
for path, params in rule_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"GET {path}: expected 200, got {response.status_code}: {response.text}"
|
||||
|
||||
for path, params in label_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"GET {path}: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS
|
||||
+ [
|
||||
transaction_group("read", "telemetryresource", "metrics", ["builder_query/*"]),
|
||||
transaction_group("list", "metaresource", "metrics-field", ["*"]),
|
||||
transaction_group("read", "metaresource", "metrics-field", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
for path, params in label_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"GET {path}: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS
|
||||
+ [
|
||||
transaction_group("read", "metaresource", "rule", ["*"]),
|
||||
transaction_group("read", "telemetryresource", "logs", ["builder_query/*"]),
|
||||
transaction_group("list", "metaresource", "metrics-field", ["*"]),
|
||||
transaction_group("read", "metaresource", "metrics-field", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
for path, params in rule_and_telemetry_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"GET {path}: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
for path, params in field_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"GET {path}: expected 200, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS
|
||||
+ [
|
||||
transaction_group("read", "metaresource", "rule", ["*"]),
|
||||
transaction_group("read", "telemetryresource", "metrics", ["builder_query/*"]),
|
||||
transaction_group("list", "metaresource", "metrics-field", ["*"]),
|
||||
transaction_group("read", "metaresource", "metrics-field", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
for path, params in label_scoped:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"GET {path}: expected 200, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_alert_authz_cleanup(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
|
||||
Reference in New Issue
Block a user