Compare commits

...

28 Commits

Author SHA1 Message Date
Gaurav Tewari
6844256881 feat: make treace default 2026-08-21 11:16:29 +05:30
Gaurav Tewari
aa9a0b9e1b feat: wireup qb builder 2026-08-21 10:58:20 +05:30
Gaurav Tewari
1a19671703 Merge remote-tracking branch 'origin/main' into feat/qb-changes-for-ai-explorer 2026-08-20 18:33:27 +05:30
Gaurav Tewari
1004f2eb90 Merge branch 'main' into feat/initial-wrapper-for-ai-explorer 2026-08-19 19:31:26 +05:30
Gaurav Tewari
406a07f823 Merge branch 'main' into feat/initial-wrapper-for-ai-explorer 2026-08-18 09:20:56 +05:30
Gaurav Tewari
aea7b98b87 Merge branch 'main' into feat/initial-wrapper-for-ai-explorer 2026-08-14 13:27:55 +05:30
Gaurav Tewari
e4c973f4e4 chore: add comments 2026-08-14 13:08:59 +05:30
Gaurav Tewari
e17baf9987 chore: iniital review 2026-08-14 12:08:39 +05:30
Gaurav Tewari
3b62f7dd62 chore: remove comments from files 2026-08-13 23:12:42 +05:30
Gaurav Tewari
9bd8d4ef69 chore: add new files 2026-08-13 22:59:07 +05:30
Gaurav Tewari
dcae1a3dce feat: sync with main 2026-08-13 19:22:50 +05:30
Gaurav Tewari
793cfea28c Merge branch 'main' into feat/add-test-for-recent-searches 2026-08-13 11:45:43 +05:30
Gaurav Tewari
42242a93d0 feat: initial wrapper for ai explorer 2026-08-13 00:33:06 +05:30
Gaurav Tewari
230dbe0050 chore: remove comments 2026-08-12 21:00:23 +05:30
Gaurav Tewari
30de2b8746 chore: update recent searches 2026-08-12 20:57:47 +05:30
Gaurav Tewari
1b32a100dc chore: refactor test cases 2026-08-12 20:49:13 +05:30
Gaurav Tewari
784c42496d chore: remove comment 2026-08-12 19:45:00 +05:30
Gaurav Tewari
5e30b7d1bd chore: review changes 2026-08-12 19:42:10 +05:30
Gaurav Tewari
6ba0461c19 chore: add small comments 2026-08-12 19:00:20 +05:30
Gaurav Tewari
6bd22ef047 fix: failing test cases 2026-08-11 16:02:40 +05:30
Gaurav Tewari
6499760f37 chore: remove comment 2026-08-11 15:02:15 +05:30
Gaurav Tewari
f21a4d3c78 chore: update test cases 2026-08-11 14:59:29 +05:30
Gaurav Tewari
78db3f8920 Merge branch 'main' into feat/add-test-for-recent-searches 2026-08-11 12:07:00 +05:30
Gaurav Tewari
868ccd3d70 feat: update tests 2026-08-10 22:36:36 +05:30
Gaurav Tewari
163c2954a1 chore: remove comments 2026-08-10 17:12:26 +05:30
Gaurav Tewari
46a2951da2 Merge remote-tracking branch 'origin/main' into feat/add-test-for-recent-searches 2026-08-10 16:27:54 +05:30
Gaurav Tewari
1239c42c68 fix: broken test cases 2026-07-09 17:26:54 +05:30
Gaurav Tewari
afb33049c9 test(qb): add integration tests for recent searches dropdown
Mount the real QuerySearch with the un-mocked CodeMirror editor and
recents store to cover the recent-searches dropdown: display on focus,
substring filtering, signal partitioning, exact-match exclusion,
apply-on-click, display cap/newest-first ordering, and delete.
2026-07-09 15:47:10 +05:30
37 changed files with 1951 additions and 46 deletions

View File

@@ -64,5 +64,6 @@
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping",
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer"
}

View File

@@ -89,5 +89,6 @@
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping",
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer"
}

View File

