Compare commits

..

9 Commits

Author SHA1 Message Date
Gaurav Tewari
084d9aad91 refactor: trace details and and header changes 2026-10-06 22:29:02 +05:30
Gaurav Tewari
2466aa166a feat: trace detail header & tooltip changes 2026-10-06 21:54:29 +05:30
Gaurav Tewari
82a588c15b feat: update badge color 2026-10-06 19:24:48 +05:30
Gaurav Tewari
13be8de648 feat: add support for badge and update ai icons 2026-10-06 19:01:40 +05:30
Gaurav Tewari
58bb656a11 chore: update tooltip design and support ai spans 2026-10-06 18:42:47 +05:30
Gaurav Tewari
f6f95d4f7c chore: another refactor approch 2026-10-06 15:50:13 +05:30
Gaurav Tewari
528cb41551 feat: add trace tab and initial refactor 2026-10-06 14:48:35 +05:30
dependabot[bot]
185677634c chore(deps): bump google.golang.org/grpc from 1.83.1 to 1.83.2 in /scripts/promqltestcorpus (#12804)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from
1.83.1 to 1.83.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/grpc/grpc-go/releases">google.golang.org/grpc's
releases</a>.</em></p>
<blockquote>
<h2>Release 1.83.2</h2>
<h1>Security</h1>
<ul>
<li>server: Reject requests missing both <code>:authority</code> and
<code>Host</code> headers with HTTP 400 and status
<code>Internal</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/pull/9365">grpc/grpc-go#9365</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/winklemad"><code>@​winklemad</code></a></li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="030ee8becb"><code>030ee8b</code></a>
Update version to 1.83.2 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9375">#9375</a>)</li>
<li><a
href="8668b69c16"><code>8668b69</code></a>
cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9365">#9365</a> to
v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9366">#9366</a>)</li>
<li><a
href="a3e952d2b7"><code>a3e952d</code></a>
cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9346">#9346</a> to
v1.83.x and update x/net dependency (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9369">#9369</a>)</li>
<li><a
href="58f8fd9a00"><code>58f8fd9</code></a>
Change version to 1.83.2-dev (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9337">#9337</a>)</li>
<li>See full diff in <a
href="https://github.com/grpc/grpc-go/compare/v1.83.1...v1.83.2">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-05 13:04:53 +00:00
Naman Verma
e8c22cb259 fix(promql): serve transpiled series without their synthetic __name__ (#13031)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

`max_over_time` expects a range vector as input, which is `labels ->
[(timestamp,value),...]`. it turns each entry into
`labelsWith__name__removed -> maxOfAllValues`.

However, if two entries have `labelsWith__name__removed` as the same,
then `max_over_time` throws an error. For eg if the input is:
1. {"host":"a", "__name__": "transpiled_1"} -> ....
2. {"host":"a", "__name__": "transpiled_2"} -> ....
then `max_over_time` will break.

Currently, `executeHybrid` in
`pkg/prometheus/clickhouseprometheusv2/transpiler_exec.go` always puts
in the `__name__` label as `signoz_transpiled_*`, which can lead to the
above scenario.

Removing this `__name__` label fixes that issue. Also, nothing ever
needs this label. When the engine asks for `signoz_transpiled_0`, our
storage finds the data with a map lookup on that name and returns it.
The series' own labels play no part in the lookup.

After this change, no synthetic `__name__` exists anymore. So, the code
that stripped it after the engine ran, and `mergeMatrixByLabelset`,
which re-merged the rows those names had split, are deleted. The
engine's own merging handles this now.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/508

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-10-05 09:56:02 +00:00
90 changed files with 2061 additions and 3802 deletions

View File

@@ -12,7 +12,6 @@
overflow: visible;
position: relative;
align-self: stretch;
clip-path: inset(0 -100vw 0 0);
}
}
@@ -35,7 +34,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) 10.5px;
padding: 10.5px;
border-bottom: 1px solid var(--l1-border);
.left-actions {
@@ -46,9 +45,6 @@
justify-content: flex-start;
.text {
padding-left: var(--spacing-4);
white-space: nowrap;
flex-shrink: 0;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
@@ -81,8 +77,6 @@
}
.select-box {
--combobox-trigger-width: auto;
display: flex;
padding: 2px 5px;
flex-direction: row;
@@ -100,9 +94,10 @@
.right-actions {
display: flex;
align-items: center;
gap: var(--spacing-1);
flex-shrink: 0;
gap: 10px;
width: 100%;
justify-content: flex-end;
--button-variant-link-color: var(--l1-foreground);
.divider-filter {
width: 1px;
@@ -110,8 +105,26 @@
background: var(--l1-background);
}
.active {
background: var(--l2-background);
.sync-icon {
cursor: pointer;
}
.right-action-icon-container {
position: relative;
display: flex;
padding: 2px;
background-color: var(--l1-background);
.settings-icon {
height: 14px;
width: 14px;
cursor: pointer;
}
&.active,
&:hover {
background: var(--l2-background);
}
}
}
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import {
ArrowUpToLine,
Filter,
Frown,
RefreshCw,
Settings2 as SettingsIcon,
@@ -13,9 +14,8 @@ import {
ComboboxList,
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { Skeleton } from 'antd';
import { Skeleton, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -57,7 +57,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
showFilterCollapse = true,
showQueryName = true,
useFieldApis,
savedViewsHeader,
} = props;
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [params, setParams] = useApiMonitoringParams();
@@ -180,6 +179,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
// Helpers to reduce cognitive complexity in main render
const renderLeftActions = (): JSX.Element => (
<section className="left-actions">
<Filter size="md" />
<Typography.Text className="text">
{displayedQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
@@ -216,13 +216,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
</Combobox>
) : (
displayedQueryName && (
<TooltipSimple
<Tooltip
title={`Filter currently in sync with query ${displayedQueryName}`}
>
<Typography.Text className="sync-tag">
{displayedQueryName}
</Typography.Text>
</TooltipSimple>
</Tooltip>
)
)}
</section>
@@ -230,54 +230,51 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const renderRightActions = (): JSX.Element => (
<section className="right-actions">
<TooltipSimple title="Reset All">
<Tooltip title="Reset All">
<Button
variant="ghost"
variant="link"
color="secondary"
size="icon"
aria-label="Reset All"
className="right-action-icon-container"
onClick={handleReset}
prefix={<RefreshCw size={14} />}
prefix={<RefreshCw className="sync-icon" size="md" />}
/>
</TooltipSimple>
</Tooltip>
{showFilterCollapse && (
<TooltipSimple title="Collapse Filters">
<Tooltip title="Collapse Filters">
<Button
variant="ghost"
variant="link"
color="secondary"
size="icon"
aria-label="Collapse Filters"
className="right-action-icon-container"
onClick={handleFilterVisibilityChange}
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size={14} />}
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
/>
</TooltipSimple>
</Tooltip>
)}
{isDynamicFilters && (
<AuthZButton
checks={QuickFilterManagePermissions}
variant="link"
color="secondary"
aria-label="Settings"
className={classNames('right-action-icon-container', {
active: isSettingsOpen,
})}
onClick={(): void => setIsSettingsOpen(true)}
testId="settings-icon-container"
prefix={
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
<SettingsIcon
className="settings-icon"
data-testid="settings-icon"
width={14}
height={14}
/>
</Tooltip>
}
/>
)}
{isDynamicFilters &&
(isSettingsDisabled ? (
<AuthZButton
checks={QuickFilterManagePermissions}
variant="ghost"
color="secondary"
size="icon"
aria-label="Settings"
onClick={(): void => setIsSettingsOpen(true)}
testId="settings-icon-container"
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
/>
) : (
<TooltipSimple title="Settings">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Settings"
className={classNames({ active: isSettingsOpen })}
onClick={(): void => setIsSettingsOpen(true)}
data-testid="settings-icon-container"
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
/>
</TooltipSimple>
))}
</section>
);
@@ -382,7 +379,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<div className="quick-filters-container">
<div className="quick-filters">
{savedViewsHeader}
{source !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
{renderLeftActions()}

View File

@@ -8,7 +8,9 @@
overflow: hidden;
}
.sidebar {
// Positioned so overlays (settings drawer) paint above the content pane
// without changing this pane's layout width.
.filters {
width: 280px;
flex-shrink: 0;
display: flex;
@@ -19,44 +21,6 @@
z-index: 2;
}
.savedViews {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--l1-background);
border-right: 1px solid var(--l1-border);
}
.quickFilters {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--l1-background);
transition: transform 0.2s ease-in-out;
z-index: 1;
&.isOpen {
transform: translateX(100%);
}
}
// No quick filters: the header sits in flow and the list stays under it
.isStatic {
border-right: 1px solid var(--l1-border);
.quickFilters {
position: static;
flex-shrink: 0;
}
.savedViews {
border-right: 0;
}
}
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
// `height: 100%`), which owns the scrolling.
.content {
@@ -67,7 +31,3 @@
flex-direction: column;
overflow: hidden;
}
.contentInner {
height: 100%;
}

View File

@@ -1,9 +1,6 @@
import { ComponentProps, ReactNode, useState } from 'react';
import { ComponentProps, ReactNode } from 'react';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
@@ -15,15 +12,8 @@ type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
ComponentProps<typeof QuickFilters>
>;
// What the page configures; the layout adds the open / close wiring.
type SavedViewsElementProps = Omit<
ComponentProps<typeof SavedViewsPanel>,
'onClose'
>;
export interface QuickFiltersLayoutProps {
quickFilterProps?: QuickFiltersElementProps;
savedViewProps?: SavedViewsElementProps;
quickFilterProps: QuickFiltersElementProps;
showFilters: boolean;
className?: string;
contentClassName?: string;
@@ -33,65 +23,20 @@ export interface QuickFiltersLayoutProps {
function QuickFiltersLayout({
quickFilterProps,
savedViewProps,
showFilters,
className,
contentClassName,
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
const isSavedViewEnabled = useSavedViewEnabled();
const hasQuickFilters = !!quickFilterProps;
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
const isViewsListPinned = hasSavedViews && !hasQuickFilters;
const isViewsListVisible = isViewsListPinned || isViewsListOpen;
const isSliding = hasQuickFilters && isViewsListOpen;
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
}
/>
) : undefined;
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{showSidebar && (
{showFilters && (
<aside
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
className={styles.filters}
data-testid="quick-filters-layout-filters"
>
<div
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
data-testid="quick-filters-layout-drawer"
>
{hasQuickFilters ? (
<QuickFilters
{...quickFilterProps}
savedViewsHeader={savedViewsHeader}
/>
) : (
savedViewsHeader
)}
</div>
<div className={styles.savedViews}>
{hasSavedViews && isViewsListVisible && (
<SavedViewsPanel
{...savedViewProps}
onClose={
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
}
/>
)}
</div>
<QuickFilters {...quickFilterProps} />
</aside>
)}
<section
@@ -99,7 +44,7 @@ function QuickFiltersLayout({
data-testid="quick-filters-layout-content"
>
<OverlayScrollbar>
<div className={styles.contentInner}>{children}</div>
<div>{children}</div>
</OverlayScrollbar>
</section>
</div>

View File

@@ -1,6 +1,4 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent } from 'tests/test-utils';
import { render, screen } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
@@ -9,33 +7,15 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
__esModule: true,
default: {
layout: 'layout',
sidebar: 'sidebar',
isStatic: 'isStatic',
savedViews: 'savedViews',
quickFilters: 'quickFilters',
isOpen: 'isOpen',
filters: 'filters',
content: 'content',
contentInner: 'contentInner',
},
}));
jest.mock('hooks/useSavedViewEnabled', () => ({
useSavedViewEnabled: jest.fn(() => true),
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({
source,
savedViewsHeader,
}: {
source: string;
savedViewsHeader?: React.ReactNode;
}): JSX.Element => (
<div data-testid="quick-filters">
{savedViewsHeader}
{source}
</div>
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="quick-filters">{source}</div>
),
}));
@@ -43,16 +23,8 @@ const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
handleFilterVisibilityChange: jest.fn(),
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
const drawer = (): HTMLElement =>
screen.getByTestId('quick-filters-layout-drawer');
describe('QuickFiltersLayout', () => {
beforeEach(() => {
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
});
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
@@ -68,16 +40,11 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
'content',
);
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
});
it('does not render the filters pane when showFilters is false', () => {
render(
<QuickFiltersLayout
showFilters={false}
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
<div>content</div>
</QuickFiltersLayout>,
);
@@ -89,140 +56,24 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByText('content')).toBeInTheDocument();
});
it('renders no sidebar at all without quick filters or saved views', () => {
render(
<QuickFiltersLayout showFilters>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
});
it('merges classNames onto the root and content panes', () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
className="root-extra"
contentClassName="content-extra"
testId="layout"
className="page-root"
contentClassName="page-content"
testId="page"
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('layout')).toHaveClass('layout', 'root-extra');
const root = screen.getByTestId('page');
expect(root).toHaveClass('layout', 'page-root');
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
'content',
'content-extra',
'page-content',
);
});
describe('saved views', () => {
it('ignores savedViewProps while the flag is off', () => {
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
const { unmount } = render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
// Saved views only, so nothing is left to show.
unmount();
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
});
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
const user = userEvent.setup();
const quickFilters = screen.getByTestId('quick-filters');
expect(quickFilters).toContainElement(
screen.getByTestId('saved-views-header'),
);
expect(drawer()).toContainElement(quickFilters);
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await user.click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
await user.click(screen.getByTestId('saved-views-close'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
});
it('hands the source to the header and the panel', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('saved-views-header')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
});
it('without quick filters: static sidebar, header on top, list always on screen with no open or close controls', () => {
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
const sidebar = screen.getByTestId('quick-filters-layout-filters');
expect(sidebar).toHaveClass('isStatic');
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
});
});
});

View File

