Compare commits

..

8 Commits

Author SHA1 Message Date
aks07
013aef1c85 refactor(quick-filters): use AuthZButton for the settings gear 2026-09-10 00:13:20 +05:30
aks07
f3c95c2593 fix(quick-filters): keep header action icons in the previous colour 2026-09-10 00:13:18 +05:30
aks07
dd866dc917 fix(quick-filters): show the settings gear whenever a signal is passed 2026-09-10 00:13:15 +05:30
aks07
f5adafcb11 chore(quick-filters): drop unused settings announcement localStorage key 2026-09-09 16:01:37 +05:30
aks07
72baa61711 feat(quick-filters): gate quick filters on quick-filter read and update
Filters content is behind read, settings gear behind read + update,
replacing the admin role check. Header actions moved to the ui Button so
AuthZTooltip can actually disable the gear. Dropped the old quick filters
settings announcement from the gear.
2026-09-09 16:01:37 +05:30
aks07
ec4512ae6a feat(authz): add quick-filter permission constants 2026-09-09 16:01:37 +05:30
nityanandagohain
6e7a2c1e1e chore: quickfilter authz 2026-09-09 16:01:37 +05:30
Gaurav Tewari
99dcd79979 chore(llm-observability): fork shared traces explorer files (#12795)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Copies, unmodified, the traces explorer files that the AI
observability explorer is about to diverge from. The point is the *next*
PR (#12796): with this baseline landed, that diff shows only the real
changes instead of ~1000 lines of brand-new files.
- No behaviour change. Nothing outside the fork imports the new copies
yet — the explorer's views are wired to them in #12796, alongside the
changes that need them.


Sources, all taken from this PR's merge base:

| Copied to `container/LLMObservability/` | Source |
| --- | --- |
| `ToolbarActions/LeftToolbarActions.tsx` |
`container/QueryBuilder/components/ToolbarActions/LeftToolbarActions.tsx`
|
| `ToolbarActions/ToolbarActions.styles.scss` |
`container/QueryBuilder/components/ToolbarActions/ToolbarActions.styles.scss`
|
| `Explorer/aiActions.ts` | `pages/TracesExplorer/aiActions.ts` |
| `Explorer/Controls/*` | `container/TracesExplorer/Controls/*` |
| `Explorer/TraceLoading/*` | `container/TracesExplorer/TraceLoading/*`
|
| `Explorer/TracesTable/*` | `container/TracesExplorer/TracesTable/*` |
| `Explorer/ListView/utils.tsx` |
`container/TracesExplorer/ListView/utils.tsx` |

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-09 09:40:13 +00:00
29 changed files with 1400 additions and 560 deletions

View File

@@ -102,6 +102,7 @@ func runGenerateAuthz(_ context.Context) error {
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
}
allowedTypes := map[string]bool{}

View File

@@ -20,7 +20,6 @@ export const Logout = async (): Promise<void> => {
deleteLocalStorageKey(LOCALSTORAGE.LOGGED_IN_USER_NAME);
deleteLocalStorageKey(LOCALSTORAGE.CHAT_SUPPORT);
deleteLocalStorageKey(LOCALSTORAGE.USER_ID);
deleteLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT);
window.dispatchEvent(new CustomEvent('LOGOUT'));
history.push(ROUTES.LOGIN);
};

View File

@@ -95,6 +95,7 @@
gap: 10px;
width: 100%;
justify-content: flex-end;
--button-variant-link-color: var(--l1-foreground);
.divider-filter {
width: 1px;

View File

@@ -15,28 +15,30 @@ import {
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { Skeleton, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isFunction, isNull } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
QuickFilterManagePermissions,
QuickFilterReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { USER_ROLES } from 'types/roles';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
import Duration from './FilterRenderers/Duration/Duration';
import Slider from './FilterRenderers/Slider/Slider';
import useFilterConfig from './hooks/useFilterConfig';
import AnnouncementTooltip from './QuickFiltersSettings/AnnouncementTooltip';
import QuickFiltersSettings from './QuickFiltersSettings/QuickFiltersSettings';
import { FiltersType, IQuickFiltersProps, QuickFiltersSource } from './types';
@@ -55,9 +57,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
showQueryName = true,
useFieldApis,
} = props;
const { user } = useAppContext();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const isAdmin = user.role === USER_ROLES.ADMIN;
const [params, setParams] = useApiMonitoringParams();
const showIP = params.showIP ?? true;
@@ -68,6 +68,12 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
refetchCustomFilters,
isCustomFiltersLoading,
} = useFilterConfig({ signal, config });
const {
deniedPermissions: deniedSettingsPermissions,
isLoading: isSettingsChecking,
} = useAuthZ(QuickFilterManagePermissions, { enabled: isDynamicFilters });
const isSettingsDisabled =
isSettingsChecking || deniedSettingsPermissions.length > 0;
const {
currentQuery,
@@ -107,16 +113,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const shouldShowDropdownInListView =
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
const showAnnouncementTooltip = useMemo(() => {
const localStorageValue = getLocalStorageKey(
LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT,
);
if (!isNull(localStorageValue)) {
return !(localStorageValue === 'false');
}
return true;
}, []);
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
@@ -232,49 +228,49 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const renderRightActions = (): JSX.Element => (
<section className="right-actions">
<Tooltip title="Reset All">
<div className="right-action-icon-container">
<RefreshCw className="sync-icon" size="md" onClick={handleReset} />
</div>
<Button
variant="link"
color="secondary"
aria-label="Reset All"
className="right-action-icon-container"
onClick={handleReset}
prefix={<RefreshCw className="sync-icon" size="md" />}
/>
</Tooltip>
{showFilterCollapse && (
<Tooltip title="Collapse Filters">
<div className="right-action-icon-container">
<ArrowUpToLine
style={{ rotate: '270deg', cursor: 'pointer' }}
size="md"
onClick={handleFilterVisibilityChange}
/>
</div>
<Button
variant="link"
color="secondary"
aria-label="Collapse Filters"
className="right-action-icon-container"
onClick={handleFilterVisibilityChange}
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
/>
</Tooltip>
)}
{isDynamicFilters && isAdmin && (
<Tooltip title="Settings">
<div
className={classNames('right-action-icon-container', {
active: isSettingsOpen,
})}
>
<SettingsIcon
className="settings-icon"
data-testid="settings-icon"
width={14}
height={14}
onClick={(): void => setIsSettingsOpen(true)}
/>
<AnnouncementTooltip
show={showAnnouncementTooltip}
position={{ top: -5, left: 15 }}
title="Edit your quick filters"
message="You can now customize and re-arrange your quick filters panel. Select the quick filters youd need and hide away the rest for faster exploration."
onClose={(): void => {
setLocalStorageKey(
LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT,
'false',
);
}}
/>
</div>
</Tooltip>
{isDynamicFilters && (
<AuthZButton
checks={QuickFilterManagePermissions}
variant="link"
color="secondary"
aria-label="Settings"
className={classNames('right-action-icon-container', {
active: isSettingsOpen,
})}
onClick={(): void => setIsSettingsOpen(true)}
testId="settings-icon-container"
prefix={
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
<SettingsIcon
className="settings-icon"
data-testid="settings-icon"
width={14}
height={14}
/>
</Tooltip>
}
/>
)}
</section>
);
@@ -361,6 +357,21 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
</>
);
const filtersSkeleton = (
<div className="quick-filters-skeleton">
{Array.from({ length: 5 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={index} />
))}
</div>
);
const filtersContent = isCustomFiltersLoading ? (
filtersSkeleton
) : (
<OverlayScrollbar>{renderContent()}</OverlayScrollbar>
);
return (
<div className="quick-filters-container">
<div className="quick-filters">
@@ -371,15 +382,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
</section>
)}
{isCustomFiltersLoading ? (
<div className="quick-filters-skeleton">
{Array.from({ length: 5 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={index} />
))}
</div>
{signal ? (
<AuthZGuardContent
checks={[QuickFilterReadPermission]}
fallbackOnLoading={filtersSkeleton}
>
{filtersContent}
</AuthZGuardContent>
) : (
<OverlayScrollbar>{renderContent()}</OverlayScrollbar>
filtersContent
)}
</div>
<div className="quick-filters-settings-container">

View File

@@ -35,10 +35,7 @@ const useFilterConfig = ({
[data],
);
const isDynamicFilters = useMemo(
() => customFilters.length > 0,
[customFilters],
);
const isDynamicFilters = !!signal;
const filterConfig = useMemo(
() => getFilterConfig(signal, customFilters, config),

View File

@@ -0,0 +1,212 @@
import { ENVIRONMENT } from 'constants/env';
import {
ApiMonitoringParams,
useApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
QuickFilterReadPermission,
QuickFilterUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
import {
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import {
otherFiltersResponse,
quickFiltersAttributeValuesResponse,
quickFiltersListResponse,
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import QuickFilters from '../QuickFilters';
import { QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersConfig } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('container/ApiMonitoring/queryParams');
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const fieldsKeysURL = `${BASE_URL}/api/v1/fields/keys`;
const attributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
const NOT_AUTHORIZED_TEXT = /is not authorized to perform/i;
const FILTER_SERVICE_NAME = 'Service Name';
const SETTINGS_CONTAINER_TEST_ID = 'settings-icon-container';
beforeEach(() => {
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: 'Test Query',
filters: { items: [] },
},
],
},
},
lastUsedQuery: 0,
redirectWithQueryBuilderData: jest.fn(),
});
mockUseApiMonitoringParams.mockReturnValue([
{ showIP: true } as ApiMonitoringParams,
jest.fn(),
]);
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersListResponse)),
),
rest.get(fieldsKeysURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(otherFiltersResponse)),
),
rest.get(attributeValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
rest.get(fieldsValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
function renderWithSignal(): void {
render(
<QuickFilters
source={QuickFiltersSource.LOGS_EXPLORER}
signal={SIGNAL}
handleFilterVisibilityChange={jest.fn()}
/>,
);
}
function renderStaticConfig(): void {
render(
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
config={QuickFiltersConfig}
handleFilterVisibilityChange={jest.fn()}
/>,
);
}
describe('QuickFilters - AuthZ', () => {
describe('read denied', () => {
it('shows the inline denial instead of the filters, header stays', async () => {
server.use(setupAuthzDeny(QuickFilterReadPermission));
renderWithSignal();
await expect(
screen.findByText(NOT_AUTHORIZED_TEXT),
).resolves.toBeInTheDocument();
expect(screen.queryByText(FILTER_SERVICE_NAME)).not.toBeInTheDocument();
expect(screen.getByText('Filters for')).toBeInTheDocument();
const settingsTrigger = await screen.findByTestId(
SETTINGS_CONTAINER_TEST_ID,
);
await waitFor(() =>
expect(settingsTrigger).toHaveAttribute(
'data-denied-permissions',
expect.stringContaining('read'),
),
);
});
});
describe('update denied', () => {
it('renders the filters but disables the settings trigger', async () => {
server.use(setupAuthzDeny(QuickFilterUpdatePermission));
renderWithSignal();
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
const settingsTrigger = await screen.findByTestId(
SETTINGS_CONTAINER_TEST_ID,
);
await waitFor(() =>
expect(settingsTrigger).toHaveAttribute(
'data-denied-permissions',
expect.stringContaining('update'),
),
);
await userEvent.click(settingsTrigger);
expect(screen.queryByText(/ADDED FILTERS/i)).not.toBeInTheDocument();
});
});
describe('all permissions granted', () => {
it('renders the filters and opens settings from the trigger', async () => {
server.use(setupAuthzAdmin());
renderWithSignal();
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
const settingsTrigger = await screen.findByTestId(
SETTINGS_CONTAINER_TEST_ID,
);
expect(settingsTrigger).not.toHaveAttribute('data-denied-permissions');
await userEvent.click(settingsTrigger);
await expect(
screen.findByText(/ADDED FILTERS/i),
).resolves.toBeInTheDocument();
});
});
describe('static config pages (no signal)', () => {
it('is not gated even when every permission is denied', async () => {
server.use(setupAuthzDenyAll());
renderStaticConfig();
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
expect(screen.queryByText(NOT_AUTHORIZED_TEXT)).not.toBeInTheDocument();
});
});
describe('permission check loading', () => {
it('shows the skeleton, not the filters or a denial', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
);
renderWithSignal();
await waitFor(() =>
// eslint-disable-next-line testing-library/no-node-access
expect(
document.querySelector('.quick-filters-skeleton'),
).toBeInTheDocument(),
);
expect(screen.queryByText(NOT_AUTHORIZED_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(FILTER_SERVICE_NAME)).not.toBeInTheDocument();
});
});
});

View File

@@ -54,6 +54,7 @@ export interface IQuickFiltersProps {
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
/** Pass to fetch quick filters for this signal; omit to use `config` as-is */
signal?: SignalType;
className?: string;
showFilterCollapse?: boolean;

View File

@@ -31,7 +31,6 @@ export enum LOCALSTORAGE {
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',

View File

@@ -0,0 +1,14 @@
.container {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
margin: 8px 0;
}
.optionsTrigger {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}

View File

@@ -0,0 +1,82 @@
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings } from '@signozhq/icons';
import FieldsSelector from 'components/FieldsSelector';
import Controls, { ControlsProps } from 'container/Controls';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
function TraceExplorerControls({
isLoading,
totalCount,
perPageOptions,
config,
showSizeChanger = true,
}: TraceExplorerControlsProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
const {
pagination,
handleCountItemsPerPageChange,
handleNavigateNext,
handleNavigatePrevious,
} = useQueryPagination(totalCount, perPageOptions);
return (
<div className={styles.container}>
{config?.fieldsSelector && (
<>
<div
className={styles.optionsTrigger}
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
{t('options_menu.options')}
<Settings size="md" />
</div>
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
fields={config.fieldsSelector.value}
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
/>
</>
)}
<Controls
isLoading={isLoading}
totalCount={totalCount}
offset={pagination.offset}
countPerPage={pagination.limit}
perPageOptions={perPageOptions}
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
handleNavigateNext={handleNavigateNext}
handleNavigatePrevious={handleNavigatePrevious}
showSizeChanger={showSizeChanger}
/>
</div>
);
}
TraceExplorerControls.defaultProps = {
config: null,
};
type TraceExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
};
TraceExplorerControls.defaultProps = {
showSizeChanger: true,
};
export default memo(TraceExplorerControls);

View File

@@ -0,0 +1,168 @@
import { Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { ILog } from 'types/api/logs/log';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export function BlockLink({
children,
to,
openInNewTab,
}: {
children: React.ReactNode;
to: string;
openInNewTab: boolean;
}): any {
// Display block to make the whole cell clickable
return (
<Link
to={to}
style={{ display: 'block' }}
target={openInNewTab ? '_blank' : '_self'}
>
{children}
</Link>
);
}
export const transformDataWithDate = (
data: QueryDataV3[],
): Omit<ILog, 'timestamp'>[] =>
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return '';
}
const traceId = readId(record.traceID) || readId(record.trace_id);
const spanId = readId(record.spanID) || readId(record.span_id);
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
spanId,
levelUp: 0,
levelDown: 0,
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string | number,
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [
{
dataIndex: 'date',
key: 'date',
title: 'Timestamp',
width: 145,
render: (value, item): JSX.Element => {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
value / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
},
},
];
const columns: ColumnsType<RowData> =
selectedColumns.map((props) => {
const name = props?.name || (props as any)?.key;
const fieldContext = props?.fieldContext || (props as any)?.type;
return {
title: name,
dataIndex: name,
key: buildCompositeKey(name, fieldContext),
width: 145,
render: (value, item): JSX.Element => {
if (value === '') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>N/A</Typography>
</BlockLink>
);
}
if (
name === 'httpMethod' ||
name === 'responseStatusCode' ||
name === 'response_status_code' ||
name === 'http_method'
) {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Badge data-testid={name} color="sakura" variant="outline">
{value}
</Badge>
</BlockLink>
);
}
if (name === 'durationNano' || name === 'duration_nano') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>{getMs(value)}ms</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>
<LineClampedText text={value} lines={3} />
</Typography>
</BlockLink>
);
},
responsive: ['md'],
};
}) || [];
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.
export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
const list = data[0]?.list;
if (!list) {
return [];
}
return list.map((item) => {
const row = item.data as Record<string, unknown>;
return {
...row,
timestamp: item.timestamp,
id: row.span_id,
};
}) as TracesTableRow[];
};

