mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 11:30:41 +00:00
Compare commits
7 Commits
test/uplot
...
ns/trace-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b065064d89 | ||
|
|
21880d50cb | ||
|
|
e9513112c2 | ||
|
|
cb06b13053 | ||
|
|
439e328f5f | ||
|
|
b265a1d723 | ||
|
|
e163ec3b1b |
@@ -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];
|
||||
|
||||
@@ -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} ${
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
12
frontend/src/container/BottomStrip/events.ts
Normal file
12
frontend/src/container/BottomStrip/events.ts
Normal 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',
|
||||
}
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
23
frontend/src/container/SavedViews/events.ts
Normal file
23
frontend/src/container/SavedViews/events.ts
Normal 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',
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -37,12 +37,6 @@ func (h *handler) GetTraceSummary(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
@@ -54,7 +48,7 @@ func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetWaterfallV4(r.Context(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans)
|
||||
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -64,7 +64,7 @@ func (m *module) GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID
|
||||
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
|
||||
// minimal fields for all spans to build the tree, then full fields for the
|
||||
// visible window only. Aggregations are not returned.
|
||||
func (m *module) GetWaterfallV4(ctx context.Context, orgID valuer.UUID, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
|
||||
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -78,9 +78,9 @@ func (m *module) GetWaterfallV4(ctx context.Context, orgID valuer.UUID, traceID
|
||||
|
||||
var trace *spantypes.GettableWaterfallTrace
|
||||
if windowed {
|
||||
trace, err = m.getWindowedWaterfall(ctx, orgID, traceID, selectedSpanID, uncollapsedSpans, bounds.Start, bounds.End)
|
||||
trace, err = m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, bounds.Start, bounds.End)
|
||||
} else {
|
||||
trace, err = m.getFullWaterfall(ctx, orgID, traceID, bounds)
|
||||
trace, err = m.getFullWaterfall(ctx, traceID, bounds)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -91,8 +91,8 @@ func (m *module) GetWaterfallV4(ctx context.Context, orgID valuer.UUID, traceID
|
||||
return trace, nil
|
||||
}
|
||||
|
||||
func (m *module) getFullWaterfall(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds) (*spantypes.GettableWaterfallTrace, error) {
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, orgID, traceID, bounds)
|
||||
func (m *module) getFullWaterfall(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) (*spantypes.GettableWaterfallTrace, error) {
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, bounds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,7 +167,7 @@ func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpan
|
||||
}
|
||||
|
||||
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
|
||||
func (m *module) getWindowedWaterfall(ctx context.Context, orgID valuer.UUID, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
|
||||
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
|
||||
// Step 1: minimal fetch → build full tree → select visible window
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, start, end)
|
||||
if err != nil {
|
||||
@@ -195,7 +195,7 @@ func (m *module) getWindowedWaterfall(ctx context.Context, orgID valuer.UUID, tr
|
||||
for i, s := range selectedSpans {
|
||||
spanIDs[i] = s.SpanID
|
||||
}
|
||||
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, orgID, traceID, start, end, spanIDs)
|
||||
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, traceID, start, end, spanIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
@@ -25,11 +24,14 @@ import (
|
||||
|
||||
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
|
||||
|
||||
// storableSpanColumns are the columns a waterfall span scans; the JSON attributes column is added per flag.
|
||||
// storableSpanColumns are the columns a waterfall span scans. A row's legacy maps are blanked when its
|
||||
// JSON holds the attributes.
|
||||
var storableSpanColumns = []string{
|
||||
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "has_error", "kind",
|
||||
colServiceName, "name",
|
||||
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
|
||||
colServiceName, "name", "attributes", "resources_string",
|
||||
"if(notEmpty(attributes), map(), attributes_string) AS attributes_string",
|
||||
"if(notEmpty(attributes), map(), attributes_number) AS attributes_number",
|
||||
"if(notEmpty(attributes), map(), attributes_bool) AS attributes_bool",
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
@@ -207,8 +209,8 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds) ([]spantypes.StorableSpan, error) {
|
||||
sb := s.selectStorableSpans(ctx, orgID)
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) ([]spantypes.StorableSpan, error) {
|
||||
sb := selectStorableSpans()
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
@@ -249,11 +251,11 @@ func (s *traceStore) GetMinimalSpans(ctx context.Context, traceID string, start,
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, orgID valuer.UUID, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
if len(spanIDs) == 0 {
|
||||
return []spantypes.StorableSpan{}, nil
|
||||
}
|
||||
sb := s.selectStorableSpans(ctx, orgID)
|
||||
sb := selectStorableSpans()
|
||||
ids := make([]any, len(spanIDs))
|
||||
for i, id := range spanIDs {
|
||||
ids[i] = id
|
||||
@@ -275,14 +277,10 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, orgID valuer.UUID,
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
// selectStorableSpans reads the JSON attributes column alongside the legacy maps when the
|
||||
// use_trace_attributes_json flag is on, as the query builder does.
|
||||
func (s *traceStore) selectStorableSpans(ctx context.Context, orgID valuer.UUID) *sqlbuilder.SelectBuilder {
|
||||
// selectStorableSpans reads the JSON attributes column alongside the legacy maps.
|
||||
func selectStorableSpans() *sqlbuilder.SelectBuilder {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(storableSpanColumns...)
|
||||
if s.flagger.BooleanOrEmpty(ctx, flagger.FeatureUseTraceAttributesJSON, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
sb.SelectMore("attributes")
|
||||
}
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
return sb
|
||||
}
|
||||
@@ -293,12 +291,9 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "parent_span_id", "has_error", "name", "kind_string",
|
||||
"status_code_string", "status_message", "resources_string",
|
||||
"attributes_string", "attributes_number", "attributes_bool",
|
||||
"attributes", "attributes_string", "attributes_number", "attributes_bool",
|
||||
"events", "links as references",
|
||||
)
|
||||
if q.TraceAttrsJSONOn {
|
||||
sb.SelectMore("attributes")
|
||||
}
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAISpanGateKeys)
|
||||
if err != nil {
|
||||
@@ -382,9 +377,10 @@ func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, sta
|
||||
"any(has_error) AS has_error",
|
||||
"any(name) AS name",
|
||||
"any(events) AS events",
|
||||
"any(attributes_string) AS attributes_string",
|
||||
"any(attributes_number) AS attributes_number",
|
||||
"any(attributes_bool) AS attributes_bool",
|
||||
"any(attributes) AS attributes",
|
||||
"if(notEmpty(attributes), map(), any(attributes_string)) AS attributes_string", // `attributes` below resolves to the aggregated alias
|
||||
"if(notEmpty(attributes), map(), any(attributes_number)) AS attributes_number",
|
||||
"if(notEmpty(attributes), map(), any(attributes_bool)) AS attributes_bool",
|
||||
"any(resources_string) AS resources_string",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
|
||||
@@ -153,25 +153,39 @@ func TestGetSpanCountByField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFlamegraphSpans(t *testing.T) {
|
||||
baseSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY span_id ORDER BY timestamp ASC, name ASC"
|
||||
withSpanIDsSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND span_id IN (?, ?) GROUP BY span_id ORDER BY timestamp ASC, name ASC"
|
||||
func TestGetTraceSpans(t *testing.T) {
|
||||
expectedSQL := "SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, has_error, kind, resource_string_service$$name, name, attributes, resources_string, if(notEmpty(attributes), map(), attributes_string) AS attributes_string, if(notEmpty(attributes), map(), attributes_number) AS attributes_number, if(notEmpty(attributes), map(), attributes_bool) AS attributes_bool, events, status_message, status_code_string, kind_string, parent_span_id, flags, is_remote, trace_state, status_code, db_name, db_operation, http_method, http_url, http_host, external_http_method, external_http_url, response_status_code, links as references FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY timestamp ASC, name ASC"
|
||||
|
||||
tests := []struct {
|
||||
s := newTestStore(sqlmock.QueryMatcherEqual)
|
||||
s.Mock().ExpectSelect(expectedSQL).
|
||||
WillReturnRows(cmock.NewRows(nil, nil))
|
||||
_, _ = s.Store().GetTraceSpans(context.Background(), testTraceID, testBounds)
|
||||
assert.NoError(t, s.Mock().ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestGetFlamegraphSpans(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spanIDs []string
|
||||
sql string
|
||||
}{
|
||||
{name: "NoSpanIDs_GeneratesBaseSQL", spanIDs: nil, sql: baseSQL},
|
||||
{name: "WithSpanIDs_GeneratesInClauseSQL", spanIDs: []string{"span-1", "span-2"}, sql: withSpanIDsSQL},
|
||||
{
|
||||
name: "NoSpanIDs_GeneratesBaseSQL",
|
||||
sql: "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes) AS attributes, if(notEmpty(attributes), map(), any(attributes_string)) AS attributes_string, if(notEmpty(attributes), map(), any(attributes_number)) AS attributes_number, if(notEmpty(attributes), map(), any(attributes_bool)) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY span_id ORDER BY timestamp ASC, name ASC",
|
||||
},
|
||||
{
|
||||
name: "WithSpanIDs_GeneratesInClauseSQL",
|
||||
spanIDs: []string{"span-1", "span-2"},
|
||||
sql: "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes) AS attributes, if(notEmpty(attributes), map(), any(attributes_string)) AS attributes_string, if(notEmpty(attributes), map(), any(attributes_number)) AS attributes_number, if(notEmpty(attributes), map(), any(attributes_bool)) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND span_id IN (?, ?) GROUP BY span_id ORDER BY timestamp ASC, name ASC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := newTestStore(sqlmock.QueryMatcherRegexp)
|
||||
s.Mock().ExpectSelect(regexp.QuoteMeta(tc.sql)).
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
s := newTestStore(sqlmock.QueryMatcherEqual)
|
||||
s.Mock().ExpectSelect(testCase.sql).
|
||||
WillReturnRows(cmock.NewRows(nil, nil))
|
||||
_, _ = s.Store().GetFlamegraphSpans(context.Background(), testTraceID, testStart, testEnd, tc.spanIDs)
|
||||
_, _ = s.Store().GetFlamegraphSpans(context.Background(), testTraceID, testStart, testEnd, testCase.spanIDs)
|
||||
assert.NoError(t, s.Mock().ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
@@ -204,7 +218,7 @@ func TestGetSpanDurationByField(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetThreadSpans(t *testing.T) {
|
||||
selectSQL := "SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, parent_span_id, has_error, name, kind_string, status_code_string, status_message, resources_string, attributes_string, attributes_number, attributes_bool, events, links as references"
|
||||
selectSQL := "SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, parent_span_id, has_error, name, kind_string, status_code_string, status_message, resources_string, attributes, attributes_string, attributes_number, attributes_bool, events, links as references"
|
||||
fromSQL := " FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND "
|
||||
orderSQL := " ORDER BY timestamp ASC, span_id ASC LIMIT ?"
|
||||
jsonInsideTrace := testStart.Add(500 * time.Second)
|
||||
@@ -213,20 +227,17 @@ func TestGetThreadSpans(t *testing.T) {
|
||||
name string
|
||||
jsonOn bool
|
||||
jsonRelease *time.Time
|
||||
selectSQL string
|
||||
whereSQL string
|
||||
}{
|
||||
{
|
||||
name: "FlagOff_ReadsAndFiltersLegacyMaps",
|
||||
name: "FlagOff_FiltersLegacyMaps",
|
||||
jsonRelease: &jsonInsideTrace,
|
||||
selectSQL: selectSQL,
|
||||
whereSQL: "(mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))",
|
||||
},
|
||||
{
|
||||
name: "FlagOn_ReleasedDuringTrace_ReadsJSONFiltersJSONThenMaps",
|
||||
name: "FlagOn_ReleasedDuringTrace_FiltersJSONThenMaps",
|
||||
jsonOn: true,
|
||||
jsonRelease: &jsonInsideTrace,
|
||||
selectSQL: selectSQL + ", attributes",
|
||||
whereSQL: "(multiIf(attributes.`gen_ai.request.model` IS NOT NULL, attributes.`gen_ai.request.model`::String, mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.agent.name` IS NOT NULL, attributes.`gen_ai.agent.name`::String, mapContains(attributes_string, 'gen_ai.agent.name'), attributes_string['gen_ai.agent.name'], NULL) IS NOT NULL)",
|
||||
},
|
||||
}
|
||||
@@ -235,7 +246,7 @@ func TestGetThreadSpans(t *testing.T) {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fl := flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureUseTraceAttributesJSON.String(): testCase.jsonOn})
|
||||
s := newTestStoreWithMetadata(sqlmock.QueryMatcherRegexp, genAIMetadataStore(testCase.jsonRelease), fl)
|
||||
s.Mock().ExpectSelect(regexp.QuoteMeta(testCase.selectSQL + fromSQL + testCase.whereSQL + orderSQL)).
|
||||
s.Mock().ExpectSelect(regexp.QuoteMeta(selectSQL + fromSQL + testCase.whereSQL + orderSQL)).
|
||||
WillReturnRows(cmock.NewRows(nil, nil))
|
||||
_, _ = s.Store().GetThreadSpans(context.Background(), valuer.GenerateUUID(), testTraceID, testBounds, spantypes.ThreadPage{Limit: 3})
|
||||
assert.NoError(t, s.Mock().ExpectationsWereMet())
|
||||
|
||||
@@ -21,7 +21,7 @@ type Handler interface {
|
||||
// Module defines the business logic for trace detail operations.
|
||||
type Module interface {
|
||||
GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error)
|
||||
GetWaterfallV4(ctx context.Context, orgID valuer.UUID, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
|
||||
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
|
||||
GetThread(ctx context.Context, orgID valuer.UUID, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error)
|
||||
|
||||
@@ -70,6 +70,7 @@ func NewFlamegraphSpanFromStorable(s *StorableSpan, level int64, selectFields []
|
||||
if len(selectFields) == 0 {
|
||||
return span
|
||||
}
|
||||
var attributes map[string]any
|
||||
for _, field := range selectFields {
|
||||
switch field.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
@@ -77,7 +78,10 @@ func NewFlamegraphSpanFromStorable(s *StorableSpan, level int64, selectFields []
|
||||
span.Resource[field.Name] = v
|
||||
}
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
if v := s.AttributeValue(field.Name); v != nil {
|
||||
if attributes == nil {
|
||||
attributes = s.Attributes()
|
||||
}
|
||||
if v, ok := attributes[field.Name]; ok {
|
||||
span.Attributes[field.Name] = v
|
||||
}
|
||||
}
|
||||
|
||||
59
pkg/types/spantypes/flamegraph_span_test.go
Normal file
59
pkg/types/spantypes/flamegraph_span_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func TestNewFlamegraphSpanFromStorable_SelectFields(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
span StorableSpan
|
||||
selectFields []telemetrytypes.TelemetryFieldKey
|
||||
wantAttrs map[string]any
|
||||
wantResource map[string]string
|
||||
}{
|
||||
{
|
||||
name: "JSONColumn_ResolvesDottedPaths",
|
||||
span: StorableSpan{AttributesJSON: jsonAttrs(map[string]any{"http.route": "/orders", "http.retry.count": int64(2)})},
|
||||
selectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "http.route", FieldContext: telemetrytypes.FieldContextAttribute},
|
||||
{Name: "http.retry.count", FieldContext: telemetrytypes.FieldContextAttribute},
|
||||
{Name: "http", FieldContext: telemetrytypes.FieldContextAttribute},
|
||||
},
|
||||
wantAttrs: map[string]any{"http.route": "/orders", "http.retry.count": float64(2)},
|
||||
wantResource: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "LegacyMaps_Resolve",
|
||||
span: StorableSpan{AttributesString: map[string]string{"http.route": "/cart"}, AttributesBool: map[string]bool{"cache.hit": true}},
|
||||
selectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "http.route", FieldContext: telemetrytypes.FieldContextAttribute},
|
||||
{Name: "cache.hit", FieldContext: telemetrytypes.FieldContextAttribute},
|
||||
{Name: "missing", FieldContext: telemetrytypes.FieldContextAttribute},
|
||||
},
|
||||
wantAttrs: map[string]any{"http.route": "/cart", "cache.hit": true},
|
||||
wantResource: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "ResourceFields_EmptyValueSkipped",
|
||||
span: StorableSpan{ResourcesString: map[string]string{"service.name": "checkout", "deployment.environment": ""}},
|
||||
selectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource},
|
||||
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource},
|
||||
},
|
||||
wantAttrs: map[string]any{},
|
||||
wantResource: map[string]string{"service.name": "checkout"},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
span := NewFlamegraphSpanFromStorable(&testCase.span, 0, testCase.selectFields)
|
||||
assert.Equal(t, testCase.wantAttrs, span.Attributes)
|
||||
assert.Equal(t, testCase.wantResource, span.Resource)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,9 @@ type SpanMapperStore interface {
|
||||
type TraceStore interface {
|
||||
GetTraceBounds(ctx context.Context, traceID string) (*TraceBounds, error)
|
||||
GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds) (*TraceStats, error)
|
||||
GetTraceSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds) ([]StorableSpan, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, bounds *TraceBounds) ([]StorableSpan, error)
|
||||
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
|
||||
GetTraceSpansByIDs(ctx context.Context, orgID valuer.UUID, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
GetThreadSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds, page ThreadPage) ([]StorableSpan, error)
|
||||
GetThreadCursor(ctx context.Context, traceID string, bounds *TraceBounds, spanID string) (*ThreadCursor, error)
|
||||
|
||||
@@ -177,7 +177,7 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
|
||||
resources := make(map[string]string, len(storable.ResourcesString))
|
||||
maps.Copy(resources, storable.ResourcesString)
|
||||
timeUnixNano := uint64(storable.StartTime.UnixNano())
|
||||
attributes := threadAttributes(storable)
|
||||
attributes := storable.Attributes()
|
||||
// Spans from before the JSON rollout have an empty document and format to nothing.
|
||||
formatted := aiobservabilitytypes.FormatMessages(storable.AttributesJSON.Nest())
|
||||
return &ThreadSpan{
|
||||
@@ -201,13 +201,3 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
|
||||
timeUnixNano: timeUnixNano,
|
||||
}
|
||||
}
|
||||
|
||||
// threadAttributes flattens the JSON column into dotted keys, as the querier does for list
|
||||
// responses, and falls back to the legacy maps for spans written before the JSON rollout.
|
||||
func threadAttributes(storable *StorableSpan) map[string]any {
|
||||
attributes := storable.AttributesJSON.Flatten()
|
||||
if len(attributes) == 0 {
|
||||
return storable.Attributes()
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
@@ -51,57 +51,6 @@ func TestNewThreadQuery_Cursor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadAttributes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
span StorableSpan
|
||||
wantAttrs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "JSONColumnPresent_LegacyMapsIgnored",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "json"}),
|
||||
AttributesString: map[string]string{"gen_ai.request.model": "map", "http.method": "GET"},
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.request.model": "json"},
|
||||
},
|
||||
{
|
||||
name: "NoJSONColumn_FallsBackToLegacyMaps",
|
||||
span: StorableSpan{
|
||||
AttributesString: map[string]string{"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`},
|
||||
AttributesNumber: map[string]float64{"gen_ai.usage.input_tokens": 12},
|
||||
AttributesBool: map[string]bool{"gen_ai.stream": true},
|
||||
},
|
||||
wantAttrs: map[string]any{
|
||||
"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`,
|
||||
"gen_ai.usage.input_tokens": float64(12),
|
||||
"gen_ai.stream": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// ClickHouse materializes a row's absent paths as null from the part's path schema.
|
||||
name: "JSONColumn_DropsNullPaths",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": chcol.Variant{}}),
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.request.model": "gpt-4o"},
|
||||
},
|
||||
{
|
||||
name: "JSONColumn_OnlyNullPaths_FallsBackToLegacyMaps",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.output.messages": chcol.Variant{}}),
|
||||
AttributesString: map[string]string{"gen_ai.input.messages": "legacy"},
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.input.messages": "legacy"},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.wantAttrs, threadAttributes(&testCase.span))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewThreadSpan_FormatsJSONMessages(t *testing.T) {
|
||||
storable := StorableSpan{
|
||||
SpanID: "f1fa1bc863e94dd0",
|
||||
|
||||
@@ -273,20 +273,12 @@ func (ws *WaterfallSpan) getPathToSelectedSpanID(selectedSpanID string) ([]strin
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (item *StorableSpan) AttributeValue(name string) any {
|
||||
if v, ok := item.AttributesString[name]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := item.AttributesNumber[name]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := item.AttributesBool[name]; ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Attributes flattens the JSON column into dotted keys, as the querier does for list responses, and
|
||||
// falls back to the legacy maps for spans written before the JSON rollout.
|
||||
func (item *StorableSpan) Attributes() map[string]any {
|
||||
if attributes := item.AttributesJSON.Flatten(); len(attributes) > 0 {
|
||||
return attributes
|
||||
}
|
||||
attributes := make(map[string]any, len(item.AttributesString)+len(item.AttributesNumber)+len(item.AttributesBool))
|
||||
for k, v := range item.AttributesString {
|
||||
attributes[k] = v
|
||||
|
||||
66
pkg/types/spantypes/waterfall_span_test.go
Normal file
66
pkg/types/spantypes/waterfall_span_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStorableSpanAttributes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
span StorableSpan
|
||||
wantAttrs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "JSONColumnPresent_LegacyMapsIgnored",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "json"}),
|
||||
AttributesString: map[string]string{"gen_ai.request.model": "map", "http.method": "GET"},
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.request.model": "json"},
|
||||
},
|
||||
{
|
||||
name: "NoJSONColumn_FallsBackToLegacyMaps",
|
||||
span: StorableSpan{
|
||||
AttributesString: map[string]string{"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`},
|
||||
AttributesNumber: map[string]float64{"gen_ai.usage.input_tokens": 12},
|
||||
AttributesBool: map[string]bool{"gen_ai.stream": true},
|
||||
},
|
||||
wantAttrs: map[string]any{
|
||||
"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`,
|
||||
"gen_ai.usage.input_tokens": float64(12),
|
||||
"gen_ai.stream": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "JSONColumn_ScalarAndObjectKey_StaysTwoPaths",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: jsonAttrs(map[string]any{"db.function": "node_refresh", "db.function.arg_count": int64(2)}),
|
||||
},
|
||||
wantAttrs: map[string]any{"db.function": "node_refresh", "db.function.arg_count": float64(2)},
|
||||
},
|
||||
{
|
||||
// ClickHouse materializes a row's absent paths as null from the part's path schema.
|
||||
name: "JSONColumn_DropsNullPaths",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": chcol.Variant{}}),
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.request.model": "gpt-4o"},
|
||||
},
|
||||
{
|
||||
name: "JSONColumn_OnlyNullPaths_FallsBackToLegacyMaps",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.output.messages": chcol.Variant{}}),
|
||||
AttributesString: map[string]string{"gen_ai.input.messages": "legacy"},
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.input.messages": "legacy"},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.wantAttrs, testCase.span.Attributes())
|
||||
})
|
||||
}
|
||||
}
|
||||
67
tests/integration/tests/tracedetail/04_attribute_homes.py
Normal file
67
tests/integration/tests/tracedetail/04_attribute_homes.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.traces import ATTRIBUTE_JSON_ROLLOUT_TIME, TraceIdGenerator, Traces, TracesKind
|
||||
|
||||
|
||||
def test_waterfall_and_flamegraph_read_each_attribute_home(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
legacy_id, json_id, dual_id = (TraceIdGenerator.span_id() for _ in range(3))
|
||||
resources = {"service.name": "tracedetail-attribute-homes"}
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=9), trace_id=trace_id, span_id=legacy_id, name="GET /legacy", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attributes={"http.route": "/legacy", "http.retry.count": 1, "cache.hit": True}, attribute_write_mode="legacy_only"),
|
||||
Traces(timestamp=now - timedelta(seconds=8), trace_id=trace_id, span_id=json_id, parent_span_id=legacy_id, name="db call", resources=resources, attributes={"http.route": "/json", "db.function": "node_refresh", "db.function.arg_count": 2}, attribute_write_mode="json_only"),
|
||||
Traces(timestamp=now - timedelta(seconds=6), trace_id=trace_id, span_id=dual_id, parent_span_id=legacy_id, name="GET /dual", resources=resources, attributes={"http.route": "/dual"}),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v4/traces/{trace_id}/waterfall"),
|
||||
json={"selectedSpanId": "", "uncollapsedSpans": []},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
waterfall = {span["span_id"]: span["attributes"] for span in response.json()["data"]["spans"]}
|
||||
assert waterfall[legacy_id] == {"http.route": "/legacy", "http.retry.count": 1, "cache.hit": True}
|
||||
# a key stored at both a scalar and an object path stays two flat keys
|
||||
assert waterfall[json_id] == {"http.route": "/json", "db.function": "node_refresh", "db.function.arg_count": 2}
|
||||
assert waterfall[dual_id] == {"http.route": "/dual"}
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v3/traces/{trace_id}/flamegraph"),
|
||||
json={
|
||||
"selectedSpanId": "",
|
||||
"selectFields": [
|
||||
{"name": "http.route", "fieldContext": "attribute"},
|
||||
{"name": "db.function.arg_count", "fieldContext": "attribute"},
|
||||
{"name": "service.name", "fieldContext": "resource"},
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
flamegraph = {span["spanId"]: span for level in response.json()["data"]["spans"] for span in level}
|
||||
assert flamegraph[legacy_id]["attributes"] == {"http.route": "/legacy"}
|
||||
assert flamegraph[json_id]["attributes"] == {"http.route": "/json", "db.function.arg_count": 2}
|
||||
assert flamegraph[dual_id]["attributes"] == {"http.route": "/dual"}
|
||||
assert all(span["resource"] == resources for span in flamegraph.values())
|
||||
Reference in New Issue
Block a user