@@ -2,8 +2,10 @@ import { cloneDeep, isEmpty } from 'lodash-es';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import {
BuilderQuery,
DistributionData,
MetricRangePayloadV5,
QueryEnvelope,
QueryRangeRequestV5,
RawData,
ScalarData,
@@ -11,6 +13,12 @@ import {
} from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
/** builder_query and builder_ai_query both carry a BuilderQuery spec, hence aggregation metadata. */
const hasBuilderQuerySpec = (
envelope: QueryEnvelope,
): envelope is QueryEnvelope & { spec: BuilderQuery } =>
envelope.type === 'builder_query' || envelope.type === 'builder_ai_query';
function getColName(
col: ScalarData['columns'][number],
legendMap: Record<string, string>,
@@ -409,21 +417,15 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
params?.compositeQuery?.queries
?.filter((query) => query.type === 'builder_query')
.reduce(
(acc, query) => {
if (
query.type === 'builder_query' &&
'aggregations' in query.spec &&
query.spec.name
) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
params?.compositeQuery?.queries?.filter(hasBuilderQuerySpec).reduce(
(acc, query) => {
if (query.spec.name && query.spec.aggregations) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
// clickhouse_sql queries have no aggregation metadata; their value columns
// are named/keyed by the real SQL alias the response carries (see getColId).

View File

@@ -14,6 +14,7 @@ import {
QueryBuilderFormula as V5QueryBuilderFormula,
QueryEnvelope,
QueryRangePayloadV5,
RequestType,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -935,3 +936,47 @@ describe('convertBuilderQueriesToV5 having normalization', () => {
});
});
});
describe('convertBuilderQueriesToV5 builder query type', () => {
const buildEnvelope = (
builderQueryType: IBuilderQuery['builderQueryType'],
requestType: RequestType,
): QueryEnvelope => {
const [envelope] = convertBuilderQueriesToV5(
{
A: {
dataSource: DataSource.TRACES,
queryName: 'A',
builderQueryType,
} as unknown as IBuilderQuery,
},
requestType,
);
return envelope;
};
it.each<[RequestType]>([['trace'], ['raw']])(
'sends builder_ai_query for the %s request type',
(requestType) => {
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
'builder_ai_query',
);
},
);
it.each<[RequestType]>([['time_series'], ['scalar'], ['distribution']])(
'falls back to builder_query for the unsupported %s request type',
(requestType) => {
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
'builder_query',
);
},
);
it.each<[string, IBuilderQuery['builderQueryType']]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('sends builder_query for %s', (_label, builderQueryType) => {
expect(buildEnvelope(builderQueryType, 'trace').type).toBe('builder_query');
});
});

View File

@@ -13,6 +13,7 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import {
BaseBuilderQuery,
BuilderQueryType,
FieldContext,
FieldDataType,
Filter,
@@ -315,6 +316,23 @@ export function createAggregation(
return [{ expression: 'count()' }];
}
// The gen_ai-scoped builder serves only trace and raw; other panels fall back to the generic builder.
const AI_QUERY_REQUEST_TYPES: RequestType[] = ['trace', 'raw'];
function resolveBuilderQueryType(
queryData: IBuilderQuery,
requestType: RequestType,
): BuilderQueryType {
if (
queryData.builderQueryType === 'builder_ai_query' &&
AI_QUERY_REQUEST_TYPES.includes(requestType)
) {
return 'builder_ai_query';
}
return 'builder_query';
}
/**
* Converts query builder data to V5 builder queries
*/
@@ -364,7 +382,7 @@ export function convertBuilderQueriesToV5(
}
return {
type: 'builder_query' as QueryType,
type: resolveBuilderQueryType(queryData, requestType),
spec,
};
},

View File

@@ -22,6 +22,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
isListViewPanel = false,
showOnlyWhereClause = false,
showTraceOperator = false,
showSpanScopeSelector = true,
version,
onSignalSourceChange,
signalSourceChangeEnabled = false,
@@ -203,6 +204,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryComponents={queryComponents}
isMultiQueryAllowed={isMultiQueryAllowed}
showTraceOperator={showTraceOperator}
showSpanScopeSelector={showSpanScopeSelector}
hasTraceOperator={hasTraceOperator}
version={version}
isAvailableToDisable={false}
@@ -228,6 +230,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
isMultiQueryAllowed={isMultiQueryAllowed}
isAvailableToDisable={false}
showTraceOperator={showTraceOperator}
showSpanScopeSelector={showSpanScopeSelector}
hasTraceOperator={hasTraceOperator}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}

View File