@@ -8,20 +8,12 @@
width: 342px;
height: 100%;
background: var(--l1-background);
border-right: 1px solid var(--l1-border);
transition: width 0.05s ease-in-out;
overflow: hidden;
color: var(--l1-foreground);
transition:
transform 0.2s ease-in-out,
visibility 0s;
&.hidden {
transform: translateX(-100%);
visibility: hidden;
pointer-events: none;
transition:
transform 0.2s ease-in-out,
visibility 0s 0.2s;
width: 0;
}
.qf-header {
display: flex;

View File

@@ -1,4 +1,3 @@
import { ReactNode } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -62,7 +61,6 @@ export interface IQuickFiltersProps {
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
savedViewsHeader?: ReactNode;
}
export enum QuickFiltersSource {

View File

@@ -5,10 +5,12 @@ import {
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { SavedViewSourcePage } from 'container/SavedViews/types';
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
import {
findSavedView,
getSavedViewQuery,
SavedViewSourcePage,
toSavedViewSource,
} from 'container/SavedViews/utils';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { DataSource } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';

View File

@@ -8,7 +8,7 @@ import {
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { getSavedViewQuery } from 'container/SavedViews/utils';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import Card from 'periscope/components/Card/Card';

View File

@@ -10,8 +10,6 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
@@ -349,62 +347,57 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<QuickFiltersLayout
showFilters
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
/>
</Tooltip>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
/>
</div>
</Tooltip>
</div>
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
</div>
</div>
</QuickFiltersLayout>
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}

View File

@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
aggregateOperator?: string;
aggregateOperator: string;
initialOptions?: InitialOptions;
}

View File

@@ -1,34 +0,0 @@
import { useMemo } from 'react';
import { Check } from '@signozhq/icons';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import SavedViewsIconButton from './SavedViewsIconButton';
import { SaveChangesMenuProps } from './types';
function SaveChangesMenu({
disabled,
onSaveAsNew,
onUpdate,
}: SaveChangesMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(
() => [
{ key: 'save-as-new', label: 'Save as new view', onClick: onSaveAsNew },
{ key: 'update', label: 'Update selected view', onClick: onUpdate },
],
[onSaveAsNew, onUpdate],
);
return (
<DropdownMenuSimple menu={{ items }} align="end">
<SavedViewsIconButton
title="Save changes"
icon={<Check size={14} />}
color="warning"
disabled={disabled}
testId="saved-views-save-changes"
/>
</DropdownMenuSimple>
);
}
export default SaveChangesMenu;

View File

@@ -1,18 +0,0 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.label {
font-size: var(--font-size-xs);
color: var(--l1-foreground);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-4);
width: 100%;
}

View File

@@ -1,94 +0,0 @@
import { FormEvent, useState } from 'react';
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { SAVE_VIEW_MODAL_TITLE } from './constants';
import { SaveViewModalProps } from './types';
import styles from './SaveViewModal.module.scss';
const NAME_INPUT_ID = 'save-view-name';
function SaveViewModal({
mode,
isSaving,
onClose,
onSave,
}: SaveViewModalProps): JSX.Element {
const [name, setName] = useState('');
const displayName = name.trim();
const handleSave = async (): Promise<void> => {
if (!displayName || isSaving) {
return;
}
if (await onSave(displayName)) {
onClose();
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
void handleSave();
};
return (
<DialogWrapper
open
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={SAVE_VIEW_MODAL_TITLE[mode]}
testId="save-view-modal"
footer={
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<X size={14} />}
onClick={onClose}
testId="save-view-cancel"
>
Cancel
</Button>
<Button
color="primary"
size="md"
prefix={<Check size={14} />}
loading={isSaving}
disabled={!displayName}
onClick={(): void => {
void handleSave();
}}
testId="save-view-submit"
>
Save view
</Button>
</div>
}
>
<form className={styles.field} onSubmit={handleSubmit}>
<label htmlFor={NAME_INPUT_ID}>
<Typography.Text className={styles.label}>Enter view name</Typography.Text>
</label>
<Input
id={NAME_INPUT_ID}
placeholder="Eg. payments-view"
value={name}
onChange={(event): void => setName(event.target.value)}
autoFocus
autoComplete="off"
testId="save-view-name"
/>
</form>
</DialogWrapper>
);
}
export default SaveViewModal;

View File

@@ -1,61 +0,0 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-5);
border-bottom: 1px solid var(--l1-border);
color: var(--l1-foreground);
flex-shrink: 0;
&.isDirty {
background: linear-gradient(
to right,
var(--callout-warning-background),
transparent
);
}
}
.name {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
padding: var(--spacing-1) var(--spacing-4);
border-radius: var(--spacing-1);
background: var(--l3-background);
color: var(--l1-foreground);
font-size: var(--font-size-sm);
&.isDirty {
background: var(--callout-warning-background);
color: var(--callout-warning-title);
}
}
.nameText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dot {
width: var(--spacing-3);
height: var(--spacing-3);
flex-shrink: 0;
border-radius: 50%;
background: var(--callout-warning-icon);
}
.skeleton {
flex: 1;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--spacing-1);
}

View File

@@ -1,146 +0,0 @@
import { useCallback, useState } from 'react';
import cx from 'classnames';
import { Menu, Plus, Undo2, X } from '@signozhq/icons';
import { Skeleton } from '@signozhq/ui/skeleton';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { MY_VIEW_NAME, SAVED_VIEW_TOAST_POSITION } from './constants';
import { useActiveSavedView } from './hooks/useActiveSavedView';
import { useRestoreLastUsedView } from './hooks/useRestoreLastUsedView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import SaveChangesMenu from './SaveChangesMenu';
import SavedViewsIconButton from './SavedViewsIconButton';
import SaveViewModal from './SaveViewModal';
import { SaveViewModalMode } from './types';
import styles from './SavedViewsHeader.module.scss';
function SavedViewsHeader({
source,
onOpenViews,
}: {
source: SavedviewtypesSourceDTO;
// Absent while the list is on screen.
onOpenViews?: () => void;
}): JSX.Element {
const { view, isLoading, hasUnsavedChanges } = useActiveSavedView(source);
const { selectView, revertView, clearView, createView, updateView, isSaving } =
useSavedViewActions(source);
useRestoreLastUsedView({ source, selectView });
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
const handleSave = useCallback(
async (displayName: string): Promise<boolean> => {
const id = await createView(displayName);
if (!id) {
return false;
}
toast.success('You have created a new view.', {
position: SAVED_VIEW_TOAST_POSITION,
...(onOpenViews && { action: { label: 'View', onClick: onOpenViews } }),
});
return true;
},
[createView, onOpenViews],
);
const handleUpdate = useCallback(async (): Promise<void> => {
if (view && (await updateView(view))) {
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
}
}, [view, updateView]);
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
const closeModal = useCallback((): void => setModalMode(null), []);
const name = view?.spec.displayName ?? MY_VIEW_NAME;
const isDirty = !!view && hasUnsavedChanges;
return (
<div
className={cx(styles.header, { [styles.isDirty]: isDirty })}
data-testid="saved-views-header"
data-source={source}
>
{isLoading ? (
<Skeleton.Input
active
size="small"
className={styles.skeleton}
testId="saved-views-loading"
/>
) : (
<TooltipSimple title={name}>
<span
className={cx(styles.name, { [styles.isDirty]: isDirty })}
data-testid="saved-views-name"
>
{isDirty && <span className={styles.dot} />}
<span className={styles.nameText}>{name}</span>
</span>
</TooltipSimple>
)}
<div className={styles.actions}>
{!view && (
<SavedViewsIconButton
title="Create new view"
icon={<Plus size={14} />}
disabled={isLoading}
onClick={(): void => setModalMode('create')}
testId="saved-views-create"
/>
)}
{view && !isDirty && (
<SavedViewsIconButton
title="Clear view"
icon={<Undo2 size={14} />}
onClick={clearView}
testId="saved-views-clear"
/>
)}
{view && isDirty && (
<>
<SaveChangesMenu
disabled={isSaving}
onSaveAsNew={openSaveAsNew}
onUpdate={(): void => {
void handleUpdate();
}}
/>
<SavedViewsIconButton
title="Discard changes"
icon={<X size={14} />}
color="warning"
disabled={isSaving}
onClick={(): void => revertView(view)}
testId="saved-views-discard"
/>
</>
)}
{!isDirty && onOpenViews && (
<SavedViewsIconButton
title="All views"
icon={<Menu size={14} />}
onClick={onOpenViews}
testId="saved-views-open"
/>
)}
</div>
{modalMode && (
<SaveViewModal
mode={modalMode}
isSaving={isSaving}
onClose={closeModal}
onSave={handleSave}
/>
)}
</div>
);
}
export default SavedViewsHeader;

View File

@@ -1,41 +0,0 @@
import { forwardRef } from 'react';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { SavedViewsIconButtonProps } from './types';
// forwardRef and rest props so a menu can use it as its trigger.
const SavedViewsIconButton = forwardRef<
HTMLButtonElement,
SavedViewsIconButtonProps
>(function SavedViewsIconButton(
{
title,
icon,
testId,
color = 'secondary',
disabled,
onClick,
...triggerProps
},
ref,
): JSX.Element {
return (
<TooltipSimple title={title}>
<Button
ref={ref}
variant="ghost"
color={color}
size="icon"
aria-label={title}
prefix={icon}
disabled={disabled}
onClick={onClick}
{...triggerProps}
data-testid={testId}
/>
</TooltipSimple>
);
});
export default SavedViewsIconButton;

View File

@@ -1,21 +0,0 @@
.panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
color: var(--l1-foreground);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
}
.title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}

View File

@@ -1,40 +0,0 @@
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './SavedViewsPanel.module.scss';
function SavedViewsPanel({
source,
onClose,
}: {
source: SavedviewtypesSourceDTO;
// Absent when the list is always on screen.
onClose?: () => void;
}): JSX.Element {
return (
<div
className={styles.panel}
data-testid="saved-views-panel"
data-source={source}
>
<div className={styles.header}>
<Typography.Text className={styles.title}>All views</Typography.Text>
{onClose && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Close"
prefix={<X size={14} />}
onClick={onClose}
data-testid="saved-views-close"
/>
)}
</div>
</div>
);
}
export default SavedViewsPanel;

View File

@@ -1,211 +0,0 @@
import { act, 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 { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
import {
explorerUrl,
makeView,
mockSavedViewsApi,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
const PATH = ROUTES.TRACES_EXPLORER;
function renderHeader(
url: string,
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>,
url,
);
}
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
async function openDirtyView(
view: ReturnType<typeof makeView>,
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
const rendered = renderHeader(viewUrl(PATH, view));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
view.spec.displayName,
),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
act(() => {
rendered.history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: view.id,
}),
);
});
await waitFor(() =>
expect(screen.getByTestId('saved-views-discard')).toBeInTheDocument(),
);
return rendered;
}
async function chooseFromSaveMenu(item: string): Promise<void> {
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
}
async function submitName(name: string): Promise<void> {
await userEvent.type(screen.getByTestId('save-view-name'), name);
await userEvent.click(screen.getByTestId('save-view-submit'));
}
describe('SavedViewsHeader saving', () => {
beforeEach(() => {
localStorage.clear();
});
describe('create', () => {
it('saves the current state under a generated name and opens it', async () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
expect(screen.getByTestId('save-view-submit')).toBeDisabled();
await submitName('Slow checkout');
await waitFor(() => expect(requests.created).toHaveLength(1));
expect(requests.created[0]).toStrictEqual(
expect.objectContaining({
generateName: true,
source: SavedviewtypesSourceDTO.traces,
schemaVersion: 'v2',
spec: expect.objectContaining({
displayName: 'Slow checkout',
panelType: 'list',
}),
}),
);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-new'),
),
);
expect(toast.success).toHaveBeenCalledWith(
'You have created a new view.',
expect.objectContaining({ position: 'top-right' }),
);
await waitFor(() => expect(isModalOpen()).toBe(false));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'Slow checkout',
),
);
});
it('keeps the modal open and reports a failed save', async () => {
const requests = mockSavedViewsApi([], { createStatus: 500 });
const { history } = renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await submitName('Slow checkout');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(requests.created).toHaveLength(1);
expect(isModalOpen()).toBe(true);
expect(screen.getByTestId('save-view-name')).toHaveValue('Slow checkout');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
expect(toast.success).not.toHaveBeenCalled();
});
it('cancel closes the modal without saving', async () => {
const requests = mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
await userEvent.click(screen.getByTestId('save-view-cancel'));
await waitFor(() => expect(isModalOpen()).toBe(false));
expect(requests.created).toHaveLength(0);
});
});
it('save as new creates another view from the changed state', async () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const requests = mockSavedViewsApi([errors], { createdId: 'view-copy' });
const { history } = await openDirtyView(errors);
await chooseFromSaveMenu('Save as new view');
expect(within(modal()).getByText('Save as new view')).toBeInTheDocument();
await submitName('Errors copy');
await waitFor(() => expect(requests.created).toHaveLength(1));
const body = requests.created[0] as {
spec: {
displayName: string;
queries: { spec: { filter: { expression: string } } }[];
};
};
expect(body.spec.displayName).toBe('Errors copy');
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
expect(requests.updated).toHaveLength(0);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-copy'),
),
);
});
it('update stores the changed state on the open view, keeping its name', async () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const requests = mockSavedViewsApi([errors]);
await openDirtyView(errors);
await chooseFromSaveMenu('Update selected view');
await waitFor(() => expect(requests.updated).toHaveLength(1));
const { id, body } = requests.updated[0] as {
id: string;
body: {
source: string;
spec: {
displayName: string;
queries: { spec: { filter: { expression: string } } }[];
};
};
};
expect(id).toBe('view-1');
expect(body.source).toBe(SavedviewtypesSourceDTO.traces);
expect(body.spec.displayName).toBe('Errors');
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(
'View updated',
expect.objectContaining({ position: 'top-right' }),
),
);
expect(requests.created).toHaveLength(0);
});
});

View File

@@ -1,325 +0,0 @@
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import { useActiveSavedView } from '../hooks/useActiveSavedView';
import SavedViewsHeader from '../SavedViewsHeader';
import {
explorerUrl,
makeView,
mockSavedViewsApi,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({
id: 'view-2',
displayName: 'Slow spans',
expression: 'duration_nano > 1000',
panelType: PANEL_TYPES.TIME_SERIES,
});
function renderHeader(
url: string,
onOpenViews: (() => void) | undefined = jest.fn(),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={onOpenViews}
/>
<RenderRecorder />
</>,
url,
);
}
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
const saveChanges = (): HTMLElement =>
screen.getByTestId('saved-views-save-changes');
async function waitForView(name: string): Promise<void> {
await waitFor(() => expect(chip()).toHaveTextContent(name));
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
}
// Each render's answer from the hook the header reads, so a state that lasts a
// single render is seen too.
interface RenderRecord {
viewId?: string;
dirty: boolean;
// The staged query had not caught up with the url yet.
isStaging: boolean;
}
let renders: RenderRecord[] = [];
function RenderRecorder(): null {
const { view, hasUnsavedChanges } = useActiveSavedView(
SavedviewtypesSourceDTO.traces,
);
const { stagedQuery } = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
renders.push({
viewId: view?.id,
dirty: hasUnsavedChanges,
isStaging: stagedQuery?.id !== compositeQuery?.id,
});
return null;
}
async function recordRenders(change: () => void): Promise<RenderRecord[]> {
renders = [];
act(change);
await waitFor(() =>
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
);
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
return renders;
}
describe('SavedViewsHeader', () => {
beforeEach(() => {
localStorage.clear();
});
it('shows My view with create and all views when no view is open', async () => {
mockSavedViewsApi([errors]);
renderHeader(explorerUrl(PATH, {}));
expect(chip()).toHaveTextContent('My view');
expect(screen.getByTestId('saved-views-create')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
expect(screen.queryByTestId('saved-views-save-changes')).toBeNull();
expect(screen.queryByTestId('saved-views-discard')).toBeNull();
});
it('shows a skeleton while the view in the url loads', async () => {
mockSavedViewsApi([errors], { getDelay: 150 });
renderHeader(viewUrl(PATH, errors));
expect(screen.getByTestId('saved-views-loading')).toBeInTheDocument();
await waitForView('Errors');
expect(screen.queryByTestId('saved-views-loading')).toBeNull();
});
it('shows the open view clean, with clear and all views', async () => {
mockSavedViewsApi([errors]);
renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-create')).toBeNull();
expect(isDirty()).toBe(false);
});
it('falls back to My view when the view in the url does not load', async () => {
mockSavedViewsApi([]);
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
);
expect(chip()).toHaveTextContent('My view');
});
it('marks the view dirty once the query changes, swapping in save and discard', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(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 waitFor(() => expect(isDirty()).toBe(true));
expect(saveChanges()).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
expect(screen.queryByTestId('saved-views-open')).toBeNull();
});
it('marks the view dirty when only the tab changes', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
const params = new URLSearchParams(history.location.search);
params.set(QueryParams.panelTypes, JSON.stringify(PANEL_TYPES.TABLE));
history.push(`${PATH}?${params.toString()}`);
});
await waitFor(() => expect(isDirty()).toBe(true));
});
it('discard puts the saved query and tab back', async () => {
mockSavedViewsApi([slow]);
const { history } = renderHeader(viewUrl(PATH, slow));
await waitForView('Slow spans');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = true'),
panelType: PANEL_TYPES.LIST,
viewKey: slow.id,
}),
);
});
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(isDirty()).toBe(false));
expect(urlParam(history, QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TIME_SERIES),
);
expect(urlParam(history, QueryParams.viewKey)).toBe(JSON.stringify(slow.id));
expect(urlParam(history, QueryParams.viewName)).toBeNull();
const query = JSON.parse(
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
);
expect(query.builder.queryData[0].filter.expression).toBe(
'duration_nano > 1000',
);
});
it('clear drops the view from the url but keeps the time range', async () => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
);
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors, '1h'));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
expect(urlParam(history, QueryParams.panelTypes)).toBeNull();
expect(urlParam(history, QueryParams.relativeTime)).toBe('1h');
expect(
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}'),
).toStrictEqual({});
});
describe('last used view', () => {
beforeEach(() => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
);
});
it('reopens on a bare explorer', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(explorerUrl(PATH, {}));
await waitForView('Errors');
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
);
});
it('leaves a url with its own query alone', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
}),
);
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(chip()).toHaveTextContent('My view');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
});
});
describe('unsaved changes indicator', () => {
it('never flashes while switching between views already loaded', async () => {
mockSavedViewsApi([errors, slow]);
const { history } = renderHeader(viewUrl(PATH, slow));
await waitForView('Slow spans');
act(() => history.push(viewUrl(PATH, errors)));
await waitForView('Errors');
const states = await recordRenders(() => history.push(viewUrl(PATH, slow)));
expect(chip()).toHaveTextContent('Slow spans');
expect(states.some((render) => render.isStaging)).toBe(true);
expect(states.filter((render) => render.dirty)).toStrictEqual([]);
});
it('holds while the query keeps changing on a dirty view', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(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 waitFor(() => expect(isDirty()).toBe(true));
const states = await recordRenders(() =>
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'duration_nano > 5'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
),
);
expect(states.some((render) => render.isStaging)).toBe(true);
expect(states.filter((render) => !render.dirty)).toStrictEqual([]);
});
});
it('on metrics compares no columns and has no all views button', async () => {
const cpu = makeView({
id: 'view-3',
displayName: 'CPU',
source: SavedviewtypesSourceDTO.metrics,
dataSource: DataSource.METRICS,
expression: '',
panelType: PANEL_TYPES.TIME_SERIES,
});
mockSavedViewsApi([cpu]);
renderWithExplorerProviders(
<SavedViewsHeader source={SavedviewtypesSourceDTO.metrics} />,
viewUrl(ROUTES.METRICS_EXPLORER_EXPLORER, cpu),
DataSource.METRICS,
);
await waitForView('CPU');
expect(isDirty()).toBe(false);
expect(screen.queryByTestId('saved-views-open')).toBeNull();
});
});

