Compare commits

..

1 Commits

Author SHA1 Message Date
Gaurav Tewari
42242a93d0 feat: initial wrapper for ai explorer 2026-08-13 00:33:06 +05:30
33 changed files with 1765 additions and 165 deletions

View File

@@ -11,6 +11,8 @@ export enum LOCALSTORAGE {
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOG_DETAILS_V2 = 'LOG_DETAILS_V2',

View File

@@ -0,0 +1,22 @@
.container {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
margin: var(--spacing-4) 0;
}
.optionsTrigger {
display: flex;
align-items: center;
gap: var(--spacing-2);
cursor: pointer;
// Was a bare div in the traces explorer; a <button> for keyboard access
// needs its chrome reset back to the original look.
border: none;
background: none;
padding: 0;
color: inherit;
font: inherit;
}

View File

@@ -0,0 +1,81 @@
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 ExplorerControls({
isLoading,
totalCount,
perPageOptions,
config,
showSizeChanger = true,
}: ExplorerControlsProps): 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 && (
<>
<button
type="button"
className={styles.optionsTrigger}
onClick={(): void => setIsFieldsSelectorOpen(true)}
data-testid="explorer-controls-options"
>
{t('options_menu.options')}
<Settings size="md" />
</button>
<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>
);
}
type ExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
};
ExplorerControls.defaultProps = {
config: null,
showSizeChanger: true,
};
export default memo(ExplorerControls);

View File