@@ -16,7 +16,6 @@ import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import cx from 'classnames';
import {
@@ -54,6 +53,11 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
fetchFieldKeysForQuery,
SuggestedFieldKey,
SuggestedFieldKeysByName,
} from './keySuggestions';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -264,10 +268,8 @@ function QuerySearch({
);
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
@@ -320,16 +322,16 @@ function QuerySearch({
lastFetchedKeyRef.current = searchText || '';
const response = await getKeySuggestions({
signal: dataSource,
const keys = await fetchFieldKeysForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
metricNamespace,
});
if (response.data.data) {
const { keys } = response.data.data;
if (keys) {
const options = generateOptions(keys);
// Deduplicate by full variant identity (name + context + data type), NOT by
// label. deduping by label removes varient which is not expected. If we need
@@ -363,6 +365,7 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
queryData.builderQueryType,
],
);

View File

@@ -0,0 +1,83 @@
import { getAIObservabilityFieldsKeys } from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { DataSource } from 'types/common/queryBuilder';
import { fetchFieldKeysForQuery } from '../keySuggestions';
jest.mock('api/generated/services/ai-observability', () => ({
getAIObservabilityFieldsKeys: jest.fn(),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn(),
}));
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
typeof getAIObservabilityFieldsKeys
>;
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
describe('fetchFieldKeysForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: {
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
},
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const keys = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: 'llm',
});
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
expect(mockedGenericKeys).not.toHaveBeenCalled();
expect(keys).toStrictEqual({
llm_call_count: [{ name: 'llm_call_count' }],
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
mockedGenericKeys.mockResolvedValue({
data: { status: 'success', data: { complete: true, keys: {} } },
} as Awaited<ReturnType<typeof getKeySuggestions>>);
await fetchFieldKeysForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
searchText: 'svc',
});
expect(mockedAIKeys).not.toHaveBeenCalled();
expect(mockedGenericKeys).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
);
});
it('normalizes a null ai_observability keys payload to undefined', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: { complete: false, keys: null },
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
await expect(
fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: '',
}),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,48 @@
import { getAIObservabilityFieldsKeys } from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
/** The only fields both key endpoints agree on; ai_observability reports a wider fieldContext (adds trace). */
export interface SuggestedFieldKey {
name: string;
fieldContext?: string;
fieldDataType?: string;
}
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
interface FetchFieldKeysParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
metricNamespace?: string;
}
/** builder_ai_query needs the ai_observability endpoint: its per-trace aggregates are computed, never ingested. */
export const fetchFieldKeysForQuery = async ({
builderQueryType,
dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysByName | undefined> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsKeys({ searchText });
return response.data?.keys ?? undefined;
}
const response = await getKeySuggestions({
signal: dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
});
return response.data.data?.keys;
};

View File