View File

@@ -1,304 +0,0 @@
import { ReactElement, useEffect, useMemo } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { render, RenderResult } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { createMemoryHistory, MemoryHistory } from 'history';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppContext } from 'providers/App/App';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import store from 'store';
import { getAppContextMock } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
export const API = 'http://localhost/api/v2/saved_views';
// The server stores queries through typed structs: nulls, empty strings,
// zeros and empty lists do not come back.
function asStoredByServer(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value)
.map(([key, v]) => [key, asStoredByServer(v)] as const)
.filter(([, v]) => v !== undefined),
);
}
return value === null || value === '' || value === 0 ? undefined : value;
}
export function queryWith(dataSource: DataSource, expression: string): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression },
aggregations: [{ expression: 'count()' }],
},
],
},
} as Query;
}
export function makeView({
id,
displayName,
source = SavedviewtypesSourceDTO.traces,
dataSource = DataSource.TRACES,
expression = 'has_error = true',
panelType = PANEL_TYPES.LIST,
}: {
id: string;
displayName: string;
source?: SavedviewtypesSourceDTO;
dataSource?: DataSource;
expression?: string;
panelType?: PANEL_TYPES;
}): SavedviewtypesSavedViewDTO {
const spec = toSavedViewSpec({
query: queryWith(dataSource, expression),
panelType,
displayName,
});
return {
id,
name: `${displayName.toLowerCase()}-abc`,
source,
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
createdBy: 'test@signoz.io',
updatedBy: 'test@signoz.io',
spec: {
...spec,
queries: asStoredByServer(
JSON.parse(JSON.stringify(spec.queries)),
) as SavedviewtypesSavedViewSpecDTO['queries'],
},
} as SavedviewtypesSavedViewDTO;
}
// Params written the way the query builder redirect writes them.
export function explorerUrl(
path: string,
{
query,
panelType,
viewKey,
relativeTime = '15m',
}: {
query?: Query;
panelType?: PANEL_TYPES;
viewKey?: string;
relativeTime?: string;
},
): string {
const params = new URLSearchParams();
if (query) {
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify({ ...query, id: `url-${Math.random()}` })),
);
}
if (panelType) {
params.set(QueryParams.panelTypes, JSON.stringify(panelType));
}
if (viewKey) {
params.set(QueryParams.viewKey, JSON.stringify(viewKey));
}
params.set(QueryParams.relativeTime, relativeTime);
return `${path}?${params.toString()}`;
}
export function viewUrl(
path: string,
view: SavedviewtypesSavedViewDTO,
relativeTime?: string,
): string {
return explorerUrl(path, {
query: getSavedViewQuery(view),
panelType: view.spec.panelType as unknown as PANEL_TYPES,
viewKey: view.id,
relativeTime,
});
}
export interface Requests {
created: unknown[];
updated: { id: string; body: unknown }[];
}
export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
createStatus = 201,
createdId = 'view-new',
getDelay = 0,
}: { createStatus?: number; createdId?: string; getDelay?: number } = {},
): Requests {
const requests: Requests = { created: [], updated: [] };
server.use(
rest.get(API, (req, res, ctx) => {
const source = req.url.searchParams.get('source');
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: views.filter((view) => !source || view.source === source),
}),
);
}),
rest.get(`${API}/:id`, (req, res, ctx) => {
const view = views.find((v) => v.id === req.params.id);
if (!view) {
return res(ctx.status(404), ctx.json({ status: 'error' }));
}
return res(
ctx.delay(getDelay),
ctx.status(200),
ctx.json({ status: 'success', data: view }),
);
}),
rest.post(API, async (req, res, ctx) => {
const body = await req.json();
requests.created.push(body);
if (createStatus >= 400) {
return res(
ctx.status(createStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
views.push({ ...body, id: createdId } as SavedviewtypesSavedViewDTO);
return res(
ctx.status(createStatus),
ctx.json({ status: 'success', data: { id: createdId } }),
);
}),
rest.put(`${API}/:id`, async (req, res, ctx) => {
const body = await req.json();
requests.updated.push({ id: String(req.params.id), body });
return res(ctx.status(204));
}),
);
return requests;
}
const mockStore = configureStore([thunk]);
// What the explorer page does around the header: writes its default query to a
// bare url, and sets the provider's tab from the url (the provider alone seeds
// it from the raw, still JSON-quoted param).
function ExplorerShell({ dataSource }: { dataSource: DataSource }): null {
const panelType = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const { handleSetConfig, updateAllQueriesOperators } = useQueryBuilder();
const defaultQuery = useMemo(
() =>
updateAllQueriesOperators(
initialQueriesMap[dataSource],
PANEL_TYPES.LIST,
dataSource,
),
[updateAllQueriesOperators, dataSource],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
useEffect(() => {
handleSetConfig(panelType, dataSource);
}, [panelType, dataSource, handleSetConfig]);
return null;
}
export function renderWithExplorerProviders(
ui: ReactElement,
initialUrl: string,
dataSource: DataSource = DataSource.TRACES,
): RenderResult & { history: MemoryHistory } {
const history = createMemoryHistory({ initialEntries: [initialUrl] });
// Parts of the explorer read window.location directly, so it follows the
// router's history.
const syncWindowLocation = (): void =>
window.history.replaceState(
null,
'',
`${history.location.pathname}${history.location.search}`,
);
syncWindowLocation();
history.listen(syncWindowLocation);
__setSearchParamsGetterForTest(
() => new URLSearchParams(history.location.search),
);
safeNavigateMock.mockImplementation((to) => {
history.push(
typeof to === 'string'
? to
: `${to.pathname ?? history.location.pathname}${to.search ?? ''}`,
);
});
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false },
mutations: { retry: false },
},
});
const result = render(
<Router history={history}>
<CompatRouter>
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
<Provider store={mockStore(store.getState())}>
<AppContext.Provider value={getAppContextMock('ADMIN')}>
<TooltipProvider>
<PreferenceContextProvider>
<QueryBuilderProvider>
<ExplorerShell dataSource={dataSource} />
{ui}
</QueryBuilderProvider>
</PreferenceContextProvider>
</TooltipProvider>
</AppContext.Provider>
</Provider>
</QueryClientProvider>
</NuqsAdapter>
</CompatRouter>
</Router>,
);
return { ...result, history };
}
export function urlParam(history: MemoryHistory, key: string): string | null {
return new URLSearchParams(history.location.search).get(key);
}

View File

@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { getSavedViewQuery } from '../getSavedViewQuery';
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
@@ -91,3 +91,36 @@ describe('getSavedViewQuery', () => {
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
});
});
describe('toSavedViewSource', () => {
it('maps every explorer source page to the v2 source', () => {
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
expect(toSavedViewSource(DataSource.TRACES)).toBe(
SavedviewtypesSourceDTO.traces,
);
expect(toSavedViewSource(DataSource.METRICS)).toBe(
SavedviewtypesSourceDTO.metrics,
);
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
});
});
describe('findSavedView', () => {
const views = [
{ ...makeView(), id: 'a' },
{ ...makeView(), id: 'b' },
];
it('returns the view with the matching id', () => {
expect(findSavedView(views, 'b')?.id).toBe('b');
});
it('returns undefined when the id is not in the list', () => {
expect(findSavedView(views, 'c')).toBeUndefined();
});
it('returns undefined for a null or not yet loaded list', () => {
expect(findSavedView(null, 'a')).toBeUndefined();
expect(findSavedView(undefined, 'a')).toBeUndefined();
});
});

View File

@@ -1,35 +0,0 @@
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { DataSource } from 'types/common/queryBuilder';
import { SaveViewModalMode } from './types';
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
// Only the logs and traces explorers have columns and formatting to save.
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
Record<SavedviewtypesSourceDTO, DataSource>
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
// What selecting a view writes to the url, so clearing it removes exactly these.
export const SAVED_VIEW_URL_PARAMS = [
QueryParams.viewKey,
QueryParams.viewName,
QueryParams.compositeQuery,
QueryParams.panelTypes,
];
export const MY_VIEW_NAME = 'My view';
export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
create: 'Create new view',
saveAsNew: 'Save as new view',
};
export const SAVED_VIEW_TOAST_POSITION = 'top-right' as const;

View File

@@ -1,76 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { DataSource } from 'types/common/queryBuilder';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
export function useActiveSavedView(
source: SavedviewtypesSourceDTO,
): UseActiveSavedViewResult {
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { data, isLoading, isError } = useGetSavedView(
{ id: viewKey },
{ query: { enabled: !!viewKey } },
);
const view = viewKey ? data?.data : undefined;
// The query builder stages the url query a render later. Until the ids
// match, keep the last answer for this view; another view starts clean.
const compositeQuery = useGetCompositeQueryParam();
const isStagedQueryCurrent =
!!stagedQuery && stagedQuery.id === compositeQuery?.id;
const lastResult = useRef<{ viewId?: string; hasUnsavedChanges: boolean }>({
hasUnsavedChanges: false,
});
const hasUnsavedChanges = useMemo((): boolean => {
if (!view) {
return false;
}
if (!isStagedQueryCurrent) {
return (
lastResult.current.viewId === view.id &&
lastResult.current.hasUnsavedChanges
);
}
return hasUnsavedViewChanges({
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
options: optionsDataSource ? options : undefined,
});
}, [
view,
isStagedQueryCurrent,
stagedQuery,
panelType,
optionsDataSource,
options,
]);
useEffect(() => {
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
}, [view?.id, hasUnsavedChanges]);
return {
view,
isLoading: !!viewKey && isLoading,
isError: !!viewKey && isError,
hasUnsavedChanges,
};
}

View File

@@ -1,55 +0,0 @@
import { useCallback } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
PreservedViewsInLocalStorage,
PreservedViewType,
} from 'container/ExplorerOptions/types';
import { UseLastUsedViewResult } from '../types';
function readLastUsedViews(): PreservedViewsInLocalStorage {
try {
return JSON.parse(
getLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) || '{}',
) as PreservedViewsInLocalStorage;
} catch {
return {};
}
}
// Read at call time, not held in state: the hook is used from more than one
// place and a cached copy would bring back a view another caller just cleared.
export function useLastUsedView(
savedViewSource: SavedviewtypesSourceDTO,
): UseLastUsedViewResult {
// The bar's enum carries the same four values as the v2 source.
const source = savedViewSource as unknown as PreservedViewType;
const getLastUsedViewKey = useCallback(
(): string | undefined => readLastUsedViews()[source]?.key,
[source],
);
const setLastUsedView = useCallback(
(id: string, displayName: string): void => {
setLocalStorageKey(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({
...readLastUsedViews(),
[source]: { key: id, value: displayName },
}),
);
},
[source],
);
const clearLastUsedView = useCallback((): void => {
const { [source]: _removed, ...rest } = readLastUsedViews();
setLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, JSON.stringify(rest));
}, [source]);
return { getLastUsedViewKey, setLastUsedView, clearLastUsedView };
}

View File

@@ -1,56 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { QueryParams } from 'constants/query';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { UseRestoreLastUsedViewArgs } from '../types';
import { useLastUsedView } from './useLastUsedView';
export function useRestoreLastUsedView({
source,
selectView,
}: UseRestoreLastUsedViewArgs): void {
const { viewKey } = useGetSavedViewParams();
const { getLastUsedViewKey, clearLastUsedView } = useLastUsedView(source);
// Decided on the first render, before the explorer writes its default query
// to the url; later an empty url means the view was cleared.
const [lastUsedViewKey] = useState(() => {
const params = getUnstableCurrentSearchParams();
const isBare =
!params.get(QueryParams.viewKey) && !params.get(QueryParams.compositeQuery);
return isBare ? getLastUsedViewKey() : undefined;
});
const hasRestored = useRef(false);
const shouldRestore = !!lastUsedViewKey && !viewKey && !hasRestored.current;
const { data, isError } = useGetSavedView(
{ id: lastUsedViewKey ?? '' },
{ query: { enabled: shouldRestore } },
);
const view = data?.data;
useEffect(() => {
if (!shouldRestore) {
return undefined;
}
if (isError) {
hasRestored.current = true;
clearLastUsedView();
return undefined;
}
if (!view) {
return undefined;
}
// Deferred like the bar's restore, so it lands after the explorer has
// written its default query to the url.
const timeoutId = setTimeout(() => {
hasRestored.current = true;
selectView(view);
}, 0);
return (): void => clearTimeout(timeoutId);
}, [shouldRestore, isError, view, clearLastUsedView, selectView]);
}

View File