@@ -1,11 +1,56 @@
.explorer {
.explorerPage {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-0);
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
}
.placeholder {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
.filter {
width: 260px;
height: 100%;
min-height: 100vh;
border: 1px solid var(--l1-border);
border-right: 0px;
background-color: var(--l1-background);
> :global(.ant-card-body) {
padding: 0;
width: 258px;
}
}
.explorer {
width: 100%;
background: var(--l1-background);
border-color: var(--l1-border);
> :global(.ant-card-body) {
padding: 0;
}
&.isFiltersExpanded {
width: calc(100% - 260px);
}
}
.views {
padding: var(--spacing-4);
// Room the traces explorer reserves for its floating options bar, which this
// explorer doesn't render yet. No spacing token for 60px.
padding-bottom: 60px;
margin-bottom: var(--spacing-12);
:global(.ant-tabs-tabpane) {
padding: 0 var(--spacing-4);
}
}
.view {
flex: 1;
}

View File

@@ -1,13 +1,253 @@
import styles from './Explorer.module.scss';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { QueryKey, useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import {
ICurrentQueryData,
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { Warning } from 'types/api';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
explorerViewToPanelType,
getExplorerViewFromUrl,
} from 'utils/explorerUtils';
// Shell for the AI Observability Explorer tab. Owns the
// /ai-observability/explorer route and is intentionally empty for now: the
// query builder + results surface land in a follow-up.
import { defaultSelectedColumns, TOOLBAR_VIEWS } from './constants';
import styles from './Explorer.module.scss';
import ListView from './ListView/ListView';
import QuerySection from './QuerySection/QuerySection';
import TableView from './TableView/TableView';
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
import TracesView from './TracesView/TracesView';
// AI Observability Explorer tab. Owns the /ai-observability/explorer route.
// Forked from the Traces Explorer (GenAI spans are trace data) and diverges
// from it as the GenAI query surface lands, so the two are deliberately not
// sharing a component.
function Explorer(): JSX.Element {
const {
updateAllQueriesOperators,
handleRunQuery,
stagedQuery,
handleSetConfig,
} = useQueryBuilder();
// Return value unused: this seeds the URL `options` param on the views that
// don't mount ListView (which runs its own useOptionsMenu).
useOptionsMenu({
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const listQueryKeyRef = useRef<QueryKey>();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
const [isCancelled, setIsCancelled] = useState(false);
useEffect(() => {
if (isLoadingQueries) {
setIsCancelled(false);
}
}, [isLoadingQueries]);
const handleCancelQuery = useCallback(() => {
if (listQueryKeyRef.current) {
void queryClient.cancelQueries(listQueryKeyRef.current);
}
setIsCancelled(true);
// Reset loading state — the active view unmounts when cancelled, so no
// child will call setIsLoadingQueries(false) otherwise.
setIsLoadingQueries(false);
}, [queryClient]);
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
);
const [warning, setWarning] = useState<Warning | undefined>();
const [isOpen, setOpen] = useState<boolean>(true);
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueriesMap.traces,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
[updateAllQueriesOperators],
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
setSelectedView(view);
handleExplorerTabChange(
explorerViewToPanelType[view],
querySearchParameters,
);
},
[handleExplorerTabChange, handleSetConfig],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current) {
void logEvent('AI Observability Explorer: Page visited', {});
logEventCalledRef.current = true;
}
}, []);
const isFilterApplied = useMemo(() => {
// if any of the non-disabled queries has filters applied, return true
const result = stagedQuery?.builder?.queryData?.filter(
(item) => !isEmpty(item.filters?.items) && !item.disabled,
);
return !!result?.length;
}, [stagedQuery]);
return (
<div className={styles.explorer} data-testid="llm-observability-explorer">
<div className={styles.placeholder}>Explorer coming soon.</div>
</div>
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
className={styles.explorerPage}
data-testid="llm-observability-explorer"
>
<Card className={styles.filter} hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</Card>
<div
className={cx(styles.explorer, {
[styles.isFiltersExpanded]: isOpen,
})}
>
<div>
<Toolbar
showAutoRefresh
leftActions={
<LeftToolbarActions
showFilter={isOpen}
handleFilterVisibilityChange={(): void => setOpen(!isOpen)}
items={TOOLBAR_VIEWS}
selectedView={selectedView}
onChangeSelectedView={handleChangeSelectedView}
/>
}
warningElement={
!isEmpty(warning) ? <WarningPopover warningData={warning} /> : <div />
}
rightActions={
<RightToolbarActions
onStageRunQuery={(): void => {
setIsCancelled(false);
handleRunQuery();
}}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
}
/>
</div>
<ExplorerCard sourcepage={DataSource.TRACES}>
<div className="query-section-container">
<QuerySection />
</div>
</ExplorerCard>
<div className={styles.views}>
{isCancelled && (
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
)}
{!isCancelled && selectedView === ExplorerViews.LIST && (
<div className={styles.view}>
<ListView
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
{!isCancelled && selectedView === ExplorerViews.TRACE && (
<div className={styles.view}>
<TracesView
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
<div className={styles.view}>
<TimeSeriesView
dataSource={DataSource.TRACES}
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
{!isCancelled && selectedView === ExplorerViews.TABLE && (
<div className={styles.view}>
<TableView
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
</div>
</div>
</div>
</Sentry.ErrorBoundary>
);
}

View File

@@ -0,0 +1,31 @@
.container {
display: flex;
flex-direction: column;
--typography-color: var(--l1-foreground);
}
.controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-4);
}
.orderByContainer {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.orderByLabel {
color: var(--muted-foreground);
// Between --periscope-font-size-small (11px) and -base (13px), so literal.
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
display: flex;
align-items: center;
gap: var(--spacing-2);
}

View File

@@ -0,0 +1,309 @@
import {
Dispatch,
memo,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import TanStackTable from 'components/TanStackTableView';
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 { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from '../constants';
import ExplorerControls from '../Controls/Controls';
import { getListViewQuery } from '../explorerUtils';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import styles from './ListView.module.scss';
import { useListTableColumns } from './useListTableColumns';
import { TraceListRow } from '../tableUtils';
import { getTraceLink, getTraceRowKey, transformDataWithDate } from './utils';
interface ListViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
}
function ListView({
isFilterApplied,
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);
// TEMP — remove after traces moves to TanStack table.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
orderBy,
],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
{
query: requestQuery,
graphType: panelType,
selectedTime: 'GLOBAL_TIME' as const,
globalSelectedInterval: globalSelectedTime as CustomTimeType,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
},
);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
const dataLength =
data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
const totalCount = useMemo(() => dataLength || 0, [dataLength]);
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
const queryTableData = useMemo(
() => queryTableDataResult || [],
[queryTableDataResult],
);
const columns = useListTableColumns(options?.selectColumns || []);
const transformedQueryTableData = useMemo(
() => transformDataWithDate(queryTableData) || [],
[queryTableData],
);
const { safeNavigate } = useSafeNavigate();
const handleColumnOrderChange = useCallback(
(reordered: TableColumnDef<TraceListRow>[]): void => {
// Column ids are composite (fieldContext.name) — disambiguates same-name fields.
config?.addColumn?.onReorder(reordered.map((column) => column.id));
},
[config],
);
const handleRowClick = useCallback(
(row: TraceListRow): void => {
safeNavigate(getTraceLink(row));
},
[safeNavigate],
);
const handleRowClickNewTab = useCallback((row: TraceListRow): void => {
window.open(getAbsoluteUrl(getTraceLink(row)), '_blank');
}, []);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const isDataAbsent =
!isLoading &&
!isFetching &&
!isError &&
transformedQueryTableData.length === 0;
useEffect(() => {
if (
!isLoading &&
!isFetching &&
!isError &&
transformedQueryTableData.length !== 0
) {
void logEvent('AI Observability Explorer: Data present', {
panelType,
});
}
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
return (
<div className={styles.container}>
<div className={styles.controls}>
<div className={styles.orderByContainer}>
<div className={styles.orderByLabel}>
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<ListViewOrderBy
value={orderBy}
onChange={handleOrderChange}
dataSource={DataSource.TRACES}
/>
</div>
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<ExplorerControls
isLoading={isFetching}
totalCount={totalCount}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
<TracesLoading />
)}
{isDataAbsent && !isFilterApplied && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isDataAbsent && isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
)}
{!isError && transformedQueryTableData.length !== 0 && (
<TanStackTable<TraceListRow>
data={transformedQueryTableData}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder={false}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
isLoading={isFetching}
getRowKey={getTraceRowKey}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
// Rows are already page-sized by the query; virtualizing would need a
// fixed-height container this page doesn't provide.
disableVirtualScroll
testId="ai-observability-list-view-table"
/>
)}
</div>
);
}
ListView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(ListView);

View File

@@ -0,0 +1,115 @@
import type { ReactElement } from 'react';
import { useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { TelemetryFieldKey } from 'api/v5/v5';
import TanStackTable from 'components/TanStackTableView';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useTimezone } from 'providers/Timezone';
import { formatCellValue, TraceListRow } from '../tableUtils';
/** Older callers passed `{ key, type }` where v5 uses `{ name, fieldContext }`. */
interface LegacyFieldKey {
key?: string;
type?: string;
}
const BADGE_FIELDS = new Set([
'httpMethod',
'responseStatusCode',
'response_status_code',
'http_method',
]);
const DURATION_FIELDS = new Set(['durationNano', 'duration_nano']);
const TIMESTAMP_COLUMN_ID = 'date';
/**
* Column definitions for the list view's TanStack table.
*
* Cell renderers must tolerate missing values: the table feeds skeleton
* placeholder rows through them before the first response lands.
*/
export function useListTableColumns(
selectedColumns: TelemetryFieldKey[],
): TableColumnDef<TraceListRow>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return useMemo<TableColumnDef<TraceListRow>[]>(() => {
const timestampColumn: TableColumnDef<TraceListRow> = {
id: TIMESTAMP_COLUMN_ID,
header: 'Timestamp',
accessorFn: (row): unknown => row?.date,
canBeHidden: false,
enableRemove: false,
width: { default: 145, min: 145 },
cell: ({ value }): ReactElement => {
const timestamp = value as string | number | undefined;
if (timestamp === undefined || timestamp === null) {
return <TanStackTable.Text> </TanStackTable.Text>;
}
const formatted =
typeof timestamp === 'string'
? formatTimezoneAdjustedTimestamp(
timestamp,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
timestamp / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return <TanStackTable.Text>{String(formatted)}</TanStackTable.Text>;
},
};
const fieldColumns = selectedColumns.map(
(field): TableColumnDef<TraceListRow> => {
const legacy = field as TelemetryFieldKey & LegacyFieldKey;
const name = field?.name || legacy?.key || '';
const fieldContext = field?.fieldContext || legacy?.type;
return {
id: buildCompositeKey(name, fieldContext),
header: name,
accessorFn: (row): unknown => row?.[name],
enableRemove: true,
width: { min: 145 },
cell: ({ value }): ReactElement => {
if (value === undefined || value === null || value === '') {
return <TanStackTable.Text data-testid={name}>N/A</TanStackTable.Text>;
}
if (BADGE_FIELDS.has(name)) {
return (
<Badge data-testid={name} color="sakura" variant="outline">
{formatCellValue(value)}
</Badge>
);
}
if (DURATION_FIELDS.has(name)) {
return (
<TanStackTable.Text data-testid={name}>
{getMs(formatCellValue(value))}ms
</TanStackTable.Text>
);
}
return (
<span data-testid={name}>
<LineClampedText text={formatCellValue(value)} lines={3} />
</span>
);
},
};
},
);
return [timestampColumn, ...fieldColumns];
}, [selectedColumns, formatTimezoneAdjustedTimestamp]);
}

View File

@@ -0,0 +1,28 @@
import ROUTES from 'constants/routes';
import { formUrlParams } from 'container/TraceDetail/utils';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { formatCellValue, TraceListRow } from '../tableUtils';
/** Rows carry the span's attributes plus `date` (the list item's timestamp). */
export const transformDataWithDate = (data: QueryDataV3[]): TraceListRow[] =>
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: TraceListRow): string =>
`${ROUTES.TRACE}/${formatCellValue(record.traceID || record.trace_id)}${formUrlParams(
{
spanId: record.spanID || record.span_id,
levelUp: 0,
levelDown: 0,
},
)}`;
/**
* Row identity for the table. Spans are unique per row; the trace id is the
* fallback for root-only rows.
*/
export const getTraceRowKey = (record: TraceListRow): string =>
formatCellValue(
record?.spanID ?? record?.span_id ?? record?.traceID ?? record?.trace_id,
);

View File

@@ -0,0 +1,61 @@
import { memo, useCallback, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
[],
);
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
const shouldRenderCustomOrderBy =
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
return {
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
);
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
version="v3"
/>
);
}
export default memo(QuerySection);