View File

@@ -0,0 +1,19 @@
.loading-traces {
padding: 24px 0;
height: 240px;
display: flex;
justify-content: center;
align-items: flex-start;
.loading-traces-content {
display: flex;
align-items: flex-start;
flex-direction: column;
.loading-gif {
height: 72px;
margin-left: -24px;
}
}
}

View File

@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next';
import { Typography } from '@signozhq/ui/typography';
import { DataSource } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import './TraceLoading.styles.scss';
export function TracesLoading(): JSX.Element {
const { t } = useTranslation('common');
return (
<div className="loading-traces">
<div className="loading-traces-content">
<img className="loading-gif" src={loadingPlaneUrl} alt="wait-icon" />
<Typography>
{t('pending_data_placeholder', { dataSource: DataSource.TRACES })}
</Typography>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { generatePath, Link } from 'react-router-dom';
import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useTimezone } from 'providers/Timezone';
import {
DURATION_FIELD_NAMES,
STATUS_FIELD_NAMES,
TIMESTAMP_FIELD_NAMES,
TRACE_ID_FIELD_NAMES,
} from './constants';
import { stringifyCellValue } from './utils';
type FieldCellProps = {
name: string;
value: unknown;
};
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (TIMESTAMP_FIELD_NAMES.has(name)) {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
const text = String(formatted);
return <TanStackTable.Text title={text}>{text}</TanStackTable.Text>;
}
if (value === '' || value == null) {
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
}
const text = stringifyCellValue(value);
if (TRACE_ID_FIELD_NAMES.has(name)) {
return (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
>
{text}
</Link>
);
}
if (STATUS_FIELD_NAMES.has(name)) {
return (
<Badge data-testid={name} color="sakura" variant="outline">
{text}
</Badge>
);
}
if (DURATION_FIELD_NAMES.has(name)) {
return (
<TanStackTable.Text data-testid={name}>{getMs(text)}ms</TanStackTable.Text>
);
}
return (
<TanStackTable.Text data-testid={name} title={text}>
{text}
</TanStackTable.Text>
);
}
export default FieldCell;

