Compare commits

...

4 Commits

Author SHA1 Message Date
Gaurav Tewari
ba761e3fb6 feat: add support for trace view as well 2026-10-27 16:12:02 +05:30
Gaurav Tewari
d412e6bc4c feat: add support for columns in list view 2026-10-27 15:26:02 +05:30
Gaurav Tewari
119ab97240 feat: llm o11y improvements (#13229)
#### Description

- **Thread "Go back" stays on the trace:** switching tabs now adds a
browser history entry, so going back from Thread returns to Overview
instead of leaving the trace page.
- **Learn more link:** the Thread empty state now points to
`/docs/ai-observability/` instead of `/docs/llm-observability/`.
- **Empty state UI:** the "no AI spans" Thread state is centered, with
title/description typography matching the "Span not found" state. (In
design it is still left, but let's move it to center it's better.)
- **Span not found only on 404:** an empty thread (span exists, no AI
spans) now shows the no-AI-spans state instead. otherwise user have
click back 2 time :( which is not right .
- **Remove Scroll & key update**: So after feedback from dev we found
scroll in accordian does not feel right. we are removing it. also
"input" key we are using to get json view might be empty sometime. so we
are using formatted onces for both input and output now.
- **Service Dot**: added little margin 

#### Issues closed by this PR

https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=279904779&issue=SigNoz%7Cnerve-pod%7C506

#### Screenshots / Screen Recordings

now - 
<img width="1722" height="1108" alt="image"
src="https://github.com/user-attachments/assets/c8fc6319-f2b8-49e9-80c9-5383616ad5a7"
/>
prev - 

<img width="1710" height="1113" alt="image"
src="https://github.com/user-attachments/assets/d1c13e95-c1a1-4fdb-aebd-ebbe69c286ba"
/>


span details - 
prev- 
<img width="912" height="997" alt="image"
src="https://github.com/user-attachments/assets/d94a4a11-5ab3-4998-b5eb-76375f013115"
/>

now - 
<img width="939" height="1013" alt="image"
src="https://github.com/user-attachments/assets/69224b76-c5f5-4937-8e2d-ff7a1fb5ed78"
/>


service dot- 
before- 
<img width="335" height="81" alt="image"
src="https://github.com/user-attachments/assets/21ad13cb-1050-45e4-82f7-bcdef002d31e"
/>

now - 
<img width="371" height="91" alt="image"
src="https://github.com/user-attachments/assets/18143a71-267b-4923-82ab-eaef1aa94bfa"
/>

#### Additional Information

- Browser back and the header back arrow now also step through tab
switches (Overview → Thread → back lands on Overview).
- The empty-state title is reworded to "This trace has no spans from
AI-related actions".

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-27 08:12:17 +00:00
Aditya Singh
e163ec3b1b feat(saved-views): analytics for saved views and the bottom strip (#13164)
#### Description
- Saved views actions now send analytics events: View created, View
updated, View details edited, View deleted, Link copied, View cleared
and Changes discarded.
- Every event carries `source` (logs, traces, metrics or meter), and
`viewId` when there is a view.
- View created also carries `from`: `header`, `save_as_new` or
`empty_state`.
  - An event fires only after the action succeeds.
- The events follow the trace details pattern: an `events.ts` file and a
`useSavedViewLogEvent` hook that adds `source`.
- Ask Noz in the bottom strip now logs `AI Assistant: Opened` with
`source: bottom_strip`. Strip opens now show in the same funnel as the
header and shortcut opens.
- Support in the bottom strip logs `Bottom Strip: Support clicked`, with
`type` set to `pylon` or `add_card`.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/58

#### Additional Information
- The saved views events use new names. The old per-explorer events,
such as `Logs Explorer: Save view clicked`, stop when #13125 turns on
the flag.
- Opening a view, opening the views panel and searching are not tracked.
- The strip has no conversation history, so #58 item 1 covers Support
only.
2026-10-27 05:45:51 +00:00
48 changed files with 1437 additions and 264 deletions

View File

@@ -14,6 +14,8 @@ export enum LOCALSTORAGE {
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
AI_OBSERVABILITY_LIST_OPTIONS = 'AI_OBSERVABILITY_LIST_OPTIONS',
AI_OBSERVABILITY_TRACE_OPTIONS = 'AI_OBSERVABILITY_TRACE_OPTIONS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',

View File

@@ -71,6 +71,7 @@ export const AIAssistantOpenSource = {
Shortcut: 'shortcut',
Cmdk: 'cmdk',
TraceDetails: 'trace_details',
BottomStrip: 'bottom_strip',
} as const;
export type AIAssistantOpenSource =
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];

View File

@@ -1,8 +1,15 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import {
openAIAssistant,
@@ -37,6 +44,14 @@ function AskNoz(): JSX.Element | null {
// The drawer does not render on the Noz full page, so the button would be inert.
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.BottomStrip,
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
return null;
}
@@ -57,7 +72,7 @@ function AskNoz(): JSX.Element | null {
color="secondary"
size="sm"
prefix={<Noz size={16} />}
onClick={(): void => openAIAssistant()}
onClick={handleOpen}
aria-label={
showPendingBadge
? `Ask Noz, ${pendingUserInputCount} ${

View File

@@ -1,4 +1,9 @@
import { fireEvent } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { render } from 'tests/test-utils';
@@ -6,6 +11,10 @@ import { render } from 'tests/test-utils';
import AskNoz from '../AskNoz';
jest.mock('hooks/useIsAIAssistantEnabled');
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
openAIAssistant: jest.fn(),
useAIAssistantStore: jest.fn(),
@@ -91,6 +100,17 @@ describe('AskNoz', () => {
expect(mockOpen).toHaveBeenCalledTimes(1);
});
it('reports the open as coming from the strip', () => {
const { getByRole } = render(<AskNoz />);
fireEvent.click(getByRole('button'));
expect(logEvent).toHaveBeenCalledWith(
AIAssistantEvents.Opened,
expect.objectContaining({ source: AIAssistantOpenSource.BottomStrip }),
);
});
});
describe('pending badge', () => {

View File

@@ -1,9 +1,16 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import logEvent from 'api/common/logEvent';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { MessageSquareText } from '@signozhq/icons';
import {
BottomStripEventKeys,
BottomStripEvents,
BottomStripSupportType,
} from '../events';
function SupportButton(): JSX.Element | null {
const chatSupport = useChatSupport();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
@@ -14,7 +21,13 @@ function SupportButton(): JSX.Element | null {
}
const handleClick = (): void => {
if (chatSupport === ChatSupportState.NeedsCard) {
const needsCard = chatSupport === ChatSupportState.NeedsCard;
void logEvent(BottomStripEvents.SupportClicked, {
[BottomStripEventKeys.Type]: needsCard
? BottomStripSupportType.AddCard
: BottomStripSupportType.Pylon,
});
if (needsCard) {
setIsAddCreditCardModalOpen(true);
return;
}

View File

@@ -1,9 +1,16 @@
import { fireEvent } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { render } from 'tests/test-utils';
import { BottomStripEvents, BottomStripSupportType } from '../../events';
import SupportButton from '../SupportButton';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('hooks/useChatSupport', () => ({
...jest.requireActual('hooks/useChatSupport'),
useChatSupport: jest.fn(),
@@ -37,6 +44,9 @@ describe('SupportButton', () => {
fireEvent.click(getByTestId(BUTTON));
expect(window.Pylon).toHaveBeenCalledWith('show');
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
type: BottomStripSupportType.Pylon,
});
});
it('does not open the credit card modal', () => {
@@ -64,6 +74,9 @@ describe('SupportButton', () => {
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
expect(window.Pylon).not.toHaveBeenCalled();
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
type: BottomStripSupportType.AddCard,
});
});
});

View File

@@ -0,0 +1,12 @@
export enum BottomStripEvents {
SupportClicked = 'Bottom Strip: Support clicked',
}
export enum BottomStripEventKeys {
Type = 'type',
}
export enum BottomStripSupportType {
Pylon = 'pylon',
AddCard = 'add_card',
}

View File

@@ -37,11 +37,8 @@ import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import { PER_PAGE_OPTIONS, TIMESTAMP_FIELD } from './configs';
import { useListViewColumns } from './useListViewColumns';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
@@ -77,6 +74,23 @@ function ListView({
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const {
selectedFields,
fieldsSelectorConfig,
onColumnOrderChange,
onColumnRemove,
} = useListViewColumns();
// Sorted names: stable on reorder, changes on add/remove (the API only returns selected columns).
const selectColumnsSignature = useMemo(
() =>
selectedFields
.map((field) => field.name)
.sort()
.join(','),
[selectedFields],
);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
@@ -97,12 +111,14 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
],
@@ -123,7 +139,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: defaultSelectedColumns,
selectColumns: selectedFields,
},
},
ENTITY_VERSION_V5,
@@ -156,13 +172,13 @@ function ListView({
[queryTableDataResult],
);
// TODO(ai-explorer): static columns until the preferences framework lands.
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
() =>
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
getFieldColumn(field),
),
[],
[
TIMESTAMP_FIELD,
...selectedFields.filter((field) => field.name !== TIMESTAMP_FIELD.name),
].map((field) => getFieldColumn(field)),
[selectedFields],
);
const rows = useMemo(
@@ -204,6 +220,7 @@ function ListView({
isLoading={isFetching}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={fieldsSelectorConfig}
/>
</div>
@@ -211,7 +228,6 @@ function ListView({
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
@@ -219,6 +235,8 @@ function ListView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
/>
</div>
);

View File

@@ -0,0 +1,119 @@
import { toast } from '@signozhq/ui/sonner';
import { TelemetryFieldKey } from 'api/v5/v5';
import ROUTES from 'constants/routes';
import { render, screen, userEvent } from 'tests/test-utils';
import { getFieldColumn } from '../../TracesTable/getFieldColumn';
import { defaultSelectedColumns } from '../configs';
import { LAST_COLUMN_ERROR, useListViewColumns } from '../useListViewColumns';
jest.mock('@signozhq/ui/sonner', () => ({
toast: { error: jest.fn(), success: jest.fn() },
}));
const [SERVICE_NAME, SPAN_NAME, DURATION] = defaultSelectedColumns;
const MODEL = {
name: 'gen_ai.request.model',
signal: 'traces',
fieldContext: 'attribute',
fieldDataType: 'string',
} as TelemetryFieldKey;
function Harness(): JSX.Element {
const {
selectedFields,
fieldsSelectorConfig,
onColumnOrderChange,
onColumnRemove,
} = useListViewColumns();
const removeFirst = (): void =>
onColumnRemove(getFieldColumn(selectedFields[0]).id);
return (
<div>
<div data-testid="columns">
{selectedFields.map((field) => field.name).join(',')}
</div>
<button
type="button"
onClick={(): void =>
fieldsSelectorConfig.fieldsSelector?.onFieldsChange([SPAN_NAME, MODEL])
}
>
save drawer
</button>
<button
type="button"
onClick={(): void =>
onColumnOrderChange(
[DURATION, SERVICE_NAME, SPAN_NAME].map((field) => getFieldColumn(field)),
)
}
>
reorder
</button>
<button type="button" onClick={removeFirst}>
remove first
</button>
</div>
);
}
const renderHarness = (): void => {
render(<Harness />, undefined, {
initialRoute: ROUTES.AI_OBSERVABILITY_EXPLORER,
});
};
describe('useListViewColumns', () => {
beforeEach(() => {
localStorage.clear();
});
it('falls back to the default columns when nothing is stored', () => {
renderHarness();
expect(screen.getByTestId('columns')).toHaveTextContent(
defaultSelectedColumns.map((field) => field.name).join(','),
);
});
it('saves the drawer selection', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByRole('button', { name: 'save drawer' }));
expect(screen.getByTestId('columns')).toHaveTextContent(
'name,gen_ai.request.model',
);
});
it('follows the table order on a header drag, keeping unlisted columns last', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByRole('button', { name: 'reorder' }));
expect(screen.getByTestId('columns')).toHaveTextContent(
/^duration_nano,service\.name,name,/,
);
});
it('removes a column from the header but never the last one', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByRole('button', { name: 'save drawer' }));
await user.click(screen.getByRole('button', { name: 'remove first' }));
expect(screen.getByTestId('columns')).toHaveTextContent(
/^gen_ai\.request\.model$/,
);
await user.click(screen.getByRole('button', { name: 'remove first' }));
expect(screen.getByTestId('columns')).toHaveTextContent(
/^gen_ai\.request\.model$/,
);
expect(jest.mocked(toast.error)).toHaveBeenCalledWith(LAST_COLUMN_ERROR);
});
});

View File

@@ -0,0 +1,76 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import type { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { defaultSelectedColumns } from './configs';
export const LAST_COLUMN_ERROR = 'There must be at least one selected column';
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
interface UseListViewColumns {
selectedFields: TelemetryFieldKey[];
fieldsSelectorConfig: OptionsMenuConfig;
onColumnOrderChange: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove: (columnId: string) => void;
}
export function useListViewColumns(): UseListViewColumns {
const {
aiObservability: { columns, updateColumns },
} = usePreferenceContext();
const selectedFields = columns.list ?? defaultSelectedColumns;
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => updateColumns('list', next),
[updateColumns],
);
const fieldsSelectorConfig = useMemo<OptionsMenuConfig>(
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
[selectedFields, onFieldsChange],
);
const onColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
const orderIndex = new Map(cols.map((col, index) => [col.id, index]));
updateColumns(
'list',
[...selectedFields].sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
),
);
},
[selectedFields, updateColumns],
);
const onColumnRemove = useCallback(
(columnId: string): void => {
const next = selectedFields.filter(
(field) => columnIdOf(field) !== columnId,
);
if (!next.length) {
toast.error(LAST_COLUMN_ERROR);
return;
}
updateColumns('list', next);
},
[selectedFields, updateColumns],
);
return {
selectedFields,
fieldsSelectorConfig,
onColumnOrderChange,
onColumnRemove,
};
}

View File

@@ -69,6 +69,8 @@ function TracesView({
columns,
selectedFields,
onFieldsChange,
onColumnOrderChange,
onColumnRemove,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
@@ -183,15 +185,6 @@ function TracesView({
[canPersistColumns, selectedFields, onFieldsChange],
);
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
const tableColumns = useMemo(
() =>
canPersistColumns
? columns
: columns.filter((column) => column.defaultVisibility !== false),
[canPersistColumns, columns],
);
return (
<div className={styles.container}>
<div className={styles.actionsContainer}>
@@ -226,13 +219,12 @@ function TracesView({
<TracesTable
data={rows}
columns={tableColumns}
columns={columns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading || isColumnsLoading}
@@ -240,6 +232,8 @@ function TracesView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
/>
</div>
);

View File

@@ -1,5 +1,6 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
@@ -40,15 +41,51 @@ export const sortByDefaultOrder = (
): TelemetryFieldKey[] =>
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied while no selection is stored. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
export const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
export const getDefaultTraceViewFields = (
availableFields: TelemetryFieldKey[],
): TelemetryFieldKey[] =>
availableFields.filter((field) => DEFAULT_VISIBLE_FIELDS.has(field.name));
/** The selection the old column store encoded; `null` when it matches the defaults. */
export const getLegacyTraceViewSelection = (
availableFields: TelemetryFieldKey[],
hiddenColumnIds: string[],
columnOrder: string[],
): TelemetryFieldKey[] | null => {
const hidden = new Set(hiddenColumnIds);
// A saved order lists exactly the columns the user kept.
if (columnOrder.length) {
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
const kept = availableFields
.filter((field) => orderIndex.has(columnIdOf(field)))
.filter((field) => !hidden.has(columnIdOf(field)));
return kept.length
? kept.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? 0) -
(orderIndex.get(columnIdOf(b)) ?? 0),
)
: null;
}
const defaults = getDefaultTraceViewFields(availableFields);
const kept = defaults.filter((field) => !hidden.has(columnIdOf(field)));
return kept.length === defaults.length ? null : kept;
};
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
): TableColumnDef<TracesTableRow>[] =>
fields.map((field) => ({
...getFieldColumn(field),
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: true,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,

View File

@@ -1,17 +1,15 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { mergeExtraFields } from 'utils/extraFields';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
hideColumn,
initializeFromDefaults,
setColumnOrder,
showColumn,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
@@ -23,27 +21,41 @@ import {
} from '../constants';
import {
buildTraceViewColumns,
columnIdOf,
getDefaultTraceViewFields,
getLegacyTraceViewSelection,
sortByDefaultOrder,
TRACE_ID_COLUMN_ID,
} from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
/** Now only column widths; it held the selection as hidden ids + order before the preference slice. */
const TABLE_STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
interface UseTraceViewColumns {
columns: TableColumnDef<TracesTableRow>[];
selectedFields: TelemetryFieldKey[];
onFieldsChange: (next: TelemetryFieldKey[]) => void;
onColumnOrderChange: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove: (columnId: string) => void;
requiredFields: readonly string[];
isLoading: boolean;
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
canPersistColumns: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
const withTraceId = (
fields: TelemetryFieldKey[],
availableFields: TelemetryFieldKey[],
): TelemetryFieldKey[] => {
if (fields.some((field) => field.name === TRACE_ID_COLUMN_ID)) {
return fields;
}
const traceId = availableFields.find(
(field) => field.name === TRACE_ID_COLUMN_ID,
);
return traceId ? [traceId, ...fields] : fields;
};
export function useTraceViewColumns(): UseTraceViewColumns {
const {
data: fetchedFields = [],
@@ -66,60 +78,99 @@ export function useTraceViewColumns(): UseTraceViewColumns {
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
const {
aiObservability: {
columns: { trace: storedFields },
updateColumns,
},
} = usePreferenceContext();
// Captured on first render, before the table prunes hidden ids it no longer renders.
const legacyHiddenIds = useHiddenColumnIds(TABLE_STORAGE_KEY);
const legacyOrder = useColumnOrder(TABLE_STORAGE_KEY);
const legacy = useRef({ hiddenIds: legacyHiddenIds, order: legacyOrder });
const migratedFields = useMemo(
() =>
isSuccess && !storedFields
? getLegacyTraceViewSelection(
availableFields,
legacy.current.hiddenIds,
legacy.current.order,
)
: null,
[isSuccess, storedFields, availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isSuccess) {
initializeFromDefaults(STORAGE_KEY, columns);
if (!migratedFields) {
return;
}
}, [isSuccess, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
updateColumns('trace', migratedFields);
// Clear the old encoding so a later reset to defaults isn't migrated again.
setColumnOrder(TABLE_STORAGE_KEY, []);
legacy.current.hiddenIds.forEach((id) => showColumn(TABLE_STORAGE_KEY, id));
legacy.current = { hiddenIds: [], order: [] };
}, [migratedFields, updateColumns]);
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
const byName = new Map(availableFields.map((field) => [field.name, field]));
const source =
storedFields ?? migratedFields ?? getDefaultTraceViewFields(availableFields);
// Stored fields the endpoint no longer reports are dropped.
const resolved = source
.map((field) => byName.get(field.name))
.filter((field): field is TelemetryFieldKey => !!field);
return withTraceId(resolved, availableFields);
}, [availableFields, storedFields, migratedFields]);
return availableFields
.filter((field) => !hidden.has(columnIdOf(field)))
.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
);
}, [availableFields, hiddenColumnIds, columnOrder]);
const columns = useMemo(
() => buildTraceViewColumns(selectedFields),
[selectedFields],
);
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
showColumn(STORAGE_KEY, column.id);
} else {
hideColumn(STORAGE_KEY, column.id);
}
});
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
updateColumns('trace', withTraceId(next, availableFields));
},
[columns, isSuccess],
[isSuccess, availableFields, updateColumns],
);
const onColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
const orderIndex = new Map(cols.map((col, index) => [col.id, index]));
onFieldsChange(
[...selectedFields].sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
),
);
},
[selectedFields, onFieldsChange],
);
const onColumnRemove = useCallback(
(columnId: string): void => {
if (columnId === TRACE_ID_COLUMN_ID) {
return;
}
onFieldsChange(
selectedFields.filter((field) => columnIdOf(field) !== columnId),
);
},
[selectedFields, onFieldsChange],
);
return {
columns,
selectedFields,
onFieldsChange,
onColumnOrderChange,
onColumnRemove,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,

View File

@@ -19,6 +19,7 @@ import {
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_TOAST_POSITION,
} from './constants';
import { SavedViewCreatedFrom } from './events';
import { useActiveSavedView } from './hooks/useActiveSavedView';
import { useSaveNewView } from './hooks/useSaveNewView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
@@ -54,7 +55,17 @@ function SavedViewsHeader({
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
const handleSave = useSaveNewView(createView);
const saveNewView = useSaveNewView(createView);
const handleSave = useCallback(
(displayName: string): Promise<boolean> =>
saveNewView(
displayName,
modalMode === 'saveAsNew'
? SavedViewCreatedFrom.SaveAsNew
: SavedViewCreatedFrom.Header,
),
[saveNewView, modalMode],
);
const handleUpdate = useCallback(async (): Promise<void> => {
if (view && (await updateView(view))) {

View File

@@ -10,6 +10,7 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
import { SavedViewCreatedFrom } from './events';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
@@ -35,7 +36,7 @@ function SavedViewsList({
search: string;
}): JSX.Element {
const { viewKey } = useGetSavedViewParams();
const { selectView, clearView, createView, isSaving } =
const { selectView, clearView, resetView, createView, isSaving } =
useSavedViewActions(source);
const saveNewView = useSaveNewView(createView);
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
@@ -102,10 +103,10 @@ function SavedViewsList({
return;
}
if (pendingAction.view.id === viewKey) {
clearView();
resetView();
}
setPendingAction(null);
}, [pendingAction, deleteView, viewKey, clearView]);
}, [pendingAction, deleteView, viewKey, resetView]);
if (isLoading) {
return (
@@ -141,7 +142,9 @@ function SavedViewsList({
<SavedViewsEmptyState
isSearching={search.trim().length > 0}
isSaving={isSaving}
onSave={saveNewView}
onSave={(displayName): Promise<boolean> =>
saveNewView(displayName, SavedViewCreatedFrom.EmptyState)
}
/>
);
}

View File

@@ -0,0 +1,322 @@
import {
act,
fireEvent,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom, SavedViewEvents } from '../events';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
expectUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
const logEventMock = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): void => logEventMock(...args),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
// copy-to-clipboard falls back to window.prompt in jsdom.
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, jest.Mock] => [null, jest.fn()],
}));
const PATH = ROUTES.TRACES_EXPLORER;
const source = SavedviewtypesSourceDTO.traces;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
function renderAll(
url = explorerUrl(PATH, {}),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsHeader source={source} onOpenViews={jest.fn()} />
<SavedViewsPanel source={source} onClose={jest.fn()} />
</>,
url,
);
}
const row = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
// Buttons and menu rows ignore clicks until their permission check settles.
async function clickWhenUsable(testId: string): Promise<void> {
await waitFor(() => expectUsable(screen.getByTestId(testId)));
await userEvent.click(screen.getByTestId(testId));
}
async function chooseMenuItem(testId: string): Promise<void> {
const item = await screen.findByTestId(testId);
await waitFor(() => expectRowUsable(item), MENU_CHECK_TIMEOUT);
fireEvent.click(item);
}
async function chooseFromRowMenu(viewName: string, key: string): Promise<void> {
await screen.findByTestId('saved-views-list');
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
await chooseMenuItem(`saved-views-row-menu-item-${key}`);
}
async function chooseFromSaveMenu(key: string): Promise<void> {
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await chooseMenuItem(`saved-views-save-changes-item-${key}`);
}
async function submitName(name: string): Promise<void> {
await userEvent.type(screen.getByTestId('save-view-name'), name);
await userEvent.click(screen.getByTestId('save-view-submit'));
}
async function waitForView(name: string): Promise<void> {
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(name),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
}
const callsOf = (event: SavedViewEvents): unknown[][] =>
logEventMock.mock.calls.filter(([name]) => name === event);
describe('SavedViews analytics', () => {
beforeEach(() => {
localStorage.clear();
logEventMock.mockClear();
jest.mocked(toast.error).mockClear();
});
it('logs a view created from the header', async () => {
mockSavedViewsApi([errors], { createdId: 'view-new' });
renderAll();
await clickWhenUsable('saved-views-create');
await submitName('Slow checkout');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-new',
from: SavedViewCreatedFrom.Header,
}),
);
});
it('logs a view created from the empty state', async () => {
mockSavedViewsApi([], { createdId: 'view-new' });
renderAll();
await screen.findByTestId('saved-views-list-create');
await clickWhenUsable('saved-views-list-create');
await submitName('Slow checkout');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-new',
from: SavedViewCreatedFrom.EmptyState,
}),
);
});
it('does not log a create that fails', async () => {
mockSavedViewsApi([errors], { createStatus: 500 });
renderAll();
await clickWhenUsable('saved-views-create');
await submitName('Slow checkout');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewCreated)).toHaveLength(0);
});
describe('on a changed view', () => {
async function openDirtyView(): Promise<void> {
const { history } = renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await screen.findByTestId('saved-views-discard');
}
it('does not log an update that fails', async () => {
mockSavedViewsApi([errors], { updateStatus: 500 });
await openDirtyView();
await chooseFromSaveMenu('update');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewUpdated)).toHaveLength(0);
});
it('logs an update', async () => {
mockSavedViewsApi([errors]);
await openDirtyView();
await chooseFromSaveMenu('update');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewUpdated, {
source,
viewId: errors.id,
}),
);
});
it('logs a view created with save as new', async () => {
mockSavedViewsApi([errors], { createdId: 'view-copy' });
await openDirtyView();
await chooseFromSaveMenu('save-as-new');
await submitName('Errors copy');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-copy',
from: SavedViewCreatedFrom.SaveAsNew,
}),
);
});
it('logs discarded changes', async () => {
mockSavedViewsApi([errors]);
await openDirtyView();
logEventMock.mockClear();
await userEvent.click(screen.getByTestId('saved-views-discard'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ChangesDiscarded, {
source,
viewId: errors.id,
});
});
});
it('logs a cleared view', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
source,
viewId: errors.id,
});
});
it('logs a view cleared from its row', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await userEvent.click(await screen.findByTestId('saved-views-row-clear'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
source,
viewId: errors.id,
});
});
it('logs only the delete when the open view is deleted', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await chooseFromRowMenu('Errors', 'delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewDeleted, {
source,
viewId: errors.id,
}),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('My view'),
);
expect(callsOf(SavedViewEvents.ViewCleared)).toHaveLength(0);
});
it('logs a copied link', async () => {
mockSavedViewsApi([errors]);
renderAll();
await chooseFromRowMenu('Errors', 'copyLink');
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.LinkCopied, {
source,
viewId: errors.id,
});
});
it('logs edited view details', async () => {
mockSavedViewsApi([errors]);
renderAll();
await chooseFromRowMenu('Errors', 'editDetails');
await submitName(' renamed');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(
SavedViewEvents.ViewDetailsEdited,
{ source, viewId: errors.id },
),
);
});
it('does not log edited view details that fail to save', async () => {
mockSavedViewsApi([errors], { updateStatus: 500 });
renderAll();
await chooseFromRowMenu('Errors', 'editDetails');
await submitName(' renamed');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewDetailsEdited)).toHaveLength(0);
});
it('does not log a delete that fails', async () => {
mockSavedViewsApi([errors], { deleteStatus: 500 });
renderAll();
await chooseFromRowMenu('Errors', 'delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewDeleted)).toHaveLength(0);
});
});

View File

@@ -19,11 +19,15 @@ export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
createStatus = 201,
updateStatus = 204,
deleteStatus = 204,
createdId = 'view-new',
getDelay = 0,
listFailures = 0,
}: {
createStatus?: number;
updateStatus?: number;
deleteStatus?: number;
createdId?: string;
getDelay?: number;
listFailures?: number;
@@ -85,20 +89,38 @@ export function mockSavedViewsApi(
const body = await req.json();
const id = String(req.params.id);
requests.updated.push({ id, body });
if (updateStatus >= 400) {
return res(
ctx.status(updateStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
const index = views.findIndex((v) => v.id === id);
if (index >= 0) {
views[index] = { ...views[index], spec: body.spec };
}
return res(ctx.status(204));
return res(ctx.status(updateStatus));
}),
rest.delete(`${API}/:id`, (req, res, ctx) => {
const id = String(req.params.id);
requests.deleted.push(id);
if (deleteStatus >= 400) {
return res(
ctx.status(deleteStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
const index = views.findIndex((v) => v.id === id);
if (index >= 0) {
views.splice(index, 1);
}
return res(ctx.status(204));
return res(ctx.status(deleteStatus));
}),
);
return requests;

View File

@@ -0,0 +1,23 @@
export enum SavedViewEvents {
ViewCreated = 'Saved Views: View created',
ViewUpdated = 'Saved Views: View updated',
ViewDetailsEdited = 'Saved Views: View details edited',
ViewDeleted = 'Saved Views: View deleted',
LinkCopied = 'Saved Views: Link copied',
ViewCleared = 'Saved Views: View cleared',
ChangesDiscarded = 'Saved Views: Changes discarded',
}
export enum SavedViewEventKeys {
// Injected on every event by useSavedViewLogEvent
Source = 'source',
ViewId = 'viewId',
From = 'from',
}
export enum SavedViewCreatedFrom {
Header = 'header',
SaveAsNew = 'save_as_new',
EmptyState = 'empty_state',
}

View File

@@ -2,14 +2,15 @@ import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
import { SavedViewCreatedFrom } from '../events';
import { UseSavedViewActionsResult } from '../types';
export function useSaveNewView(
createView: UseSavedViewActionsResult['createView'],
): (displayName: string) => Promise<boolean> {
): (displayName: string, from: SavedViewCreatedFrom) => Promise<boolean> {
return useCallback(
async (displayName: string): Promise<boolean> => {
if (!(await createView(displayName))) {
async (displayName: string, from: SavedViewCreatedFrom): Promise<boolean> => {
if (!(await createView(displayName, from))) {
return false;
}
toast.success('View created', {

View File

@@ -17,6 +17,7 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
@@ -30,12 +31,18 @@ import {
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
} from '../constants';
import {
SavedViewCreatedFrom,
SavedViewEventKeys,
SavedViewEvents,
} from '../events';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
// Columns and formatting need no handling here: with a viewKey in the url the
// preference provider reads them from the view, and without one it falls back
@@ -50,6 +57,8 @@ export function useSavedViewActions(
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
@@ -107,13 +116,16 @@ export function useSavedViewActions(
}
}
selectView(view);
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[optionsDataSource, preferencesSlice, selectView],
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
);
// The query builder redirects only ever add params, so the url is rebuilt
// here; the time range and everything else a view does not set stays.
const clearView = useCallback((): void => {
const resetView = useCallback((): void => {
clearLastUsedView();
const params = getUnstableCurrentSearchParams();
@@ -121,8 +133,18 @@ export function useSavedViewActions(
safeNavigate(`${pathname}?${params.toString()}`);
}, [clearLastUsedView, pathname, safeNavigate]);
const clearView = useCallback((): void => {
resetView();
logSavedViewEvent(SavedViewEvents.ViewCleared, {
[SavedViewEventKeys.ViewId]: viewKey,
});
}, [resetView, logSavedViewEvent, viewKey]);
const createView = useCallback(
async (displayName: string): Promise<string | undefined> => {
async (
displayName: string,
from: SavedViewCreatedFrom,
): Promise<string | undefined> => {
try {
const { data } = await createSavedView({
data: {
@@ -137,6 +159,10 @@ export function useSavedViewActions(
[QueryParams.viewKey]: data.id,
});
setLastUsedView(data.id, displayName);
logSavedViewEvent(SavedViewEvents.ViewCreated, {
[SavedViewEventKeys.ViewId]: data.id,
[SavedViewEventKeys.From]: from,
});
return data.id;
} catch (error) {
toast.error(
@@ -158,6 +184,7 @@ export function useSavedViewActions(
stagedQuery,
currentQuery,
setLastUsedView,
logSavedViewEvent,
],
);
@@ -173,6 +200,9 @@ export function useSavedViewActions(
},
});
await invalidateViews(view.id);
logSavedViewEvent(SavedViewEvents.ViewUpdated, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
toast.error(
@@ -185,13 +215,14 @@ export function useSavedViewActions(
return false;
}
},
[updateSavedView, source, toCurrentSpec, invalidateViews],
[updateSavedView, source, toCurrentSpec, invalidateViews, logSavedViewEvent],
);
return {
selectView,
revertView,
clearView,
resetView,
createView,
updateView,
isSaving: isCreating || isUpdating,

View File

@@ -0,0 +1,24 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
export type SavedViewLogEvent = (
event: SavedViewEvents,
attributes?: Record<string, unknown>,
) => void;
export function useSavedViewLogEvent(
source: SavedviewtypesSourceDTO,
): SavedViewLogEvent {
return useCallback(
(event: SavedViewEvents, attributes: Record<string, unknown> = {}): void => {
void logEvent(event, {
[SavedViewEventKeys.Source]: source,
...attributes,
});
},
[source],
);
}

View File

@@ -22,9 +22,11 @@ import {
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
} from '../constants';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
import { UseSavedViewRowActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
function showErrorToast(error: unknown, fallback: string): void {
toast.error(
@@ -42,6 +44,7 @@ export function useSavedViewRowActions(
const queryClient = useQueryClient();
const { pathname } = useLocation();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
useUpdateSavedView();
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
@@ -55,8 +58,11 @@ export function useSavedViewRowActions(
});
copyToClipboard(getAbsoluteUrl(path));
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.LinkCopied, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[pathname, copyToClipboard],
[pathname, copyToClipboard, logSavedViewEvent],
);
const copyViewId = useCallback(
@@ -83,13 +89,16 @@ export function useSavedViewRowActions(
});
await invalidateSavedViews(queryClient, source, view.id);
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
showErrorToast(error, 'Could not update the view');
return false;
}
},
[updateSavedView, source, queryClient],
[updateSavedView, source, queryClient, logSavedViewEvent],
);
// The deleted view's own query is left alone: refetching it would fail
@@ -100,13 +109,16 @@ export function useSavedViewRowActions(
await deleteSavedView({ pathParams: { id: view.id } });
await invalidateSavedViews(queryClient, source);
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDeleted, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
showErrorToast(error, 'Could not delete the view');
return false;
}
},
[deleteSavedView, source, queryClient],
[deleteSavedView, source, queryClient, logSavedViewEvent],
);
return {

View File

@@ -10,6 +10,8 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom } from './events';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
@@ -51,7 +53,12 @@ export interface UseSavedViewActionsResult {
selectView: (view: SavedviewtypesSavedViewDTO) => void;
revertView: (view: SavedviewtypesSavedViewDTO) => void;
clearView: () => void;
createView: (displayName: string) => Promise<string | undefined>;
/** Clears the open view without logging it, for flows that are not a clear. */
resetView: () => void;
createView: (
displayName: string,
from: SavedViewCreatedFrom,
) => Promise<string | undefined>;
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
isSaving: boolean;
}

View File

@@ -25,9 +25,6 @@
.ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box
) {
@include custom-scrollbar;
max-height: 150px;
overflow-y: auto;
padding: var(--spacing-4) !important;
}
}

View File

@@ -5,7 +5,6 @@ import { Collapse } from 'antd';
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../../AIThreadMessage/types';
import MessagesSection from '../../SpanMessages/MessagesSection';
import { RawMessagesValue } from '../../utils/spanMessages';
import { ThreadView } from '../../TraceDetailsThread/types';
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
@@ -20,7 +19,6 @@ interface MessagesAccordionProps extends Pick<
icon: ReactNode;
title: string;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
@@ -28,7 +26,6 @@ function MessagesAccordion({
icon,
title,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
@@ -36,10 +33,8 @@ function MessagesAccordion({
}: MessagesAccordionProps): JSX.Element | null {
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState(ThreadView.Formatted);
const canFormat = messages.length > 0;
const effectiveView = canFormat ? view : ThreadView.Json;
if (!canFormat && !raw) {
if (messages.length === 0) {
return null;
}
@@ -66,12 +61,12 @@ function MessagesAccordion({
{title}
</span>
),
extra: isOpen && canFormat && (
extra: isOpen && (
// The view toggle lives in the header; its clicks must not fold the panel.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span onClick={stopToggle}>
<ThreadViewToggle
value={effectiveView}
value={view}
onChange={setView}
testId={`${testId}-view-toggle`}
/>
@@ -79,9 +74,8 @@ function MessagesAccordion({
),
children: (
<MessagesSection
view={effectiveView}
view={view}
messages={messages}
raw={raw}
testId={`${testId}-body`}
messageType={messageType}
canLinkToToolCall={canLinkToToolCall}
@@ -95,8 +89,4 @@ function MessagesAccordion({
);
}
MessagesAccordion.defaultProps = {
raw: undefined,
};
export default MessagesAccordion;

View File

@@ -3,7 +3,10 @@ import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
import { MessageType } from '../../AIThreadMessage/types';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
import {
getSpanMessages,
hasFormattedMessages,
} from '../../utils/spanMessages';
import { ThreadSpan } from '../../TraceDetailsThread/types';
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
import MessagesAccordion from './MessagesAccordion';
@@ -25,7 +28,7 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
toolCallKeys,
);
if (!hasAnyMessages(data)) {
if (!hasFormattedMessages(data)) {
return null;
}
@@ -35,7 +38,6 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
icon={<ArrowUpRight size={14} />}
title="Input"
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-ai-messages-input"
canLinkToToolCall={canLinkToToolCall}
@@ -45,7 +47,6 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
icon={<ArrowDownRight size={14} />}
title="Output"
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-ai-messages-output"
canLinkToToolCall={canLinkToToolCall}

View File

@@ -47,6 +47,7 @@
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
margin-right: 4px;
}
// Truncating text inside a badge (service name, status message).

View File

@@ -3,8 +3,7 @@ import AIThreadMessage, {
} from '../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadView } from '../TraceDetailsThread/types';
import RawMessages from './RawMessages';
import { RawMessagesValue } from '../utils/spanMessages';
import JsonBlock from '../AIThreadMessage/JsonBlock';
import styles from './SpanMessages.module.scss';
@@ -17,7 +16,6 @@ interface MessagesSectionProps extends MessageProps {
title?: string;
view: ThreadView;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
@@ -25,21 +23,19 @@ function MessagesSection({
title,
view,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: MessagesSectionProps): JSX.Element | null {
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? messages.length === 0 : !raw) {
if (messages.length === 0) {
return null;
}
return (
<section className={styles.section} data-testid={testId}>
{title && <div className={styles.sectionTitle}>{title}</div>}
{isFormatted ? (
{view === ThreadView.Formatted ? (
<div className={styles.messages}>
{messages.map((message, index) => (
<AIThreadMessage
@@ -54,7 +50,7 @@ function MessagesSection({
))}
</div>
) : (
<RawMessages value={raw as RawMessagesValue} />
<JsonBlock data={messages} />
)}
</section>
);
@@ -62,7 +58,6 @@ function MessagesSection({
MessagesSection.defaultProps = {
title: undefined,
raw: undefined,
};
export default MessagesSection;

View File

@@ -1,21 +0,0 @@
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { RawMessagesValue } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
interface RawMessagesProps {
value: RawMessagesValue;
}
function RawMessages({ value }: RawMessagesProps): JSX.Element {
if (typeof value === 'object') {
return <JsonBlock data={value} />;
}
return (
<span className={styles.rawUnavailable} data-testid="span-raw-messages">
Unable to render messages
</span>
);
}
export default RawMessages;

View File

@@ -28,12 +28,6 @@
min-width: 0;
}
.rawUnavailable {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}
.missing {
padding: var(--spacing-10) var(--spacing-6);
border-radius: var(--radius-2);

View File

@@ -6,11 +6,7 @@ import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { MessageType } from '../AIThreadMessage/types';
import MessagesSection from './MessagesSection';
import {
getSpanMessages,
hasFormattedMessages,
hasRawMessages,
} from '../utils/spanMessages';
import { getSpanMessages, hasFormattedMessages } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
@@ -32,9 +28,8 @@ function SpanMessages({
onToolLinkClick,
}: SpanMessagesProps): JSX.Element {
const data = useMemo(() => getSpanMessages(span), [span]);
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
if (!hasFormattedMessages(data)) {
return (
<div
className={cx(styles.missing, className)}
@@ -45,11 +40,11 @@ function SpanMessages({
);
}
if (!isFormatted) {
if (view === ThreadView.Json) {
return (
<div className={cx(styles.root, className)} data-testid="span-messages-json">
<JsonBlock
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
data={{ input: data.formattedInput, output: data.formattedOutput }}
/>
</div>
);
@@ -60,7 +55,6 @@ function SpanMessages({
<MessagesSection
view={view}
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-messages-input"
canLinkToToolCall={canLinkToToolCall}
@@ -69,7 +63,6 @@ function SpanMessages({
<MessagesSection
view={view}
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-messages-output"
canLinkToToolCall={canLinkToToolCall}

View File

@@ -48,41 +48,36 @@ describe('SpanMessages', () => {
).toBeInTheDocument();
});
it('shows the empty state in JSON view when there are no raw messages', () => {
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
it('shows the empty state in JSON view when there are no formatted messages', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.inputMessages]: '[{"role":"user"}]' },
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
});
it('renders raw messages in JSON view', () => {
it('renders formatted messages in JSON view, ignoring the raw attribute', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
formattedInput: [
{ role: 'user', parts: [{ type: 'text', content: 'formatted-text' }] },
],
attributes: {
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"raw-text"}]',
},
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-json')).toBeInTheDocument();
});
it('opens every key in JSON view', () => {
const span = makeSpan({
attributes: {
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"visible-text"}]',
},
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByText(/visible-text/)).toBeInTheDocument();
expect(screen.getByText(/formatted-text/)).toBeInTheDocument();
expect(screen.queryByText(/raw-text/)).not.toBeInTheDocument();
});
it('collapses tall JSON behind Show more in JSON view', () => {
const span = makeSpan({
attributes: {
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
{ role: 'user', content: 'hi' },
]),
},
formattedInput: [{ role: 'user', parts: [{ type: 'text', content: 'hi' }] }],
});
render(<SpanMessages span={span} view={ThreadView.Json} />);

View File

@@ -99,10 +99,12 @@ function TraceDetailsHeader({
const handlePreviousBtnClick = useCallback((): void => {
if (hasInAppHistory()) {
history.goBack();
} else if (!isOverview) {
history.push(ROUTES.AI_OBSERVABILITY_EXPLORER);
} else {
history.push(ROUTES.TRACES_EXPLORER);
}
}, []);
}, [isOverview]);
const handleToggleTraceDetails = useCallback((): void => {
setShowTraceDetails((prev) => !prev);

View File

@@ -22,7 +22,8 @@ function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
void setTab(value as TraceDetailsTab);
// Pushed so the thread's "Go back" returns to the previous tab, not the previous page.
void setTab(value as TraceDetailsTab, { history: 'push' });
};
return (

View File

@@ -1,10 +1,8 @@
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import TraceDetailsHeader from '../TraceDetailsHeader';
import { useTraceSummary } from '../useTraceSummary';
@@ -100,6 +98,22 @@ describe('TraceDetailsHeader – back button', () => {
expect(mockPush).toHaveBeenCalledWith(ROUTES.TRACES_EXPLORER);
expect(mockGoBack).not.toHaveBeenCalled();
});
it('pushes to the AI observability explorer when opened directly on another tab', async () => {
mockHasInAppHistory.mockReturnValue(false);
const user = userEvent.setup({ delay: null });
render(
<NuqsTestingAdapter searchParams="?tab=thread">
<TraceDetailsHeader {...baseProps} />
</NuqsTestingAdapter>,
);
await user.click(screen.getByRole('button', { name: /back/i }));
expect(mockPush).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith(ROUTES.AI_OBSERVABILITY_EXPLORER);
expect(mockGoBack).not.toHaveBeenCalled();
});
});
describe('TraceDetailsHeader – action cluster', () => {

View File

@@ -1,29 +1,31 @@
.root {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-12);
padding: var(--spacing-20) var(--spacing-24);
}
.message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-4);
max-width: 428px;
padding: var(--spacing-16);
text-align: center;
}
.emoji {
font-size: 32px;
line-height: 1;
.noDataImg {
height: 32px;
width: 32px;
}
.paragraphs {
display: flex;
flex-direction: column;
gap: var(--paragraph-base-500-line-height);
}
.highlight {
.title {
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
.description {
max-width: 420px;
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.actions {
margin-top: var(--spacing-4);
}

View File

@@ -1,8 +1,9 @@
import { ArrowRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { openInNewTab } from 'utils/navigation';
import noDataUrl from '@/assets/Icons/no-data.svg';
import { LLM_OBSERVABILITY_DOCS_URL } from '../constants';
import styles from './ThreadNoAiSpans.module.scss';
@@ -10,32 +11,28 @@ import styles from './ThreadNoAiSpans.module.scss';
function ThreadNoAiSpans(): JSX.Element {
return (
<div className={styles.root} data-testid="thread-empty">
<div className={styles.message}>
<span className={styles.emoji} aria-hidden>
🚏
</span>
<div className={styles.paragraphs}>
<Typography size="base" weight="medium" color="muted">
Looks like this trace does not have any spans from AI-related actions.
</Typography>
<Typography size="base" weight="medium" className={styles.highlight}>
Thread view summarising your entire AI-journey history will appear here
when the spans have the necessary datapoints.
</Typography>
</div>
<img src={noDataUrl} alt="" className={styles.noDataImg} />
<span className={styles.title}>
This trace has no spans from AI-related actions
</span>
<span className={styles.description}>
Thread view summarising your entire AI-journey history will appear here when
the spans have the necessary datapoints.
</span>
<div className={styles.actions}>
<Button
variant="solid"
color="secondary"
size="md"
suffix={<ArrowRight size={12} />}
onClick={(): void => {
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
}}
testId="thread-empty-learn-more"
>
Learn more
</Button>
</div>
<Button
variant="solid"
color="secondary"
size="md"
suffix={<ArrowRight size={12} />}
onClick={(): void => {
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
}}
testId="thread-empty-learn-more"
>
Learn more
</Button>
</div>
);
}

View File

@@ -40,6 +40,7 @@ function TraceDetailsThread(): JSX.Element {
spansAboveFirstPage,
isLoading,
isError,
isNotFound,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
@@ -52,6 +53,7 @@ function TraceDetailsThread(): JSX.Element {
interestedSpanId,
spans,
isLoading,
isNotFound,
);
const interestedSpanIndex = spans.findIndex(
(span) => span.span_id === interestedSpanId,

View File

@@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { getTraceThread } from 'api/generated/services/tracedetail';
import { act, renderHook, waitFor } from 'tests/test-utils';
import { useTraceThread } from '../useTraceThread';
@@ -154,6 +154,7 @@ describe('useTraceThread', () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockGetTraceThread).toHaveBeenCalledTimes(1);
expect(result.current.isNotFound).toBe(true);
});
it('retries other errors', async () => {
@@ -162,5 +163,6 @@ describe('useTraceThread', () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockGetTraceThread).toHaveBeenCalledTimes(4);
expect(result.current.isNotFound).toBe(false);
});
});

View File

@@ -25,6 +25,8 @@ interface UseTraceThreadResult {
spansAboveFirstPage: number;
isLoading: boolean;
isError: boolean;
/** The interested span (or trace) doesn't exist; an empty thread is not this. */
isNotFound: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
@@ -99,6 +101,7 @@ export function useTraceThread({
spansAboveFirstPage,
isLoading: query.isLoading,
isError: query.isError,
isNotFound: isNotFoundError(query.error),
hasNextPage: !!query.hasNextPage,
hasPreviousPage: !!query.hasPreviousPage,
isFetchingNextPage: query.isFetchingNextPage,

View File

@@ -28,6 +28,7 @@ export function getInterestedSpanStatus(
interestedSpanId: string | undefined,
spans: ThreadSpan[],
isLoading: boolean,
isNotFound: boolean,
): InterestedSpanStatus {
if (!interestedSpanId) {
return InterestedSpanStatus.None;
@@ -35,9 +36,13 @@ export function getInterestedSpanStatus(
if (isLoading) {
return InterestedSpanStatus.Loading;
}
if (spans.length === 0) {
if (isNotFound) {
return InterestedSpanStatus.NotFound;
}
// The span exists but the thread is empty, so the no-AI-spans state applies.
if (spans.length === 0) {
return InterestedSpanStatus.None;
}
return spans.some((span) => span.span_id === interestedSpanId)
? InterestedSpanStatus.Found
: InterestedSpanStatus.NoIOMessages;

View File

@@ -60,4 +60,4 @@ export enum TraceDetailsTab {
}
export const LLM_OBSERVABILITY_DOCS_URL =
'https://signoz.io/docs/llm-observability/';
'https://signoz.io/docs/ai-observability/';

View File

@@ -1,49 +1,19 @@
import { parseJsonObject } from '../AIThreadMessage/utils';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadSpan } from '../TraceDetailsThread/types';
import { GEN_AI_KEYS } from './aiTraceUtils';
/** Parsed JSON, or the original string when it isn't JSON. */
export type RawMessagesValue = object | string;
export interface SpanMessagesData {
formattedInput: ThreadMessage[];
formattedOutput: ThreadMessage[];
rawInput?: RawMessagesValue;
rawOutput?: RawMessagesValue;
}
function toRawValue(value: unknown): RawMessagesValue | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value === 'string') {
return parseJsonObject(value) ?? value;
}
return typeof value === 'object' ? value : JSON.stringify(value);
}
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
const rawInput = toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]);
const rawOutput = toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]);
// Spans from before the JSON column have no formatted messages.
return {
formattedInput: span.formattedInput ?? [],
formattedOutput: span.formattedOutput ?? [],
rawInput,
rawOutput,
};
}
export function hasFormattedMessages(data: SpanMessagesData): boolean {
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
}
export function hasRawMessages(data: SpanMessagesData): boolean {
return !!data.rawInput || !!data.rawOutput;
}
export function hasAnyMessages(data: SpanMessagesData): boolean {
return hasFormattedMessages(data) || hasRawMessages(data);
}

View File

@@ -0,0 +1,176 @@
import { useHistory } from 'react-router-dom';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { AI_OBSERVABILITY_URL_OPTIONS } from '../configs/aiObservabilityLoaderConfig';
import { usePreferenceContext } from '../context/PreferenceContextProvider';
const SERVICE_NAME = {
name: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
} as TelemetryFieldKey;
const SPAN_NAME = {
name: 'name',
signal: 'traces',
fieldContext: 'span',
} as TelemetryFieldKey;
const LLM_CALL_COUNT = {
name: 'llm_call_count',
signal: 'traces',
fieldContext: 'trace',
} as TelemetryFieldKey;
const options = (columns: unknown[]): string =>
JSON.stringify({ selectColumns: columns });
const explorerSearch = (panelType: string, aiOptions?: string): string => {
const params = new URLSearchParams({ panelTypes: JSON.stringify(panelType) });
if (aiOptions) {
params.set(AI_OBSERVABILITY_URL_OPTIONS, aiOptions);
}
return `?${params.toString()}`;
};
const explorerRoute = (panelType: string, aiOptions?: string): string =>
`${ROUTES.AI_OBSERVABILITY_EXPLORER}${explorerSearch(panelType, aiOptions)}`;
/** The `aiOptions` value of the last url the slice navigated to. */
const lastNavigatedOptions = (): string | null => {
const [to] =
safeNavigateMock.mock.calls[safeNavigateMock.mock.calls.length - 1];
return new URL(to as string, 'http://localhost').searchParams.get(
AI_OBSERVABILITY_URL_OPTIONS,
);
};
const names = (columns: TelemetryFieldKey[] | null): string =>
columns ? columns.map((c) => c.name).join(',') : 'none';
function Consumer(): JSX.Element {
const {
aiObservability: { columns, updateColumns },
} = usePreferenceContext();
const history = useHistory();
return (
<div>
<div data-testid="list">{names(columns.list)}</div>
<div data-testid="trace">{names(columns.trace)}</div>
<button
type="button"
onClick={(): void =>
updateColumns('list', [SPAN_NAME, LLM_CALL_COUNT, SPAN_NAME])
}
>
set list
</button>
<button type="button" onClick={(): void => updateColumns('list', [])}>
clear list
</button>
<button
type="button"
onClick={(): void => history.push(explorerSearch('trace'))}
>
to trace
</button>
</div>
);
}
describe('AI observability preferences', () => {
beforeEach(() => {
localStorage.clear();
});
it('loads each tab from localStorage and mirrors the active tab into the url', async () => {
setLocalStorageKey(
LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS,
options([SERVICE_NAME]),
);
render(<Consumer />, undefined, { initialRoute: explorerRoute('list') });
expect(screen.getByTestId('list')).toHaveTextContent('service.name');
expect(screen.getByTestId('trace')).toHaveTextContent('none');
await waitFor(() => expect(safeNavigateMock).toHaveBeenCalled());
expect(lastNavigatedOptions()).toBe(options([SERVICE_NAME]));
expect(safeNavigateMock).toHaveBeenLastCalledWith(expect.any(String), {
replace: true,
});
});
it('prefers the url for the active tab and sanitizes it', async () => {
setLocalStorageKey(
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_OPTIONS,
options([SERVICE_NAME]),
);
const fromUrl = options([
{ key: 'llm_call_count', signal: 'traces', fieldContext: 'trace' },
{ name: 'body', signal: 'logs' },
LLM_CALL_COUNT,
]);
render(<Consumer />, undefined, {
initialRoute: explorerRoute('trace', fromUrl),
});
await waitFor(() =>
expect(screen.getByTestId('trace')).toHaveTextContent(/^llm_call_count$/),
);
});
it('drops trace aggregates and duplicates from list columns, and resets on an empty selection', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<Consumer />, undefined, { initialRoute: explorerRoute('list') });
await user.click(screen.getByRole('button', { name: 'set list' }));
expect(screen.getByTestId('list')).toHaveTextContent(/^name$/);
expect(getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS)).toBe(
options([SPAN_NAME]),
);
await user.click(screen.getByRole('button', { name: 'clear list' }));
expect(screen.getByTestId('list')).toHaveTextContent('none');
expect(
getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS),
).toBeNull();
});
it('rewrites the url param per tab so a tab never reads another tab’s columns', async () => {
setLocalStorageKey(
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_OPTIONS,
options([LLM_CALL_COUNT]),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<Consumer />, undefined, {
initialRoute: explorerRoute('list', options([SERVICE_NAME])),
});
await user.click(screen.getByRole('button', { name: 'to trace' }));
await waitFor(() =>
expect(lastNavigatedOptions()).toBe(options([LLM_CALL_COUNT])),
);
});
it('removes the param on tabs without columns and leaves other pages alone', async () => {
const { unmount } = render(<Consumer />, undefined, {
initialRoute: explorerRoute('graph', options([SERVICE_NAME])),
});
await waitFor(() => expect(safeNavigateMock).toHaveBeenCalled());
expect(lastNavigatedOptions()).toBeNull();
unmount();
safeNavigateMock.mockClear();
render(<Consumer />, undefined, {
initialRoute: `${ROUTES.TRACES_EXPLORER}?${AI_OBSERVABILITY_URL_OPTIONS}=x`,
});
expect(safeNavigateMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,129 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import removeLocalStorageKey from 'api/browser/localstorage/remove';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DEFAULT_PANEL_TYPE } from 'container/LLMObservability/Explorer/constants';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { AIObservabilityColumnsView } from '../types';
// Not `options`: the Traces/Logs preferences and ExplorerCard already read and write that param.
export const AI_OBSERVABILITY_URL_OPTIONS = 'aiOptions';
const STORAGE_KEYS: Record<AIObservabilityColumnsView, LOCALSTORAGE> = {
list: LOCALSTORAGE.AI_OBSERVABILITY_LIST_OPTIONS,
trace: LOCALSTORAGE.AI_OBSERVABILITY_TRACE_OPTIONS,
};
const PANEL_TYPE_TO_VIEW: Partial<
Record<PANEL_TYPES, AIObservabilityColumnsView>
> = {
[PANEL_TYPES.LIST]: 'list',
[PANEL_TYPES.TRACE]: 'trace',
};
/** The tab whose columns the url param carries; `null` for tabs without columns. */
export const getActiveColumnsView = (
search: string,
): AIObservabilityColumnsView | null => {
const raw = new URLSearchParams(search).get(QueryParams.panelTypes);
try {
const panelType = raw ? (JSON.parse(raw) as PANEL_TYPES) : DEFAULT_PANEL_TYPE;
return PANEL_TYPE_TO_VIEW[panelType] ?? null;
} catch {
return null;
}
};
type RawColumn = Partial<TelemetryFieldKey> & { key?: string };
/** Drops malformed, non-trace and duplicate fields; `null` when nothing usable is left. */
export const sanitizeColumns = (
view: AIObservabilityColumnsView,
columns: unknown,
): TelemetryFieldKey[] | null => {
if (!Array.isArray(columns)) {
return null;
}
const seen = new Set<string>();
const sanitized = (columns as RawColumn[]).reduce<TelemetryFieldKey[]>(
(acc, column) => {
const name = column?.name ?? column?.key;
if (typeof name !== 'string' || !name) {
return acc;
}
if (column.signal && column.signal !== 'traces') {
return acc;
}
// Per-trace aggregates have no value on a span row.
if (view === 'list' && column.fieldContext === 'trace') {
return acc;
}
const id = buildCompositeKey(
name,
column.fieldContext,
column.fieldDataType,
);
if (seen.has(id)) {
return acc;
}
seen.add(id);
const { key: _key, ...field } = column;
acc.push({ ...field, name } as TelemetryFieldKey);
return acc;
},
[],
);
return sanitized.length ? sanitized : null;
};
const parseSelectColumns = (raw: string | null): unknown => {
if (!raw) {
return null;
}
try {
return JSON.parse(raw)?.selectColumns;
} catch {
return null;
}
};
export const serializeColumns = (columns: TelemetryFieldKey[]): string =>
JSON.stringify({ selectColumns: columns });
export const loadLocalColumns = (
view: AIObservabilityColumnsView,
): TelemetryFieldKey[] | null =>
sanitizeColumns(
view,
parseSelectColumns(getLocalStorageKey(STORAGE_KEYS[view])),
);
export const loadUrlColumns = (
view: AIObservabilityColumnsView,
search: string,
): TelemetryFieldKey[] | null =>
sanitizeColumns(
view,
parseSelectColumns(
new URLSearchParams(search).get(AI_OBSERVABILITY_URL_OPTIONS),
),
);
export const saveLocalColumns = (
view: AIObservabilityColumnsView,
columns: TelemetryFieldKey[] | null,
): void => {
if (columns) {
setLocalStorageKey(STORAGE_KEYS[view], serializeColumns(columns));
} else {
removeLocalStorageKey(STORAGE_KEYS[view]);
}
};

View File

@@ -12,6 +12,7 @@ import {
} from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
import { useAIObservabilityPreferenceSync } from '../sync/useAIObservabilityPreferenceSync';
import { usePreferenceSync } from '../sync/usePreferenceSync';
const PreferenceContext = createContext<PreferenceContextValue | undefined>(
@@ -47,12 +48,15 @@ export function PreferenceContextProvider({
dataSource: DataSource.TRACES,
});
const aiObservabilitySlice = useAIObservabilityPreferenceSync();
const value = useMemo<PreferenceContextValue>(
() => ({
logs: logsSlice,
traces: tracesSlice,
aiObservability: aiObservabilitySlice,
}),
[logsSlice, tracesSlice],
[logsSlice, tracesSlice, aiObservabilitySlice],
);
return (

View File

@@ -0,0 +1,78 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { TelemetryFieldKey } from 'api/v5/v5';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import {
AI_OBSERVABILITY_URL_OPTIONS,
getActiveColumnsView,
loadLocalColumns,
loadUrlColumns,
sanitizeColumns,
saveLocalColumns,
serializeColumns,
} from '../configs/aiObservabilityLoaderConfig';
import {
AIObservabilityColumns,
AIObservabilityColumnsView,
AIObservabilityPreferenceSlice,
} from '../types';
export function useAIObservabilityPreferenceSync(): AIObservabilityPreferenceSlice {
const { pathname, search } = useLocation();
const { safeNavigate } = useSafeNavigate();
const isExplorer = pathname === ROUTES.AI_OBSERVABILITY_EXPLORER;
const [columns, setColumns] = useState<AIObservabilityColumns>(() => ({
list: loadLocalColumns('list'),
trace: loadLocalColumns('trace'),
}));
// The url is read once per explorer visit; after that the slice owns the param.
const hasReadUrl = useRef(false);
useEffect(() => {
if (!isExplorer) {
hasReadUrl.current = false;
return;
}
const view = getActiveColumnsView(search);
if (!hasReadUrl.current) {
hasReadUrl.current = true;
const urlColumns = view ? loadUrlColumns(view, search) : null;
if (view && urlColumns) {
setColumns((prev) => ({ ...prev, [view]: urlColumns }));
return;
}
}
const viewColumns = view ? columns[view] : null;
const desired = viewColumns ? serializeColumns(viewColumns) : null;
const params = new URLSearchParams(search);
if (params.get(AI_OBSERVABILITY_URL_OPTIONS) === desired) {
return;
}
if (desired) {
params.set(AI_OBSERVABILITY_URL_OPTIONS, desired);
} else {
params.delete(AI_OBSERVABILITY_URL_OPTIONS);
}
safeNavigate(`${pathname}?${params.toString()}`, { replace: true });
}, [isExplorer, pathname, search, columns, safeNavigate]);
const updateColumns = useCallback(
(view: AIObservabilityColumnsView, newColumns: TelemetryFieldKey[]): void => {
// An emptied selection falls back to the view's defaults.
const sanitized = sanitizeColumns(view, newColumns);
saveLocalColumns(view, sanitized);
setColumns((prev) => ({ ...prev, [view]: sanitized }));
},
[],
);
return useMemo(() => ({ columns, updateColumns }), [columns, updateColumns]);
}

View File

@@ -1,6 +1,5 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import { LogViewMode } from 'container/OptionsMenu/types';
import { FontSize } from 'container/OptionsMenu/types';
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
export enum PreferenceMode {
SAVED_VIEW = 'savedView',
@@ -15,9 +14,26 @@ export interface PreferenceSlice {
updateFormatting: (newFormatting: FormattingOptions) => void;
}
export type AIObservabilityColumnsView = 'list' | 'trace';
/** `null` means no stored preference; the view applies its own defaults. */
export type AIObservabilityColumns = Record<
AIObservabilityColumnsView,
TelemetryFieldKey[] | null
>;
export interface AIObservabilityPreferenceSlice {
columns: AIObservabilityColumns;
updateColumns: (
view: AIObservabilityColumnsView,
newColumns: TelemetryFieldKey[],
) => void;
}
export interface PreferenceContextValue {
logs: PreferenceSlice;
traces: PreferenceSlice;
aiObservability: AIObservabilityPreferenceSlice;
}
export interface FormattingOptions {