View File

@@ -0,0 +1,7 @@
.header {
display: flex;
justify-content: flex-end;
align-items: center;
padding: var(--spacing-6);
flex-shrink: 0;
}

View File

@@ -0,0 +1,131 @@
import {
Dispatch,
memo,
MutableRefObject,
SetStateAction,
useEffect,
useMemo,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Space } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { QueryTable } from 'container/QueryTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import styles from './TableView.module.scss';
function TableView({
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
}): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
],
[globalSelectedTime, maxTime, minTime, stagedQuery],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: stagedQuery || initialQueriesMap.traces,
graphType: panelType || PANEL_TYPES.TABLE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: 'traces',
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
},
);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
const queryTableData = useMemo(
() =>
data?.payload?.data?.newResult?.data?.result ||
data?.payload.data.result ||
[],
[data],
);
useEffect(() => {
if (data?.payload) {
setWarning(data.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
return (
<Space.Compact block direction="vertical">
{isError && error && <ErrorInPlace error={error as APIError} />}
{!isError && data && (
<div className={styles.header}>
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
</div>
)}
{!isError && (
<QueryTable
query={stagedQuery || initialQueriesMap.traces}
queryTableData={queryTableData as QueryDataV3[]}
loading={isLoading}
sticky
/>
)}
</Space.Compact>
);
}
TableView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(TableView);

View File

@@ -0,0 +1,145 @@
import {
Dispatch,
MutableRefObject,
SetStateAction,
useEffect,
useMemo,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
function TimeSeriesViewContainer({
dataSource = DataSource.TRACES,
isFilterApplied,
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: TimeSeriesViewProps): JSX.Element {
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
currentQuery.builder.queryData.forEach(
({ aggregateAttribute, aggregateOperator }) => {
const isExistDurationNanoAttribute =
aggregateAttribute?.key === 'durationNano' ||
aggregateAttribute?.key === 'duration_nano';
const isCountOperator =
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
},
);
return isValid.every(Boolean);
}, [currentQuery]);
const defaultUnit = isValidToConvertToMs ? 'ms' : 'short';
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit(defaultUnit);
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
],
[globalSelectedTime, maxTime, minTime, stagedQuery],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: stagedQuery || initialQueriesMap[dataSource],
graphType: panelType || PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource,
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
},
);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
const responseData = useMemo(
() => (isValidToConvertToMs ? convertDataValueToMs(data) : data),
[data, isValidToConvertToMs],
);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
return (
<div>
<TimeSeriesView
isFilterApplied={isFilterApplied}
isError={isError}
error={error as APIError}
isLoading={isLoading || isFetching}
data={responseData}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
dataSource={dataSource}
setWarning={setWarning}
allowExport
/>
</div>
);
}
interface TimeSeriesViewProps {
dataSource?: DataSource;
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
}
TimeSeriesViewContainer.defaultProps = {
dataSource: DataSource.TRACES,
queryKeyRef: undefined,
};
export default TimeSeriesViewContainer;