View File

@@ -0,0 +1,26 @@
.tableWrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.tracesTable {
--tanstack-table-row-height: 54px;
--tanstack-table-header-height: 54px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-right-override: 15px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-header-padding-left-override: 5px;
--tanstack-cell-header-padding-left-first-column: 15px;
--tanstack-plain-body-line-clamp: 1;
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-header-cell-bg: var(--l1-background-hover);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
}

View File

@@ -0,0 +1,116 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import TanStackTable from 'components/TanStackTableView';
import type {
CellTypographySize,
TableColumnDef,
} from 'components/TanStackTableView/types';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import APIError from 'types/api/error';
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import type { TracesTableRow } from './getFieldColumn';
import styles from './TracesTable.module.scss';
export type TracesTableProps = {
data: TracesTableRow[];
columns: TableColumnDef<TracesTableRow>[];
columnStorageKey?: string;
respectColumnOrder?: boolean;
panelType: PanelTypeKeys;
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
getRowHref: (row: TracesTableRow) => string;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: APIError | Error | null;
isFilterApplied: boolean;
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove?: (columnId: string) => void;
cellTypographySize?: CellTypographySize;
};
function TracesTable({
data,
columns,
columnStorageKey,
respectColumnOrder = false,
panelType,
getRowHref,
isLoading,
isFetching,
isError,
error,
isFilterApplied,
onColumnOrderChange,
onColumnRemove,
cellTypographySize = 'medium',
}: TracesTableProps): JSX.Element {
const history = useHistory();
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
},
[history, getRowHref],
);
const handleRowClickNewTab = useCallback(
(row: TracesTableRow): void => {
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
},
[getRowHref],
);
return (
<>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && data.length === 0)) && <TracesLoading />}
{isDataAbsent && !isFilterApplied && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isDataAbsent && isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{!isError && data.length !== 0 && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}
columns={columns}
className={styles.tracesTable}
columnStorageKey={columnStorageKey}
respectColumnOrder={respectColumnOrder}
isLoading={isFetching}
cellTypographySize={cellTypographySize}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
/>
</div>
)}
</>
);
}
TracesTable.defaultProps = {
columnStorageKey: undefined,
respectColumnOrder: false,
onColumnOrderChange: undefined,
onColumnRemove: undefined,
cellTypographySize: 'medium',
};
export default TracesTable;