@@ -34,6 +34,7 @@ export const QueryV2 = forwardRef(function QueryV2(
filterConfigs,
isListViewPanel = false,
showTraceOperator = false,
showSpanScopeSelector = true,
hasTraceOperator = false,
version,
showOnlyWhereClause = false,
@@ -93,9 +94,9 @@ export const QueryV2 = forwardRef(function QueryV2(
[dataSource, panelType],
);
const showSpanScopeSelector = useMemo(
() => dataSource === DataSource.TRACES,
[dataSource],
const shouldShowSpanScopeSelector = useMemo(
() => dataSource === DataSource.TRACES && showSpanScopeSelector,
[dataSource, showSpanScopeSelector],
);
const showInlineQuerySearch = useMemo(() => {
@@ -185,7 +186,7 @@ export const QueryV2 = forwardRef(function QueryV2(
/>
</div>
{showSpanScopeSelector && (
{shouldShowSpanScopeSelector && (
<div className="traces-search-filter-container">
<div className="traces-search-filter-in">in</div>
<SpanScopeSelector query={query} />
@@ -255,7 +256,7 @@ export const QueryV2 = forwardRef(function QueryV2(
/>
</div>
{showSpanScopeSelector && (
{shouldShowSpanScopeSelector && (
<div className="traces-search-filter-container">
<div className="traces-search-filter-in">in</div>
<SpanScopeSelector query={query} />

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',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',

View File

@@ -348,6 +348,19 @@ export const initialQueryMeterWithType: Query = {
},
};
export const initialQueryAIWithType: Query = {
...initialQueryWithType,
builder: {
...initialQueryWithType.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
builderQueryType: 'builder_ai_query',
},
],
},
};
export const operatorsByTypes: Record<LocalDataType, string[]> = {
string: Object.values(StringOperators),
number: Object.values(NumberOperators),

View File

@@ -0,0 +1,21 @@
.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;
// Resets button chrome: this was a bare div in the traces explorer.
border: none;
background: none;
padding: 0;
color: inherit;
font: inherit;
}

View File

@@ -0,0 +1,79 @@
import { memo, useState } from 'react';
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 [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"
>
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,42 @@
.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);
&.isFiltersExpanded {
width: calc(100% - 260px);
}
}
.views {
padding: var(--spacing-4);
// Room for the floating options bar this explorer doesn't render yet.
padding-bottom: 60px;
margin-bottom: var(--spacing-12);
}

View File

@@ -1,13 +1,247 @@
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 { initialQueryAIWithType } 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 {
DEFAULT_PANEL_TYPE,
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';
// Forked from the Traces Explorer; diverges as the GenAI query surface lands.
function Explorer(): JSX.Element {
const {
updateAllQueriesOperators,
handleRunQuery,
stagedQuery,
handleSetConfig,
} = useQueryBuilder();
// TODO(ai-explorer): destructure `{ options }` when save-view / add-to-dashboard
// land (Traces Explorer passes it to getExportQueryData). Until then the call
// only seeds `?options=` for views that do not mount ListView.
// TODO: shares the Traces Explorer's saved columns; needs its own ai_o11y key.
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(DEFAULT_PANEL_TYPE);
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);
// The active view unmounts on cancel, so no child will reset this.
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(
initialQueryAIWithType,
DEFAULT_PANEL_TYPE,
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 && (
<ListView
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
)}
{!isCancelled && selectedView === ExplorerViews.TRACE && (
<TracesView
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
)}
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
<TimeSeriesView
dataSource={DataSource.TRACES}
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
)}
{!isCancelled && selectedView === ExplorerViews.TABLE && (
<TableView
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
)}
</div>
</div>
</div>
</Sentry.ErrorBoundary>
);
}

View File

@@ -0,0 +1,37 @@
.container {
display: flex;
flex-direction: column;
--typography-color: var(--l1-foreground);
}
// Offset clears the toolbar, query builder, and controls row above the table.
.table {
max-height: calc(100vh - 360px);
overflow-y: auto;
}
.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,307 @@
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 { initialQueryAIWithType, 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 { buildCompositeKey } from 'container/OptionsMenu/utils';
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);
// TODO: column edits leak to Traces Explorer; needs its own ai_o11y key.
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 || initialQueryAIWithType, orderBy),
[stagedQuery, orderBy],
);
// Query-key slice for selectColumns: stable on reorder, changes on
// add/remove/replace. Composite key so resource.foo ≠ attribute.foo.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => buildCompositeKey(c.name, c.fieldContext))
.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}
className={styles.table}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder={false}
onColumnOrderChange={handleColumnOrderChange}
isLoading={isFetching}
getRowKey={getTraceRowKey}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
disableVirtualScroll
testId="ai-observability-list-view-table"
/>
)}
</div>
);
}
ListView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(ListView);

View File

@@ -0,0 +1,111 @@
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';
/** Cells must tolerate missing values: skeleton rows pass through them. */
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,
enableMove: false,
width: { default: 180, min: 180 },
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: false,
width: { min: 192 },
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,25 @@
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: span id per row, trace id as the root-only fallback. */
export const getTraceRowKey = (record: TraceListRow): string =>
formatCellValue(
record?.spanID ?? record?.span_id ?? record?.traceID ?? record?.trace_id,
);

View File

@@ -0,0 +1,42 @@
import { memo, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
import { DEFAULT_PANEL_TYPE } from '../constants';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
// Only reaches the builder for timeseries/table; list and trace panels get
// QueryBuilderV2's own listViewTracesFilterConfigs instead.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: false, isDisabled: true },
having: { isHidden: false, isDisabled: true },
}),
[],
);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
);
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
showSpanScopeSelector={false}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={isListViewPanel}
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,17 @@
import { Typography } from '@signozhq/ui/typography';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import styles from './TraceLoading.module.scss';
export function TracesLoading(): JSX.Element {
return (
<div className={styles.loadingTraces}>
<div className={styles.content}>
<img className={styles.gif} src={loadingPlaneUrl} alt="wait-icon" />
<Typography>Retrieving your traces!</Typography>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
.container {
display: flex;
flex-direction: column;
}
.actionsContainer {
display: flex;
justify-content: space-between;
align-items: center;
}
.controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-4);
}
.table {
max-height: calc(100vh - 330px);
overflow-y: auto;
}

View File

@@ -0,0 +1,213 @@
/* 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 { initialQueryAIWithType, 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 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 || initialQueryAIWithType),
[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 (
<div className={styles.container}>
{tableData.length !== 0 && (
<div className={styles.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>
</div>
)}
{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}
className={styles.table}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
isLoading={isLoading}
getRowKey={getRootSpanRowKey}
disableVirtualScroll
testId="ai-observability-traces-view-table"
/>
)}
</div>
);
}
TracesView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(TracesView);

View File

@@ -0,0 +1,77 @@
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';
/** Fixed root-span columns — no user selection, so nothing can be removed. */
export const columns: TableColumnDef<TraceListRow>[] = [
{
id: 'serviceName',
header: 'Root Service Name',
accessorFn: (row): unknown => row?.['service.name'],
enableRemove: false,
width: { min: 200 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
),
},
{
id: 'name',
header: 'Root Operation Name',
accessorFn: (row): unknown => row?.name,
enableRemove: false,
width: { min: 260 },
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: 170 },
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: 120 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
),
},
{
id: 'traceID',
header: 'TraceID',
accessorFn: (row): unknown => row?.trace_id,
enableRemove: false,
width: { min: 290 },
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,5 @@
import { formatCellValue, TraceListRow } from '../tableUtils';
/** Row identity for root spans: one row per trace; tolerates skeleton rows. */
export const getRootSpanRowKey = (record: TraceListRow): string =>
formatCellValue(record?.trace_id ?? record?.traceID);

View File

@@ -0,0 +1,53 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
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',
},
};
//TODO: Change this later
export const defaultSelectedColumns: string[] = [
'service.name',
'name',
'duration_nano',
'http_method',
'response_status_code',
'timestamp',
];
export const PER_PAGE_OPTIONS: number[] = DEFAULT_PER_PAGE_OPTIONS;