View File

@@ -0,0 +1,19 @@
.loadingTraces {
padding: var(--spacing-12) 0;
height: 240px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.content {
display: flex;
align-items: flex-start;
flex-direction: column;
}
.gif {
height: 72px;
margin-left: calc(var(--spacing-12) * -1);
}

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 styles from './TraceLoading.module.scss';
export function TracesLoading(): JSX.Element {
const { t } = useTranslation('common');
return (
<div className={styles.loadingTraces}>
<div className={styles.content}>
<img className={styles.gif} src={loadingPlaneUrl} alt="wait-icon" />
<Typography>
{t('pending_data_placeholder', { dataSource: DataSource.TRACES })}
</Typography>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
// The traces explorer borrowed this rule from ListView's global stylesheet;
// the fork keeps its own copy so the two can't drift into each other.
.controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,215 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
Dispatch,
memo,
MutableRefObject,
SetStateAction,
useEffect,
useMemo,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import TanStackTable from 'components/TanStackTableView';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { PER_PAGE_OPTIONS } from '../constants';
import ExplorerControls from '../Controls/Controls';
import { getListViewQuery } from '../explorerUtils';
import { TraceListRow } from '../tableUtils';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import { columns } from './configs';
import { ActionsContainer, Container } from './styles';
import styles from './TracesView.module.scss';
import { getRootSpanRowKey } from './utils';
interface TracesViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
}
function TracesView({
isFilterApplied,
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
[stagedQuery],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
panelType,
paginationQueryData,
],
[
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
panelType,
paginationQueryData,
],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: transformedQuery,
graphType: panelType || PANEL_TYPES.TRACE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: paginationQueryData,
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
},
);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
const tableData = useMemo(
(): TraceListRow[] => responseData?.map((listItem) => listItem.data) ?? [],
[responseData],
);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
useEffect(() => {
if (!isLoading && !isFetching && !isError && tableData.length !== 0) {
void logEvent('AI Observability Explorer: Data present', {
panelType: 'TRACE',
});
}
}, [isLoading, isFetching, isError, panelType, tableData]);
return (
<Container>
{tableData.length !== 0 && (
<ActionsContainer>
<Typography>
This tab only shows Root Spans. More details
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
{' '}
here
</Typography.Link>
</Typography>
<div className={styles.controls}>
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<ExplorerControls
isLoading={isLoading}
totalCount={responseData?.length || 0}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</ActionsContainer>
)}
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && tableData.length === 0)) && <TracesLoading />}
{!isLoading &&
!isFetching &&
!isError &&
!isFilterApplied &&
tableData.length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
{!isLoading &&
!isFetching &&
tableData.length === 0 &&
!isError &&
isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
)}
{tableData.length !== 0 && (
<TanStackTable<TraceListRow>
data={tableData}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
isLoading={isLoading}
getRowKey={getRootSpanRowKey}
// Rows are already page-sized by the query; virtualizing would need a
// fixed-height container this page doesn't provide.
disableVirtualScroll
testId="ai-observability-traces-view-table"
/>
)}
</Container>
);
}
TracesView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(TracesView);