@@ -1,193 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateGetSavedView,
invalidateListSavedViews,
useCreateSavedView,
useUpdateSavedView,
} from 'api/generated/services/saved-view';
import {
RenderErrorResponseDTO,
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
SAVED_VIEW_OPTIONS_DATA_SOURCE,
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
} from '../constants';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
// 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
// to the user's own.
export function useSavedViewActions(
source: SavedviewtypesSourceDTO,
): UseSavedViewActionsResult {
const queryClient = useQueryClient();
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const { currentQuery, stagedQuery, panelType, redirectWithQueryBuilderData } =
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
const { mutateAsync: updateSavedView, isLoading: isUpdating } =
useUpdateSavedView();
const invalidateViews = useCallback(
async (id?: string): Promise<void> => {
await Promise.all([
invalidateListSavedViews(queryClient),
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
// The saved views bar still lists through v1.
queryClient.invalidateQueries([{ sourcepage: source }]),
]);
},
[queryClient, source],
);
const toCurrentSpec = useCallback(
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
toSavedViewSpec({
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
displayName,
options: optionsDataSource ? options : undefined,
color,
}),
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
);
const selectView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
handleExplorerTabChange(view.spec.panelType, {
query: getSavedViewQuery(view),
viewKey: view.id,
});
setLastUsedView(view.id, view.spec.displayName);
},
[handleExplorerTabChange, setLastUsedView],
);
// The preference provider re-reads a view's columns only when its list
// changes, so the list is refetched as the view is applied again.
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
void invalidateListSavedViews(queryClient);
selectView(view);
},
[queryClient, selectView],
);
// 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 => {
clearLastUsedView();
const params = getUnstableCurrentSearchParams();
SAVED_VIEW_URL_PARAMS.forEach((param) => params.delete(param));
safeNavigate(`${pathname}?${params.toString()}`);
}, [clearLastUsedView, pathname, safeNavigate]);
const createView = useCallback(
async (displayName: string): Promise<string | undefined> => {
try {
const { data } = await createSavedView({
data: {
generateName: true,
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: toCurrentSpec(displayName),
},
});
await invalidateViews();
redirectWithQueryBuilderData(stagedQuery ?? currentQuery, {
[QueryParams.viewKey]: data.id,
});
setLastUsedView(data.id, displayName);
return data.id;
} catch (error) {
toast.error(
toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Could not save the view',
).getErrorMessage(),
{ position: SAVED_VIEW_TOAST_POSITION },
);
return undefined;
}
},
[
createSavedView,
source,
toCurrentSpec,
invalidateViews,
redirectWithQueryBuilderData,
stagedQuery,
currentQuery,
setLastUsedView,
],
);
const updateView = useCallback(
async (view: SavedviewtypesSavedViewDTO): Promise<boolean> => {
try {
await updateSavedView({
pathParams: { id: view.id },
data: {
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: toCurrentSpec(view.spec.displayName, view.spec.display?.color),
},
});
await invalidateViews(view.id);
return true;
} catch (error) {
toast.error(
toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Could not update the view',
).getErrorMessage(),
{ position: SAVED_VIEW_TOAST_POSITION },
);
return false;
}
},
[updateSavedView, source, toCurrentSpec, invalidateViews],
);
return {
selectView,
revertView,
clearView,
createView,
updateView,
isSaving: isCreating || isUpdating,
};
}

View File

@@ -1,83 +0,0 @@
import { ReactElement } from 'react';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
OptionsQuery,
'selectColumns' | 'format' | 'maxLines' | 'fontSize'
>;
export interface ToSavedViewSpecArgs {
query: Query;
panelType: PANEL_TYPES;
displayName: string;
options?: SavedViewOptions;
color?: string;
}
export interface HasUnsavedViewChangesArgs {
view: SavedviewtypesSavedViewDTO;
stagedQuery: Query | null;
panelType: PANEL_TYPES;
options?: SavedViewOptions;
}
export interface UseLastUsedViewResult {
getLastUsedViewKey: () => string | undefined;
setLastUsedView: (id: string, displayName: string) => void;
clearLastUsedView: () => void;
}
export interface UseActiveSavedViewResult {
view: SavedviewtypesSavedViewDTO | undefined;
isLoading: boolean;
isError: boolean;
hasUnsavedChanges: boolean;
}
export interface UseSavedViewActionsResult {
selectView: (view: SavedviewtypesSavedViewDTO) => void;
revertView: (view: SavedviewtypesSavedViewDTO) => void;
clearView: () => void;
createView: (displayName: string) => Promise<string | undefined>;
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
isSaving: boolean;
}
export interface UseRestoreLastUsedViewArgs {
source: SavedviewtypesSourceDTO;
selectView: UseSavedViewActionsResult['selectView'];
}
export type SaveViewModalMode = 'create' | 'saveAsNew';
export interface SaveViewModalProps {
mode: SaveViewModalMode;
isSaving: boolean;
onClose: () => void;
// Resolves true when saved; the modal stays open otherwise.
onSave: (displayName: string) => Promise<boolean>;
}
export interface SaveChangesMenuProps {
disabled: boolean;
onSaveAsNew: () => void;
onUpdate: () => void;
}
export interface SavedViewsIconButtonProps {
title: string;
icon: ReactElement;
testId: string;
color?: 'secondary' | 'warning';
disabled?: boolean;
onClick?: () => void;
}

View File

@@ -0,0 +1,49 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
export type SavedViewSourcePage = DataSource | 'meter';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
meter: SavedviewtypesSourceDTO.meter,
};
export function toSavedViewSource(
sourcePage: SavedViewSourcePage,
): SavedviewtypesSourceDTO {
return SAVED_VIEW_SOURCE[sourcePage];
}
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
const { queries, panelType } = view.spec;
return mapQueryDataFromApi({
queries: queries as QueryEnvelope[],
panelType: panelType as unknown as PANEL_TYPES,
queryType: EQueryType.QUERY_BUILDER,
unit: undefined,
});
}
export function findSavedView(
views: SavedviewtypesSavedViewDTO[] | null | undefined,
id: string,
): SavedviewtypesSavedViewDTO | undefined {
return views?.find((view) => view.id === id);
}

View File

@@ -1,20 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { findSavedView } from '../findSavedView';
describe('findSavedView', () => {
const views = [{ id: 'a' }, { id: 'b' }] as SavedviewtypesSavedViewDTO[];
it('returns the view with the matching id', () => {
expect(findSavedView(views, 'b')?.id).toBe('b');
});
it('returns undefined when the id is not in the list', () => {
expect(findSavedView(views, 'c')).toBeUndefined();
});
it('returns undefined for a null or not yet loaded list', () => {
expect(findSavedView(null, 'a')).toBeUndefined();
expect(findSavedView(undefined, 'a')).toBeUndefined();
});
});

View File

@@ -1,217 +0,0 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { getSavedViewQuery } from '../getSavedViewQuery';
import { hasUnsavedViewChanges } from '../hasUnsavedViewChanges';
import { toSavedViewSpec } from '../toSavedViewSpec';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
}));
function query(dataSource: DataSource, expression = 'has_error = true'): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression },
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
// The server stores the queries through typed structs: nulls, empty strings,
// zeros and empty lists do not come back.
function asStoredByServer(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value)
.map(([key, v]) => [key, asStoredByServer(v)] as const)
.filter(([, v]) => v !== undefined);
return Object.fromEntries(entries);
}
return value === null || value === '' || value === 0 ? undefined : value;
}
function viewFrom(
spec: SavedviewtypesSavedViewSpecDTO,
): SavedviewtypesSavedViewDTO {
return {
id: 'view-1',
spec: {
...spec,
queries: asStoredByServer(
JSON.parse(JSON.stringify(spec.queries)),
) as SavedviewtypesSavedViewSpecDTO['queries'],
},
} as SavedviewtypesSavedViewDTO;
}
// What the query builder stages after the view is opened: the stored query,
// with its defaults filled in.
function stagedFrom(
view: SavedviewtypesSavedViewDTO,
dataSource: DataSource,
): Query {
const saved = getSavedViewQuery(view);
return {
...saved,
builder: {
...saved.builder,
queryData: saved.builder.queryData.map((queryData) => ({
...initialQueryBuilderFormValuesMap[dataSource],
...queryData,
})),
},
};
}
const columns = [
{ name: 'service.name', key: 'service.name' },
{ name: 'name', key: 'name' },
] as TelemetryFieldKey[];
const formatting = {
format: 'raw' as LogViewMode,
maxLines: 2,
fontSize: FontSize.SMALL,
};
describe('hasUnsavedViewChanges', () => {
const options = { selectColumns: columns, ...formatting };
const view = viewFrom(
toSavedViewSpec({
query: query(DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options,
}),
);
const staged = stagedFrom(view, DataSource.TRACES);
it('is dirty when the columns change', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
options: { ...options, selectColumns: [columns[0]] },
}),
).toBe(true);
});
it('reads a traces view without columns as the default columns', () => {
const bare = viewFrom(
toSavedViewSpec({
query: query(DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
}),
);
expect(
hasUnsavedViewChanges({
view: bare,
stagedQuery: stagedFrom(bare, DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
options: { ...options, selectColumns: defaultTraceSelectedColumns },
}),
).toBe(false);
});
describe('on logs', () => {
const logsView = viewFrom(
toSavedViewSpec({
query: query(DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options: { selectColumns: columns, ...formatting },
}),
);
const logsStaged = stagedFrom(logsView, DataSource.LOGS);
// The explorer always adds the required logs columns.
const shownColumns = ensureLogsRequiredColumns(columns);
it('is clean with the required columns the explorer adds', () => {
expect(
hasUnsavedViewChanges({
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
options: { selectColumns: shownColumns, ...formatting },
}),
).toBe(false);
});
it('is dirty when the formatting changes', () => {
expect(
hasUnsavedViewChanges({
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
}),
).toBe(true);
});
it('reads a view without formatting as the default formatting', () => {
const bare = viewFrom(
toSavedViewSpec({
query: query(DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
}),
);
expect(
hasUnsavedViewChanges({
view: bare,
stagedQuery: stagedFrom(bare, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
options: {
selectColumns: defaultLogsSelectedColumns,
format: 'table',
maxLines: 1,
fontSize: FontSize.SMALL,
},
}),
).toBe(false);
});
});
it('ignores formatting on traces', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
options: { ...options, ...formatting, maxLines: 5 },
}),
).toBe(false);
});
});

View File

@@ -1,17 +0,0 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { toSavedViewSource } from '../toSavedViewSource';
describe('toSavedViewSource', () => {
it('maps every explorer source page to the v2 source', () => {
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
expect(toSavedViewSource(DataSource.TRACES)).toBe(
SavedviewtypesSourceDTO.traces,
);
expect(toSavedViewSource(DataSource.METRICS)).toBe(
SavedviewtypesSourceDTO.metrics,
);
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
});
});

View File

@@ -1,95 +0,0 @@
import {
Querybuildertypesv5RequestTypeDTO,
SavedviewtypesPanelTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { toSavedViewPanelType, toSavedViewSpec } from '../toSavedViewSpec';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
}));
function tracesQuery(): Query {
const base = initialQueriesMap.traces;
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression: 'has_error = true' },
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
const columns = [
{
name: 'service.name',
key: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
isIndexed: true,
},
{ key: 'name' },
] as unknown as TelemetryFieldKey[];
describe('toSavedViewPanelType', () => {
it('folds a dashboard only panel type to graph', () => {
expect(toSavedViewPanelType(PANEL_TYPES.PIE)).toBe(
SavedviewtypesPanelTypeDTO.graph,
);
});
});
describe('toSavedViewSpec', () => {
it('stores only the field identity of the columns, naming key only ones', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options: {
selectColumns: columns,
format: 'raw' as LogViewMode,
maxLines: 2,
fontSize: FontSize.SMALL,
},
});
expect(spec.selectedFields).toStrictEqual([
{
name: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
},
{ name: 'name' },
]);
expect(spec.display).toStrictEqual({
format: 'raw',
maxLines: 2,
fontSize: FontSize.SMALL,
});
});
it('keeps the colour in display', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType: PANEL_TYPES.TIME_SERIES,
displayName: 'Errors',
color: 'red',
});
expect(spec.panelType).toBe(SavedviewtypesPanelTypeDTO.graph);
expect(spec.requestType).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
expect(spec.display).toStrictEqual({ color: 'red' });
});
});

View File

@@ -1,8 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
export function findSavedView(
views: SavedviewtypesSavedViewDTO[] | null | undefined,
id: string,
): SavedviewtypesSavedViewDTO | undefined {
return views?.find((view) => view.id === id);
}

View File

@@ -1,17 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
const { queries, panelType } = view.spec;
return mapQueryDataFromApi({
queries: queries as QueryEnvelope[],
panelType: panelType as unknown as PANEL_TYPES,
queryType: EQueryType.QUERY_BUILDER,
unit: undefined,
});
}

View File

@@ -1,49 +0,0 @@
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Preferences } from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
function withColumnNames(
columns: TelemetryFieldKey[] | undefined,
): TelemetryFieldKey[] | null {
if (!columns) {
return null;
}
return columns.map((column) => ({
...column,
name: column.name ?? column.key,
}));
}
// The columns and formatting the explorer shows for a saved view, with the
// defaults filled in where the view has none.
export function getViewColumnsAndFormatting(
spec: SavedviewtypesSavedViewSpecDTO | undefined,
dataSource: DataSource,
): Preferences {
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
if (dataSource === DataSource.LOGS) {
return {
columns: ensureLogsRequiredColumns(
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
),
formatting: {
maxLines: spec?.display?.maxLines || 1,
format: (spec?.display?.format as LogViewMode) || 'table',
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
version: 1,
},
};
}
if (dataSource === DataSource.TRACES) {
return { columns: selectedFields || defaultTraceSelectedColumns };
}
return { columns: [] };
}

View File

@@ -1,81 +0,0 @@
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import isEqual from 'lodash-es/isEqual';
import { DataSource } from 'types/common/queryBuilder';
import { HasUnsavedViewChangesArgs } from '../types';
import { toSavedViewPanelType } from './toSavedViewSpec';
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
// The server drops empty values when it stores a query, while the query
// builder fills them with defaults, so both sides lose them before comparing.
function withoutEmptyValues(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value
.map(withoutEmptyValues)
.filter((item) => item !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value)
.map(([key, item]) => [key, withoutEmptyValues(item)] as const)
.filter(([, item]) => item !== undefined);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
if (value === null || value === '' || value === 0) {
return undefined;
}
return value;
}
// Compared as stored (v5 envelopes): mapped back to the builder's shape, each
// side gets different defaults.
export function hasUnsavedViewChanges({
view,
stagedQuery,
panelType,
options,
}: HasUnsavedViewChangesArgs): boolean {
if (!stagedQuery) {
return false;
}
if (view.spec.panelType !== toSavedViewPanelType(panelType)) {
return true;
}
const { queries: stagedQueries } = mapCompositeQueryFromQuery(
stagedQuery,
panelType,
);
if (
!isEqual(
withoutEmptyValues(view.spec.queries),
withoutEmptyValues(stagedQueries),
)
) {
return true;
}
if (!options) {
return false;
}
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
dataSource,
);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);
if (!isEqual(toNames(columns), toNames(options.selectColumns ?? []))) {
return true;
}
if (dataSource !== DataSource.LOGS || !formatting) {
return false;
}
return (
formatting.format !== options.format ||
formatting.maxLines !== options.maxLines ||
formatting.fontSize !== options.fontSize
);
}

View File

@@ -1,23 +0,0 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewSourcePage } from '../types';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
meter: SavedviewtypesSourceDTO.meter,
};
export function toSavedViewSource(
sourcePage: SavedViewSourcePage,
): SavedviewtypesSourceDTO {
return SAVED_VIEW_SOURCE[sourcePage];
}

View File

@@ -1,80 +0,0 @@
import {
Querybuildertypesv5QueryEnvelopeDTO,
SavedviewtypesDisplayDTO,
SavedviewtypesPanelTypeDTO,
SavedviewtypesSavedViewSpecDTO,
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { panelTypeToRequestType } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { ToSavedViewSpecArgs } from '../types';
const SAVED_VIEW_PANEL_TYPES = new Set<string>(
Object.values(SavedviewtypesPanelTypeDTO),
);
// Explorers only produce list / trace / graph / table; anything else a caller
// hands over (a dashboard panel type) is stored as a time series view.
export function toSavedViewPanelType(
panelType: PANEL_TYPES,
): SavedviewtypesPanelTypeDTO {
return SAVED_VIEW_PANEL_TYPES.has(panelType)
? (panelType as unknown as SavedviewtypesPanelTypeDTO)
: SavedviewtypesPanelTypeDTO.graph;
}
// The api stores the field identity only; `key` and the column flags are ui state.
function toFieldKey(
column: TelemetryFieldKey,
): TelemetrytypesTelemetryFieldKeyDTO {
return {
name: column.name ?? column.key ?? '',
...(column.signal && {
signal: column.signal as unknown as TelemetrytypesSignalDTO,
}),
...(column.fieldContext && {
fieldContext:
column.fieldContext as unknown as TelemetrytypesFieldContextDTO,
}),
...(column.fieldDataType && {
fieldDataType:
column.fieldDataType as unknown as TelemetrytypesFieldDataTypeDTO,
}),
...(column.description && { description: column.description }),
...(column.unit && { unit: column.unit }),
};
}
export function toSavedViewSpec({
query,
panelType,
displayName,
options,
color,
}: ToSavedViewSpecArgs): SavedviewtypesSavedViewSpecDTO {
const { queries } = mapCompositeQueryFromQuery(query, panelType);
const display: SavedviewtypesDisplayDTO = {
...(color && { color }),
...(options?.format && { format: options.format }),
...(options?.maxLines && { maxLines: options.maxLines }),
...(options?.fontSize && { fontSize: options.fontSize }),
};
return {
displayName,
panelType: toSavedViewPanelType(panelType),
requestType: panelTypeToRequestType(panelType),
queries: (queries ?? []) as unknown as Querybuildertypesv5QueryEnvelopeDTO[],
...(options?.selectColumns?.length && {
selectedFields: options.selectColumns.map(toFieldKey),
}),
...(Object.keys(display).length > 0 && { display }),
};
}

View File

@@ -73,20 +73,32 @@ export const useHandleExplorerTabChange = (): {
}
const query = currentQueryData?.query || getUpdateQuery(newPanelType);
const nextViewKey = currentQueryData?.viewKey || viewKey;
const nextViewName = currentQueryData?.viewName || viewName;
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
...(nextViewKey ? { [QueryParams.viewKey]: nextViewKey } : {}),
...(nextViewName ? { [QueryParams.viewName]: nextViewName } : {}),
},
redirectToUrl,
undefined,
newTab,
);
if (redirectToUrl) {
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
},
redirectToUrl,
undefined,
newTab,
);
} else {
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
},
undefined,
undefined,
newTab,
);
}
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],
);

