mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-07 13:40:40 +01:00
Compare commits
4 Commits
test/keyle
...
cursor/poc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
184743a334 | ||
|
|
55d5ddb9ba | ||
|
|
fead012459 | ||
|
|
5f50dcd349 |
@@ -23,6 +23,7 @@ export const getKeySuggestions = (
|
||||
fieldDataType = '',
|
||||
signalSource = '',
|
||||
metricNamespace = '',
|
||||
type = '',
|
||||
} = props;
|
||||
|
||||
const encodedSignal = encodeURIComponent(signal);
|
||||
@@ -32,8 +33,9 @@ export const getKeySuggestions = (
|
||||
const encodedFieldDataType = encodeURIComponent(fieldDataType);
|
||||
const encodedSource = encodeURIComponent(signalSource);
|
||||
const encodedMetricNamespace = encodeURIComponent(metricNamespace);
|
||||
const encodedType = encodeURIComponent(type);
|
||||
|
||||
return axios.get(
|
||||
`/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}&metricNamespace=${encodedMetricNamespace}`,
|
||||
`/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}&metricNamespace=${encodedMetricNamespace}&type=${encodedType}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,6 +31,13 @@ interface FieldsSelectorProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
/**
|
||||
* Caller-supplied field list. When provided, Other Fields offers exactly
|
||||
* these and key discovery is skipped — use it when the set of selectable
|
||||
* fields is known up front rather than fetched. Omit to discover keys from
|
||||
* the API for `signal`.
|
||||
*/
|
||||
availableFields?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
type FieldsSelectorContentProps = Omit<FieldsSelectorProps, 'isOpen'>;
|
||||
@@ -49,6 +56,7 @@ function FieldsSelectorContent({
|
||||
width = DEFAULT_PANEL_WIDTH,
|
||||
height,
|
||||
defaultPosition,
|
||||
availableFields,
|
||||
}: FieldsSelectorContentProps): JSX.Element {
|
||||
const resolvedHeight =
|
||||
height ?? window.innerHeight - DEFAULT_PANEL_HEIGHT_OFFSET;
|
||||
@@ -153,6 +161,7 @@ function FieldsSelectorContent({
|
||||
addedFields={draftFields}
|
||||
onAdd={handleAdd}
|
||||
isAtLimit={isAtLimit}
|
||||
availableFields={availableFields}
|
||||
/>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
|
||||
@@ -21,6 +21,11 @@ interface OtherFieldsProps {
|
||||
addedFields: TelemetryFieldKey[];
|
||||
onAdd: (field: TelemetryFieldKey) => void;
|
||||
isAtLimit: boolean;
|
||||
/**
|
||||
* Caller-supplied field list. When provided, key discovery is skipped and
|
||||
* these are filtered locally by the search term instead.
|
||||
*/
|
||||
availableFields?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
function OtherFields({
|
||||
@@ -29,7 +34,10 @@ function OtherFields({
|
||||
addedFields,
|
||||
onAdd,
|
||||
isAtLimit,
|
||||
availableFields,
|
||||
}: OtherFieldsProps): JSX.Element {
|
||||
const useRegistry = Boolean(availableFields);
|
||||
|
||||
const { data, isFetching } = useGetQueryKeySuggestions(
|
||||
{
|
||||
signal,
|
||||
@@ -41,11 +49,34 @@ function OtherFields({
|
||||
signal,
|
||||
debouncedInputValue,
|
||||
],
|
||||
enabled: true,
|
||||
enabled: !useRegistry,
|
||||
},
|
||||
);
|
||||
|
||||
const otherFields: TelemetryFieldKey[] = useMemo(() => {
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
);
|
||||
|
||||
if (useRegistry && availableFields) {
|
||||
const search = debouncedInputValue.trim().toLowerCase();
|
||||
return availableFields
|
||||
.filter((attr) => {
|
||||
const id = attr.key ?? buildCompositeKey(attr.name, attr.fieldContext);
|
||||
if (addedIds.has(id)) {
|
||||
return false;
|
||||
}
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
return attr.name.toLowerCase().includes(search);
|
||||
})
|
||||
.map((attr) => ({
|
||||
...attr,
|
||||
key: attr.key ?? buildCompositeKey(attr.name, attr.fieldContext),
|
||||
}));
|
||||
}
|
||||
|
||||
const suggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
// Normalize: synthesize `key` once so downstream reads can trust it.
|
||||
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
|
||||
@@ -57,15 +88,12 @@ function OtherFields({
|
||||
fieldDataType: attr.fieldDataType,
|
||||
}),
|
||||
);
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
);
|
||||
return normalizedSuggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
);
|
||||
}, [data, addedFields]);
|
||||
}, [data, addedFields, availableFields, debouncedInputValue, useRegistry]);
|
||||
|
||||
if (isFetching) {
|
||||
if (!useRegistry && isFetching) {
|
||||
return (
|
||||
<div className={cx(styles.section, styles.sectionOther)}>
|
||||
<div className={styles.sectionHeader}>OTHER FIELDS</div>
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import {
|
||||
QueryKeyDataSuggestionsProps,
|
||||
QueryKeyRequestProps,
|
||||
} from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import './ListViewOrderBy.styles.scss';
|
||||
@@ -11,6 +14,21 @@ interface ListViewOrderByProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
dataSource: DataSource;
|
||||
/**
|
||||
* Narrows the key suggestions to one context, e.g. `trace` to offer only
|
||||
* trace-level aggregates. Omit to offer every key for `dataSource`.
|
||||
*/
|
||||
fieldContext?: QueryKeyRequestProps['fieldContext'];
|
||||
/**
|
||||
* Key prepended to the options when the search box is empty, so the caller's
|
||||
* primary sort field is reachable in one click. Defaults to `timestamp`.
|
||||
*/
|
||||
seedKey?: string;
|
||||
/**
|
||||
* Query type the keys must be valid for, e.g. `builder_ai_query`. Omit for the
|
||||
* default builder query.
|
||||
*/
|
||||
queryType?: string;
|
||||
}
|
||||
|
||||
// Loader component for the dropdown when loading or no results
|
||||
@@ -26,6 +44,9 @@ function ListViewOrderBy({
|
||||
value,
|
||||
onChange,
|
||||
dataSource,
|
||||
fieldContext,
|
||||
seedKey = 'timestamp',
|
||||
queryType,
|
||||
}: ListViewOrderByProps): JSX.Element {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [debouncedInput, setDebouncedInput] = useState('');
|
||||
@@ -36,11 +57,19 @@ function ListViewOrderBy({
|
||||
|
||||
// Fetch key suggestions based on debounced input
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['orderByKeySuggestions', dataSource, debouncedInput],
|
||||
queryKey: [
|
||||
'orderByKeySuggestions',
|
||||
dataSource,
|
||||
debouncedInput,
|
||||
fieldContext,
|
||||
queryType,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const response = await getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText: debouncedInput,
|
||||
fieldContext,
|
||||
type: queryType,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -63,7 +92,7 @@ function ListViewOrderBy({
|
||||
|
||||
const keyNames = rawKeys.map((key) => key.name);
|
||||
const uniqueKeys = [
|
||||
...new Set(searchInput ? keyNames : ['timestamp', ...keyNames]),
|
||||
...new Set(searchInput ? keyNames : [seedKey, ...keyNames]),
|
||||
];
|
||||
|
||||
const updatedOptions = uniqueKeys.flatMap((key) => [
|
||||
@@ -72,7 +101,7 @@ function ListViewOrderBy({
|
||||
]);
|
||||
|
||||
setSelectOptions(updatedOptions);
|
||||
}, [data, searchInput]);
|
||||
}, [data, searchInput, seedKey]);
|
||||
|
||||
// Handle search input with debounce
|
||||
const handleSearch = (input: string): void => {
|
||||
|
||||
@@ -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 Trace View column visibility. */
|
||||
AI_TRACE_VIEW_COLUMNS = 'AI_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',
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
BASE_TRACE_VIEW_COLUMNS,
|
||||
renderTraceDurationCell,
|
||||
TraceViewColumn,
|
||||
} from 'container/TracesExplorer/TracesView/configs';
|
||||
import { TraceViewColumnSelection } from 'container/TracesExplorer/TracesView/useTraceViewColumns';
|
||||
|
||||
function isBlank(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
function renderCountCell(value: unknown): JSX.Element {
|
||||
if (isBlank(value)) {
|
||||
return <Typography>—</Typography>;
|
||||
}
|
||||
const count = Number(value);
|
||||
return (
|
||||
<Typography>
|
||||
{Number.isFinite(count) ? count.toLocaleString() : String(value)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCostCell(value: unknown): JSX.Element {
|
||||
if (isBlank(value)) {
|
||||
return <Typography>—</Typography>;
|
||||
}
|
||||
const cost = Number(value);
|
||||
return (
|
||||
<Typography>
|
||||
{Number.isFinite(cost) ? `$${cost.toFixed(4)}` : String(value)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace-level gen_ai aggregates. The query-range response does not carry these
|
||||
* yet, so they render as em dashes until the AI trace API lands — visible but
|
||||
* empty is intentional, it lets the column set be reviewed ahead of the data.
|
||||
*/
|
||||
const AI_ONLY_COLUMNS: TraceViewColumn[] = [
|
||||
{
|
||||
field: {
|
||||
name: 'input_tokens',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Input Tokens',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'output_tokens',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Output Tokens',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'total_tokens',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Total Tokens',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'llm_call_count',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'LLM Calls',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'tool_call_count',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Tool Calls',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'distinct_tool_count',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Distinct Tools',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'estimated_cost_usd',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'float64',
|
||||
},
|
||||
title: 'Est. Cost (USD)',
|
||||
render: renderCostCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'max_llm_latency_ns',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Max LLM Latency',
|
||||
render: renderTraceDurationCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'last_activity_time',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Last Activity',
|
||||
},
|
||||
{
|
||||
field: { name: 'start_time', fieldContext: 'trace', fieldDataType: 'int64' },
|
||||
title: 'Start Time',
|
||||
},
|
||||
{
|
||||
field: { name: 'end_time', fieldContext: 'trace', fieldDataType: 'int64' },
|
||||
title: 'End Time',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'trace_duration_nano',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Trace Duration',
|
||||
render: renderTraceDurationCell,
|
||||
},
|
||||
{
|
||||
field: { name: 'error_count', fieldContext: 'trace', fieldDataType: 'int64' },
|
||||
title: 'Errors',
|
||||
render: renderCountCell,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'root_span_name',
|
||||
fieldContext: 'trace',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
title: 'Root Span Name',
|
||||
},
|
||||
{
|
||||
field: { name: 'input', fieldContext: 'trace', fieldDataType: 'string' },
|
||||
title: 'Input',
|
||||
},
|
||||
{
|
||||
field: { name: 'output', fieldContext: 'trace', fieldDataType: 'string' },
|
||||
title: 'Output',
|
||||
},
|
||||
];
|
||||
|
||||
export const AI_TRACE_VIEW_COLUMNS: TraceViewColumn[] = [
|
||||
...BASE_TRACE_VIEW_COLUMNS,
|
||||
...AI_ONLY_COLUMNS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Hand this to `<TracesView columnSelection={…} />` to get the AI column set
|
||||
* plus the Options → Edit columns picker. Module-level so its identity is
|
||||
* stable across renders.
|
||||
*/
|
||||
export const AI_TRACE_VIEW_COLUMN_SELECTION: TraceViewColumnSelection = {
|
||||
columns: AI_TRACE_VIEW_COLUMNS,
|
||||
storageKey: LOCALSTORAGE.AI_TRACE_VIEW_COLUMNS,
|
||||
defaultVisible: BASE_TRACE_VIEW_COLUMNS.map((column) => column.field.name),
|
||||
};
|
||||
@@ -11,4 +11,12 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
// Rendered as a <button> for keyboard access — strip the native chrome so it
|
||||
// still reads as the inline text trigger it was.
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
@@ -15,6 +16,7 @@ function TraceExplorerControls({
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
availableFields,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
@@ -30,13 +32,15 @@ function TraceExplorerControls({
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
data-testid="trace-view-options-trigger"
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
</button>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
@@ -44,6 +48,7 @@ function TraceExplorerControls({
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
availableFields={availableFields}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -63,20 +68,20 @@ function TraceExplorerControls({
|
||||
);
|
||||
}
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
/** Forwarded to FieldsSelector — see `availableFields` there. */
|
||||
availableFields?: TelemetryFieldKey[];
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
showSizeChanger: true,
|
||||
availableFields: undefined,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
|
||||
@@ -4,47 +4,117 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { ListItem } from 'types/api/widgets/getQuery';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
export const columns: ColumnsType<ListItem['data']> = [
|
||||
/** Query type whose keys are trace-level AI aggregates. */
|
||||
export const AI_QUERY_TYPE = 'builder_ai_query';
|
||||
|
||||
/**
|
||||
* Order by defaults, kept inside Trace View for now — a row here is a whole
|
||||
* trace, so most-recent-activity is the sensible landing sort and only
|
||||
* trace-level fields are sortable. The dropdown seeds its first option from the
|
||||
* same key, so the two cannot disagree. Revisit if a caller needs to override.
|
||||
*/
|
||||
export const DEFAULT_TRACE_VIEW_ORDER_BY = 'last_activity_time:desc';
|
||||
export const TRACE_VIEW_ORDER_BY_FIELD_CONTEXT = 'trace';
|
||||
|
||||
/**
|
||||
* One Trace View column: the telemetry field it reads, its header, and how the
|
||||
* cell renders. Callers own their column sets — Trace View has no knowledge of
|
||||
* any particular product's columns.
|
||||
*/
|
||||
export interface TraceViewColumn {
|
||||
field: TelemetryFieldKey;
|
||||
title: string;
|
||||
/** Defaults to `renderTraceCellValue`. */
|
||||
render?: (value: unknown) => JSX.Element;
|
||||
}
|
||||
|
||||
function isBlank(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
/** Fallback cell: em dash when empty, otherwise stringified. */
|
||||
export function renderTraceCellValue(value: unknown): JSX.Element {
|
||||
if (isBlank(value)) {
|
||||
return <Typography>—</Typography>;
|
||||
}
|
||||
return <Typography>{String(value)}</Typography>;
|
||||
}
|
||||
|
||||
/** Nanosecond duration rendered as milliseconds. */
|
||||
export function renderTraceDurationCell(value: unknown): JSX.Element {
|
||||
if (isBlank(value)) {
|
||||
return <Typography>—</Typography>;
|
||||
}
|
||||
return <Typography>{getMs(String(value))}ms</Typography>;
|
||||
}
|
||||
|
||||
function renderTraceIdCell(value: unknown): JSX.Element {
|
||||
if (isBlank(value)) {
|
||||
return <Typography>—</Typography>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: String(value),
|
||||
})}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{String(value)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The root-span columns Trace View renders when the caller configures no
|
||||
* column selection. Matches the pre-selection behaviour exactly.
|
||||
*/
|
||||
export const BASE_TRACE_VIEW_COLUMNS: TraceViewColumn[] = [
|
||||
{
|
||||
field: {
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
title: 'Root Service Name',
|
||||
dataIndex: 'service.name',
|
||||
key: 'serviceName',
|
||||
},
|
||||
{
|
||||
field: { name: 'name', fieldContext: 'span', fieldDataType: 'string' },
|
||||
title: 'Root Operation Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'duration_nano',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'int64',
|
||||
},
|
||||
title: 'Root Duration (in ms)',
|
||||
dataIndex: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
render: (duration: number): JSX.Element => (
|
||||
<Typography>{getMs(String(duration))}ms</Typography>
|
||||
),
|
||||
render: renderTraceDurationCell,
|
||||
},
|
||||
{
|
||||
field: { name: 'span_count', fieldContext: 'trace', fieldDataType: 'int64' },
|
||||
title: 'No of Spans',
|
||||
dataIndex: 'span_count',
|
||||
key: 'span_count',
|
||||
},
|
||||
{
|
||||
field: { name: 'trace_id', fieldContext: 'span', fieldDataType: 'string' },
|
||||
title: 'TraceID',
|
||||
dataIndex: 'trace_id',
|
||||
key: 'traceID',
|
||||
render: (traceID: string): JSX.Element => (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceID,
|
||||
})}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{traceID}
|
||||
</Link>
|
||||
),
|
||||
render: renderTraceIdCell,
|
||||
},
|
||||
];
|
||||
|
||||
/** Build antd columns, preserving the order given. */
|
||||
export function buildTraceViewColumns(
|
||||
columns: TraceViewColumn[],
|
||||
): ColumnsType<ListItem['data']> {
|
||||
return columns.map(({ field, title, render }) => ({
|
||||
title,
|
||||
dataIndex: field.name,
|
||||
key: field.name,
|
||||
render: (value: unknown): JSX.Element =>
|
||||
(render ?? renderTraceCellValue)(value),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -13,6 +15,7 @@ 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 ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -28,20 +31,46 @@ import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { QueryKeyRequestProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import {
|
||||
AI_QUERY_TYPE,
|
||||
BASE_TRACE_VIEW_COLUMNS,
|
||||
buildTraceViewColumns,
|
||||
DEFAULT_TRACE_VIEW_ORDER_BY,
|
||||
PER_PAGE_OPTIONS,
|
||||
TRACE_VIEW_ORDER_BY_FIELD_CONTEXT,
|
||||
} from './configs';
|
||||
import { ActionsContainer, Container } from './styles';
|
||||
import useTraceViewColumns, {
|
||||
TraceViewColumnSelection,
|
||||
} from './useTraceViewColumns';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
/**
|
||||
* Opt in to user-editable columns. Omit for the base root-span columns with
|
||||
* no Options → Edit columns picker.
|
||||
*/
|
||||
columnSelection?: TraceViewColumnSelection;
|
||||
/**
|
||||
* Query type these rows come from. `builder_ai_query` turns on Order by,
|
||||
* scoped to trace-level aggregates — the sort key and field context are Trace
|
||||
* View defaults for now. Omit and no `order` is sent at all, as today.
|
||||
*
|
||||
* Order-by state is per-view on purpose: List View sorts spans by `timestamp`,
|
||||
* which is not a valid sort over traces, so sharing one value across views
|
||||
* would push an unexecutable sort through a view switch.
|
||||
*/
|
||||
queryType?: string;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
@@ -49,6 +78,8 @@ function TracesView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
columnSelection,
|
||||
queryType,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -62,9 +93,56 @@ function TracesView({
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
// Column visibility is owned here rather than by useOptionsMenu, whose
|
||||
// TRACES_LIST_OPTIONS storage is already claimed by List View.
|
||||
const { visibleColumns, selectedFields, availableFields, onFieldsChange } =
|
||||
useTraceViewColumns(columnSelection);
|
||||
|
||||
const fieldsSelectorConfig = useMemo(
|
||||
() =>
|
||||
columnSelection
|
||||
? {
|
||||
fieldsSelector: {
|
||||
value: selectedFields,
|
||||
onFieldsChange,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
[columnSelection, selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
buildTraceViewColumns(
|
||||
columnSelection ? visibleColumns : BASE_TRACE_VIEW_COLUMNS,
|
||||
),
|
||||
[columnSelection, visibleColumns],
|
||||
);
|
||||
|
||||
// Only the AI query exposes sortable trace-level aggregates, so it alone gets
|
||||
// an Order by control.
|
||||
const isOrderByEnabled = queryType === AI_QUERY_TYPE;
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>(() =>
|
||||
isOrderByEnabled ? DEFAULT_TRACE_VIEW_ORDER_BY : '',
|
||||
);
|
||||
|
||||
// Seed the dropdown from the same key we start sorted by, so its first option
|
||||
// is always the sort currently applied.
|
||||
const orderBySeedKey = DEFAULT_TRACE_VIEW_ORDER_BY.split(':')[0];
|
||||
|
||||
const handleOrderChange = useCallback((value: string): void => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
[stagedQuery],
|
||||
// Empty means "no sort" — passing '' would shape an order on a blank column.
|
||||
() =>
|
||||
getListViewQuery(
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
orderBy || undefined,
|
||||
),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
@@ -76,6 +154,11 @@ function TracesView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
// `orderBy` belongs here — unlike column visibility, it changes the
|
||||
// request and must refetch. Column visibility is deliberately absent:
|
||||
// it is client-side only, and this array doubles as the parent's
|
||||
// cancelQueries handle.
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
@@ -84,6 +167,7 @@ function TracesView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -100,6 +184,8 @@ function TracesView({
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
// No selectColumns: the backend returns all columns and visibility is
|
||||
// resolved client-side, so toggling a column is refetch-free.
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
},
|
||||
@@ -153,6 +239,17 @@ function TracesView({
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
{isOrderByEnabled && (
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={handleOrderChange}
|
||||
dataSource={DataSource.TRACES}
|
||||
fieldContext={TRACE_VIEW_ORDER_BY_FIELD_CONTEXT}
|
||||
seedKey={orderBySeedKey}
|
||||
queryType={queryType}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
@@ -162,6 +259,8 @@ function TracesView({
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
config={fieldsSelectorConfig}
|
||||
availableFields={columnSelection ? availableFields : undefined}
|
||||
/>
|
||||
</div>
|
||||
</ActionsContainer>
|
||||
@@ -190,7 +289,7 @@ function TracesView({
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ResizeTable
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
columns={tableColumns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
scroll={{ x: true }}
|
||||
@@ -203,6 +302,8 @@ function TracesView({
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
columnSelection: undefined,
|
||||
queryType: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { TraceViewColumn } from './configs';
|
||||
|
||||
/**
|
||||
* Opt-in column selection for Trace View. Passing one enables the Options →
|
||||
* Edit columns picker; omitting it leaves Trace View on its base columns with
|
||||
* no picker at all.
|
||||
*
|
||||
* Pass a module-level constant, not an inline literal — a fresh object each
|
||||
* render invalidates the memoised column set on every pass.
|
||||
*/
|
||||
export interface TraceViewColumnSelection {
|
||||
/** Every column offered, in default display order. */
|
||||
columns: TraceViewColumn[];
|
||||
/** Where this caller persists visibility. Must be unique per view. */
|
||||
storageKey: LOCALSTORAGE;
|
||||
/** Field names visible before the user customises anything. */
|
||||
defaultVisible: string[];
|
||||
}
|
||||
|
||||
interface UseTraceViewColumnsReturn {
|
||||
/** Columns to render, in the user's persisted order. */
|
||||
visibleColumns: TraceViewColumn[];
|
||||
/** Visible fields, for the picker's "added" list. */
|
||||
selectedFields: TelemetryFieldKey[];
|
||||
/** Every offered field, for the picker's "other" list. */
|
||||
availableFields: TelemetryFieldKey[];
|
||||
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
|
||||
}
|
||||
|
||||
function readStoredKeys(
|
||||
selection: TraceViewColumnSelection | undefined,
|
||||
): string[] {
|
||||
if (!selection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { storageKey, columns, defaultVisible } = selection;
|
||||
const raw = getLocalStorageKey(storageKey);
|
||||
if (!raw) {
|
||||
return defaultVisible;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as string[];
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
return defaultVisible;
|
||||
}
|
||||
// Drop anything the caller no longer offers, so a stale localStorage
|
||||
// entry from an earlier column set self-heals instead of rendering blank.
|
||||
const allowed = new Set(columns.map((column) => column.field.name));
|
||||
const filtered = parsed.filter((key) => allowed.has(key));
|
||||
return filtered.length > 0 ? filtered : defaultVisible;
|
||||
} catch {
|
||||
return defaultVisible;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns Trace View column visibility client-side: toggling reorders or hides
|
||||
* columns without touching the query, so it never triggers a refetch.
|
||||
*/
|
||||
function useTraceViewColumns(
|
||||
selection?: TraceViewColumnSelection,
|
||||
): UseTraceViewColumnsReturn {
|
||||
const [visibleKeys, setVisibleKeys] = useState<string[]>(() =>
|
||||
readStoredKeys(selection),
|
||||
);
|
||||
|
||||
const offeredColumns = selection?.columns;
|
||||
|
||||
const columnsByName = useMemo(
|
||||
() =>
|
||||
new Map((offeredColumns ?? []).map((column) => [column.field.name, column])),
|
||||
[offeredColumns],
|
||||
);
|
||||
|
||||
// Ordered by visibleKeys, so reordering in the picker moves the column.
|
||||
const visibleColumns = useMemo(
|
||||
() =>
|
||||
visibleKeys
|
||||
.map((key) => columnsByName.get(key))
|
||||
.filter((column): column is TraceViewColumn => Boolean(column)),
|
||||
[columnsByName, visibleKeys],
|
||||
);
|
||||
|
||||
const selectedFields = useMemo(
|
||||
() => visibleColumns.map((column) => column.field),
|
||||
[visibleColumns],
|
||||
);
|
||||
|
||||
const availableFields = useMemo(
|
||||
() => (offeredColumns ?? []).map((column) => column.field),
|
||||
[offeredColumns],
|
||||
);
|
||||
|
||||
const onFieldsChange = useCallback(
|
||||
(fields: TelemetryFieldKey[]): void => {
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const nextKeys = fields.map((field) => field.name);
|
||||
const keys = nextKeys.length > 0 ? nextKeys : selection.defaultVisible;
|
||||
setVisibleKeys(keys);
|
||||
setLocalStorageKey(selection.storageKey, JSON.stringify(keys));
|
||||
},
|
||||
[selection],
|
||||
);
|
||||
|
||||
return {
|
||||
visibleColumns,
|
||||
selectedFields,
|
||||
availableFields,
|
||||
onFieldsChange,
|
||||
};
|
||||
}
|
||||
|
||||
export default useTraceViewColumns;
|
||||
@@ -17,6 +17,8 @@ import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import { AI_TRACE_VIEW_COLUMN_SELECTION } from 'container/AIObservability/TraceView/aiTraceViewColumns';
|
||||
import { AI_QUERY_TYPE } from 'container/TracesExplorer/TracesView/configs';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import {
|
||||
@@ -330,6 +332,12 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
// DEMO ONLY — surfaces the AI Observability column set and sort
|
||||
// here because the AI Explorer does not exist yet. Drop these two
|
||||
// props (and the import) to return Trace View to its five base
|
||||
// columns with no Order by control.
|
||||
columnSelection={AI_TRACE_VIEW_COLUMN_SELECTION}
|
||||
queryType={AI_QUERY_TYPE}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,11 +30,20 @@ export interface QueryKeySuggestionsResponseProps {
|
||||
export interface QueryKeyRequestProps {
|
||||
signal: 'traces' | 'logs' | 'metrics';
|
||||
searchText: string;
|
||||
fieldContext?: 'resource' | 'scope' | 'attribute' | 'span';
|
||||
/**
|
||||
* `trace` scopes to trace-level fields. Valid for the `traces` signal per
|
||||
* `telemetrytypes.FieldContext` — the union was previously missing it.
|
||||
*/
|
||||
fieldContext?: 'resource' | 'scope' | 'attribute' | 'span' | 'trace';
|
||||
fieldDataType?: FieldDataType;
|
||||
metricName?: string;
|
||||
metricNamespace?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
/**
|
||||
* Query type the keys must be valid for, e.g. `builder_ai_query` to get only
|
||||
* trace-level AI aggregates. Omit for the default builder query.
|
||||
*/
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface QueryKeyValueSuggestionsProps {
|
||||
|
||||
Reference in New Issue
Block a user