View File

@@ -0,0 +1,80 @@
import type { ReactElement } from 'react';
import { generatePath, Link } from 'react-router-dom';
import TanStackTable from 'components/TanStackTableView';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formatCellValue, TraceListRow } from '../tableUtils';
/**
* Root-span columns. Fixed set — unlike the list view there are no user-selected
* columns here, so nothing can be removed (there'd be no preference to sync to).
*/
export const columns: TableColumnDef<TraceListRow>[] = [
{
id: 'serviceName',
header: 'Root Service Name',
accessorFn: (row): unknown => row?.['service.name'],
enableRemove: false,
width: { min: 145 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
),
},
{
id: 'name',
header: 'Root Operation Name',
accessorFn: (row): unknown => row?.name,
enableRemove: false,
width: { min: 145 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
),
},
{
id: 'durationNano',
header: 'Root Duration (in ms)',
accessorFn: (row): unknown => row?.duration_nano,
enableRemove: false,
width: { min: 145 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>
{value === undefined || value === null
? ''
: `${getMs(formatCellValue(value))}ms`}
</TanStackTable.Text>
),
},
{
id: 'span_count',
header: 'No of Spans',
accessorFn: (row): unknown => row?.span_count,
enableRemove: false,
width: { min: 145 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
),
},
{
id: 'traceID',
header: 'TraceID',
accessorFn: (row): unknown => row?.trace_id,
enableRemove: false,
width: { min: 145 },
cell: ({ value }): ReactElement => {
const traceID = formatCellValue(value);
if (!traceID) {
return <TanStackTable.Text> </TanStackTable.Text>;
}
return (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, { id: traceID })}
data-testid="trace-id"
>
{traceID}
</Link>
);
},
},
];