View File

@@ -4,10 +4,15 @@
min-height: 0;
overflow: hidden;
.log-quick-filter-left-section {
width: 0%;
flex-shrink: 0;
}
.log-module-right-section {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
min-height: 0;
.log-explorer-query-container {
@@ -34,4 +39,26 @@
}
}
}
&.filter-visible {
.log-quick-filter-left-section {
width: 260px;
height: 100%;
overflow: visible;
min-height: 0;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.log-module-right-section {
width: calc(100% - 260px);
}
}
}

View File

@@ -1,130 +0,0 @@
import { MemoryRouter } from 'react-router-dom-v5-compat';
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { fireEvent, render, screen } from 'tests/test-utils';
import LogsExplorer from '../index';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.LOGS_EXPLORER}`,
}),
}));
jest.mock(
'container/Toolbar/Toolbar',
() =>
function Toolbar({
showLiveLogs,
onGoLive,
}: {
showLiveLogs?: boolean;
onGoLive?: () => void;
}): JSX.Element {
return (
<div>
<button type="button" data-testid="go-live" onClick={onGoLive}>
Live
</button>
<span data-testid="live-state">{showLiveLogs ? 'live' : 'not-live'}</span>
</div>
);
},
);
jest.mock(
'container/TimeSeriesView/TimeSeriesView',
() =>
function TimeSeriesView(): JSX.Element {
return <div>Time Series Chart</div>;
},
);
jest.mock(
'container/LogsExplorerChart',
() =>
function LogsExplorerChart(): JSX.Element {
return <div>Frequency chart content</div>;
},
);
jest.mock('container/LiveLogs/LiveLogsContainer', () => ({
__esModule: true,
default: (): JSX.Element => <div>Live logs</div>,
}));
jest.mock('constants/panelTypes', () => ({
AVAILABLE_EXPORT_PANEL_TYPES: ['graph', 'table'],
}));
jest.mock('d3-interpolate', () => ({
interpolate: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): unknown => ({
preferences: {
columns: [],
formatting: { maxLines: 1, format: 'table', fontSize: 'small', version: 1 },
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: jest.fn(),
}),
}));
function page(): JSX.Element {
return (
<MemoryRouter initialEntries={[ROUTES.LOGS_EXPLORER]}>
<PreferenceContextProvider>
<LogsExplorer />
</PreferenceContextProvider>
</MemoryRouter>
);
}
describe('Logs Explorer live mode', () => {
// useLocation is mocked without a search string, so the tab is driven
// through the hook that reads it from the url.
const panelTypeSpy = jest.spyOn(
panelTypesQueryParamHooks,
'useGetPanelTypesQueryParam',
);
afterAll(() => {
panelTypeSpy.mockRestore();
});
it('ends when the url moves off the list tab, whatever moved it', () => {
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
const { rerender } = render(page());
fireEvent.click(screen.getByTestId('go-live'));
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
panelTypeSpy.mockReturnValue(PANEL_TYPES.TIME_SERIES);
rerender(page());
expect(screen.getByTestId('live-state')).toHaveTextContent('not-live');
});
it('stays on while the url keeps the list tab', () => {
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
const { rerender } = render(page());
fireEvent.click(screen.getByTestId('go-live'));
rerender(page());
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
});
});

View File

@@ -1,5 +1,4 @@
import { MemoryRouter } from 'react-router-dom-v5-compat';
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
// https://virtuoso.dev/mocking-in-tests/
import { VirtuosoMockContext } from 'react-virtuoso';
import {
@@ -131,30 +130,6 @@ describe('Logs Explorer Tests', () => {
// expect(oldLogsCTA).toBeInTheDocument();
});
it('renders the tab the url panelTypes param names', () => {
// useLocation is mocked without a search string in this file, so drive
// the param through the hook that reads it.
const panelTypeSpy = jest
.spyOn(panelTypesQueryParamHooks, 'useGetPanelTypesQueryParam')
.mockReturnValue(PANEL_TYPES.TIME_SERIES);
const { container } = render(
<MemoryRouter initialEntries={['/logs-explorer/?panelTypes=%22graph%22']}>
<PreferenceContextProvider>
<LogsExplorer />
</PreferenceContextProvider>
</MemoryRouter>,
);
expect(container.querySelector('.timeseries-view-tab')).toHaveClass(
'active-tab',
);
expect(container.querySelector('.list-view-tab')).not.toHaveClass(
'active-tab',
);
panelTypeSpy.mockRestore();
});
// update this test properly
it.skip('Logs Explorer Page should render with data', async () => {
// mocking the query range API to return the logs

View File

@@ -3,10 +3,10 @@ import { useQueryClient } from 'react-query';
import * as Sentry from '@sentry/react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -51,13 +51,9 @@ function LogsExplorer(): JSX.Element {
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const selectedView = panelTypeToExplorerView[panelTypesFromUrl];
useEffect(() => {
if (selectedView !== ExplorerViews.LIST) {
setShowLiveLogs(false);
}
}, [selectedView]);
const [selectedView, setSelectedView] = useState<ExplorerViews>(
() => panelTypeToExplorerView[panelTypesFromUrl],
);
const [showFilters, setShowFilters] = useState<boolean>(() => {
const localStorageValue = getLocalStorageKey(
@@ -120,10 +116,15 @@ function LogsExplorer(): JSX.Element {
);
handleSetConfig(nextPanelType, DataSource.LOGS);
setSelectedView(view);
if (view !== ExplorerViews.LIST) {
setShowLiveLogs(false);
}
handleExplorerTabChange(nextPanelType, querySearchParameters);
},
[handleSetConfig, handleExplorerTabChange],
[handleSetConfig, handleExplorerTabChange, setSelectedView],
);
// ─── AI Assistant page actions (only when license feature is on) ───────────
@@ -224,19 +225,21 @@ function LogsExplorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<EventSourceProvider>
<QuickFiltersLayout
className="logs-module-page"
showFilters={showFilters}
savedViewProps={{ source: SavedviewtypesSourceDTO.logs }}
quickFilterProps={{
className: 'qf-logs-explorer',
signal: SignalType.LOGS,
source: QuickFiltersSource.LOGS_EXPLORER,
handleFilterVisibilityChange,
useFieldApis: quickFilterFieldApis,
}}
<div
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
>
<section className="log-module-right-section">
{showFilters && (
<section className={cx('log-quick-filter-left-section')}>
<QuickFilters
className="qf-logs-explorer"
signal={SignalType.LOGS}
source={QuickFiltersSource.LOGS_EXPLORER}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={quickFilterFieldApis}
/>
</section>
)}
<section className={cx('log-module-right-section')}>
<Toolbar
showAutoRefresh={false}
leftActions={
@@ -289,7 +292,7 @@ function LogsExplorer(): JSX.Element {
</div>
</div>
</section>
</QuickFiltersLayout>
</div>
</EventSourceProvider>
</Sentry.ErrorBoundary>
);

View File

@@ -5,18 +5,23 @@ import { Typography } from '@signozhq/ui/typography';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: string;
tooltip: ReactNode;
tooltipClassName?: string;
icon?: ReactNode;
children: ReactNode;
}
function EntityMetadataItem({
tooltip,
tooltipClassName,
icon,
children,
}: EntityMetadataItemProps): JSX.Element {
return (
<TooltipSimple title={tooltip}>
<TooltipSimple
title={tooltip}
tooltipContentProps={{ className: tooltipClassName }}
>
<span className={styles.item}>
{icon}
<Typography.Text as="span">{children}</Typography.Text>
@@ -27,6 +32,7 @@ function EntityMetadataItem({
EntityMetadataItem.defaultProps = {
icon: null,
tooltipClassName: undefined,
};
export default EntityMetadataItem;

View File

@@ -8,3 +8,7 @@
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
--typography-font-size: var(--periscope-font-size-base);
}
.tokenTooltip {
min-width: 160px;
}

View File

@@ -1,11 +1,21 @@
import { CalendarClock, Server, Timer } from '@signozhq/icons';
import {
CalendarClock,
CircleDollarSign,
Coins,
Server,
Timer,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { formatCost, formatTokens } from '../SpanHoverCard/aiUsage';
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
import styles from './EntityMetadataRow.module.scss';
interface EntityMetadataRowProps {
@@ -16,6 +26,8 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: TraceTokenUsage;
cost?: number | null;
}
const ICON_SIZE = 14;
@@ -32,6 +44,8 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const durationTooltip =
@@ -91,6 +105,25 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{tokens && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
tooltipClassName={tooltipStyles.tooltipContent}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(tokens.input)} → {formatTokens(tokens.output)}
</EntityMetadataItem>
)}
{cost != null && (
<EntityMetadataItem
tooltip="Total cost"
icon={<CircleDollarSign size={ICON_SIZE} />}
>
{formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -102,6 +135,8 @@ EntityMetadataRow.defaultProps = {
execTimePercent: undefined,
timestamp: undefined,
statusCode: undefined,
tokens: undefined,
cost: undefined,
};
export default EntityMetadataRow;

View File

@@ -0,0 +1,38 @@
import { formatTokens } from '../SpanHoverCard/aiUsage';
import TooltipRow from '../TraceTooltip/TooltipRow';
import TooltipSection from '../TraceTooltip/TooltipSection';
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: TraceTokenUsage;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
return (
<div className={`${tooltipStyles.body} ${styles.tokenTooltip}`}>
<TooltipSection>
<TooltipRow label="Tokens" value={formatTokens(input + output)} isTotal />
</TooltipSection>
<TooltipSection>
<TooltipRow label="Input" value={formatTokens(input)} />
<TooltipRow label="Output" value={formatTokens(output)} />
{cacheRead !== undefined && (
<TooltipRow label="Cache read" value={formatTokens(cacheRead)} />
)}
{cacheWrite !== undefined && (
<TooltipRow label="Cache write" value={formatTokens(cacheWrite)} />
)}
{reasoning !== undefined && (
<TooltipRow label="Reasoning" value={formatTokens(reasoning)} />
)}
</TooltipSection>
</div>
);
}
export default TokenUsageTooltip;

View File

@@ -0,0 +1,37 @@
import { CSSProperties } from 'react';
import { Badge } from '@signozhq/ui/badge';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAiSpanKind } from '../utils/genAi';
import { AI_SPAN_BADGES } from './constants';
interface SpanHintBadgeProps {
span: SpanV3;
}
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
const aiSpanKind = getAiSpanKind(span);
if (aiSpanKind) {
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
return (
<Badge
variant="outline"
// The outline variant derives text, fill and border from this color.
style={{ '--badge-background': color } as CSSProperties}
data-testid={`span-hint-badge-${aiSpanKind}`}
>
{label}
</Badge>
);
}
if (!span.response_status_code) {
return null;
}
return <HttpStatusBadge statusCode={span.response_status_code} />;
}
export default SpanHintBadge;

View File

@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import SpanHintBadge from '../SpanHintBadge';
const spanWith = (
attributes: Record<string, string | number>,
responseStatusCode = '',
): SpanV3 =>
({
attributes,
resource: {},
response_status_code: responseStatusCode,
}) as unknown as SpanV3;
describe('SpanHintBadge', () => {
it.each([
[{ 'gen_ai.request.model': 'gpt-4o' }, 'LLM'],
[{ 'gen_ai.tool.name': 'search' }, 'TOOL'],
[{ 'gen_ai.agent.name': 'planner' }, 'AGENT'],
])('shows only the AI badge for %p', (attributes, label) => {
render(<SpanHintBadge span={spanWith(attributes, '200')} />);
expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.queryByText('200')).not.toBeInTheDocument();
});
it('falls back to the HTTP status code for non-AI spans', () => {
render(<SpanHintBadge span={spanWith({}, '503')} />);
expect(screen.getByText('503')).toBeInTheDocument();
});
it('renders nothing for a non-AI span without a status code', () => {
const { container } = render(<SpanHintBadge span={spanWith({})} />);
expect(container).toBeEmptyDOMElement();
});
});

View File

@@ -0,0 +1,10 @@
import { AiSpanKind } from '../utils/genAi';
export const AI_SPAN_BADGES: Record<
AiSpanKind,
{ label: string; color: string }
> = {
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
};

View File

@@ -12,37 +12,16 @@
// Hover card may be rendered while the SpanDetailsPanel is docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
--tooltip-z-index: 1000;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}
.content {
font-size: 12px;
color: var(--l1-foreground);
min-width: 220px;
max-width: 320px;
}
.name {
font-weight: 600;
margin-bottom: 4px;
}
.row {
line-height: 1.5;
color: var(--l2-foreground);
}
.preview {
// container for additional preview rows
}
.previewKey {
color: var(--l2-foreground);
}
.previewValue {
color: var(--l1-foreground);
.header {
display: flex;
flex-direction: column;
gap: 4px;
padding-bottom: 8px;
}

View File

@@ -4,6 +4,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
@@ -12,6 +14,12 @@ import { useMemo } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import { getSpanAiDetails, SpanAiDetails } from './aiUsage';
import SpanUsageBreakdown from './SpanUsageBreakdown';
import TooltipRow from '../TraceTooltip/TooltipRow';
import TooltipSection from '../TraceTooltip/TooltipSection';
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
import styles from './SpanHoverCard.module.scss';
/**
@@ -38,6 +46,7 @@ export interface SpanTooltipContentProps {
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
ai?: SpanAiDetails;
}
export function SpanTooltipContent({
@@ -47,33 +56,46 @@ export function SpanTooltipContent({
relativeStartMs,
durationMs,
previewRows,
ai,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
return (
<div className={styles.content}>
<div className={styles.name} style={{ color }}>
{spanName}
</div>
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
<div className={styles.row}>
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
<div className={cx(tooltipStyles.body, styles.content)}>
<div className={styles.header}>
<Typography.Text className={tooltipStyles.title} style={{ color }}>
{spanName}
</Typography.Text>
{ai?.usage && (
<Typography.Text size="small" weight="medium">
Usage Breakdown
</Typography.Text>
)}
</div>
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
<TooltipSection>
{ai?.model && <TooltipRow label="model" value={ai.model} />}
{ai?.toolName && <TooltipRow label="tool" value={ai.toolName} />}
{ai?.agentName && <TooltipRow label="agent" value={ai.agentName} />}
<TooltipRow label="status" value={hasError ? 'error' : 'ok'} />
<TooltipRow label="start" value={`${toFixed(relativeStartMs, 2)} ms`} />
<TooltipRow
label="duration"
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
/>
</TooltipSection>
{previewRows && previewRows.length > 0 && (
<div className={styles.preview}>
<TooltipSection>
{previewRows.map((row) => (
<div
<TooltipRow
key={row.key}
className={styles.row}
data-testid={`span-hover-card-preview-${row.key}`}
>
<span className={styles.previewKey}>{row.key}:</span>{' '}
<span className={styles.previewValue}>{row.value}</span>
</div>
label={row.key}
value={row.value}
testId={`span-hover-card-preview-${row.key}`}
/>
))}
</div>
</TooltipSection>
)}
</div>
);
@@ -137,6 +159,7 @@ export function SpanHoverCard({
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
ai: getSpanAiDetails(span),
},
};
}, [
@@ -166,7 +189,7 @@ export function SpanHoverCard({
side="right"
align="start"
sideOffset={8}
className={styles.popover}
className={cx(tooltipStyles.tooltipContent, styles.popover)}
>
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
</TooltipContent>

View File

@@ -0,0 +1,47 @@
import {
formatCost,
formatTokens,
getUsageTotals,
SpanAiUsage,
} from './aiUsage';
import TooltipRow from '../TraceTooltip/TooltipRow';
import TooltipSection from '../TraceTooltip/TooltipSection';
interface SpanUsageBreakdownProps {
usage: SpanAiUsage;
}
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
const { outputTokens = 0, cacheReadTokens, cacheCreationTokens, cost } = usage;
const { inputUsage, totalUsage } = getUsageTotals(usage);
return (
<>
<TooltipSection>
<TooltipRow label="Input usage" value={formatTokens(inputUsage)} isTotal />
{cacheReadTokens !== undefined && (
<TooltipRow label="cache read" value={formatTokens(cacheReadTokens)} />
)}
{cacheCreationTokens !== undefined && (
<TooltipRow
label="cache creation"
value={formatTokens(cacheCreationTokens)}
/>
)}
</TooltipSection>
<TooltipSection>
<TooltipRow
label="Output usage"
value={formatTokens(outputTokens)}
isTotal
/>
</TooltipSection>
<TooltipSection>
<TooltipRow label="Total usage" value={formatTokens(totalUsage)} isTotal />
{cost !== undefined && <TooltipRow label="cost" value={formatCost(cost)} />}
</TooltipSection>
</>
);
}
export default SpanUsageBreakdown;

View File

@@ -0,0 +1,144 @@
import { render, screen } from '@testing-library/react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getSpanAiDetails, getUsageTotals } from '../aiUsage';
import { SpanTooltipContent } from '../SpanHoverCard';
const baseProps = {
spanName: 'chat gpt-4o',
color: '#fff',
hasError: false,
relativeStartMs: 268,
durationMs: 1110,
};
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
({ attributes, resource: {} }) as unknown as SpanV3;
const usageAttributes = {
'gen_ai.usage.input_tokens': 1000,
'gen_ai.usage.output_tokens': 200,
'gen_ai.usage.cache_read.input_tokens': 600,
'gen_ai.usage.cache_creation.input_tokens': 100,
};
describe('getSpanAiDetails', () => {
it('returns undefined for a span without gen_ai attributes', () => {
expect(getSpanAiDetails(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
});
it('reads model, tool, agent, token usage and cost', () => {
expect(
getSpanAiDetails(
spanWith({
'gen_ai.request.model': 'gpt-4o',
'gen_ai.tool.name': 'search',
'gen_ai.agent.name': 'planner',
'gen_ai.usage.input_tokens': '120',
'gen_ai.usage.output_tokens': 30,
'signoz.gen_ai.usage.tokens.cost': '0.00000056',
}),
),
).toStrictEqual({
model: 'gpt-4o',
toolName: 'search',
agentName: 'planner',
usage: {
inputTokens: 120,
outputTokens: 30,
cacheReadTokens: undefined,
cacheCreationTokens: undefined,
cost: 0.00000056,
isAnthropic: false,
},
});
});
it.each<Record<string, string>>([
{ 'gen_ai.provider.name': 'Anthropic' },
{ 'gen_ai.system': 'anthropic' },
{ 'gen_ai.response.model': 'claude-sonnet-4' },
{ 'gen_ai.request.model': 'anthropic.claude-v2' },
])('detects Anthropic from %p', (attributes) => {
expect(
getSpanAiDetails(spanWith({ ...usageAttributes, ...attributes }))?.usage
?.isAnthropic,
).toBe(true);
});
});
describe('getUsageTotals', () => {
it('adds cache tokens to input for Anthropic', () => {
const usage = getSpanAiDetails(
spanWith({ ...usageAttributes, 'gen_ai.provider.name': 'anthropic' }),
)?.usage;
expect(usage && getUsageTotals(usage)).toStrictEqual({
inputUsage: 1700,
totalUsage: 1900,
});
});
it('treats input as already including cache tokens for other providers', () => {
const usage = getSpanAiDetails(
spanWith({ ...usageAttributes, 'gen_ai.provider.name': 'openai' }),
)?.usage;
expect(usage && getUsageTotals(usage)).toStrictEqual({
inputUsage: 1000,
totalUsage: 1200,
});
});
});
describe('SpanTooltipContent', () => {
it('renders status, start and duration for a non-AI span', () => {
render(<SpanTooltipContent {...baseProps} />);
expect(screen.getByText('chat gpt-4o')).toBeInTheDocument();
expect(screen.getByText('ok')).toBeInTheDocument();
expect(screen.getByText('268 ms')).toBeInTheDocument();
expect(screen.getByText('1.11 s')).toBeInTheDocument();
expect(screen.queryByText('Usage Breakdown')).not.toBeInTheDocument();
});
it('renders the usage breakdown with cache rows and cost', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{
model: 'gpt-4o',
usage: {
inputTokens: 1000,
outputTokens: 200,
cacheReadTokens: 600,
cacheCreationTokens: 100,
cost: 0.00000056,
isAnthropic: false,
},
}}
/>,
);
expect(screen.getByText('Usage Breakdown')).toBeInTheDocument();
expect(screen.getByText('1,000')).toBeInTheDocument();
expect(screen.getByText('cache read')).toBeInTheDocument();
expect(screen.getByText('600')).toBeInTheDocument();
expect(screen.getByText('cache creation')).toBeInTheDocument();
expect(screen.getByText('1,200')).toBeInTheDocument();
expect(screen.getByText('$ 0.00000056')).toBeInTheDocument();
});
it('hides cache rows and cost when the attributes are absent', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{ usage: { inputTokens: 10, outputTokens: 5, isAnthropic: false } }}
/>,
);
expect(screen.queryByText('cache read')).not.toBeInTheDocument();
expect(screen.queryByText('cache creation')).not.toBeInTheDocument();
expect(screen.queryByText('cost')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,97 @@
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { GEN_AI_KEYS } from '../utils/genAi';
export interface SpanAiUsage {
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
cost?: number;
isAnthropic: boolean;
}
export interface SpanAiDetails {
model?: string;
toolName?: string;
agentName?: string;
usage?: SpanAiUsage;
}
function getNumber(span: SpanV3, key: string): number | undefined {
const value = getSpanAttribute(span, key);
if (value === undefined || value === '') {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function isAnthropicSpan(span: SpanV3): boolean {
const provider = (
getSpanAttribute(span, GEN_AI_KEYS.providerName) ??
getSpanAttribute(span, GEN_AI_KEYS.system) ??
''
).toLowerCase();
const model = (
getSpanAttribute(span, GEN_AI_KEYS.responseModel) ??
getSpanAttribute(span, GEN_AI_KEYS.requestModel) ??
''
).toLowerCase();
return (
provider === 'anthropic' ||
model.includes('claude') ||
model.includes('anthropic')
);
}
export function getSpanAiDetails(span: SpanV3): SpanAiDetails | undefined {
const usage: SpanAiUsage = {
inputTokens: getNumber(span, GEN_AI_KEYS.inputTokens),
outputTokens: getNumber(span, GEN_AI_KEYS.outputTokens),
cacheReadTokens: getNumber(span, GEN_AI_KEYS.cacheReadTokens),
cacheCreationTokens: getNumber(span, GEN_AI_KEYS.cacheCreationTokens),
cost: getNumber(span, GEN_AI_KEYS.cost),
isAnthropic: isAnthropicSpan(span),
};
const hasUsage =
usage.inputTokens !== undefined || usage.outputTokens !== undefined;
const details: SpanAiDetails = {
model: getSpanAttribute(span, GEN_AI_KEYS.requestModel) || undefined,
toolName: getSpanAttribute(span, GEN_AI_KEYS.toolName) || undefined,
agentName: getSpanAttribute(span, GEN_AI_KEYS.agentName) || undefined,
usage: hasUsage ? usage : undefined,
};
return details.model || details.toolName || details.agentName || hasUsage
? details
: undefined;
}
/**
* Anthropic reports input_tokens without cached tokens; every other provider
* (OpenAI, Azure OpenAI, Gemini, Mistral, DeepSeek, Groq, xAI) counts cached
* tokens inside input_tokens.
*/
export function getUsageTotals(usage: SpanAiUsage): {
inputUsage: number;
totalUsage: number;
} {
const input = usage.inputTokens ?? 0;
const inputUsage = usage.isAnthropic
? input + (usage.cacheReadTokens ?? 0) + (usage.cacheCreationTokens ?? 0)
: input;
return { inputUsage, totalUsage: inputUsage + (usage.outputTokens ?? 0) };
}
export function formatTokens(value: number): string {
return value.toLocaleString('en-US');
}
export function formatCost(value: number): string {
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
}

View File

@@ -34,6 +34,15 @@
}
}
.tabs {
flex-shrink: 0;
}
.tab {
flex: unset;
padding: 0 24px;
}
.filterSection {
display: flex;
align-items: center;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import {
@@ -18,40 +18,26 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { useTraceStore } from '../stores/traceStore';
import TraceDownloadPanel from './TraceDownloadPanel';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
import MissingSpansBanner from './MissingSpansBanner';
import { useTraceSummary } from './useTraceSummary';
import TraceDetailsTabs from './TraceDetailsTabs';
import TraceOptionsMenu from './TraceOptionsMenu';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
interface FilterMetadata {
startTime: number;
endTime: number;
traceId: string;
}
export interface TraceMetadataForHeader {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
totalSpansCount: number;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
isDataLoaded?: boolean;
traceMetadata?: TraceMetadataForHeader;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
}
const SKELETON_COUNT = 3;
@@ -73,16 +59,22 @@ function DetailsLoader(): JSX.Element {
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
isDataLoaded,
traceMetadata,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV3URLProps>();
const { data: summary, isLoading } = useTraceSummary();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
// Overview-only panels must not linger over another tab.
useEffect(() => {
setIsPreviewFieldsOpen(false);
setIsAnalyticsOpen(false);
}, [tab]);
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
@@ -116,9 +108,13 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
: 0;
useTraceDetailsStripInfo({
totalSpansCount: summary?.totalSpansCount ?? 0,
totalErrorSpansCount: summary?.totalErrorSpansCount ?? 0,
});
const startTime = (summary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (summary?.endTimestampMillis ?? 0) / 1e3;
return (
<div className={styles.wrapper}>
@@ -140,9 +136,10 @@ function TraceDetailsHeader({
badgeValue={traceID || ''}
maxCharacters={100}
/>
<TraceDetailsTabs />
</div>
)}
{isDataLoaded && (
{isOverview && onFilteredSpansChange && summary && (
<div
className={cx(
styles.filterSection,
@@ -171,9 +168,9 @@ function TraceDetailsHeader({
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
traceId={traceID || ''}
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
totalSpansCount={traceMetadata?.totalSpansCount || 0}
startTime={startTime}
endTime={endTime}
totalSpansCount={summary.totalSpansCount}
/>
</div>
</TooltipProvider>
@@ -183,9 +180,9 @@ function TraceDetailsHeader({
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
startTime={startTime}
endTime={endTime}
traceID={traceID || ''}
onFilteredSpansChange={onFilteredSpansChange}
isExpanded={isFilterExpanded}
onExpand={(): void => setIsFilterExpanded(true)}
@@ -196,28 +193,29 @@ function TraceDetailsHeader({
)}
</div>
{showTraceDetails && (
{(showTraceDetails || !isOverview) && (
<div className={styles.subHeader}>
{traceMetadata ? (
{isLoading || !summary ? (
<DetailsLoader />
) : (
<EntityMetadataRow
entity="trace"
service={{
name: traceMetadata.rootServiceName,
entryPoint: traceMetadata.rootServiceEntryPoint,
name: summary.rootServiceName,
entryPoint: summary.rootServiceEntryPoint,
}}
durationMs={durationMs}
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
durationMs={summary.endTimestampMillis - summary.startTimestampMillis}
timestamp={dayjs(summary.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceMetadata.rootSpanStatusCode}
tokens={summary.ai?.tokens}
cost={summary.ai?.totalCost}
/>
) : (
<DetailsLoader />
)}
</div>
)}
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
{isOverview && summary?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -0,0 +1,44 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { TraceDetailsTab } from '../constants';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import styles from './TraceDetailsHeader.module.scss';
function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
// Clicking the active item deselects it and reports ''.
if (value) {
void setTab(value as TraceDetailsTab);
}
};
return (
<ToggleGroup
type="single"
value={tab}
onChange={handleChange}
className={styles.tabs}
size="sm"
>
<ToggleGroupItem
value={TraceDetailsTab.Overview}
className={styles.tab}
data-testid="trace-details-tab-overview"
>
Overview
</ToggleGroupItem>
<ToggleGroupItem
value={TraceDetailsTab.Thread}
className={styles.tab}
data-testid="trace-details-tab-thread"
>
Thread
</ToggleGroupItem>
</ToggleGroup>
);
}
export default TraceDetailsTabs;

View File

@@ -1,9 +1,12 @@
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
import { MOCK_TRACE_SUMMARY } from '../mockTraceSummary';
import * as traceSummaryModule from '../useTraceSummary';
import TraceDetailsHeader from '../TraceDetailsHeader';
const mockGoBack = jest.fn();
@@ -50,15 +53,9 @@ jest.mock('components/FieldsSelector', () => ({
),
}));
const baseProps = {
filterMetadata: {
startTime: 0,
endTime: 1,
traceId: 'trace-123',
},
onFilteredSpansChange: jest.fn(),
isDataLoaded: false,
};
const baseProps = { onFilteredSpansChange: jest.fn() };
const metadataText = /Missing Span/;
describe('TraceDetailsHeader – back button', () => {
beforeEach(() => {
@@ -94,8 +91,8 @@ describe('TraceDetailsHeader – action cluster', () => {
mockReplace.mockClear();
});
it('does not render the action buttons while data is still loading', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
it('does not render the action buttons without a filter handler', () => {
render(<TraceDetailsHeader />);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
@@ -105,8 +102,8 @@ describe('TraceDetailsHeader – action cluster', () => {
).not.toBeInTheDocument();
});
it('renders Analytics and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
it('renders Analytics and Settings action buttons', () => {
render(<TraceDetailsHeader {...baseProps} />);
expect(
screen.getByRole('button', { name: /^analytics$/i }),
@@ -117,7 +114,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
render(<TraceDetailsHeader {...baseProps} />);
const panel = screen.getByTestId('analytics-panel');
expect(panel).toHaveAttribute('data-open', 'false');
@@ -133,68 +130,125 @@ describe('TraceDetailsHeader – action cluster', () => {
});
describe('TraceDetailsHeader – trace metadata row', () => {
// Plain prop, no API mock needed: traceMetadata is passed straight in.
const traceMetadata = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
rootServiceName: 'inventory-frontend',
rootServiceEntryPoint: 'large-trace-root',
rootSpanStatusCode: '404',
hasMissingSpans: false,
totalSpansCount: 42,
};
afterEach(() => {
jest.restoreAllMocks();
});
it('renders the metadata (service, entry point, duration, status) when provided', () => {
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
it('shows skeletons instead of the metadata while the summary loads', () => {
jest
.spyOn(traceSummaryModule, 'useTraceSummary')
.mockReturnValue({ data: undefined, isLoading: true });
const { container } = render(<TraceDetailsHeader {...baseProps} />);
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
expect(screen.getByText('404')).toBeInTheDocument();
// Duration goes through the shared formatter (e.g. "2 min").
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
it('renders the summary metadata with tokens and cost', () => {
render(<TraceDetailsHeader {...baseProps} />);
const { startTimestampMillis, endTimestampMillis } = MOCK_TRACE_SUMMARY;
const duration = getYAxisFormattedValue(
`${traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis}`,
`${endTimestampMillis - startTimestampMillis}`,
'ms',
);
expect(screen.getByText(metadataText)).toBeInTheDocument();
expect(screen.getByText(duration)).toBeInTheDocument();
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.getByText('$ 0.0421')).toBeInTheDocument();
});
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
const user = userEvent.setup({ delay: null });
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
render(<TraceDetailsHeader {...baseProps} />);
// Visible by default (showTraceDetails defaults to true).
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
// Hide it.
await user.click(screen.getByRole('button', { name: /trace options/i }));
await user.click(
await screen.findByRole('menuitem', { name: /hide trace details/i }),
);
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
// Show it again.
await user.click(screen.getByRole('button', { name: /trace options/i }));
await user.click(
await screen.findByRole('menuitem', { name: /show trace details/i }),
);
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
});
it('does not render the metadata row when traceMetadata is absent', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
});
describe('TraceDetailsHeader – tabs', () => {
// In-memory URL state, so a tab switch can't leak into the next test.
const renderHeader = (): void => {
render(
<NuqsTestingAdapter hasMemory>
<TraceDetailsHeader {...baseProps} />
</NuqsTestingAdapter>,
);
};
it('shows the Overview-only sections on the Overview tab', () => {
renderHeader();
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /^analytics$/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /trace options/i }),
).toBeInTheDocument();
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
});
it('keeps only the metadata row on the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /trace options/i }),
).not.toBeInTheDocument();
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByRole('button', { name: /trace options/i }));
await user.click(
await screen.findByRole('menuitem', { name: /hide trace details/i }),
);
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('closes the Analytics panel when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByRole('button', { name: /^analytics$/i }));
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
'data-open',
'true',
);
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
'data-open',
'false',
);
});
});

View File

@@ -0,0 +1,22 @@
import { TraceSummary } from './types';
// Placeholder until GET /api/v1/traces/{id}/summary feeds the header.
export const MOCK_TRACE_SUMMARY: TraceSummary = {
startTimestampMillis: 1789627365929,
endTimestampMillis: 1789627367867,
rootServiceName: '',
rootServiceEntryPoint: 'Missing Span',
totalSpansCount: 3,
totalErrorSpansCount: 0,
hasMissingSpans: true,
ai: {
tokens: {
input: 12040,
output: 3110,
cacheRead: 8000,
cacheWrite: 1200,
reasoning: 900,
},
totalCost: 0.0421,
},
};

View File

@@ -0,0 +1,23 @@
export interface TraceTokenUsage {
input: number;
output: number;
cacheRead?: number;
cacheWrite?: number;
reasoning?: number;
}
/** Response of GET /api/v1/traces/{id}/summary. */
export interface TraceSummary {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
totalSpansCount: number;
totalErrorSpansCount: number;
hasMissingSpans: boolean;
ai?: {
tokens?: TraceTokenUsage;
// null when no span has a cost attribute
totalCost: number | null;
};
}

View File

@@ -0,0 +1,12 @@
import { MOCK_TRACE_SUMMARY } from './mockTraceSummary';
import { TraceSummary } from './types';
interface UseTraceSummaryResult {
data: TraceSummary | undefined;
isLoading: boolean;
}
// Placeholder until GET /api/v1/traces/{id}/summary is available.
export function useTraceSummary(): UseTraceSummaryResult {
return { data: MOCK_TRACE_SUMMARY, isLoading: false };
}

View File

@@ -1,10 +1,3 @@
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.layoutRow {
display: flex;
flex: 1;

View File

@@ -0,0 +1,500 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import NoData from '../NoData/NoData';
import {
setTraceStoreAvailableColorByFields,
useTraceStore,
} from '../stores/traceStore';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from '../TraceWaterfall/types';
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from '../utils';
import cx from 'classnames';
import styles from './TraceDetailsOverview.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
interface TraceDetailsOverviewProps {
filteredSpanIds: string[];
isFilterActive: boolean;
}
function TraceDetailsOverview({
filteredSpanIds,
isFilterActive,
}: TraceDetailsOverviewProps): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
Set<string>
>(new Set());
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
// Tracks which traceId the load event already fired for, so navigating
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef('');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() }, { replace: true });
}, [urlQuery, safeNavigate]);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
if (!spanId) {
return;
}
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
const frozenParamsRef = useRef({
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
});
const queryParams = fullDataLoadedRef.current
? frozenParamsRef.current
: {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
});
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
frozenParamsRef.current = {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {
const parentIds = new Set(
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
);
setLocalUncollapsedNodes(parentIds);
}
}, [isFullDataLoaded, allSpans]);
// Backend mode: sync uncollapsed state from API response
useEffect(() => {
if (
!isFullDataLoaded &&
traceData &&
traceData.payload &&
traceData.payload.uncollapsedSpans
) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData, isFullDataLoaded]);
// Tracks whether we've already done the initial URL→selectedSpan handoff
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
useEffect(() => {
if (deepLinkResolvedRef.current) {
return;
}
if (allSpans.length === 0) {
return;
}
if (selectedSpanId) {
const span = allSpans.find((s) => s.span_id === selectedSpanId);
if (!span) {
// Span not in the current window — wait for more data (backend
// pagination) before marking resolved.
return;
}
setSelectedSpan(span);
} else {
setSelectedSpan((prev) => prev ?? allSpans[0]);
}
deepLinkResolvedRef.current = true;
}, [selectedSpanId, allSpans]);
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
// visible. Keyed on URL `spanId`(selectedSpanId).
useEffect(() => {
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
return;
}
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
if (ancestors.size === 0) {
return;
}
setLocalUncollapsedNodes((prev) => {
// Check if all ancestors are already expanded — avoid unnecessary state update
let allPresent = true;
for (const id of ancestors) {
if (!prev.has(id)) {
allPresent = false;
break;
}
}
if (allPresent) {
return prev;
}
const next = new Set(prev);
for (const id of ancestors) {
next.add(id);
}
return next;
});
}, [isFullDataLoaded, selectedSpanId, allSpans]);
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
logTraceEvent(
key === 'flame'
? TraceDetailEvents.FlameGraphToggled
: TraceDetailEvents.WaterfallToggled,
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
);
setActiveKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
);
const RIGHT_DOCK_MIN = 480;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const handleVariantChange = useCallback(
(newVariant: SpanDetailVariant): void => {
setLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
newVariant,
);
setSpanDetailVariant(newVariant);
},
[],
);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
return (
<>
{showNoData ? (
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</>
)}
</>
);
}
export default TraceDetailsOverview;

View File

@@ -0,0 +1,5 @@
function TraceDetailsThread(): JSX.Element {
return <div data-testid="trace-details-thread" />;
}
export default TraceDetailsThread;

View File

@@ -0,0 +1,6 @@
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,21 @@
import { ReactNode } from 'react';
import TraceStoreSync from '../stores/TraceStoreSync';
import styles from './TraceDetailsWrapper.module.scss';
interface TraceDetailsWrapperProps {
children: ReactNode;
}
function TraceDetailsWrapper({
children,
}: TraceDetailsWrapperProps): JSX.Element {
return (
<TraceStoreSync>
<div className={styles.root}>{children}</div>
</TraceStoreSync>
);
}
export default TraceDetailsWrapper;

View File

@@ -33,10 +33,5 @@
position: fixed;
z-index: 1000;
pointer-events: none;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import cx from 'classnames';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -17,6 +18,7 @@ import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
import { useScrollToSpan } from './hooks/useScrollToSpan';
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
import styles from './FlamegraphCanvas.module.scss';
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
@@ -203,7 +205,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
const tooltipElement = tooltipContent
? createPortal(
<div
className={styles.tooltip}
className={cx(tooltipStyles.surface, styles.tooltip)}
style={{
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),

View File

@@ -0,0 +1,34 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './TraceTooltip.module.scss';
interface TooltipRowProps {
label: string;
value: string;
isTotal?: boolean;
testId?: string;
}
function TooltipRow({
label,
value,
isTotal,
testId,
}: TooltipRowProps): JSX.Element {
return (
<div className={styles.row} data-testid={testId}>
<Typography.Text
size="small"
color={isTotal ? undefined : 'muted'}
truncate={1}
>
{label}
</Typography.Text>
<Typography.Text size="small" truncate={1} className={styles.rowValue}>
{value}
</Typography.Text>
</div>
);
}
export default TooltipRow;

View File

@@ -0,0 +1,13 @@
import { ReactNode } from 'react';
import styles from './TraceTooltip.module.scss';
interface TooltipSectionProps {
children: ReactNode;
}
function TooltipSection({ children }: TooltipSectionProps): JSX.Element {
return <div className={styles.section}>{children}</div>;
}
export default TooltipSection;

View File

@@ -0,0 +1,82 @@
// Figma `dark/shadow-dropdown` popover surface, shared by every trace-details
// tooltip so they look the same.
$surface-background: color-mix(in srgb, var(--l3-background) 60%, transparent);
$surface-border-color: var(--l2-border);
$surface-radius: var(--radius-2);
$surface-padding: var(--spacing-4);
$surface-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
// For plain elements, e.g. the flamegraph's cursor-following portal.
.surface {
background-color: $surface-background;
border: 1px solid $surface-border-color;
border-radius: $surface-radius;
padding: $surface-padding;
box-shadow: $surface-shadow;
backdrop-filter: blur(20px);
}
// For @signozhq/ui Tooltip content: it paints both the bubble and the arrow
// from these variables.
.tooltipContent {
--tooltip-background: #{$surface-background};
--tooltip-border-color: #{$surface-border-color};
--tooltip-border-radius: #{$surface-radius};
--tooltip-padding: #{$surface-padding};
--tooltip-box-shadow: #{$surface-shadow};
backdrop-filter: blur(20px);
}
.body {
display: flex;
flex-direction: column;
letter-spacing: var(--paragraph-small-400-letter-spacing);
color: var(--l1-foreground-hover);
}
.title {
--typography-font-family: var(--code-small-500-font-family);
--typography-font-size: var(--code-small-500-font-size);
--typography-font-weight: var(--code-small-500-font-weight);
--typography-line-height: var(--code-small-500-line-height);
letter-spacing: var(--code-small-500-letter-spacing);
word-break: break-all;
}
.section {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 0;
border-top: 1px solid;
border-image: linear-gradient(
90deg,
var(--l3-background-transparent),
var(--l3-border),
var(--l3-background-transparent)
)
1;
&:first-child {
padding-top: 0;
border-top: none;
}
&:last-child {
padding-bottom: 0;
}
}
.row {
display: flex;
justify-content: space-between;
gap: 16px;
min-width: 0;
}
.rowValue {
flex-shrink: 0;
max-width: 60%;
text-align: right;
font-variant-numeric: tabular-nums;
}

View File

@@ -217,8 +217,9 @@
.statusCell {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
cursor: pointer;
padding-left: var(--spacing-2);
--badge-border-radius: 2px;
--badge-padding: 3px 6px;
@@ -408,6 +409,11 @@
}
}
.aiSpanIcon {
flex-shrink: 0;
margin: 0 4px;
}
.treeLabel {
color: var(--l1-foreground);
font-family: 'Inter';

View File

@@ -25,7 +25,6 @@ import {
} from '@tanstack/react-table';
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
@@ -33,7 +32,13 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
import {
ChevronDown,
ChevronRight,
Link,
ListPlus,
Sparkle,
} from '@signozhq/icons';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
@@ -44,6 +49,8 @@ import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
import { isAiSpan } from '../../../utils/genAi';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../types';
@@ -328,14 +335,23 @@ const SpanOverview = memo(function SpanOverview({
</span>
{/* Colored service dot */}
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
{isAiSpan(span) ? (
<Sparkle
size={10}
color={effectiveColor}
fill={effectiveColor}
className={styles.aiSpanIcon}
/>
) : (
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
)}
{/* Span name + service name */}
<span className={styles.treeLabel}>
@@ -971,9 +987,7 @@ function Success(props: ISuccessProps): JSX.Element {
onMouseLeave={(): void => applyHoverClass(null)}
onClick={(): void => handleSpanClick(span)}
>
{span.response_status_code && (
<HttpStatusBadge statusCode={span.response_status_code} />
)}
<SpanHintBadge span={span} />
</div>
);
})}

View File

@@ -53,3 +53,8 @@ export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
);
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
export enum TraceDetailsTab {
Overview = 'overview',
Thread = 'thread',
}

View File

@@ -0,0 +1,15 @@
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
import { TraceDetailsTab } from '../constants';
export function useTraceDetailsTab(): UseQueryStateReturn<
TraceDetailsTab,
TraceDetailsTab
> {
return useQueryState(
'tab',
parseAsStringEnum(Object.values(TraceDetailsTab)).withDefault(
TraceDetailsTab.Overview,
),
);
}

View File

@@ -1,89 +1,18 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { useCallback, useEffect, useState } from 'react';
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
import NoData from './NoData/NoData';
import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
import { TraceDetailsTab } from './constants';
import { useTraceDetailsTab } from './hooks/useTraceDetailsTab';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from './utils';
import cx from 'classnames';
import styles from './TraceDetailsV3.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
import TraceDetailsOverview from './TraceDetailsOverview/TraceDetailsOverview';
import TraceDetailsThread from './TraceDetailsThread/TraceDetailsThread';
import TraceDetailsWrapper from './TraceDetailsWrapper/TraceDetailsWrapper';
function TraceDetailsV3(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
Set<string>
>(new Set());
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState(false);
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
// Tracks which traceId the load event already fired for, so navigating
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef('');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() }, { replace: true });
}, [urlQuery, safeNavigate]);
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean): void => {
setFilteredSpanIds(spanIds);
@@ -92,453 +21,26 @@ function TraceDetailsV3(): JSX.Element {
[],
);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
// The filter query lives in Filters' local state, which unmounts with the
// Overview tab; drop its result too so both start empty on return.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
if (!spanId) {
return;
if (!isOverview) {
handleFilteredSpansChange([], false);
}
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
const frozenParamsRef = useRef({
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
});
const queryParams = fullDataLoadedRef.current
? frozenParamsRef.current
: {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
});
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
frozenParamsRef.current = {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {
const parentIds = new Set(
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
);
setLocalUncollapsedNodes(parentIds);
}
}, [isFullDataLoaded, allSpans]);
// Backend mode: sync uncollapsed state from API response
useEffect(() => {
if (
!isFullDataLoaded &&
traceData &&
traceData.payload &&
traceData.payload.uncollapsedSpans
) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData, isFullDataLoaded]);
// Tracks whether we've already done the initial URL→selectedSpan handoff
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
useEffect(() => {
if (deepLinkResolvedRef.current) {
return;
}
if (allSpans.length === 0) {
return;
}
if (selectedSpanId) {
const span = allSpans.find((s) => s.span_id === selectedSpanId);
if (!span) {
// Span not in the current window — wait for more data (backend
// pagination) before marking resolved.
return;
}
setSelectedSpan(span);
} else {
setSelectedSpan((prev) => prev ?? allSpans[0]);
}
deepLinkResolvedRef.current = true;
}, [selectedSpanId, allSpans]);
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
// visible. Keyed on URL `spanId`(selectedSpanId).
useEffect(() => {
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
return;
}
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
if (ancestors.size === 0) {
return;
}
setLocalUncollapsedNodes((prev) => {
// Check if all ancestors are already expanded — avoid unnecessary state update
let allPresent = true;
for (const id of ancestors) {
if (!prev.has(id)) {
allPresent = false;
break;
}
}
if (allPresent) {
return prev;
}
const next = new Set(prev);
for (const id of ancestors) {
next.add(id);
}
return next;
});
}, [isFullDataLoaded, selectedSpanId, allSpans]);
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
logTraceEvent(
key === 'flame'
? TraceDetailEvents.FlameGraphToggled
: TraceDetailEvents.WaterfallToggled,
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
);
setActiveKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
);
const RIGHT_DOCK_MIN = 480;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const handleVariantChange = useCallback(
(newVariant: SpanDetailVariant): void => {
setLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
newVariant,
);
setSpanDetailVariant(newVariant);
},
[],
);
const filterMetadata = useMemo(
() => ({
startTime: (traceData?.payload?.startTimestampMillis || 0) / 1e3,
endTime: (traceData?.payload?.endTimestampMillis || 0) / 1e3,
traceId: traceId || '',
}),
[
traceData?.payload?.startTimestampMillis,
traceData?.payload?.endTimestampMillis,
traceId,
],
);
const traceMetadataForHeader = useMemo(():
| TraceMetadataForHeader
| undefined => {
const payload = traceData?.payload;
if (!payload) {
return undefined;
}
const rootSpan = payload.spans?.find((s) => s.level === 0);
return {
startTimestampMillis: payload.startTimestampMillis,
endTimestampMillis: payload.endTimestampMillis,
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
hasMissingSpans: payload.hasMissingSpans || false,
totalSpansCount: payload.totalSpansCount || 0,
};
}, [traceData?.payload]);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
}, [isOverview, handleFilteredSpansChange]);
return (
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
traceMetadata={traceMetadataForHeader}
<TraceDetailsWrapper>
<TraceDetailsHeader onFilteredSpansChange={handleFilteredSpansChange} />
{isOverview ? (
<TraceDetailsOverview
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
{showNoData ? (
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall')
? waterfallChildren
: null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</>
)}
</div>
</TraceStoreSync>
) : (
<TraceDetailsThread />
)}
</TraceDetailsWrapper>
);
}

View File

@@ -4,36 +4,28 @@ import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { useAppContext } from 'providers/App/App';
import {
setTraceStoreAvailableColorByFields,
setTraceStoreCallbacks,
setTraceStoreUserPreferences,
} from './traceStore';
interface TraceStoreSyncProps {
availableColorByFields: string[] | undefined;
children: ReactNode;
}
/**
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
* from AppContext, and the user-pref mutation hook) into the Zustand store.
* Bridges React-managed inputs (`userPreferences` from AppContext and the
* user-pref mutation hook) into the Zustand store. `availableColorByFields`
* is pushed by TraceDetailsOverview, which owns the span data.
*
* Renders nothing until `userPreferences` resolves so the flamegraph never
* paints with the default colour first and then swaps to the user's
* persisted choice. AppProvider fires the prefs query as soon as the user
* is logged in, so this gate is usually already settled by mount time.
*/
function TraceStoreSync({
availableColorByFields,
children,
}: TraceStoreSyncProps): JSX.Element | null {
function TraceStoreSync({ children }: TraceStoreSyncProps): JSX.Element | null {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
useEffect(() => {
setTraceStoreUserPreferences(userPreferences ?? null);
}, [userPreferences]);

View File

@@ -37,7 +37,7 @@ interface TraceStoreState {
availableColorByOptions: ColorByOption[];
previewFields: TelemetryFieldKey[];
// --- Setters used only by TraceStoreSync ---
// --- Setters used only by TraceStoreSync and TraceDetailsOverview ---
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
setCallbacks: (callbacks: {

View File

@@ -0,0 +1,32 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { AiSpanKind, getAiSpanKind, isAiSpan } from '../genAi';
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
({ attributes, resource: {} }) as unknown as SpanV3;
describe('getAiSpanKind', () => {
it.each<[Record<string, string | number>, AiSpanKind]>([
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
[
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
AiSpanKind.Tool,
],
[
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
AiSpanKind.Agent,
],
])('classifies %p as %s', (attributes, kind) => {
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
});
it('returns undefined for non-AI spans and empty values', () => {
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
expect(isAiSpan(spanWith({}))).toBe(false);
});
});

View File

@@ -0,0 +1,54 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
export const GEN_AI_KEYS = {
providerName: 'gen_ai.provider.name',
system: 'gen_ai.system',
requestModel: 'gen_ai.request.model',
responseModel: 'gen_ai.response.model',
toolName: 'gen_ai.tool.name',
agentName: 'gen_ai.agent.name',
inputTokens: 'gen_ai.usage.input_tokens',
outputTokens: 'gen_ai.usage.output_tokens',
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
cost: 'signoz.gen_ai.usage.tokens.cost',
} as const;
export enum AiSpanKind {
Llm = 'llm',
Tool = 'tool',
Agent = 'agent',
}
const LLM_KEYS = [
GEN_AI_KEYS.requestModel,
GEN_AI_KEYS.inputTokens,
GEN_AI_KEYS.outputTokens,
GEN_AI_KEYS.cacheReadTokens,
GEN_AI_KEYS.cacheCreationTokens,
GEN_AI_KEYS.reasoningTokens,
];
function hasAttribute(span: SpanV3, key: string): boolean {
const value = span.attributes?.[key] ?? span.resource?.[key];
return value !== undefined && value !== null && value !== '';
}
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
return AiSpanKind.Tool;
}
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
return AiSpanKind.Agent;
}
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
return AiSpanKind.Llm;
}
return undefined;
}
export function isAiSpan(span: SpanV3): boolean {
return getAiSpanKind(span) !== undefined;
}

View File

@@ -5,7 +5,6 @@ import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
@@ -112,9 +111,8 @@ function TracesExplorer(): JSX.Element {
setIsLoadingQueries(false);
}, [queryClient]);
const selectedView = useMemo(
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
[searchParams, panelTypesFromUrl],
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
);
const [warning, setWarning] = useState<Warning | undefined>();
@@ -140,6 +138,8 @@ function TracesExplorer(): JSX.Element {
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
setSelectedView(view);
handleExplorerTabChange(
explorerViewToPanelType[view],
querySearchParameters,
@@ -285,7 +285,6 @@ function TracesExplorer(): JSX.Element {
<QuickFiltersLayout
className="trace-explorer-page"
showFilters={isOpen}
savedViewProps={{ source: SavedviewtypesSourceDTO.traces }}
quickFilterProps={{
className: 'qf-traces-explorer',
source: QuickFiltersSource.TRACES_EXPLORER,

View File

@@ -2,9 +2,13 @@
import { useEffect, useState } from 'react';
import { useListSavedViews } from 'api/generated/services/saved-view';
import { TelemetryFieldKey } from 'api/v5/v5';
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { findSavedView, toSavedViewSource } from 'container/SavedViews/utils';
import { DataSource } from 'types/common/queryBuilder';
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
@@ -34,11 +38,43 @@ export function usePreferenceSync({
const [savedViewPreferences, setSavedViewPreferences] =
useState<Preferences | null>(null);
const withColumnNames = (
columns: TelemetryFieldKey[] | undefined,
): TelemetryFieldKey[] | null => {
if (!columns) {
return null;
}
return columns.map((column) => ({
...column,
name: column.name ?? column.key,
}));
};
useEffect(() => {
const spec = savedViewId
? findSavedView(viewsData?.data, savedViewId)?.spec
: undefined;
setSavedViewPreferences(getViewColumnsAndFormatting(spec, dataSource));
const selectedFields = spec?.selectedFields as
| TelemetryFieldKey[]
| undefined;
let columns: TelemetryFieldKey[] = [];
let formatting: FormattingOptions | undefined;
if (dataSource === DataSource.LOGS) {
columns = ensureLogsRequiredColumns(
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
);
formatting = {
maxLines: spec?.display?.maxLines || 1,
format: (spec?.display?.format as LogViewMode) || 'table',
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
version: 1,
};
}
if (dataSource === DataSource.TRACES) {
columns = selectedFields || defaultTraceSelectedColumns;
}
setSavedViewPreferences({ columns, formatting });
}, [viewsData, dataSource, savedViewId, mode]);
// We are using a reSync state because we have URL updates as well as local storage updates

View File

@@ -381,6 +381,12 @@ func toMatrix(series []transpiledSeries, startMs, stepMs int64) promql.Matrix {
// lookback cannot resurrect the previous grid point. Each unit's synthetic
// samples sit on its own grid: the query grid, or the subquery grid for
// units inside subqueries.
//
// Synthetic series carry no __name__: substituted units all drop it, so
// nameless matches the replaced expressions' output. A stamped name splits
// or arms into per-unit series that a later name drop collides into the
// duplicate-labelset error. hybridQuerier.Select resolves the selector
// from the matcher, not from series labels.
func (e *executor) executeHybrid(ctx context.Context, plan *transpilePlan, results [][]transpiledSeries) (promql.Matrix, error) {
synthetic := make(map[string][]*series, len(plan.units))
staleMarker := math.Float64frombits(promValue.StaleNaN)
@@ -395,9 +401,7 @@ func (e *executor) executeHybrid(ctx context.Context, plan *transpilePlan, resul
}
list := make([]*series, 0, len(results[i]))
for _, cs := range results[i] {
builder := labels.NewBuilder(cs.lset)
builder.Set(metricNameLabel, unit.name)
s := &series{lset: builder.Labels()}
s := &series{lset: cs.lset}
s.ts = make([]int64, 0, gridLen)
s.vs = make([]float64, 0, gridLen)
for idx := 0; idx < gridLen; idx++ {
@@ -440,65 +444,17 @@ func (e *executor) executeHybrid(ctx context.Context, plan *transpilePlan, resul
return nil, err
}
// Deep-copy before Close returns the result's slices to the engine pool,
// and drop the synthetic __name__ that filter comparisons preserve.
// Deep-copy before Close returns the result's slices to the engine pool.
out := make(promql.Matrix, 0, len(matrix))
for _, s := range matrix {
lset := s.Metric
if name := lset.Get(metricNameLabel); len(name) >= len(syntheticNamePrefix) && name[:len(syntheticNamePrefix)] == syntheticNamePrefix {
builder := labels.NewBuilder(lset)
builder.Del(metricNameLabel)
lset = builder.Labels()
}
floats := make([]promql.FPoint, len(s.Floats))
copy(floats, s.Floats)
out = append(out, promql.Series{Metric: lset.Copy(), Floats: floats})
}
// The strip can leave twins: two units' outputs that only their
// synthetic names told apart (e.g. -metric_a or -metric_b, both {}
// once real names are dropped). The engine assembles its matrix by
// labelset. It merges such temporally-disjoint elements into one
// series. Reproduce that, with its duplicate error on same-timestamp
// overlap.
out, err = mergeMatrixByLabelset(out)
if err != nil {
return nil, err
out = append(out, promql.Series{Metric: s.Metric.Copy(), Floats: floats})
}
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].Metric, out[j].Metric) < 0 })
return out, nil
}
// mergeMatrixByLabelset merges series that share a labelset. It interleaves
// their points in timestamp order. A timestamp present in both is the
// engine's duplicate-labelset error.
func mergeMatrixByLabelset(matrix promql.Matrix) (promql.Matrix, error) {
index := make(map[uint64]int, len(matrix))
out := matrix[:0]
for _, s := range matrix {
hash := s.Metric.Hash()
idx, ok := index[hash]
if ok && labels.Equal(out[idx].Metric, s.Metric) {
merged := make([]promql.FPoint, 0, len(out[idx].Floats)+len(s.Floats))
a, b := out[idx].Floats, s.Floats
for len(a) > 0 && len(b) > 0 {
switch {
case a[0].T < b[0].T:
merged, a = append(merged, a[0]), a[1:]
case b[0].T < a[0].T:
merged, b = append(merged, b[0]), b[1:]
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "vector cannot contain metrics with the same labelset")
}
}
out[idx].Floats = append(append(merged, a...), b...)
continue
}
index[hash] = len(out)
out = append(out, s)
}
return out, nil
}
func resultToMatrix(res *promql.Result) (promql.Matrix, error) {
switch v := res.Value.(type) {
case promql.Matrix:

View File

@@ -14,7 +14,6 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -679,26 +678,3 @@ func TestMergeSameLabelsetSeries(t *testing.T) {
require.Error(t, err, "two values on one evaluation timestamp is the engine's duplicate error")
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
}
// Hybrid twin case: stripping the synthetic __name__ can leave two engine
// output series distinguishable only by those names (-metric_a or -metric_b:
// both {} once real names are dropped). Pinned by conformance cases
// name_label_dropping.test:137 and operators.test:1016.
func TestMergeMatrixByLabelset(t *testing.T) {
empty := labels.EmptyLabels()
out, err := mergeMatrixByLabelset(promql.Matrix{
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -1}}},
{Metric: empty, Floats: []promql.FPoint{{T: 600_000, F: -4}}},
})
require.NoError(t, err)
require.Len(t, out, 1)
assert.Equal(t, []promql.FPoint{{T: 0, F: -1}, {T: 600_000, F: -4}}, out[0].Floats)
_, err = mergeMatrixByLabelset(promql.Matrix{
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -1}}},
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -3}}},
})
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
}

View File

@@ -76,18 +76,18 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/goleak v1.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.272.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@@ -377,29 +377,29 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
@@ -409,8 +409,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,81 @@
from collections.abc import Callable
from datetime import UTC, datetime
from http import HTTPStatus
from uuid import uuid4
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import get_all_series, make_query_request
MINUTE_MS = 60_000
QUERY_TIMEOUT = 30
# Valid PromQL: the [30m:5m] subquery evaluates the or expression every 5m,
# yielding sum(flicker) while the flicker metric has data and sum(steady)
# after it stops. sum() drops __name__ from both, so every evaluation shares
# one labelset and the result is a single series. query_range must return it —
# not a "vector cannot contain metrics with the same labelset" error — and
# must agree with /prometheus/api/v1/query on the same expression.
def test_or_arms_merge_under_subquery_name_drop(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
base_ms = (now_ms // (5 * MINUTE_MS)) * (5 * MINUTE_MS) - 45 * MINUTE_MS
flicker = f"or_flicker_arm_{uuid4().hex[:8]}"
steady = f"or_steady_arm_{uuid4().hex[:8]}"
insert_metrics(
[
Metrics(
metric_name=name,
labels={"host": "server-01"},
timestamp=datetime.fromtimestamp((base_ms + minute * MINUTE_MS) / 1000, tz=UTC),
value=1.0,
)
# The flicker arm stops at minute 9, so every 30m subquery window
# below sees it present at some 5m-aligned steps and absent (past
# lookback) at others, with the steady arm filling the gaps.
for name, minutes in ((flicker, range(10)), (steady, range(36)))
for minute in minutes
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = f"max_over_time((sum({flicker}) or sum({steady}))[30m:5m])"
start_ms = base_ms + 20 * MINUTE_MS
end_ms = base_ms + 35 * MINUTE_MS
# A range query is semantically the instant evaluation of the same
# expression at each grid timestamp. Each one must yield the single
# nameless series; together they are the reference for query_range.
instant_values: dict[int, float] = {}
for ts_ms in range(start_ms, end_ms + 1, 5 * MINUTE_MS):
response = requests.get(
signoz.self.host_configs["8080"].get("/prometheus/api/v1/query"),
params={"query": query, "time": ts_ms / 1000},
timeout=QUERY_TIMEOUT,
headers={"authorization": f"Bearer {token}"},
)
assert response.status_code == HTTPStatus.OK, response.text[:300]
body = response.json()
assert body.get("status") == "success", body
result = body["data"]["result"]
assert [series["metric"] for series in result] == [{}], (ts_ms, result)
instant_values[ts_ms] = float(result[0]["value"][1])
assert set(instant_values.values()) == {1.0}, instant_values
# query_range over the same grid must agree point for point.
spec = {"name": "A", "query": query, "step": 300}
response = make_query_request(signoz, token, start_ms, end_ms, [{"type": "promql", "spec": spec}])
assert response.status_code == HTTPStatus.OK, response.text[:300]
series = get_all_series(response.json(), "A")
assert len(series) == 1, f"both or arms must merge into one series: {series}"
points = {point["timestamp"]: point["value"] for point in series[0].get("values") or []}
assert points == instant_values, (points, instant_values)