View File

@@ -0,0 +1,75 @@
import { initialQueryAIWithType, 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(initialQueryAIWithType);
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,16 @@
/** Span attributes, flattened. Every field access must tolerate undefined. */
export type TraceListRow = Record<string, unknown>;
/** Renders a row value as text; objects are JSON-serialised. */
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,12 @@ 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.
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) =>

View File

@@ -37,6 +37,8 @@ export type QueryBuilderProps = {
showOnlyTraceOperator?: boolean;
showTraceViewSelector?: boolean;
showTraceOperator?: boolean;
/** Traces-only control; the AI explorer hides it since the gen_ai gate already scopes spans. */
showSpanScopeSelector?: boolean;
version: string;
onChangeTraceView?: (view: TraceView) => void;
onSignalSourceChange?: (value: string) => void;

View File

@@ -8,6 +8,7 @@ import {
} from 'types/common/queryBuilder';
import {
BuilderQueryType,
Filter,
Having as HavingV5,
LogAggregation,
@@ -90,6 +91,8 @@ export type IBuilderQuery = {
offset?: number;
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
source?: 'meter' | '';
/** Envelope type this query serializes to in the v5 payload; absent means builder_query. */
builderQueryType?: BuilderQueryType;
};
export interface IClickHouseQuery {

View File

@@ -16,6 +16,7 @@ export type RequestType =
export type QueryType =
| 'builder_query'
| 'builder_ai_query'
| 'builder_trace_operator'
| 'builder_formula'
| 'builder_sub_query'
@@ -23,6 +24,12 @@ export type QueryType =
| 'clickhouse_sql'
| 'promql';
/** Envelope types carrying a BuilderQuery spec; builder_ai_query routes to the gen_ai-scoped trace builder. */
export type BuilderQueryType = Extract<
QueryType,
'builder_query' | 'builder_ai_query'
>;
export type OrderDirection = 'asc' | 'desc';
export type JoinType = 'inner' | 'left' | 'right' | 'full' | 'cross';