View File

@@ -0,0 +1,18 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
'http_method',
'http.method',
'http.request.method',
'responseStatusCode',
'response_status_code',
'http.status_code',
'http.response.status_code',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -0,0 +1,26 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
export type TracesTableRow = { id: string } & Record<string, unknown>;
export function getFieldColumn(
field: TelemetryFieldKey,
): TableColumnDef<TracesTableRow> {
const { name, fieldContext, fieldDataType } = field;
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
return {
id: buildCompositeKey(name, fieldContext, fieldDataType),
header: name,
accessorFn: (row): unknown => row[name],
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,
width: { min: 192 },
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
};
}

View File

@@ -0,0 +1,12 @@
export function stringifyCellValue(value: unknown): string {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}

View File

@@ -1,9 +1,6 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -0,0 +1,235 @@
/**
* AI Assistant page-action factories for the Traces Explorer.
*
* Mirrors the logs equivalents — each factory closes over live page
* state/callbacks so `execute()` always operates on the current query, and
* the page component instantiates them via `useMemo` + `usePageActions`.
*
* See `pages/LogsExplorer/aiActions.ts` for the rationale behind writing
* BOTH `filters.items` and `filter.expression` and then re-using the same
* URL parser shape via `redirectWithQueryBuilderData`.
*/
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import {
aiFilterToTagFilterItem,
FILTER_OP_ENUM,
FILTER_VALUE_DESCRIPTION,
FilterDeps,
replaceFirstQueryData,
} from 'container/AIAssistant/pageActions/builderQueryHelpers';
import {
ActionResult,
PageAction,
} from 'container/AIAssistant/pageActions/types';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
interface AIFilter {
key: string;
op: string;
value: string;
}
interface RunQueryParams {
filters: AIFilter[];
}
interface AddFilterParams {
key: string;
op: string;
value: string;
}
type TracesView = 'list' | 'timeseries' | 'table' | 'trace';
interface ChangeViewParams {
view: TracesView;
}
interface SaveViewParams {
name: string;
}
/**
* Replace all active span filters and navigate to the updated query URL
* (which makes the WHERE clause reflect the new filters and triggers a re-run).
*/
export function tracesRunQueryAction(
deps: FilterDeps,
): PageAction<RunQueryParams> {
return {
id: 'traces.runQuery',
description: 'Replace the active trace filters and re-run the query',
parameters: {
type: 'object',
properties: {
filters: {
type: 'array',
description: 'Replacement filter list',
items: {
type: 'object',
properties: {
key: {
type: 'string',
description: 'Attribute key, e.g. service.name, http.status_code',
},
op: {
type: 'string',
enum: [...FILTER_OP_ENUM],
},
value: {
type: 'string',
description: FILTER_VALUE_DESCRIPTION,
},
},
required: ['key', 'op', 'value'],
},
},
},
required: ['filters'],
},
autoApply: true,
execute: async ({ filters }): Promise<ActionResult> => {
const baseQuery = deps.currentQuery.builder.queryData[0];
if (!baseQuery) {
throw new Error('No active query found in Traces Explorer.');
}
const tagItems = filters.map(aiFilterToTagFilterItem);
const newFilters = { items: tagItems, op: 'AND' };
const updatedBuilderQuery: IBuilderQuery = {
...baseQuery,
filters: newFilters,
filter: convertFiltersToExpression(newFilters),
};
deps.handleSetQueryData(0, updatedBuilderQuery);
deps.redirectWithQueryBuilderData(
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
);
return {
summary: `Query updated with ${filters.length} filter(s) and re-run.`,
};
},
getContext: (): Record<string, unknown> => ({
filters:
deps.currentQuery.builder.queryData[0]?.filters?.items?.map(
(f: TagFilterItem) => ({
key: f.key?.key,
op: f.op,
value: f.value,
}),
) ?? [],
}),
};
}
/**
* Append a single filter to the existing trace query and navigate to the
* updated URL.
*/
export function tracesAddFilterAction(
deps: FilterDeps,
): PageAction<AddFilterParams> {
return {
id: 'traces.addFilter',
description: 'Add a single filter to the current trace query and re-run',
parameters: {
type: 'object',
properties: {
key: {
type: 'string',
description: 'Attribute key, e.g. service.name, http.status_code',
},
op: {
type: 'string',
enum: [...FILTER_OP_ENUM],
},
value: {
type: 'string',
description: FILTER_VALUE_DESCRIPTION,
},
},
required: ['key', 'op', 'value'],
},
autoApply: true,
execute: async ({ key, op, value }): Promise<ActionResult> => {
const baseQuery = deps.currentQuery.builder.queryData[0];
if (!baseQuery) {
throw new Error('No active query found in Traces Explorer.');
}
const existing = baseQuery.filters?.items ?? [];
const newItem = aiFilterToTagFilterItem({ key, op, value });
const newFilters = { items: [...existing, newItem], op: 'AND' };
const updatedBuilderQuery: IBuilderQuery = {
...baseQuery,
filters: newFilters,
filter: convertFiltersToExpression(newFilters),
};
deps.handleSetQueryData(0, updatedBuilderQuery);
deps.redirectWithQueryBuilderData(
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
);
return { summary: `Filter added: ${key} ${op} "${value}". Query re-run.` };
},
};
}
/**
* Switch the traces explorer between list / timeseries / table / trace views.
*/
export function tracesChangeViewAction(deps: {
onChangeView: (view: TracesView) => void;
}): PageAction<ChangeViewParams> {
return {
id: 'traces.changeView',
description:
'Switch the Traces Explorer between list, timeseries, table, and trace views',
parameters: {
type: 'object',
properties: {
view: {
type: 'string',
enum: ['list', 'timeseries', 'table', 'trace'],
description: 'The panel view to switch to',
},
},
required: ['view'],
},
execute: async ({ view }): Promise<ActionResult> => {
deps.onChangeView(view);
return { summary: `Switched to the "${view}" view.` };
},
};
}
/**
* Save the current trace query as a named view (stub — wires to real API
* when available).
*/
export function tracesSaveViewAction(deps: {
onSaveView: (name: string) => Promise<void>;
}): PageAction<SaveViewParams> {
return {
id: 'traces.saveView',
description: 'Save the current trace query as a named view',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name for the saved view' },
},
required: ['name'],
},
execute: async ({ name }): Promise<ActionResult> => {
await deps.onSaveView(name);
return { summary: `View "${name}" saved.` };
},
};
}