View File

@@ -0,0 +1,12 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
flex-direction: column;
`;
export const ActionsContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;

View File

@@ -0,0 +1,8 @@
import { formatCellValue, TraceListRow } from '../tableUtils';
/**
* Row identity for the root-span table: one row per trace. Tolerates the
* skeleton placeholder rows the table renders before the first response.
*/
export const getRootSpanRowKey = (record: TraceListRow): string =>
formatCellValue(record?.trace_id ?? record?.traceID);

View File

@@ -0,0 +1,49 @@
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const TOOLBAR_VIEWS = {
list: {
name: 'list',
label: 'List',
show: true,
key: 'list',
},
timeseries: {
name: 'timeseries',
label: 'Timeseries',
disabled: false,
show: true,
key: 'timeseries',
},
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
table: {
name: 'table',
label: 'Table',
disabled: false,
show: true,
key: 'table',
},
clickhouse: {
name: 'clickhouse',
label: 'Clickhouse',
disabled: false,
show: false,
key: 'clickhouse',
},
};
export const defaultSelectedColumns: string[] = [
'service.name',
'name',
'duration_nano',
'http_method',
'response_status_code',
'timestamp',
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -0,0 +1,75 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
export const getListViewQuery = (
stagedQuery: Query,
orderBy?: string,
): Query => {
const query = stagedQuery
? cloneDeep(stagedQuery)
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {
const queryData = query.builder.queryData[i];
queryData.groupBy = [];
queryData.having = {
expression: '',
};
queryData.orderBy = orderByPayload;
}
if (
query.builder.queryTraceOperator &&
query.builder.queryTraceOperator.length > 0
) {
for (let i = 0; i < query.builder.queryTraceOperator.length; i++) {
const queryTraceOperator = query.builder.queryTraceOperator[i];
queryTraceOperator.groupBy = [];
queryTraceOperator.having = {
expression: '',
};
queryTraceOperator.orderBy = orderByPayload;
}
}
return query;
};
export const getQueryByPanelType = (
stagedQuery: Query,
panelType: PANEL_TYPES,
): Query => {
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
return getListViewQuery(stagedQuery);
}
return stagedQuery;
};
export const getExportQueryData = (
query: Query,
panelType: PANEL_TYPES,
options: OptionsQuery,
): Query => {
if (panelType === PANEL_TYPES.LIST) {
const updatedQuery = cloneDeep(query);
set(
updatedQuery,
'builder.queryData[0].selectColumns',
options.selectColumns,
);
return updatedQuery;
}
return query;
};

View File

@@ -0,0 +1,25 @@
/**
* A table row: the span's flattened attributes. Which keys are present depends
* on the query and the selected columns, so every field access has to tolerate
* `undefined` — including the skeleton placeholder rows the table renders
* before the first response lands.
*/
export type TraceListRow = Record<string, unknown>;
/**
* Renders a dynamic row value as text. Values are whatever the span carried, so
* objects (a JSON body, say) are serialised rather than left to stringify to
* `[object Object]`.
*/
export const formatCellValue = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (value !== null && typeof value === 'object') {
return JSON.stringify(value);
}
return '';
};

View File

@@ -18,6 +18,13 @@ jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
}));
// Same for the Explorer tab, which renders a full query-builder surface
// (toolbar + date picker).
jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
}));
function setupList(items = mockRules): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.25.7
require (
dario.cat/mergo v1.0.2
github.com/AfterShip/clickhouse-sql-parser v0.5.6
github.com/AfterShip/clickhouse-sql-parser v0.5.5
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/clickhouse-go-mock v0.14.0

4
go.sum
View File

@@ -66,8 +66,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AfterShip/clickhouse-sql-parser v0.5.6 h1:njgRLxQz/pE16ZO1MSjWadIzabwqsjDMMX8RR5Dbv7Y=
github.com/AfterShip/clickhouse-sql-parser v0.5.6/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
github.com/AfterShip/clickhouse-sql-parser v0.5.5 h1:LCA23yAA4GgF73PoYXb67yzCdC4sXsj4geQz1Oij3U8=
github.com/AfterShip/clickhouse-sql-parser v0.5.5/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=

View File

@@ -25,10 +25,6 @@ func TestErrIfStatementIsNotValid_Pass(t *testing.T) {
{"GlobalNotIn", "SELECT a FROM t WHERE a GLOBAL NOT IN (SELECT b FROM t2)"},
{"Union", "SELECT * FROM t UNION ALL SELECT * FROM t2"},
{"Intersect", "SELECT * FROM t INTERSECT SELECT * FROM t2"},
// A parenthesised left operand of a set operator. https://github.com/AfterShip/clickhouse-sql-parser/pull/312
{"ParenthesisedUnionLeftOperand", "SELECT a FROM ((SELECT 1 AS a) UNION ALL (SELECT 2 AS a))"},
{"ParenthesisedExceptLeftOperand", "SELECT a FROM ((SELECT 1 AS a) EXCEPT (SELECT 2 AS a))"},
{"ParenthesisedUnionLeftOperandAtStatementLevel", "(SELECT 1 AS a) UNION ALL (SELECT 2 AS a)"},
{"WindowFunction", "SELECT sum(v) OVER (PARTITION BY a ORDER BY t) FROM t"},
{"UnrelatedSetting", "SELECT * FROM t SETTINGS max_threads = 4"},
{"TerminatedBlockComment", "SELECT /* keep me */ count() FROM t"},
@@ -187,10 +183,12 @@ func TestErrIfStatementIsNotValid_ShouldPassButFails(t *testing.T) {
query string
expectedCode errors.Code
}{
// The left operand commits the parser to a subquery, leaving the operator nowhere to bind. Parenthesising only the right operand is fine.
{"ParenthesisedUnionLeftOperand", "SELECT a FROM ((SELECT 1 AS a) UNION ALL (SELECT 2 AS a))", CodeClickHouseSQLUnparseable},
{"ParenthesisedExceptLeftOperand", "SELECT a FROM ((SELECT 1 AS a) EXCEPT (SELECT 2 AS a))", CodeClickHouseSQLUnparseable},
{"ParenthesisedUnionLeftOperandAtStatementLevel", "(SELECT 1 AS a) UNION ALL (SELECT 2 AS a)", CodeClickHouseSQLUnparseable},
// The one keyword PR 305 left behind, because ON also opens a join condition.
{"UnquotedOnAsColumnName", "SELECT on + 1 FROM t", CodeClickHouseSQLUnparseable},
// ClickHouse accepts NULLS FIRST|LAST as an ORDER BY modifier; the parser's grammar has no rule for it.
{"OrderByNullsLast", "SELECT x FROM t ORDER BY x DESC NULLS LAST", CodeClickHouseSQLUnparseable},
}
for _, testCase := range testCases {

View File

@@ -469,8 +469,6 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
expectedErr: nil,
},
{
// The `[*]` path is extracted per value, not as an Array(String) compared to a
// scalar — ClickHouse rejects that outright (code 130).
name: "IN operator with json search",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -481,7 +479,7 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((JSON_VALUE(body, '$.\"user_names\"[*]') = ?) AND JSON_EXISTS(body, '$.\"user_names\"[*]')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(String)') = ?) AND JSON_EXISTS(body, '$.\"user_names\"[*]')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("user_names[*]")},
},

View File

@@ -94,11 +94,7 @@ func (c *conditionBuilder) conditionFor(
}
conditions := []string{}
for _, value := range values {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.E(fieldExpression, value))
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
@@ -108,11 +104,7 @@ func (c *conditionBuilder) conditionFor(
}
conditions := []string{}
for _, value := range values {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.NE(fieldExpression, value))
}
return sb.And(conditions...), nil
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:

View File

@@ -410,11 +410,7 @@ func (c *conditionBuilder) conditionForResolvedKey(
// instead of using IN, we use `=` + `OR` to make use of index
conditions := []string{}
for _, value := range values {
cond, err := c.conditionForResolvedKey(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.E(fieldExpression, value))
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
@@ -425,11 +421,7 @@ func (c *conditionBuilder) conditionForResolvedKey(
// instead of using NOT IN, we use `!=` + `AND` to make use of index
conditions := []string{}
for _, value := range values {
cond, err := c.conditionForResolvedKey(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.NE(fieldExpression, value))
}
return sb.And(conditions...), nil

View File

@@ -905,52 +905,3 @@ func TestConditionForJSONBodySearch(t *testing.T) {
})
}
}
// IN on the body column routes each value back through the `=` path; the SQL it produces
// must stay what the shared IN handling produced before, including for a mixed-type list.
func TestConditionForBodyIn(t *testing.T) {
testCases := []struct {
name string
values []any
expectedSQL string
expectedArgs []any
}{
{
name: "strings",
values: []any{"alpha", "beta"},
expectedSQL: "(body = ? OR body = ?)",
expectedArgs: []any{"alpha", "beta"},
},
{
name: "mixed types are stringified before they reach the column",
values: []any{"alpha", float64(1), true},
expectedSQL: "(body = ? OR body = ? OR body = ?)",
expectedArgs: []any{"alpha", "1", "true"},
},
}
fl := flaggertest.New(t)
fm := NewFieldMapper(fl)
conditionBuilder := NewConditionBuilder(fm, fl)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{
Name: "body",
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("1").From("t")
cond, _, err := conditionBuilder.ConditionFor(context.Background(), valuer.UUID{}, 0, 0, &key,
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{},
qbtypes.FilterOperatorIn, tc.values, sb)
require.NoError(t, err)
sb.Where(cond...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, tc.expectedSQL)
assert.Equal(t, tc.expectedArgs, args)
})
}
}

View File

@@ -135,11 +135,7 @@ func (c *conditionBuilder) conditionFor(
// instead of using IN, we use `=` + `OR` to make use of index
conditions := []string{}
for _, value := range values {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.E(fieldExpression, value))
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
@@ -150,11 +146,7 @@ func (c *conditionBuilder) conditionFor(
// instead of using NOT IN, we use `!=` + `AND` to make use of index
conditions := []string{}
for _, value := range values {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.NE(fieldExpression, value))
}
return sb.And(conditions...), nil

View File

@@ -3,13 +3,11 @@ from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import build_order_by, build_raw_query, get_rows, make_query_request
def test_logs_json_body_simple_searches(
@@ -913,61 +911,3 @@ def test_logs_json_body_listing(
assert len(results) == 1
count = results[0]["data"][0][0]
assert count == 4 # 4 logs have status="success"
@pytest.mark.parametrize(
"expression,expected_services",
[
pytest.param("body.service IN ['auth', 'payment']", {"auth", "payment"}, id="in_scalar_path"),
pytest.param("body.status IN [200, 500]", {"auth", "payment"}, id="in_number_path"),
pytest.param("body.service NOT IN ['auth']", {"payment", "search"}, id="not_in_scalar_path"),
# An `[]` path is extracted as an array. Comparing that array to each scalar in the
# list is something ClickHouse rejects outright (code 130), so this shape used to
# fail the whole query; per-value extraction reads the first element instead.
pytest.param("body.user_names[*] IN ['alpha', 'gamma']", {"auth", "payment"}, id="in_array_path"),
],
)
def test_logs_json_body_in_operator(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
expected_services: set[str],
) -> None:
"""IN over a body JSON path fans out to one comparison per value."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
specs = [("auth", 200, ["alpha", "beta"]), ("payment", 500, ["gamma"]), ("search", 404, ["beta", "alpha"])]
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": "api"},
body=json.dumps({"service": service, "status": status, "user_names": user_names}),
)
for i, (service, status, user_names) in enumerate(specs)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
# flag off: the body comes back as the raw JSON string
assert {json.loads(row["data"]["body"])["service"] for row in get_rows(response)} == expected_services