View File

@@ -0,0 +1,132 @@
import {
ArrowUpToLine,
Atom,
Filter,
SquareMousePointer,
Terminal,
Binoculars,
} from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import './ToolbarActions.styles.scss';
interface LeftToolbarActionsProps {
items: any;
selectedView: string;
onChangeSelectedView: (view: ExplorerViews) => void;
showFilter: boolean;
handleFilterVisibilityChange: () => void;
}
const activeTab = 'active-tab';
export default function LeftToolbarActions({
items,
selectedView,
onChangeSelectedView,
showFilter,
handleFilterVisibilityChange,
}: LeftToolbarActionsProps): JSX.Element {
const { clickhouse, list, timeseries, table, trace } = items;
return (
<div className="left-toolbar">
{!showFilter && (
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<Filter size={12} />
<ArrowUpToLine size={12} style={{ transform: 'rotate(90deg)' }} />
</Button>
</Tooltip>
)}
<div className="left-toolbar-query-actions">
{list?.show && (
<Tooltip title="List View">
<Button
disabled={list.disabled}
className={cx(
'list-view-tab',
'explorer-view-option',
selectedView === list.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(list.key)}
>
<SquareMousePointer size={14} data-testid="search-view" />
List View
</Button>
</Tooltip>
)}
{trace?.show && (
<Tooltip title="Trace View">
<Button
disabled={trace.disabled}
className={cx(
'trace-view-tab',
'explorer-view-option',
selectedView === trace.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(trace.key)}
>
<SquareMousePointer size={14} data-testid="trace-view" />
Trace View
</Button>
</Tooltip>
)}
{timeseries?.show && (
<Tooltip title="Time Series">
<Button
disabled={timeseries.disabled}
className={cx(
'timeseries-view-tab',
'explorer-view-option',
selectedView === timeseries.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(timeseries.key)}
>
<Atom size={14} data-testid="query-builder-view" />
Time Series
</Button>
</Tooltip>
)}
{clickhouse?.show && (
<Tooltip title="Clickhouse">
<Button
disabled={clickhouse.disabled}
className={cx(
'clickhouse-view-tab',
'explorer-view-option',
selectedView === clickhouse.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(clickhouse.key)}
>
<Terminal size={14} data-testid="clickhouse-view" />
Clickhouse
</Button>
</Tooltip>
)}
{table?.show && (
<Tooltip title="Table">
<Button
disabled={table.disabled}
className={cx(
'table-view-tab',
'explorer-view-option',
selectedView === table.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(table.key)}
>
<Binoculars size={14} data-testid="query-builder-view-v2" />
Table
</Button>
</Tooltip>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
.left-toolbar {
display: flex;
align-items: center;
.filter-btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
height: 32px;
margin-right: 12px;
border: 1px solid var(--l1-border);
}
.left-toolbar-query-actions {
display: flex;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
flex-direction: row;
border-bottom: none;
margin-bottom: -1px;
.prom-ql-icon {
height: 14px;
width: 14px;
}
.explorer-view-option {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
border: none;
padding: 9px;
box-shadow: none;
border-radius: 0px;
border-left: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
gap: 8px;
&.active-tab {
background-color: var(--primary-background);
border-bottom: 1px solid var(--primary-background);
color: var(--primary-foreground);
&:hover {
background-color: var(--primary-background) !important;
}
}
&:disabled {
background-color: var(--l3-background);
opacity: 0.6;
}
&:first-child {
border-left: 1px solid transparent;
}
&:hover {
background-color: transparent !important;
border-left: 1px solid transparent !important;
color: var(--l1-foreground);
}
}
}
.frequency-chart-view-controller {
display: flex;
align-items: center;
padding-left: 8px;
gap: 8px;
}
}
.right-toolbar {
display: flex;
align-items: center;
background-color: var(--bg-robin-600);
}
.right-actions {
display: flex;
align-items: center;
}
.loading-container {
display: flex;
gap: 8px;
align-items: center;
.loading-btn {
display: flex;
width: 32px;
height: 33px;
padding: 4px 10px;
justify-content: center;
align-items: center;
gap: 6px;
flex-shrink: 0;
border-radius: 2px;
background: var(--l3-background);
box-shadow: none;
border: none;
}
.cancel-run {
display: flex;
height: 33px;
padding: 4px 10px;
justify-content: center;
align-items: center;
gap: 6px;
flex: 1 0 0;
border-radius: 2px;
background: var(--danger-background);
border: none;
}
.cancel-run:hover {
background-color: var(--bg-cherry-400) !important;
color: var(--l1-foreground) !important;
}
}

View File

@@ -329,13 +329,14 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(9);
expect(result).toHaveLength(10);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'license',
'logs',
'meter-metrics',
'metrics',
'quick-filter',
'role',
'serviceaccount',
'subscription',
@@ -420,13 +421,14 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(9);
expect(result).toHaveLength(10);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'license',
'logs',
'meter-metrics',
'metrics',
'quick-filter',
'role',
'serviceaccount',
'subscription',

View File

@@ -5,6 +5,7 @@ import {
FileKey,
Gauge,
Key,
ListFilter,
Logs,
Receipt,
Shield,
@@ -77,6 +78,14 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
selectorPlaceholder: 'Type * to cover the workspace subscription',
docsAnchor: 'subscription',
},
'quick-filter': {
label: 'Quick Filters',
description: 'Quick filters shown in the logs, traces, and other explorers.',
icon: ListFilter,
selectorPlaceholder:
'Type quick filter ID, separate multiple with comma or space',
docsAnchor: 'quick-filter',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',

View File

@@ -13,6 +13,11 @@ export default {
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'quick-filter',
type: 'metaresource',
allowedVerbs: ['list', 'read', 'update'],
},
{
kind: 'subscription',
type: 'metaresource',

View File

@@ -0,0 +1,16 @@
import { buildPermission } from '../utils';
export const QuickFilterReadPermission = buildPermission(
'read',
'quick-filter:*',
);
export const QuickFilterUpdatePermission = buildPermission(
'update',
'quick-filter:*',
);
// Editing quick filters needs read as well as update
export const QuickFilterManagePermissions = [
QuickFilterReadPermission,
QuickFilterUpdatePermission,
];

View File

@@ -1,4 +1,4 @@
"""Seed data for the queriercommon keyless-semantics and explicit-context tests.
"""Seed data for the queriercommon keyless-semantics tests.
Three identities exist in every signal. GOLD and SILVER carry the test keys.
NONE carries no key at all. The tests assert which identities a filter
@@ -8,7 +8,6 @@ The attribute names are outside every semantic-convention family, so the
seeded data pins base behavior with any semconv overlay state.
"""
import json
from collections.abc import Callable, Generator
from datetime import UTC, datetime, timedelta
@@ -123,103 +122,3 @@ def keyless_series(insert_metrics: Callable[[list[Metrics]], None]) -> Generator
]
)
yield start, start + points * 60
EXPLICIT_PREFIX = "explicit-ctx"
# String attribute that identifies the row. It has one context only. Each
# assertion reads it back.
IDENTITY_KEY = "probe.id"
# Attribute with no column of the same name. It tests a key under the
# signal's own context that metadata does not know. On logs, the rows without
# the attribute have the value nested in the body JSON.
ATTRIBUTE_ONLY_KEY = "route.tag"
CONTESTED_VALUE = "checkout"
# Row identities. Each row shows where the contested value is:
# - COLUMN_ONLY: in the column (`name` on spans, `severity_text` on logs).
# - ATTRIBUTE_ONLY: in the string attribute with the same name.
# - BOTH: in the column and in the string attribute.
# - NEITHER: in none of them.
# - NUMBER_ATTRIBUTE: in a number attribute with the same name. Its data
# type is different from the column.
COLUMN_ONLY = f"{EXPLICIT_PREFIX}-column"
ATTRIBUTE_ONLY = f"{EXPLICIT_PREFIX}-attribute"
BOTH = f"{EXPLICIT_PREFIX}-both"
NEITHER = f"{EXPLICIT_PREFIX}-neither"
NUMBER_ATTRIBUTE = f"{EXPLICIT_PREFIX}-number"
NUMBER_VALUE = 42
# (identity, value in the column, value in the string attribute, value in
# the number attribute, resource service.name, attribute service.name,
# has route.tag, insert offset in seconds)
ROWS = [
(COLUMN_ONLY, True, False, False, "svc-a", None, True, 1),
(ATTRIBUTE_ONLY, False, True, False, "svc-b", "svc-a", False, 2),
(BOTH, True, True, False, "svc-a", "svc-a", True, 3),
(NEITHER, False, False, False, "svc-b", "svc-b", False, 4),
(NUMBER_ATTRIBUTE, False, False, True, "svc-b", None, False, 5),
]
# Logs only. The scope name is a declared path. A scope attribute also has
# the name `name`. A second scope attribute has a plain name.
SCOPE_NAME = "scope-a"
SCOPE_ATTRIBUTE_KEY = "env"
SCOPE_ATTRIBUTE_VALUE = "prod"
@pytest.fixture(name="ambiguous_rows", scope="function")
def ambiguous_rows(
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> Generator[datetime]:
"""Inserts one span and one log for each identity. Every row has a
resource `service.name`. Some rows also have a span or log attribute
`service.name` with a different value. On logs, the rows without the
`route.tag` attribute have the value in the body JSON. Logs with the
column value have the scope name. Logs with the attribute value have the
scope attributes. Yields the base timestamp."""
now = datetime.now(tz=UTC).replace(microsecond=0) - timedelta(minutes=1)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=offset),
duration=timedelta(milliseconds=10),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name=CONTESTED_VALUE if column else "other",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": resource_service},
attributes={
IDENTITY_KEY: identity,
**({"name": CONTESTED_VALUE} if attribute else {}),
**({"name": NUMBER_VALUE} if number else {}),
**({"service.name": attribute_service} if attribute_service else {}),
**({ATTRIBUTE_ONLY_KEY: CONTESTED_VALUE} if tagged else {}),
},
)
for identity, column, attribute, number, resource_service, attribute_service, tagged, offset in ROWS
]
)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=offset),
body=json.dumps({} if tagged else {"route": {"tag": CONTESTED_VALUE}}),
severity_text="ERROR" if column else "INFO",
scope_name=SCOPE_NAME if column else "",
scope_attributes={"name": CONTESTED_VALUE, SCOPE_ATTRIBUTE_KEY: SCOPE_ATTRIBUTE_VALUE} if attribute else {},
resources={"service.name": resource_service},
attributes={
IDENTITY_KEY: identity,
**({"severity_text": "ERROR"} if attribute else {}),
**({"severity_text": NUMBER_VALUE} if number else {}),
**({"service.name": attribute_service} if attribute_service else {}),
**({ATTRIBUTE_ONLY_KEY: CONTESTED_VALUE} if tagged else {}),
},
)
for identity, column, attribute, number, resource_service, attribute_service, tagged, offset in ROWS
]
)
yield now

View File

@@ -1,381 +0,0 @@
from collections.abc import Callable
from datetime import datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
RequestType,
assert_scalar_value,
build_aggregation,
build_group_by_field,
build_order_by,
build_raw_query,
build_scalar_query,
get_all_warnings,
get_column_data_from_response,
get_scalar_table_data,
make_query_request,
)
from fixtures.queriercommon import (
ATTRIBUTE_ONLY,
BOTH,
COLUMN_ONLY,
EXPLICIT_PREFIX,
IDENTITY_KEY,
NEITHER,
NUMBER_ATTRIBUTE,
)
# One name can exist in more than one place. `name` is a span column and a
# span attribute. `severity_text` is a log column and a log attribute.
# `service.name` is a resource attribute and a span or log attribute.
#
# Rules for a filter:
# - A key with an explicit context reads that context only.
# - A bare key that is a column and an attribute reads both. The query
# returns an ambiguity warning.
# - A bare key that is a resource attribute and an attribute reads the
# resource attribute. The query returns an ambiguity warning.
# - An `attribute.` key returns the warning when the attribute has two data
# types.
# - A string operand matches a number attribute through a text cast.
# - A key under the signal's own context (`span.`, `log.`) that exists only
# as an attribute reads the attribute. On logs it also reads the body JSON
# path.
FILTER_MATRIX = [
pytest.param("{contested} = '{value}'", {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH}, True, id="bare_column_and_attribute"),
pytest.param("{own}.{contested} = '{value}'", {COLUMN_ONLY, BOTH}, False, id="own_context_column_only"),
pytest.param("attribute.{contested} = '{value}'", {ATTRIBUTE_ONLY, BOTH}, True, id="attribute_context_warns_about_two_types"),
pytest.param("{contested} != '{value}'", {NEITHER, NUMBER_ATTRIBUTE}, True, id="bare_negative_excludes_every_carrier"),
pytest.param("{contested} EXISTS", {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH, NEITHER, NUMBER_ATTRIBUTE}, True, id="bare_exists_is_the_column"),
pytest.param("{contested} NOT EXISTS", set(), True, id="bare_not_exists_is_never"),
pytest.param("attribute.{contested} EXISTS", {ATTRIBUTE_ONLY, BOTH, NUMBER_ATTRIBUTE}, True, id="attribute_exists_spans_both_types"),
pytest.param("attribute.{contested} NOT EXISTS", {COLUMN_ONLY, NEITHER}, True, id="attribute_not_exists"),
pytest.param("{contested} = '42'", {NUMBER_ATTRIBUTE}, True, id="bare_string_operand_reaches_the_number_attribute"),
pytest.param("attribute.{contested}:string = '{value}'", {ATTRIBUTE_ONLY, BOTH}, False, id="type_suffix_selects_the_string_attribute"),
pytest.param("attribute.{contested}:float64 = 42", {NUMBER_ATTRIBUTE}, False, id="type_suffix_selects_the_number_attribute"),
pytest.param("service.name = 'svc-a'", {COLUMN_ONLY, BOTH}, True, id="bare_resource_wins_with_warning"),
pytest.param("service.name != 'svc-a'", {ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE}, True, id="bare_resource_negative"),
pytest.param("resource.service.name = 'svc-a'", {COLUMN_ONLY, BOTH}, False, id="resource_context_no_warning"),
pytest.param("resource.service.name != 'svc-a'", {ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE}, False, id="resource_context_negative"),
pytest.param("attribute.service.name = 'svc-a'", {ATTRIBUTE_ONLY, BOTH}, False, id="attribute_context_no_warning"),
pytest.param(
"{own}.route.tag = 'checkout'",
{"traces": {COLUMN_ONLY, BOTH}, "logs": {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH, NEITHER, NUMBER_ATTRIBUTE}},
False,
id="own_context_miss_corrects_to_attribute_and_on_logs_to_body",
),
pytest.param("route.tag = 'checkout'", {COLUMN_ONLY, BOTH}, False, id="bare_attribute_only_key"),
]
SIGNALS = [
pytest.param("traces", "span", "name", "checkout", "other", id="traces"),
pytest.param("logs", "log", "severity_text", "ERROR", "INFO", id="logs"),
]
@pytest.mark.parametrize("expression_template,expected,expects_ambiguity_warning", FILTER_MATRIX)
@pytest.mark.parametrize("signal,own_context,contested,value,other_value", SIGNALS)
def test_filter_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
signal: str,
own_context: str,
contested: str,
value: str,
other_value: str, # pylint: disable=unused-argument
expression_template: str,
expected: set[str] | dict[str, set[str]],
expects_ambiguity_warning: bool,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
expression = expression_template.format(own=own_context, contested=contested, value=value)
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
signal,
limit=100,
filter_expression=expression,
order=[build_order_by("timestamp", "asc")],
select_fields=[{"name": IDENTITY_KEY}],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
matched = {row for row in get_column_data_from_response(response.json(), IDENTITY_KEY) if row.startswith(EXPLICIT_PREFIX)}
assert matched == (expected[signal] if isinstance(expected, dict) else expected), expression
warnings = [w["message"] for w in get_all_warnings(response.json())]
assert any("ambiguous" in w for w in warnings) == expects_ambiguity_warning, warnings
# Rules for a group by:
# - A bare key that is a column and an attribute groups by the column only.
# - A key with an explicit context groups by that context only.
GROUP_BY_MATRIX = [
pytest.param(None, {"{value}": 2, "{other}": 3}, id="bare_groups_by_the_column"),
pytest.param("own", {"{value}": 2, "{other}": 3}, id="own_context_groups_by_the_column"),
pytest.param("attribute", {"{value}": 2}, id="attribute_context_groups_by_the_attribute"),
]
@pytest.mark.parametrize("context,expected_template", GROUP_BY_MATRIX)
@pytest.mark.parametrize("signal,own_context,contested,value,other_value", SIGNALS)
def test_group_by_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
signal: str,
own_context: str,
contested: str,
value: str,
other_value: str,
context: str | None,
expected_template: dict[str, int],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
field_context = own_context if context == "own" else context
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.SCALAR,
queries=[
build_scalar_query(
"A",
signal,
[build_aggregation("count()", "rows")],
group_by=[build_group_by_field(contested, "string", field_context) if field_context else {"name": contested}],
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
expected = {key.format(value=value, other=other_value): count for key, count in expected_template.items()}
groups = {row[0]: row[1] for row in get_scalar_table_data(response.json()) if row[0] in expected}
assert groups == expected, get_scalar_table_data(response.json())
# Rule for a raw select of a bare key that is a resource attribute and an
# attribute: each row shows the resource value. This is also true for a row
# where the attribute has a different value.
@pytest.mark.parametrize("signal", ["traces", "logs"])
def test_select_of_ambiguous_name(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
signal: str,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
signal,
limit=100,
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
order=[build_order_by("timestamp", "asc")],
select_fields=[{"name": IDENTITY_KEY}, {"name": "service.name"}],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0]["rows"] or []
by_identity = {row["data"][IDENTITY_KEY]: row["data"]["service.name"] for row in rows if row["data"].get(IDENTITY_KEY, "").startswith(EXPLICIT_PREFIX)}
assert by_identity == {
COLUMN_ONLY: "svc-a",
ATTRIBUTE_ONLY: "svc-b",
BOTH: "svc-a",
NEITHER: "svc-b",
NUMBER_ATTRIBUTE: "svc-b",
}
# Rules for an order by, descending, with the timestamp descending as the
# second key:
# - A bare key or a key under the signal's own context sorts by the column
# only.
# - An `attribute.` key sorts by the attribute on traces. The number
# attribute sorts as text. Rows without the attribute come last.
# - An `attribute.` key sorts by the column on logs.
BY_COLUMN = [ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE, COLUMN_ONLY, BOTH]
ORDER_BY_MATRIX = [
pytest.param(None, {"traces": BY_COLUMN, "logs": BY_COLUMN}, id="bare_orders_by_the_column"),
pytest.param("own", {"traces": BY_COLUMN, "logs": BY_COLUMN}, id="own_context_orders_by_the_column"),
pytest.param(
"attribute",
{"traces": [ATTRIBUTE_ONLY, BOTH, NUMBER_ATTRIBUTE, COLUMN_ONLY, NEITHER], "logs": BY_COLUMN},
id="attribute_context_orders_by_the_attribute_on_traces_only",
),
]
@pytest.mark.parametrize("context,expected", ORDER_BY_MATRIX)
@pytest.mark.parametrize("signal,own_context,contested,value,other_value", SIGNALS)
def test_order_by_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
signal: str,
own_context: str,
contested: str,
value: str, # pylint: disable=unused-argument
other_value: str, # pylint: disable=unused-argument
context: str | None,
expected: dict[str, list[str]],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
prefix = f"{own_context}." if context == "own" else f"{context}." if context else ""
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
signal,
limit=100,
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
order=[build_order_by(f"{prefix}{contested}", "desc"), build_order_by("timestamp", "desc")],
select_fields=[{"name": IDENTITY_KEY}],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
ordered = [row for row in get_column_data_from_response(response.json(), IDENTITY_KEY) if row.startswith(EXPLICIT_PREFIX)]
assert ordered == expected[signal]
# Rules for an aggregation argument:
# - A bare key counts the values of the column only.
# - An `attribute.` key counts the attribute in both data types. The number
# attribute adds one distinct value.
AGGREGATION_MATRIX = [
pytest.param(None, 2, id="bare_counts_the_column"),
pytest.param("own", 2, id="own_context_counts_the_column"),
pytest.param("attribute", 2, id="attribute_context_counts_both_attribute_types"),
]
@pytest.mark.parametrize("context,expected", AGGREGATION_MATRIX)
@pytest.mark.parametrize("signal,own_context,contested,value,other_value", SIGNALS)
def test_aggregation_argument_resolution(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
signal: str,
own_context: str,
contested: str,
value: str, # pylint: disable=unused-argument
other_value: str, # pylint: disable=unused-argument
context: str | None,
expected: int,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
prefix = f"{own_context}." if context == "own" else f"{context}." if context else ""
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.SCALAR,
queries=[
build_scalar_query(
"A",
signal,
[build_aggregation(f"count_distinct({prefix}{contested})", "distinct")],
filter_expression=f"{IDENTITY_KEY} LIKE '{EXPLICIT_PREFIX}%'",
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert_scalar_value(response, "A", expected)
# Rules for logs only:
# - A `body.` key reads the body JSON path. It does not read the attribute
# with the same name.
# - A `log.` key reads the attribute and the body JSON path together. This
# is also true when metadata reports the attribute.
# - A `scope.` key resolves through metadata only. When metadata does not
# report the key, the query fails with "key not found". This is also true
# for the declared path `scope.name` and for rows that have the scope
# data.
LOGS_ONLY_MATRIX = [
pytest.param("body.route.tag = 'checkout'", {ATTRIBUTE_ONLY, NEITHER, NUMBER_ATTRIBUTE}, id="body_context_reads_the_body_json"),
pytest.param("log.route.tag = 'checkout'", {COLUMN_ONLY, ATTRIBUTE_ONLY, BOTH, NEITHER, NUMBER_ATTRIBUTE}, id="log_context_reads_attribute_and_body"),
pytest.param("scope.name = 'scope-a'", "key `name` not found", id="scope_name_needs_metadata"),
pytest.param("scope.env = 'prod'", "key `env` not found", id="scope_attribute_needs_metadata"),
pytest.param("scope.env EXISTS", "key `env` not found", id="scope_attribute_exists_needs_metadata"),
]
@pytest.mark.parametrize("expression,expected", LOGS_ONLY_MATRIX)
def test_logs_body_and_scope_contexts(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
ambiguous_rows: datetime,
expression: str,
expected: set[str] | str,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((ambiguous_rows - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((ambiguous_rows + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
"logs",
limit=100,
filter_expression=expression,
order=[build_order_by("timestamp", "asc")],
select_fields=[{"name": IDENTITY_KEY}],
)
],
)
if isinstance(expected, str):
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert expected in response.text, response.text
return
assert response.status_code == HTTPStatus.OK, response.text
matched = {row for row in get_column_data_from_response(response.json(), IDENTITY_KEY) if row.startswith(EXPLICIT_PREFIX)}
assert matched == expected, expression