Compare commits

..

12 Commits

Author SHA1 Message Date
nityanandagohain
aa140b3456 feat: trace based filters for logs, supporting aggregations as well 2026-05-21 18:47:25 +05:30
primus-bot[bot]
fb3e316ce9 chore(release): bump to v0.125.1 (#11381)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-05-20 17:46:41 +00:00
Manika Malhotra
b753b95a8a chore: replace antd badge with signozhq badge (#11377)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: replace antd badge with signozhq badge

* chore: add badge to banned components
2026-05-20 14:24:52 +00:00
Vinicius Lourenço
4757550189 fix(alerts): ensure edit alert name is updated correctly and not override after save (#11348) 2026-05-20 13:57:52 +00:00
Vinicius Lourenço
96ad37fea9 fix(tanstack-table): reset page to 1 when change page size (#11344)
* fix(tanstack-table): reset page to 1 when change page size

* fix(tanstack): ensure page change callback is called
2026-05-20 13:57:40 +00:00
Vinicius Lourenço
5419e8461c fix(alerts-new): show tabs and breadcrumbs on create alert (#11316)
* fix(alerts-new): show tabs and breadcrumbs on create alert

* fix(pr): address comments

* fix(composite-query): not automatically showing the create alerts when have this query param

* fix(breadcrumb): align ui with periscope
2026-05-20 13:29:23 +00:00
Aditya Singh
e634eb4452 fix: expand waterfall ancestors on flamegraph click (#11373)
* fix: scroll to span in frontend mode when nodes are collapsed

* fix: fix tests

* feat: change default span details position
2026-05-20 13:28:22 +00:00
Piyush Singariya
a50bc53f4c chore: Accept body as Map in FE (#11291)
* fix: backend changes for message key postprocessing

* fix: message postprocessing

* chore: update in e2e tests

* fix: table view

* fix: support body as json in FE

* chore: separate frontend from backend changes

* chore: remove dead code
2026-05-20 12:52:53 +00:00
Srikanth Chekuri
9f60bdf54a chore: create source field in dashboards (#11367)
* chore: create source field in dashboards

* chore: consolidate checks to module

* chore: run generate

* chore: address review comments

* chore: separate test file

* chore: address review comments
2026-05-20 12:37:25 +00:00
Nikhil Mantri
e41639dea0 chore: function refactor (#11371) 2026-05-20 12:10:42 +00:00
Piyush Singariya
847bc71f4e fix: postprocess json logs message key (#11189)
* fix: backend changes for message key postprocessing

* fix: message postprocessing

* chore: update in e2e tests

* fix: table view

* chore: separate frontend from backend changes

* fix: integration tests
2026-05-20 11:46:27 +00:00
Ashwin Bhatkal
8d7d3e5c64 fix(metrics-explorer): show actual timestamp in metric sidesheet 'Last Received' tooltip (#11370) 2026-05-20 10:51:27 +00:00
94 changed files with 2172 additions and 853 deletions

View File

@@ -45,15 +45,9 @@ jobs:
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
runs-on: ubuntu-latest
timeout-minutes: 30
env:
SIGNOZ_BUILDX_GHA_SCOPE: signoz-e2e
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup-buildx
uses: docker/setup-buildx-action@v3
- name: expose-gha-runtime
uses: crazy-max/ghaction-github-runtime@v3
- name: python
uses: actions/setup-python@v5
with:

View File

@@ -69,15 +69,9 @@ jobs:
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
runs-on: ubuntu-latest
env:
SIGNOZ_BUILDX_GHA_SCOPE: signoz-integration
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup-buildx
uses: docker/setup-buildx-action@v3
- name: expose-gha-runtime
uses: crazy-max/ghaction-github-runtime@v3
- name: python
uses: actions/setup-python@v5
with:

View File

@@ -34,7 +34,6 @@ DOCKER_BUILD_ARCHS_ENTERPRISE = $(addprefix docker-build-enterprise-,$(ARCHS))
DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile
DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz
JS_BUILD_CONTEXT = $(SRC)/frontend
DOCKER_BUILDX_PRUNE_FLAGS ?= --force
##############################################################
# directories
@@ -230,21 +229,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
@find tests -type f -name "*.pyo" -delete 2>/dev/null || true
@echo ">> python cache cleaned"
.PHONY: py-docker-clean
py-docker-clean: ## Remove Docker image and build caches used by python integration tests
@echo ">> removing SigNoz integration test image"
@docker image rm -f signoz:integration 2>/dev/null || true
@echo ">> removing local integration buildx cache directories"
@rm -rf /tmp/signoz-integration-buildx-cache /tmp/signoz-integration-buildx-cache-next /tmp/signoz-e2e-buildx-cache /tmp/signoz-e2e-buildx-cache-next
@echo ">> pruning docker buildx cache with flags: $(DOCKER_BUILDX_PRUNE_FLAGS)"
@docker buildx prune $(DOCKER_BUILDX_PRUNE_FLAGS)
.PHONY: py-test-clean
py-test-clean: ## Tear down python test stack and remove python/Docker test caches
@$(MAKE) py-test-teardown || true
@$(MAKE) py-clean
@$(MAKE) py-docker-clean
##############################################################
# generate commands

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.13
FROM golang:1.25-bookworm
ARG OS="linux"
@@ -23,8 +21,7 @@ RUN set -eux; \
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
@@ -32,9 +29,7 @@ COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
COPY Makefile Makefile
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
RUN chmod 755 /root /root/signoz

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.13
FROM node:22-bookworm AS build
WORKDIR /opt/
@@ -32,8 +30,7 @@ RUN set -eux; \
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
@@ -41,9 +38,7 @@ COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
COPY Makefile Makefile
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.125.0
image: signoz/signoz:v0.125.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.125.0
image: signoz/signoz:v0.125.1
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.125.0}
image: signoz/signoz:${VERSION:-v0.125.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.125.0}
image: signoz/signoz:${VERSION:-v0.125.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -2342,6 +2342,8 @@ components:
type: boolean
org_id:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
updatedAt:
format: date-time
type: string
@@ -2371,6 +2373,12 @@ components:
timeRangeEnabled:
type: boolean
type: object
DashboardtypesSource:
enum:
- user
- system
- integration
type: object
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object

View File

@@ -49,6 +49,14 @@ func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publi
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.Get(ctx, orgID, publicDashboard.DashboardID)
if err != nil {
return err
}
if err := dashboard.ErrIfNotPublishable(); err != nil {
return err
}
storablePublicDashboard, err := module.store.GetPublic(ctx, publicDashboard.DashboardID.StringValue())
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
@@ -129,6 +137,14 @@ func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publi
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.Get(ctx, orgID, publicDashboard.DashboardID)
if err != nil {
return err
}
if err := dashboard.ErrIfNotPublishable(); err != nil {
return err
}
return module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
}
@@ -138,6 +154,10 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
return err
}
if err := dashboard.ErrIfNotDeletable(); err != nil {
return err
}
if dashboard.Locked {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "dashboard is locked, please unlock the dashboard to be delete it")
}
@@ -168,6 +188,14 @@ func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashb
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
dashboard, err := module.Get(ctx, orgID, dashboardID)
if err != nil {
return err
}
if err := dashboard.ErrIfNotPublishable(); err != nil {
return err
}
err = module.store.DeletePublic(ctx, dashboardID.StringValue())
if err != nil {
return err

View File

@@ -15,6 +15,7 @@
const BANNED_COMPONENTS = {
Typography: 'Use @signozhq/ui Typography instead of antd Typography.',
Badge: 'Use @signozhq/ui/badge instead of antd Badge.',
};
export default {

View File

@@ -47,7 +47,6 @@ export const TracesFunnels = Loadable(
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),
);
export const TracesFunnelDetails = Loadable(
// eslint-disable-next-line sonarjs/no-identical-functions
() =>
import(
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesModulePage'
@@ -313,13 +312,6 @@ export const PublicDashboardPage = Loadable(
),
);
export const AlertTypeSelectionPage = Loadable(
() =>
import(
/* webpackChunkName: "Alert Type Selection Page" */ 'pages/AlertTypeSelection'
),
);
export const MeterExplorerPage = Loadable(
() =>
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),

View File

@@ -5,7 +5,6 @@ import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AlertTypeSelectionPage,
AllAlertChannels,
AllErrors,
ApiMonitoring,
@@ -213,13 +212,6 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'LIST_ALL_ALERT',
},
{
path: ROUTES.ALERT_TYPE_SELECTION,
exact: true,
component: AlertTypeSelectionPage,
isPrivate: true,
key: 'ALERT_TYPE_SELECTION',
},
{
path: ROUTES.ALERTS_NEW,
exact: true,
@@ -533,18 +525,6 @@ export const LIST_LICENSES: AppRoutes = {
key: 'LIST_LICENSES',
};
export const oldRoutes = [
'/pipelines',
'/logs-explorer',
'/logs-explorer/live',
'/logs-save-views',
'/traces-save-views',
'/settings/access-tokens',
'/settings/api-keys',
'/messaging-queues',
'/alerts/edit',
];
export const oldNewRoutesMapping: Record<string, string> = {
'/pipelines': '/logs/pipelines',
'/logs-explorer': '/logs/logs-explorer',
@@ -555,7 +535,9 @@ export const oldNewRoutesMapping: Record<string, string> = {
'/settings/api-keys': '/settings/service-accounts',
'/messaging-queues': '/messaging-queues/overview',
'/alerts/edit': '/alerts/overview',
'/alerts/type-selection': '/alerts/new',
};
export const oldRoutes = Object.keys(oldNewRoutesMapping);
export const ROUTES_NOT_TO_BE_OVERRIDEN: string[] = [
ROUTES.WORKSPACE_LOCKED,

View File

@@ -2999,6 +2999,11 @@ export interface CoretypesPatchableObjectsDTO {
deletions: CoretypesObjectGroupDTO[] | null;
}
export enum DashboardtypesSourceDTO {
user = 'user',
system = 'system',
integration = 'integration',
}
export interface DashboardtypesDashboardDTO {
/**
* @type string
@@ -3022,6 +3027,7 @@ export interface DashboardtypesDashboardDTO {
* @type string
*/
org_id?: string;
source?: DashboardtypesSourceDTO;
/**
* @type string
* @format date-time

View File

@@ -0,0 +1,17 @@
.breadcrumb {
padding-left: 16px;
ol {
align-items: center;
}
:global(.ant-breadcrumb-separator) {
color: var(--muted-foreground);
}
}
.divider {
border-color: var(--l1-border);
margin: 16px 0;
margin-top: 10px;
}

View File

@@ -0,0 +1,32 @@
import { Breadcrumb, Divider } from 'antd';
import styles from './AlertBreadcrumb.module.scss';
import BreadcrumbItem, { BreadcrumbItemConfig } from './BreadcrumbItem';
export interface AlertBreadcrumbProps {
items: BreadcrumbItemConfig[];
className?: string;
showDivider?: boolean;
}
function AlertBreadcrumb({
items,
className,
showDivider = true,
}: AlertBreadcrumbProps): JSX.Element {
const breadcrumbItems = items.map((item) => ({
title: <BreadcrumbItem {...item} />,
}));
return (
<>
<Breadcrumb
className={`${styles.breadcrumb} ${className || ''}`}
items={breadcrumbItems}
/>
{showDivider && <Divider className={styles.divider} />}
</>
);
}
export default AlertBreadcrumb;

View File

@@ -0,0 +1,9 @@
.item {
--button-padding: 0;
--button-font-size: var(--periscope-font-size-base);
}
.itemLast {
color: var(--muted-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,45 @@
import { Button } from '@signozhq/ui/button';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isModifierKeyPressed } from 'utils/app';
import styles from './BreadcrumbItem.module.scss';
export type BreadcrumbItemConfig =
| {
title: string | null;
route?: string;
}
| {
title: string | null;
isLast?: true;
};
function BreadcrumbItem({
title,
...props
}: BreadcrumbItemConfig): JSX.Element {
const { safeNavigate } = useSafeNavigate();
if ('isLast' in props) {
return <div className={styles.itemLast}>{title}</div>;
}
return (
<Button
variant="ghost"
color="secondary"
className={styles.item}
onClick={(e: React.MouseEvent): void => {
if (!('route' in props) || !props.route) {
return;
}
safeNavigate(props.route, { newTab: isModifierKeyPressed(e) });
}}
>
{title}
</Button>
);
}
export default BreadcrumbItem;

View File

@@ -0,0 +1,6 @@
export { default } from './AlertBreadcrumb';
export {
default as BreadcrumbItem,
type BreadcrumbItemConfig,
} from './BreadcrumbItem';
export type { AlertBreadcrumbProps } from './AlertBreadcrumb';

View File

@@ -50,6 +50,7 @@ import {
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -217,20 +218,17 @@ function LogDetailInner({
const logBody = useMemo(() => {
if (!isBodyJsonQueryEnabled) {
return log?.body || '';
return (log?.body as string) ?? '';
}
try {
const json = JSON.parse(log?.body || '');
if (typeof json?.message === 'string' && json.message !== '') {
return json.message;
}
return log?.body || '';
} catch {
return log?.body || '';
// Feature enabled: body is always a map; message is always a string
const bodyObj = log?.body as ILogBody;
if (!bodyObj) {
return '';
}
if (bodyObj.message) {
return bodyObj.message;
}
return JSON.stringify(bodyObj);
}, [isBodyJsonQueryEnabled, log?.body]);
const htmlBody = useMemo(

View File

@@ -9,7 +9,10 @@ import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -99,7 +102,7 @@ function RawLogView({
// Check if body is selected
const showBody = selectedFields.some((field) => field.name === 'body');
if (showBody) {
parts.push(`${attributesText} ${data.body}`);
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
} else {
parts.push(attributesText);
}

View File

@@ -2,7 +2,10 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
@@ -87,7 +90,7 @@ export function useLogsTableColumns({
? {
id: 'body',
header: 'Body',
accessorFn: (log): string => log.body,
accessorFn: (log): string => getBodyDisplayString(log.body),
canBeHidden: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (

View File

@@ -626,6 +626,10 @@ function TanStackTableInner<TData>(
onChange={(value): void => {
setLimit(+value);
pagination.onLimitChange?.(+value);
if (page !== 1) {
setPage(1);
pagination.onPageChange?.(1);
}
}}
items={paginationPageSizeItems}
/>

View File

@@ -401,6 +401,62 @@ describe('TanStackTableView Integration', () => {
expect(onLimitChange).toHaveBeenCalledWith(20);
});
});
it('resets page to 1 when limit changes', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const onPageChange = jest.fn();
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10, onPageChange },
enableQueryParams: true,
},
onUrlUpdate,
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
// Navigate to page 2
const nav = screen.getByRole('navigation');
const page2 = Array.from(nav.querySelectorAll('button')).find(
(btn) => btn.textContent?.trim() === '2',
);
if (!page2) {
throw new Error('Page 2 button not found in pagination');
}
await user.click(page2);
await waitFor(() => {
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('2');
});
// Change page size
const comboboxTrigger = document.querySelector(
'button[aria-haspopup="dialog"]',
) as HTMLElement;
await user.click(comboboxTrigger);
const option20 = await screen.findByRole('option', { name: '20' });
await user.click(option20);
// Verify page reset to 1 (nuqs removes default values from URL)
await waitFor(() => {
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
const lastPage = lastCall[0].searchParams.get('page');
expect(lastPage === '1' || lastPage === null).toBe(true);
expect(lastCall[0].searchParams.get('limit')).toBe('20');
});
// Verify onPageChange callback was called with 1
expect(onPageChange).toHaveBeenCalledWith(1);
});
});
describe('sorting', () => {

View File

@@ -29,7 +29,6 @@ const ROUTES = {
ALERTS_NEW: '/alerts/new',
ALERT_HISTORY: '/alerts/history',
ALERT_OVERVIEW: '/alerts/overview',
ALERT_TYPE_SELECTION: '/alerts/type-selection',
ALL_CHANNELS: '/settings/channels',
CHANNELS_NEW: '/settings/channels/new',
CHANNELS_EDIT: '/settings/channels/edit/:channelId',

View File

@@ -1,12 +1,23 @@
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import AlertTypeSelectionPage from 'pages/AlertTypeSelection';
import CreateAlertPage from 'pages/CreateAlert';
import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_TO_TITLE, ALERT_TYPE_URL_MAP } from './constants';
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: '',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
jest
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
.mockReturnValue({
@@ -54,20 +65,13 @@ describe('Alert rule documentation redirection', () => {
window.open = mockWindowOpen;
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERT_TYPE_SELECTION}`,
}),
}));
beforeEach(() => {
act(() => {
renderResult = render(
<AlertTypeSelectionPage />,
<CreateAlertPage />,
{},
{
initialRoute: ROUTES.ALERT_TYPE_SELECTION,
initialRoute: ROUTES.ALERTS_NEW,
},
);
});

View File

@@ -15,6 +15,18 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({

View File

@@ -0,0 +1,75 @@
.create-alert-tabs {
&__extra {
display: flex;
align-items: center;
gap: 16px;
}
}
.create-alert-wrapper {
margin-top: 10px;
.divider {
border-color: var(--l1-border);
margin: 16px 0;
}
.breadcrumb-divider {
margin-top: 10px;
}
}
.create-alert__breadcrumb {
padding-left: 16px;
ol {
align-items: center;
}
.breadcrumb-item {
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: 0.25px;
padding: 0;
}
.ant-breadcrumb-separator,
.breadcrumb-item--last {
color: var(--muted-foreground);
font-family: 'Geist Mono';
}
}
.alerts-container {
.top-level-tab.periscope-tab {
padding: 2px 0;
}
.ant-tabs {
&-nav {
padding: 0 8px;
margin-bottom: 0 !important;
&::before {
border-bottom: 1px solid var(--l1-border) !important;
}
}
&-tab {
&[data-node-key='TriggeredAlerts'] {
margin-left: 16px;
}
&:not(:first-of-type) {
margin-left: 24px !important;
}
[aria-selected='false'] {
.periscope-tab {
color: var(--l2-foreground);
}
}
}
}
}

View File

@@ -0,0 +1,111 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import SelectAlertType from '..';
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
describe('SelectAlertType', () => {
const mockOnSelect = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
useAppContextSpy.mockReturnValue(getAppContextMockState());
});
it('should render all alert type options when anomaly detection is enabled', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<SelectAlertType onSelect={mockOnSelect} />);
expect(screen.getByText('metric_based_alert')).toBeInTheDocument();
expect(screen.getByText('log_based_alert')).toBeInTheDocument();
expect(screen.getByText('traces_based_alert')).toBeInTheDocument();
expect(screen.getByText('exceptions_based_alert')).toBeInTheDocument();
expect(screen.getByText('anomaly_based_alert')).toBeInTheDocument();
});
it('should render all alert type options except anomaly based alert when anomaly detection is disabled', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
expect(screen.getByText('metric_based_alert')).toBeInTheDocument();
expect(screen.getByText('log_based_alert')).toBeInTheDocument();
expect(screen.getByText('traces_based_alert')).toBeInTheDocument();
expect(screen.getByText('exceptions_based_alert')).toBeInTheDocument();
expect(screen.queryByText('anomaly_based_alert')).not.toBeInTheDocument();
});
it('should call onSelect with metrics based alert type', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockOnSelect).toHaveBeenCalledWith(
AlertTypes.METRICS_BASED_ALERT,
false,
);
});
it('should call onSelect with anomaly based alert type', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<SelectAlertType onSelect={mockOnSelect} />);
fireEvent.click(screen.getByText('anomaly_based_alert'));
expect(mockOnSelect).toHaveBeenCalledWith(
AlertTypes.ANOMALY_BASED_ALERT,
false,
);
});
it('should call onSelect with log based alert type', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
fireEvent.click(screen.getByText('log_based_alert'));
expect(mockOnSelect).toHaveBeenCalledWith(AlertTypes.LOGS_BASED_ALERT, false);
});
it('should call onSelect with traces based alert type', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
fireEvent.click(screen.getByText('traces_based_alert'));
expect(mockOnSelect).toHaveBeenCalledWith(
AlertTypes.TRACES_BASED_ALERT,
false,
);
});
it('should call onSelect with exceptions based alert type', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
fireEvent.click(screen.getByText('exceptions_based_alert'));
expect(mockOnSelect).toHaveBeenCalledWith(
AlertTypes.EXCEPTIONS_BASED_ALERT,
false,
);
});
});

View File

@@ -1,13 +1,37 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as useCompositeQueryParamHooks from 'hooks/queryBuilder/useGetCompositeQueryParam';
import * as navigateHooks from 'hooks/useSafeNavigate';
import * as useUrlQueryHooks from 'hooks/useUrlQuery';
import * as appHooks from 'providers/App/App';
import { render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { DataSource } from 'types/common/queryBuilder';
import CreateAlertRule from '../index';
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: '',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: function MockDateTimeSelector(): JSX.Element {
return <div data-testid="datetime-selector">Mock DateTime Selector</div>;
},
}));
jest.mock('container/FormAlertRules', () => ({
__esModule: true,
default: function MockFormAlertRules({
@@ -48,10 +72,14 @@ const useCompositeQueryParamSpy = jest.spyOn(
'useGetCompositeQueryParam',
);
const useUrlQuerySpy = jest.spyOn(useUrlQueryHooks, 'default');
const useSafeNavigateSpy = jest.spyOn(navigateHooks, 'useSafeNavigate');
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const mockSetUrlQuery = jest.fn();
const mockToString = jest.fn();
const mockGetUrlQuery = jest.fn();
const mockSafeNavigate = jest.fn();
const mockDeleteUrlQuery = jest.fn();
const FORM_ALERT_RULES_TEXT = 'Form Alert Rules';
const CREATE_ALERT_V2_TEXT = 'Create Alert V2';
@@ -63,8 +91,13 @@ describe('CreateAlertRule', () => {
set: mockSetUrlQuery,
toString: mockToString,
get: mockGetUrlQuery,
delete: mockDeleteUrlQuery,
} as Partial<URLSearchParams> as URLSearchParams);
useCompositeQueryParamSpy.mockReturnValue(initialQueriesMap.metrics);
useSafeNavigateSpy.mockReturnValue({
safeNavigate: mockSafeNavigate,
});
useAppContextSpy.mockReturnValue(getAppContextMockState());
});
it('should render classic flow when showClassicCreateAlertsPage is true', () => {
@@ -72,18 +105,53 @@ describe('CreateAlertRule', () => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
if (key === QueryParams.alertType) {
return AlertTypes.METRICS_BASED_ALERT;
}
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render new flow by default', () => {
mockGetUrlQuery.mockReturnValue(null);
it('should render new flow when alertType is provided', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.alertType) {
return AlertTypes.METRICS_BASED_ALERT;
}
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
});
it('should render type selection when no alertType in URL and no compositeQuery', () => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue(null);
render(<CreateAlertRule />);
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
});
it('should skip type selection and render alert form when compositeQuery is present', () => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue({
...initialQueriesMap.metrics,
builder: {
...initialQueriesMap.metrics.builder,
queryData: [
{
...initialQueriesMap.metrics.builder.queryData[0],
dataSource: DataSource.METRICS,
},
],
},
});
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
});
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
@@ -111,8 +179,13 @@ describe('CreateAlertRule', () => {
expect(screen.getByText(AlertTypes.LOGS_BASED_ALERT)).toBeInTheDocument();
});
it('should use alertType from compositeQuery dataSource when alertType is not in URL', () => {
mockGetUrlQuery.mockReturnValue(null);
it('should use alertType from URL over compositeQuery dataSource', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.alertType) {
return AlertTypes.LOGS_BASED_ALERT;
}
return null;
});
useCompositeQueryParamSpy.mockReturnValue({
...initialQueriesMap.metrics,
builder: {
@@ -127,14 +200,123 @@ describe('CreateAlertRule', () => {
});
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.TRACES_BASED_ALERT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.LOGS_BASED_ALERT)).toBeInTheDocument();
});
it('should default to METRICS_BASED_ALERT when no alertType and no compositeQuery', () => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue(null);
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
describe('handleSelectType navigation', () => {
beforeEach(() => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue(null);
});
it('should navigate with threshold alert params for metrics alert', () => {
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate with threshold alert params for logs alert', () => {
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('log_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.LOGS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate with threshold alert params for traces alert', () => {
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('traces_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.TRACES_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate with threshold alert params for exceptions alert', () => {
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('exceptions_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.EXCEPTIONS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate with anomaly detection params for anomaly alert', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('anomaly_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'anomaly_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate even when showClassicCreateAlertsPage flag is present', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
return null;
});
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
});
});

View File

@@ -208,3 +208,11 @@ export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = {
[AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults,
[AlertTypes.EXCEPTIONS_BASED_ALERT]: exceptionAlertDefaults,
};
export const ALERT_TYPE_BREADCRUMB_TITLE: Record<AlertTypes, string> = {
[AlertTypes.ANOMALY_BASED_ALERT]: 'Anomaly-Based Alert',
[AlertTypes.METRICS_BASED_ALERT]: 'Metric-Based Alert',
[AlertTypes.LOGS_BASED_ALERT]: 'Log-Based Alert',
[AlertTypes.TRACES_BASED_ALERT]: 'Traces-Based Alert',
[AlertTypes.EXCEPTIONS_BASED_ALERT]: 'Exceptions-Based Alert',
};

View File

@@ -1,21 +1,34 @@
import { useMemo } from 'react';
import { Form } from 'antd';
import { useCallback, useEffect, useMemo } from 'react';
import { Form, Tabs, TabsProps } from 'antd';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import CreateAlertV2 from 'container/CreateAlertV2';
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { AlertListTabs } from 'pages/AlertList/types';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config';
import { ALERTS_VALUES_MAP } from './defaults';
import { ALERTS_VALUES_MAP, ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
import SelectAlertType from './SelectAlertType';
import './CreateAlertRule.styles.scss';
function CreateRules(): JSX.Element {
const [formInstance] = Form.useForm();
const compositeQuery = useGetCompositeQueryParam();
const queryParams = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const ruleTypeFromURL = queryParams.get(QueryParams.ruleType);
const alertTypeFromURL = queryParams.get(QueryParams.alertType);
@@ -23,6 +36,15 @@ function CreateRules(): JSX.Element {
const showClassicCreateAlertsPageFlag =
queryParams.get(QueryParams.showClassicCreateAlertsPage) === 'true';
const isTypeSelectionMode =
!alertTypeFromURL && !ruleTypeFromURL && !compositeQuery;
useEffect(() => {
if (isTypeSelectionMode) {
logEvent('Alert: New alert data source selection page visited', {});
}
}, [isTypeSelectionMode]);
const alertType = useMemo(() => {
if (ruleTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
return AlertTypes.ANOMALY_BASED_ALERT;
@@ -45,22 +67,142 @@ function CreateRules(): JSX.Element {
[alertType, version],
);
// Load old alerts flow always for anomaly based alerts and when showClassicCreateAlertsPage is true
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
return (
<FormAlertRules
alertType={alertType}
formInstance={formInstance}
initialValue={initialAlertValue}
ruleId=""
/>
);
}
const handleTabChange = useCallback(
(tab: string): void => {
queryParams.set('tab', tab);
queryParams.delete('subTab');
queryParams.delete('search');
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${queryParams.toString()}`);
},
[safeNavigate, queryParams],
);
return <CreateAlertV2 alertType={alertType} />;
const handleSelectType = useCallback(
(type: AlertTypes, newTab?: boolean): void => {
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
queryParams.set(
QueryParams.ruleType,
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
);
queryParams.set(QueryParams.alertType, AlertTypes.METRICS_BASED_ALERT);
} else {
queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT);
queryParams.set(QueryParams.alertType, type);
}
safeNavigate(`${ROUTES.ALERTS_NEW}?${queryParams.toString()}`, { newTab });
},
[queryParams, safeNavigate],
);
const alertContent = useMemo(() => {
if (isTypeSelectionMode) {
return <SelectAlertType onSelect={handleSelectType} />;
}
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
return (
<FormAlertRules
alertType={alertType}
formInstance={formInstance}
initialValue={initialAlertValue}
ruleId=""
/>
);
}
return <CreateAlertV2 alertType={alertType} />;
}, [
isTypeSelectionMode,
handleSelectType,
showClassicCreateAlertsPageFlag,
alertType,
formInstance,
initialAlertValue,
]);
const items: TabsProps['items'] = [
{
label: (
<div className="periscope-tab top-level-tab">
<GalleryVerticalEnd size={14} />
Triggered Alerts
</div>
),
key: AlertListTabs.TRIGGERED_ALERTS,
children: null,
},
{
label: (
<div className="periscope-tab top-level-tab">
<Pyramid size={14} />
Alert Rules
</div>
),
key: AlertListTabs.ALERT_RULES,
children: (
<div className="create-alert-wrapper">
<AlertBreadcrumb
className="create-alert__breadcrumb"
items={
isTypeSelectionMode
? [
{
title: 'Alert Rules',
route: `${ROUTES.LIST_ALL_ALERT}?tab=${AlertListTabs.ALERT_RULES}`,
},
{ title: 'Select Alert Type', isLast: true },
]
: [
{
title: 'Alert Rules',
route: `${ROUTES.LIST_ALL_ALERT}?tab=${AlertListTabs.ALERT_RULES}`,
},
{ title: 'Select Alert Type', route: ROUTES.ALERTS_NEW },
{
title: ALERT_TYPE_BREADCRUMB_TITLE[alertType],
isLast: true,
},
]
}
/>
{alertContent}
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Configuration
</div>
),
key: AlertListTabs.CONFIGURATION,
children: null,
},
];
return (
<Tabs
destroyInactiveTabPane
items={items}
activeKey={AlertListTabs.ALERT_RULES}
onChange={handleTabChange}
className="alerts-container create-alert-tabs"
tabBarExtraContent={
<div className="create-alert-tabs__extra">
<DateTimeSelector showAutoRefresh />
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
}
/>
);
}
export default CreateRules;

View File

@@ -9,6 +9,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { RotateCcw } from '@signozhq/icons';
import { useAlertRuleOptional } from 'providers/Alert';
import { Labels } from 'types/api/alerts/def';
import { useCreateAlertState } from '../context';
@@ -18,6 +19,7 @@ import './styles.scss';
function CreateAlertHeader(): JSX.Element {
const { alertState, setAlertState, isEditMode } = useCreateAlertState();
const alertRuleContext = useAlertRuleOptional();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
@@ -74,9 +76,13 @@ function CreateAlertHeader(): JSX.Element {
<Input
type="text"
value={alertState.name}
onChange={(e): void =>
setAlertState({ type: 'SET_ALERT_NAME', payload: e.target.value })
}
onChange={(e): void => {
const newName = e.target.value;
setAlertState({ type: 'SET_ALERT_NAME', payload: newName });
if (isEditMode && alertRuleContext?.setAlertRuleName) {
alertRuleContext.setAlertRuleName(newName);
}
}}
className="alert-header__input title"
placeholder="Enter alert rule name"
data-testid="alert-name-input"

View File

@@ -20,6 +20,11 @@ import {
} from './utils';
import './styles.scss';
import {
invalidateGetRuleByID,
invalidateListRules,
} from 'api/generated/services/rules';
import { useQueryClient } from 'react-query';
function Footer(): JSX.Element {
const {
@@ -115,6 +120,7 @@ function Footer(): JSX.Element {
testAlertRule,
]);
const queryClient = useQueryClient();
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
alertType,
@@ -133,6 +139,9 @@ function Footer(): JSX.Element {
},
{
onSuccess: () => {
void invalidateGetRuleByID(queryClient, { id: ruleId });
void invalidateListRules(queryClient);
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');
},

View File

@@ -7,6 +7,7 @@ import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationS
import * as createAlertState from '../../context';
import Footer from '../Footer';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
// Mock the hooks used by Footer component
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -64,6 +65,12 @@ const mockAlertContextState = createMockAlertContextState({
},
});
const WrappedFooter = (): JSX.Element => (
<MockQueryClientProvider>
<Footer />
</MockQueryClientProvider>
);
jest
.spyOn(createAlertState, 'useCreateAlertState')
.mockReturnValue(mockAlertContextState);
@@ -97,20 +104,20 @@ describe('Footer', () => {
});
it('should render the component with 3 buttons', () => {
render(<Footer />);
render(<WrappedFooter />);
expect(screen.getByText(SAVE_ALERT_RULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(TEST_NOTIFICATION_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
it('discard action works correctly', () => {
render(<Footer />);
render(<WrappedFooter />);
fireEvent.click(screen.getByText(DISCARD_TEXT));
expect(mockDiscardAlertRule).toHaveBeenCalled();
});
it('save alert rule action works correctly', () => {
render(<Footer />);
render(<WrappedFooter />);
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
expect(mockCreateAlertRule).toHaveBeenCalled();
});
@@ -120,13 +127,13 @@ describe('Footer', () => {
...mockAlertContextState,
isEditMode: true,
});
render(<Footer />);
render(<WrappedFooter />);
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
expect(mockUpdateAlertRule).toHaveBeenCalled();
});
it('test notification action works correctly', () => {
render(<Footer />);
render(<WrappedFooter />);
fireEvent.click(screen.getByText(TEST_NOTIFICATION_TEXT));
expect(mockTestAlertRule).toHaveBeenCalled();
});
@@ -136,7 +143,7 @@ describe('Footer', () => {
...mockAlertContextState,
isCreatingAlertRule: true,
});
render(<Footer />);
render(<WrappedFooter />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
@@ -152,7 +159,7 @@ describe('Footer', () => {
...mockAlertContextState,
isUpdatingAlertRule: true,
});
render(<Footer />);
render(<WrappedFooter />);
// Target the button elements directly instead of the text spans inside them
expect(
@@ -169,7 +176,7 @@ describe('Footer', () => {
...mockAlertContextState,
isTestingAlertRule: true,
});
render(<Footer />);
render(<WrappedFooter />);
// Target the button elements directly instead of the text spans inside them
expect(
@@ -189,7 +196,7 @@ describe('Footer', () => {
name: '',
},
});
render(<Footer />);
render(<WrappedFooter />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
@@ -217,7 +224,7 @@ describe('Footer', () => {
},
});
render(<Footer />);
render(<WrappedFooter />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
@@ -245,7 +252,7 @@ describe('Footer', () => {
},
});
render(<Footer />);
render(<WrappedFooter />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
@@ -261,7 +268,7 @@ describe('Footer', () => {
...mockAlertContextState,
isTestingAlertRule: true,
});
render(<Footer />);
render(<WrappedFooter />);
// When testing alert rule, the play icon is replaced with a loader icon
expect(
@@ -276,7 +283,7 @@ describe('Footer', () => {
...mockAlertContextState,
isUpdatingAlertRule: true,
});
render(<Footer />);
render(<WrappedFooter />);
// When updating alert rule, the check icon is replaced with a loader icon
expect(
@@ -291,7 +298,7 @@ describe('Footer', () => {
...mockAlertContextState,
isCreatingAlertRule: true,
});
render(<Footer />);
render(<WrappedFooter />);
// When creating alert rule, the check icon is replaced with a loader icon
expect(

View File

@@ -38,6 +38,7 @@ import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/map
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty, isEqual } from 'lodash-es';
import Tabs2 from 'periscope/components/Tabs2';
import { useAlertRuleOptional } from 'providers/Alert';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { AppState } from 'store/reducers';
@@ -92,7 +93,6 @@ const ALERT_SETUP_GUIDE_URLS: Record<AlertTypes, string> = {
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function FormAlertRules({
alertType,
formInstance,
@@ -160,6 +160,32 @@ function FormAlertRules({
const [alertDef, setAlertDef] = useState<AlertDef>(initialValue);
const [yAxisUnit, setYAxisUnit] = useState<string>(currentQuery.unit || '');
const alertRuleContext = useAlertRuleOptional();
const providerAlertName = alertRuleContext?.alertRuleName;
useEffect(() => {
if (providerAlertName) {
setAlertDef((prev) => {
if (prev.alert === providerAlertName) {
return prev;
}
return { ...prev, alert: providerAlertName };
});
formInstance.setFieldsValue({ alert: providerAlertName });
}
}, [providerAlertName, formInstance]);
// Wrap setAlertDef to sync alert name to provider when user types
const handleSetAlertDef = useCallback(
(newDef: AlertDef) => {
setAlertDef(newDef);
// Sync alert name change to provider for header display
if (newDef.alert !== alertDef.alert && alertRuleContext?.setAlertRuleName) {
alertRuleContext.setAlertRuleName(newDef.alert);
}
},
[alertDef.alert, alertRuleContext],
);
const alertTypeFromURL = urlQuery.get(QueryParams.ruleType);
const [detectionMethod, setDetectionMethod] = useState<string | null>(null);
@@ -680,7 +706,7 @@ function FormAlertRules({
const renderBasicInfo = (): JSX.Element => (
<BasicInfo
alertDef={alertDef}
setAlertDef={setAlertDef}
setAlertDef={handleSetAlertDef}
isNewRule={isNewRule}
/>
);

View File

@@ -111,7 +111,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
number: allAlertRules?.length,
layout: 'new',
});
safeNavigate(ROUTES.ALERT_TYPE_SELECTION, {
safeNavigate(ROUTES.ALERTS_NEW, {
newTab: isModifierKeyPressed(e),
});
},

View File

@@ -22,3 +22,9 @@
gap: 8px;
}
}
// FieldRenderer is used inside log/trace/metric detail drawers (z-index 1000).
// The design-system tooltip defaults to z-index 50 and would render behind them.
.field-renderer-tooltip-content {
--tooltip-z-index: 1000;
}

View File

@@ -1,4 +1,5 @@
import { Divider, Tooltip } from 'antd';
import { Divider } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { TagContainer, TagLabel, TagValue } from './FieldRenderer.styles';
@@ -7,6 +8,10 @@ import { getFieldAttributes } from './utils';
import './FieldRenderer.styles.scss';
const TOOLTIP_CONTENT_PROPS = {
className: 'field-renderer-tooltip-content',
};
function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
const { dataType, newField, logType } = getFieldAttributes(field);
@@ -14,11 +19,16 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
<span className="field-renderer-container">
{dataType && newField && logType ? (
<>
<Tooltip placement="left" title={newField} mouseLeaveDelay={0}>
<TooltipSimple
title={newField}
side="left"
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
arrow
>
<Typography.Text truncate={1} className="label">
{newField}{' '}
</Typography.Text>
</Tooltip>
</TooltipSimple>
<div className="tags">
<TagContainer>

View File

@@ -14,7 +14,7 @@ import { ILog } from 'types/api/logs/log';
import { ActionItemProps } from './ActionItem';
import TableView from './TableView';
import { removeEscapeCharacters } from './utils';
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
import './Overview.styles.scss';
@@ -112,7 +112,7 @@ function Overview({
children: (
<div className="logs-body-content">
<MEditor
value={removeEscapeCharacters(logData.body)}
value={removeEscapeCharacters(getBodyDisplayString(logData.body))}
language="json"
options={options}
onChange={(): void => {}}

View File

@@ -106,10 +106,20 @@ function TableView({
isListViewPanel,
]);
const flattenLogData: Record<string, string> | null = useMemo(
() => (logData ? flattenObject(logData) : null),
[logData],
);
// When USE_JSON_BODY is enabled, body arrives as a pre-parsed object. Serialize it
// back to a string so flattenObject keeps `body` as a single table row instead of
// recursively expanding it into dotted sub-keys (body.message, body.foo.bar, …),
// which would break the tree view in BodyContent that relies on record.field === 'body'.
const flattenLogData: Record<string, string> | null = useMemo(() => {
if (!logData) {
return null;
}
const normalizedLog =
typeof logData.body === 'object' && logData.body !== null
? { ...logData, body: JSON.stringify(logData.body) }
: logData;
return flattenObject(normalizedLog);
}, [logData]);
const handleClick = (
operator: string,

View File

@@ -10,7 +10,7 @@ const MAX_BODY_BYTES = 100 * 1024; // 100 KB
// Hook for async JSON processing
const useAsyncJSONProcessing = (
value: string,
value: string | Record<string, unknown>,
shouldProcess: boolean,
handleChangeSelectedView?: ChangeViewFunctionType,
): {
@@ -40,11 +40,17 @@ const useAsyncJSONProcessing = (
return (): void => {};
}
// Avoid processing if the json is too large
const byteSize = new Blob([value]).size;
if (byteSize > MAX_BODY_BYTES) {
return (): void => {};
}
// When value is already a parsed object skip the size check and JSON parsing
const parseBody = (): Record<string, unknown> | null => {
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
const byteSize = new Blob([value as string]).size;
if (byteSize > MAX_BODY_BYTES) {
return null;
}
return recursiveParseJSON(value as string);
};
processingRef.current = true;
setJsonState({ isLoading: true, treeData: null, error: null });
@@ -53,8 +59,8 @@ const useAsyncJSONProcessing = (
const processAsync = (): void => {
setTimeout(() => {
try {
const parsedBody = recursiveParseJSON(value);
if (!isEmpty(parsedBody)) {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,
@@ -82,8 +88,8 @@ const useAsyncJSONProcessing = (
// eslint-disable-next-line sonarjs/no-identical-functions
(): void => {
try {
const parsedBody = recursiveParseJSON(value);
if (!isEmpty(parsedBody)) {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,

View File

@@ -4,7 +4,11 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import dompurify from 'dompurify';
import { uniqueId } from 'lodash-es';
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
import {
ILog,
ILogAggregateAttributesResources,
ILogBody,
} from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
@@ -433,3 +437,8 @@ export const getSanitizedLogBody = (
return '{}';
}
};
// Returns a plain string for display contexts (Monaco editor, table cells, raw log row).
export function getBodyDisplayString(body: string | ILogBody): string {
return typeof body === 'string' ? body : JSON.stringify(body as ILogBody);
}

View File

@@ -1,8 +1,11 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Spin, Tooltip } from 'antd';
import { Button, Spin } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { useGetMetricHighlights } from 'api/generated/services/metrics';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { Info } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import { HighlightsProps } from './types';
@@ -11,6 +14,10 @@ import {
formatTimestampToReadableDate,
} from './utils';
const TOOLTIP_CONTENT_PROPS = {
className: 'metric-highlights-tooltip-content',
};
function Highlights({ metricName }: HighlightsProps): JSX.Element {
const {
data: metricHighlightsData,
@@ -39,6 +46,13 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
const lastReceivedText = formatTimestampToReadableDate(
metricHighlights?.lastReceived,
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const lastReceivedTooltipText = metricHighlights?.lastReceived
? `Last received on ${formatTimezoneAdjustedTimestamp(
metricHighlights.lastReceived,
DATE_TIME_FORMATS.DASH_DATETIME_UTC,
)}`
: 'No data received yet';
if (isErrorMetricHighlights) {
return (
@@ -90,27 +104,42 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
className="metric-details-grid-value"
data-testid="metric-highlights-data-points"
>
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
</Tooltip>
<TooltipSimple
title={metricHighlights?.dataPoints?.toLocaleString()}
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
arrow
>
<span>
{formatNumberIntoHumanReadableFormat(
metricHighlights?.dataPoints ?? 0,
)}
</span>
</TooltipSimple>
</Typography.Text>
<Typography.Text
className="metric-details-grid-value"
data-testid="metric-highlights-time-series-total"
>
<Tooltip
title="Active time series are those that have received data points in the last 1
hour."
placement="top"
<TooltipSimple
title="Active time series are those that have received data points in the last 1 hour."
side="top"
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
arrow
>
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
</Tooltip>
</TooltipSimple>
</Typography.Text>
<Typography.Text
className="metric-details-grid-value"
data-testid="metric-highlights-last-received"
>
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
<TooltipSimple
title={lastReceivedTooltipText}
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
arrow
>
<span>{lastReceivedText}</span>
</TooltipSimple>
</Typography.Text>
</>
)}

View File

@@ -510,6 +510,12 @@
color: var(--bg-robin-400) !important;
}
// The MetricDetails Drawer sits at z-index 1000; the design-system tooltip
// defaults to z-index 50 and would otherwise render behind the drawer.
.metric-highlights-tooltip-content {
--tooltip-z-index: 1000;
}
@keyframes fade-in-out {
0% {
opacity: 0;

View File

@@ -1,10 +1,22 @@
import { render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import TimezoneProvider from 'providers/Timezone';
import Highlights from '../Highlights';
import { formatTimestampToReadableDate } from '../utils';
import { getMockMetricHighlightsData, MOCK_METRIC_NAME } from './testUtlls';
function renderHighlights(metricName: string): ReturnType<typeof render> {
return render(
<TimezoneProvider>
<TooltipProvider>
<Highlights metricName={metricName} />
</TooltipProvider>
</TimezoneProvider>,
);
}
const useGetMetricHighlightsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricHighlights',
@@ -16,7 +28,7 @@ describe('Highlights', () => {
});
it('should render all highlights data correctly', () => {
render(<Highlights metricName={MOCK_METRIC_NAME} />);
renderHighlights(MOCK_METRIC_NAME);
const dataPoints = screen.getByTestId('metric-highlights-data-points');
const timeSeriesTotal = screen.getByTestId(
@@ -41,7 +53,7 @@ describe('Highlights', () => {
),
);
render(<Highlights metricName={MOCK_METRIC_NAME} />);
renderHighlights(MOCK_METRIC_NAME);
expect(
screen.getByTestId('metric-highlights-error-state'),
@@ -58,7 +70,7 @@ describe('Highlights', () => {
),
);
render(<Highlights metricName={MOCK_METRIC_NAME} />);
renderHighlights(MOCK_METRIC_NAME);
expect(screen.getByText('SAMPLES')).toBeInTheDocument();
expect(screen.getByText('TIME SERIES')).toBeInTheDocument();

View File

@@ -1,17 +1,5 @@
.new-explorer-cta {
display: flex;
.new-explorer-cta-with-badge {
display: inline-flex;
align-items: center;
color: var(--muted-foreground);
/* Bifrost (Ancient)/Content/sm */
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.ant-btn-icon {
margin-inline-end: 0px;
}
gap: 6px;
}

View File

@@ -1,9 +1,5 @@
import ROUTES from 'constants/routes';
export const RIBBON_STYLES = {
top: '-0.75rem',
};
export const buttonText: Record<string, string> = {
[ROUTES.LOGS_EXPLORER]: 'Old Explorer',
[ROUTES.TRACE]: 'New Explorer',

View File

@@ -1,12 +1,13 @@
import React, { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Badge, Button } from 'antd';
import { Button } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Undo } from '@signozhq/icons';
import { isModifierKeyPressed } from 'utils/app';
import { buttonText, RIBBON_STYLES } from './config';
import { buttonText } from './config';
import './NewExplorerCTA.styles.scss';
@@ -70,9 +71,12 @@ function NewExplorerCTA(): JSX.Element | null {
}
return (
<Badge.Ribbon style={RIBBON_STYLES} text="New">
<span className="new-explorer-cta-with-badge">
{button}
</Badge.Ribbon>
<Badge color="robin" variant="default">
New
</Badge>
</span>
);
}

View File

@@ -2,6 +2,7 @@ import { Expand } from '@signozhq/icons';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useTimezone } from 'providers/Timezone';
import { ILog } from 'types/api/logs/log';
@@ -26,7 +27,9 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
DATE_TIME_FORMATS.UTC_MONTH_SHORT,
)}
</div>
<div className="logs-preview-list-item-body">{log.body}</div>
<div className="logs-preview-list-item-body">
{getBodyDisplayString(log.body)}
</div>
<div
className="logs-preview-list-item-expand"
onClick={makeLogDetailsHandler(log)}

View File

@@ -33,14 +33,12 @@ function TopNav(): JSX.Element | null {
[location.pathname],
);
const isNewAlertsLandingPage = useMemo(
() =>
matchPath(location.pathname, { path: ROUTES.ALERTS_NEW, exact: true }) &&
!location.search,
[location.pathname, location.search],
const isAlertCreationPage = useMemo(
() => matchPath(location.pathname, { path: ROUTES.ALERTS_NEW, exact: true }),
[location.pathname],
);
if (isSignUpPage || isDisabled || isRouteToSkip || isNewAlertsLandingPage) {
if (isSignUpPage || isDisabled || isRouteToSkip || isAlertCreationPage) {
return null;
}

View File

@@ -41,7 +41,6 @@
}
.alert-details {
margin-top: 10px;
.divider {
border-color: var(--l1-border);
margin: 16px 0;

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Breadcrumb, Button, Divider } from 'antd';
import { Divider } from 'antd';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import { Filters } from 'components/AlertDetailsFilters/Filters';
import RouteTab from 'components/RouteTab';
import Spinner from 'components/Spinner';
@@ -10,13 +11,12 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useAlertRule } from 'providers/Alert';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
import { isModifierKeyPressed } from 'utils/app';
import AlertHeader from './AlertHeader/AlertHeader';
import AlertNotFound from './AlertNotFound';
@@ -24,42 +24,11 @@ import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
import './AlertDetails.styles.scss';
function BreadCrumbItem({
title,
isLast,
route,
}: {
title: string | null;
isLast?: boolean;
route?: string;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
if (isLast) {
return <div className="breadcrumb-item breadcrumb-item--last">{title}</div>;
}
const handleNavigate = (e: React.MouseEvent): void => {
if (!route) {
return;
}
safeNavigate(ROUTES.LIST_ALL_ALERT, { newTab: isModifierKeyPressed(e) });
};
return (
<Button type="text" className="breadcrumb-item" onClick={handleNavigate}>
{title}
</Button>
);
}
BreadCrumbItem.defaultProps = {
isLast: false,
route: '',
};
function AlertDetails(): JSX.Element {
const { pathname } = useLocation();
const { routes } = useRouteTabUtils();
const params = useUrlQuery();
const { alertRuleName } = useAlertRule();
const { isLoading, isError, ruleId, isValidRuleId, alertDetailsResponse } =
useGetAlertRuleDetails();
@@ -69,7 +38,7 @@ function AlertDetails(): JSX.Element {
}, [params]);
const getDocumentTitle = useMemo(() => {
const alertTitle = alertDetailsResponse?.data?.alert;
const alertTitle = alertRuleName ?? alertDetailsResponse?.data?.alert;
if (alertTitle) {
return alertTitle;
}
@@ -80,7 +49,7 @@ function AlertDetails(): JSX.Element {
return document.title;
}
return 'Alert Not Found';
}, [alertDetailsResponse?.data?.alert, isTestAlert, isLoading]);
}, [alertRuleName, alertDetailsResponse?.data?.alert, isTestAlert, isLoading]);
useEffect(() => {
document.title = getDocumentTitle;
@@ -126,20 +95,13 @@ function AlertDetails(): JSX.Element {
<div
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
>
<Breadcrumb
<AlertBreadcrumb
className="alert-details__breadcrumb"
items={[
{
title: (
<BreadCrumbItem title="Alert Rules" route={ROUTES.LIST_ALL_ALERT} />
),
},
{
title: <BreadCrumbItem title={ruleId} isLast />,
},
{ title: 'Alert Rules', route: ROUTES.LIST_ALL_ALERT },
{ title: ruleId, isLast: true },
]}
/>
<Divider className="divider breadcrumb-divider" />
{alertRuleDetails && <AlertHeader alertDetails={alertRuleDetails} />}
<Divider className="divider" />

View File

@@ -33,13 +33,12 @@ const menuItemStyleV2: CSSProperties = {
function AlertActionButtons({
ruleId,
alertDetails,
setUpdatedName,
}: {
ruleId: string;
alertDetails: AlertHeaderProps['alertDetails'];
setUpdatedName: (name: string) => void;
}): JSX.Element {
const { alertRuleState, setAlertRuleState } = useAlertRule();
const { alertRuleState, setAlertRuleState, alertRuleName, setAlertRuleName } =
useAlertRule();
const [intermediateName, setIntermediateName] = useState<string>(
alertDetails.alert,
);
@@ -53,7 +52,7 @@ function AlertActionButtons({
const { handleAlertDelete } = useAlertRuleDelete({ ruleId });
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
alertDetails: alertDetails as unknown as AlertDef,
setUpdatedName,
setAlertRuleName,
intermediateName,
});
@@ -113,6 +112,12 @@ function AlertActionButtons({
}
}, [setAlertRuleState, alertRuleState, alertDetails.state]);
useEffect(() => {
if (alertRuleName !== undefined) {
setIntermediateName(alertRuleName);
}
}, [alertRuleName]);
// on unmount remove the alert state
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => (): void => setAlertRuleState(undefined), []);

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo } from 'react';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import CreateAlertV2Header from 'container/CreateAlertV2/CreateAlertHeader';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
@@ -20,8 +20,17 @@ export type AlertHeaderProps = {
};
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const { state, alert: alertName, labels } = alertDetails;
const { alertRuleState } = useAlertRule();
const [updatedName, setUpdatedName] = useState(alertName);
const { alertRuleState, alertRuleName, setAlertRuleName } = useAlertRule();
useEffect(() => {
if (alertRuleName === undefined && alertName) {
setAlertRuleName(alertName);
}
}, [alertRuleName, alertName, setAlertRuleName]);
useEffect(() => (): void => setAlertRuleName(undefined), [setAlertRuleName]);
const displayName = alertRuleName ?? alertName;
const labelsWithoutSeverity = useMemo(() => {
if (labels) {
@@ -40,7 +49,7 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
<div className="alert-title-wrapper">
<AlertState state={alertRuleState ?? state ?? ''} />
<div className="alert-title">
<LineClampedText text={updatedName || alertName} />
<LineClampedText text={displayName || ''} />
</div>
</div>
</div>
@@ -64,7 +73,6 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
<AlertActionButtons
alertDetails={alertDetails}
ruleId={alertDetails?.id || ''}
setUpdatedName={setUpdatedName}
/>
</div>
</div>

View File

@@ -12,7 +12,9 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
getGetRuleByIDQueryKey,
invalidateGetRuleByID,
invalidateListRules,
updateRuleByID,
useGetRuleByID,
useListRules,
@@ -490,11 +492,11 @@ export const useAlertRuleDuplicate = ({
};
export const useAlertRuleUpdate = ({
alertDetails,
setUpdatedName,
setAlertRuleName,
intermediateName,
}: {
alertDetails: AlertDef;
setUpdatedName: (name: string) => void;
setAlertRuleName: (name: string | undefined) => void;
intermediateName: string;
}): {
handleAlertUpdate: () => void;
@@ -502,17 +504,29 @@ export const useAlertRuleUpdate = ({
} => {
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const queryClient = useQueryClient();
const { mutate: updateAlertRule, isLoading } = useMutation(
[REACT_QUERY_KEY.UPDATE_ALERT_RULE, alertDetails.id],
(args: { data: AlertDef; id: string }) =>
updateRuleByID({ id: args.id }, toPostableRuleDTOFromAlertDef(args.data)),
{
onMutate: () => setUpdatedName(intermediateName),
onSuccess: () =>
notifications.success({ message: 'Alert renamed successfully' }),
onMutate: () => setAlertRuleName(intermediateName),
onSuccess: () => {
const ruleId = alertDetails.id || '';
const ruleQueryKey = getGetRuleByIDQueryKey({ id: ruleId });
const existingRule = queryClient.getQueryData<GetRuleByID200>(ruleQueryKey);
if (existingRule) {
queryClient.setQueryData<GetRuleByID200>(ruleQueryKey, {
...existingRule,
data: { ...existingRule.data, alert: intermediateName },
});
}
void invalidateListRules(queryClient);
notifications.success({ message: 'Alert renamed successfully' });
},
onError: (error) => {
setUpdatedName(alertDetails.alert);
setAlertRuleName(alertDetails.alert);
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
@@ -551,7 +565,6 @@ export const useAlertRuleDelete = ({
history.push(ROUTES.LIST_ALL_ALERT);
},
// eslint-disable-next-line sonarjs/no-identical-functions
onError: (error) =>
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,

View File

@@ -1,15 +1,44 @@
.alerts-container {
.ant-tabs-nav {
padding: 0 8px;
.top-level-tab.periscope-tab {
padding: 2px 0;
}
.ant-tabs {
&-nav {
padding: 0 8px;
margin-bottom: 0 !important;
&::before {
border-bottom: 1px solid var(--l1-border) !important;
}
}
&-tab {
&[data-node-key='TriggeredAlerts'] {
margin-left: 16px;
}
&:not(:first-of-type) {
margin-left: 24px !important;
}
[aria-selected='false'] {
.periscope-tab {
color: var(--l2-foreground);
}
}
}
}
.configuration-tabs {
margin-top: -16px;
.ant-tabs-nav {
.ant-tabs-nav-wrap {
padding: 0 8px;
}
}
}
.alert-rules-container {
margin-top: 10px;
}
}

View File

@@ -1,56 +0,0 @@
import { useCallback, useEffect } from 'react';
import { Row } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import SelectAlertType from 'container/CreateAlertRule/SelectAlertType';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { AlertTypes } from 'types/api/alerts/alertTypes';
function AlertTypeSelectionPage(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const queryParams = useUrlQuery();
useEffect(() => {
logEvent('Alert: New alert data source selection page visited', {});
}, []);
const handleSelectType = useCallback(
(type: AlertTypes, newTab?: boolean): void => {
// For anamoly based alert, we need to set the ruleType to anomaly_rule
// and alertType to metrics_based_alert
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
queryParams.set(
QueryParams.ruleType,
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
);
queryParams.set(QueryParams.alertType, AlertTypes.METRICS_BASED_ALERT);
// For other alerts, we need to set the ruleType to threshold_rule
// and alertType to the selected type
} else {
queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT);
queryParams.set(QueryParams.alertType, type);
}
const showClassicCreateAlertsPageFlag = queryParams.get(
QueryParams.showClassicCreateAlertsPage,
);
if (showClassicCreateAlertsPageFlag === 'true') {
queryParams.set(QueryParams.showClassicCreateAlertsPage, 'true');
}
safeNavigate(`${ROUTES.ALERTS_NEW}?${queryParams.toString()}`, { newTab });
},
[queryParams, safeNavigate],
);
return (
<Row wrap={false}>
<SelectAlertType onSelect={handleSelectType} />
</Row>
);
}
export default AlertTypeSelectionPage;

View File

@@ -1,189 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as navigateHooks from 'hooks/useSafeNavigate';
import * as useUrlQueryHooks from 'hooks/useUrlQuery';
import * as appHooks from 'providers/App/App';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import AlertTypeSelection from '../AlertTypeSelection';
const useUrlQuerySpy = jest.spyOn(useUrlQueryHooks, 'default');
const useSafeNavigateSpy = jest.spyOn(navigateHooks, 'useSafeNavigate');
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const mockSetUrlQuery = jest.fn();
const mockSafeNavigate = jest.fn();
const mockToString = jest.fn();
const mockGetUrlQuery = jest.fn();
describe('AlertTypeSelection', () => {
beforeEach(() => {
jest.clearAllMocks();
useAppContextSpy.mockReturnValue(getAppContextMockState());
useUrlQuerySpy.mockReturnValue({
set: mockSetUrlQuery,
toString: mockToString,
get: mockGetUrlQuery,
} as Partial<URLSearchParams> as URLSearchParams);
useSafeNavigateSpy.mockReturnValue({
safeNavigate: mockSafeNavigate,
});
});
it('should render all alert type options when anomaly detection is enabled', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<AlertTypeSelection />);
expect(screen.getByText('metric_based_alert')).toBeInTheDocument();
expect(screen.getByText('log_based_alert')).toBeInTheDocument();
expect(screen.getByText('traces_based_alert')).toBeInTheDocument();
expect(screen.getByText('exceptions_based_alert')).toBeInTheDocument();
expect(screen.getByText('anomaly_based_alert')).toBeInTheDocument();
});
it('should render all alert type options except anomaly based alert when anomaly detection is disabled', () => {
render(<AlertTypeSelection />);
expect(screen.getByText('metric_based_alert')).toBeInTheDocument();
expect(screen.getByText('log_based_alert')).toBeInTheDocument();
expect(screen.getByText('traces_based_alert')).toBeInTheDocument();
expect(screen.getByText('exceptions_based_alert')).toBeInTheDocument();
expect(screen.queryByText('anomaly_based_alert')).not.toBeInTheDocument();
});
it('should navigate to metrics based alert with correct params', () => {
render(<AlertTypeSelection />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledTimes(2);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
AlertDetectionTypes.THRESHOLD_ALERT,
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate to anomaly based alert with correct params', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<AlertTypeSelection />);
fireEvent.click(screen.getByText('anomaly_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledTimes(2);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate to log based alert with correct params', () => {
render(<AlertTypeSelection />);
fireEvent.click(screen.getByText('log_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledTimes(2);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
AlertDetectionTypes.THRESHOLD_ALERT,
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.LOGS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate to traces based alert with correct params', () => {
render(<AlertTypeSelection />);
fireEvent.click(screen.getByText('traces_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledTimes(2);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
AlertDetectionTypes.THRESHOLD_ALERT,
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.TRACES_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate to exceptions based alert with correct params', () => {
render(<AlertTypeSelection />);
fireEvent.click(screen.getByText('exceptions_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledTimes(2);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
AlertDetectionTypes.THRESHOLD_ALERT,
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.EXCEPTIONS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate to classic create alerts page with correct params if showClassicCreateAlertsPage is true', () => {
useUrlQuerySpy.mockReturnValue({
set: mockSetUrlQuery,
toString: mockToString,
get: mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
return null;
}),
} as Partial<URLSearchParams> as URLSearchParams);
render(<AlertTypeSelection />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledTimes(3);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
AlertDetectionTypes.THRESHOLD_ALERT,
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.showClassicCreateAlertsPage,
'true',
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
});

View File

@@ -1,3 +0,0 @@
import AlertTypeSelectionPage from './AlertTypeSelection';
export default AlertTypeSelectionPage;

View File

@@ -526,7 +526,7 @@ function SpanDetailsPanel({
const PANEL_WIDTH = 500;
const PANEL_MARGIN_RIGHT = 20;
const PANEL_MARGIN_TOP = 25;
const PANEL_MARGIN_TOP = 50;
const PANEL_MARGIN_BOTTOM = 25;
const content = (

View File

@@ -580,10 +580,9 @@ function Success(props: ISuccessProps): JSX.Element {
}
return next;
});
return;
}
// Backend mode: trigger API call (current behavior)
// keeping this for both mode to support scroll to view to function well.
// interestedspan would not make api call in frontend mode so it is safe to use for both mode.
// Backend mode: trigger refetch via interestedSpanId
setInterestedSpanId({
spanId,
isUncollapsed: !collapse,
@@ -782,19 +781,26 @@ function Success(props: ISuccessProps): JSX.Element {
[],
);
// Backend mode: scroll + select to the interestedSpanId target. `spans` in
// deps so we retry once a refetch lands (chevron / pagination / deep-link).
useEffect(() => {
if (interestedSpanId.spanId !== '') {
const idx = spans.findIndex(
(span) => span.span_id === interestedSpanId.spanId,
);
if (idx !== -1) {
scrollSpanIntoView(spans[idx], spans);
setSelectedSpan(spans[idx]);
}
} else {
setSelectedSpan((prev) => prev ?? spans[0]);
if (isFullDataLoaded || interestedSpanId.spanId === '') {
return;
}
}, [interestedSpanId, setSelectedSpan, spans, scrollSpanIntoView]);
const idx = spans.findIndex(
(span) => span.span_id === interestedSpanId.spanId,
);
if (idx !== -1) {
scrollSpanIntoView(spans[idx], spans);
setSelectedSpan(spans[idx]);
}
}, [
interestedSpanId,
setSelectedSpan,
spans,
scrollSpanIntoView,
isFullDataLoaded,
]);
// Covers URL-driven navigation to an already-loaded span (flamegraph /
// filter / browser back) that the interestedSpanId-keyed effect doesn't see.

View File

@@ -199,10 +199,12 @@ const mockSpans = [
createMockSpan('span-3', 1),
];
// Shared TestComponent for all tests
// Shared TestComponent for all tests. Default selectedSpan to the root mirrors
// what TraceDetailsV3's deep-link one-shot effect does when there's no spanId
// in the URL — Success no longer owns that default itself.
function TestComponent(): JSX.Element {
const [selectedSpan, setSelectedSpan] = React.useState<SpanV3 | undefined>(
undefined,
mockSpans[0],
);
return (

View File

@@ -75,6 +75,7 @@ function TraceDetailsV3(): JSX.Element {
});
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.
@@ -175,12 +176,36 @@ function TraceDetailsV3(): JSX.Element {
}
}, [traceData, isFullDataLoaded]);
// Frontend mode: auto-expand ancestors of the selected span so it becomes visible
// Tracks whether we've already done the initial URL→selectedSpan handoff
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
useEffect(() => {
if (!isFullDataLoaded || !interestedSpanId.spanId || allSpans.length === 0) {
if (deepLinkResolvedRef.current) {
return;
}
const ancestors = getAncestorSpanIds(allSpans, interestedSpanId.spanId);
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;
}
@@ -203,7 +228,7 @@ function TraceDetailsV3(): JSX.Element {
}
return next;
});
}, [isFullDataLoaded, interestedSpanId.spanId, allSpans]);
}, [isFullDataLoaded, selectedSpanId, allSpans]);
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
@@ -217,7 +242,7 @@ function TraceDetailsV3(): JSX.Element {
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED,
) as SpanDetailVariant) || SpanDetailVariant.DIALOG,
);
const handleVariantChange = useCallback(

View File

@@ -9,6 +9,8 @@ import React, {
interface AlertRuleContextType {
alertRuleState: string | undefined;
setAlertRuleState: React.Dispatch<React.SetStateAction<string | undefined>>;
alertRuleName: string | undefined;
setAlertRuleName: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const AlertRuleContext = createContext<AlertRuleContextType | undefined>(
@@ -23,13 +25,18 @@ function AlertRuleProvider({
const [alertRuleState, setAlertRuleState] = useState<string | undefined>(
undefined,
);
const [alertRuleName, setAlertRuleName] = useState<string | undefined>(
undefined,
);
const value = React.useMemo(
() => ({
alertRuleState,
setAlertRuleState,
alertRuleName,
setAlertRuleName,
}),
[alertRuleState],
[alertRuleState, alertRuleName],
);
return (
@@ -47,4 +54,7 @@ export const useAlertRule = (): AlertRuleContextType => {
return context;
};
export const useAlertRuleOptional = (): AlertRuleContextType | undefined =>
useContext(AlertRuleContext);
export default AlertRuleProvider;

View File

@@ -1,3 +1,8 @@
export interface ILogBody {
message?: string | null;
[key: string]: unknown;
}
export interface ILog {
date: string;
timestamp: number | string;
@@ -8,7 +13,7 @@ export interface ILog {
traceFlags: number;
severityText: string;
severityNumber: number;
body: string;
body: string | ILogBody;
resources_string: Record<string, never>;
scope_string: Record<string, never>;
attributesString: Record<string, never>;

View File

@@ -132,7 +132,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
METER: ['ADMIN', 'EDITOR', 'VIEWER'],
METER_EXPLORER_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
PUBLIC_DASHBOARD: ['ADMIN', 'EDITOR', 'VIEWER'],
ALERT_TYPE_SELECTION: ['ADMIN', 'EDITOR'],
AI_ASSISTANT: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],

2
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.3
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -112,7 +113,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect

View File

@@ -38,7 +38,7 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
}
func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postableDashboard dashboardtypes.PostableDashboard) (*dashboardtypes.Dashboard, error) {
dashboard, err := dashboardtypes.NewDashboard(orgID, createdBy, postableDashboard)
dashboard, err := dashboardtypes.NewDashboard(orgID, createdBy, dashboardtypes.SourceUser, postableDashboard)
if err != nil {
return nil, err
}
@@ -72,7 +72,16 @@ func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboard
return nil, err
}
return dashboardtypes.NewDashboardsFromStorableDashboards(storableDashboards), nil
// system dashboards are hidden from the listing endpoint but still gettable by id.
filtered := make([]*dashboardtypes.StorableDashboard, 0, len(storableDashboards))
for _, storable := range storableDashboards {
if storable.Source == dashboardtypes.SourceSystem {
continue
}
filtered = append(filtered, storable)
}
return dashboardtypes.NewDashboardsFromStorableDashboards(filtered), nil
}
func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatableDashboard dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error) {
@@ -81,6 +90,10 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
return nil, err
}
if err := dashboard.ErrIfNotMutable(); err != nil {
return nil, err
}
err = dashboard.Update(ctx, updatableDashboard, updatedBy, diff)
if err != nil {
return nil, err
@@ -105,6 +118,10 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
return err
}
if err := dashboard.ErrIfNotLockable(); err != nil {
return err
}
err = dashboard.LockUnlock(lock, isAdmin, updatedBy)
if err != nil {
return err
@@ -128,6 +145,10 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
return err
}
if err := dashboard.ErrIfNotDeletable(); err != nil {
return err
}
if dashboard.Locked {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "dashboard is locked, please unlock the dashboard to be delete it")
}

View File

@@ -231,7 +231,7 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
return nil, err
}
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req, pageGroups)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -314,19 +314,12 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
return nil, err
}
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req, pageGroups)
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
// Reuse the pods phase-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostablePods.
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -409,14 +402,7 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
return nil, err
}
// Reuse the pods phase-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostablePods.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -498,27 +484,14 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
return nil, err
}
// Reuse the nodes condition-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostableNodes. With default groupBy
// [k8s.cluster.name], counts are bucketed per cluster; with a custom groupBy,
// they aggregate across clusters in that group.
nodeConditionCountsMap, err := m.getPerGroupNodeConditionCounts(ctx, &inframonitoringtypes.PostableNodes{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
// With default groupBy [k8s.cluster.name], counts are bucketed per cluster;
// with a custom groupBy, they aggregate across clusters in that group.
nodeConditionCountsMap, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
// Same pattern for pod phase counts via PostablePods shim.
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -689,14 +662,7 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
return nil, err
}
// Reuse the pods phase-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostablePods.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -784,16 +750,9 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
return nil, err
}
// Reuse the pods phase-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostablePods. Pods owned by a StatefulSet carry
// k8s.statefulset.name as a resource attribute, so default-groupBy gives
// per-statefulset phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
// Pods owned by a StatefulSet carry k8s.statefulset.name as a resource attribute,
// so default-groupBy gives per-statefulset phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -881,16 +840,9 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
return nil, err
}
// Reuse the pods phase-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostablePods. Pods owned by a Job carry
// k8s.job.name as a resource attribute, so default-groupBy gives
// per-job phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
// Pods owned by a Job carry k8s.job.name as a resource attribute, so default-groupBy
// gives per-job phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -978,16 +930,9 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
return nil, err
}
// Reuse the pods phase-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostablePods. Pods owned by a DaemonSet carry
// k8s.daemonset.name as a resource attribute, so default-groupBy gives
// per-daemonset phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
// Pods owned by a DaemonSet carry k8s.daemonset.name as a resource attribute,
// so default-groupBy gives per-daemonset phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}

View File

@@ -170,27 +170,29 @@ func (m *module) getNodesTableMetadata(ctx context.Context, req *inframonitoring
// Groups absent from the result map have implicit zero counts (caller default).
func (m *module) getPerGroupNodeConditionCounts(
ctx context.Context,
req *inframonitoringtypes.PostableNodes,
start, end int64,
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
) (map[string]nodeConditionCounts, error) {
if len(pageGroups) == 0 || len(req.GroupBy) == 0 {
if len(pageGroups) == 0 || len(groupBy) == 0 {
return map[string]nodeConditionCounts{}, nil
}
// Merged filter expression (user filter + page-groups IN clauses).
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
// Merge user filter with page-groups IN clauses.
userFilterExpr := ""
if filter != nil {
userFilterExpr = filter.Expression
}
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, pageGroupsFilterExpr)
// Resolve tables. Same convention as pods.
adjustedStart, adjustedEnd, _, localTimeSeriesTable := telemetrymetrics.WhichTSTableToUse(
uint64(req.Start), uint64(req.End), nil,
uint64(start), uint64(end), nil,
)
samplesTable := telemetrymetrics.WhichSamplesTableToUse(
uint64(req.Start), uint64(req.End),
uint64(start), uint64(end),
metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil,
)
valueCol := telemetrymetrics.ValueColumnForSamplesTable(samplesTable)
@@ -201,7 +203,7 @@ func (m *module) getPerGroupNodeConditionCounts(
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS node_name", timeSeriesFPs.Var(nodeNameAttrKey)),
}
for _, key := range req.GroupBy {
for _, key := range groupBy {
timeSeriesFPsSelectCols = append(timeSeriesFPsSelectCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", timeSeriesFPs.Var(key.Name), quoteIdentifier(key.Name)),
)
@@ -213,8 +215,8 @@ func (m *module) getPerGroupNodeConditionCounts(
timeSeriesFPs.GE("unix_milli", adjustedStart),
timeSeriesFPs.L("unix_milli", adjustedEnd),
)
if filterExpr != "" {
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
if mergedFilterExpr != "" {
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
return nil, err
}
@@ -223,7 +225,7 @@ func (m *module) getPerGroupNodeConditionCounts(
}
}
timeSeriesFPsGroupBy := []string{"fingerprint", "node_name"}
for _, key := range req.GroupBy {
for _, key := range groupBy {
timeSeriesFPsGroupBy = append(timeSeriesFPsGroupBy, quoteIdentifier(key.Name))
}
timeSeriesFPs.GroupBy(timeSeriesFPsGroupBy...)
@@ -233,7 +235,7 @@ func (m *module) getPerGroupNodeConditionCounts(
latestConditionPerNode := sqlbuilder.NewSelectBuilder()
latestConditionPerNodeSelectCols := []string{"tsfp.node_name AS node_name"}
latestConditionPerNodeGroupBy := []string{"node_name"}
for _, key := range req.GroupBy {
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
latestConditionPerNodeSelectCols = append(latestConditionPerNodeSelectCols, fmt.Sprintf("tsfp.%s AS %s", col, col))
latestConditionPerNodeGroupBy = append(latestConditionPerNodeGroupBy, col)
@@ -248,17 +250,17 @@ func (m *module) getPerGroupNodeConditionCounts(
))
latestConditionPerNode.Where(
latestConditionPerNode.E("samples.metric_name", nodeConditionMetricName),
latestConditionPerNode.GE("samples.unix_milli", req.Start),
latestConditionPerNode.L("samples.unix_milli", req.End),
latestConditionPerNode.GE("samples.unix_milli", start),
latestConditionPerNode.L("samples.unix_milli", end),
"tsfp.node_name != ''",
)
latestConditionPerNode.GroupBy(latestConditionPerNodeGroupBy...)
latestConditionPerNodeSQL, latestConditionPerNodeArgs := latestConditionPerNode.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- countNodesPerCondition (outer SELECT) -----
countNodesPerConditionSelectCols := make([]string, 0, len(req.GroupBy)+2)
countNodesPerConditionGroupBy := make([]string, 0, len(req.GroupBy))
for _, key := range req.GroupBy {
countNodesPerConditionSelectCols := make([]string, 0, len(groupBy)+2)
countNodesPerConditionGroupBy := make([]string, 0, len(groupBy))
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
countNodesPerConditionSelectCols = append(countNodesPerConditionSelectCols, col)
countNodesPerConditionGroupBy = append(countNodesPerConditionGroupBy, col)
@@ -289,8 +291,8 @@ func (m *module) getPerGroupNodeConditionCounts(
result := make(map[string]nodeConditionCounts)
for rows.Next() {
groupVals := make([]string, len(req.GroupBy))
scanPtrs := make([]any, 0, len(req.GroupBy)+2)
groupVals := make([]string, len(groupBy))
scanPtrs := make([]any, 0, len(groupBy)+2)
for i := range groupVals {
scanPtrs = append(scanPtrs, &groupVals[i])
}

View File

@@ -189,27 +189,29 @@ func (m *module) getPodsTableMetadata(ctx context.Context, req *inframonitoringt
// Groups absent from the result map have implicit zero counts (caller default).
func (m *module) getPerGroupPodPhaseCounts(
ctx context.Context,
req *inframonitoringtypes.PostablePods,
start, end int64,
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
) (map[string]podPhaseCounts, error) {
if len(pageGroups) == 0 || len(req.GroupBy) == 0 {
if len(pageGroups) == 0 || len(groupBy) == 0 {
return map[string]podPhaseCounts{}, nil
}
// Merged filter expression (user filter + page-groups IN clauses).
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
// Merge user filter with page-groups IN clauses.
userFilterExpr := ""
if filter != nil {
userFilterExpr = filter.Expression
}
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, pageGroupsFilterExpr)
// Resolve tables. Same convention as hosts (distributed names from helpers).
adjustedStart, adjustedEnd, _, localTimeSeriesTable := telemetrymetrics.WhichTSTableToUse(
uint64(req.Start), uint64(req.End), nil,
uint64(start), uint64(end), nil,
)
samplesTable := telemetrymetrics.WhichSamplesTableToUse(
uint64(req.Start), uint64(req.End),
uint64(start), uint64(end),
metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil,
)
valueCol := telemetrymetrics.ValueColumnForSamplesTable(samplesTable)
@@ -220,7 +222,7 @@ func (m *module) getPerGroupPodPhaseCounts(
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", timeSeriesFPs.Var(podUIDAttrKey)),
}
for _, key := range req.GroupBy {
for _, key := range groupBy {
timeSeriesFPsSelectCols = append(timeSeriesFPsSelectCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", timeSeriesFPs.Var(key.Name), quoteIdentifier(key.Name)),
)
@@ -232,8 +234,8 @@ func (m *module) getPerGroupPodPhaseCounts(
timeSeriesFPs.GE("unix_milli", adjustedStart),
timeSeriesFPs.L("unix_milli", adjustedEnd),
)
if filterExpr != "" {
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
if mergedFilterExpr != "" {
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
return nil, err
}
@@ -242,7 +244,7 @@ func (m *module) getPerGroupPodPhaseCounts(
}
}
timeSeriesFPsGroupBy := []string{"fingerprint", "pod_uid"}
for _, key := range req.GroupBy {
for _, key := range groupBy {
timeSeriesFPsGroupBy = append(timeSeriesFPsGroupBy, quoteIdentifier(key.Name))
}
timeSeriesFPs.GroupBy(timeSeriesFPsGroupBy...)
@@ -251,7 +253,7 @@ func (m *module) getPerGroupPodPhaseCounts(
latestPhasePerPod := sqlbuilder.NewSelectBuilder()
latestPhasePerPodSelectCols := []string{"tsfp.pod_uid AS pod_uid"}
latestPhasePerPodGroupBy := []string{"pod_uid"}
for _, key := range req.GroupBy {
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
latestPhasePerPodSelectCols = append(latestPhasePerPodSelectCols, fmt.Sprintf("tsfp.%s AS %s", col, col))
latestPhasePerPodGroupBy = append(latestPhasePerPodGroupBy, col)
@@ -266,17 +268,17 @@ func (m *module) getPerGroupPodPhaseCounts(
))
latestPhasePerPod.Where(
latestPhasePerPod.E("samples.metric_name", podPhaseMetricName),
latestPhasePerPod.GE("samples.unix_milli", req.Start),
latestPhasePerPod.L("samples.unix_milli", req.End),
latestPhasePerPod.GE("samples.unix_milli", start),
latestPhasePerPod.L("samples.unix_milli", end),
"tsfp.pod_uid != ''",
)
latestPhasePerPod.GroupBy(latestPhasePerPodGroupBy...)
latestPhasePerPodSQL, latestPhasePerPodArgs := latestPhasePerPod.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- countPodsPerPhase (outer SELECT) -----
countPodsPerPhaseSelectCols := make([]string, 0, len(req.GroupBy)+5)
countPodsPerPhaseGroupBy := make([]string, 0, len(req.GroupBy))
for _, key := range req.GroupBy {
countPodsPerPhaseSelectCols := make([]string, 0, len(groupBy)+5)
countPodsPerPhaseGroupBy := make([]string, 0, len(groupBy))
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
countPodsPerPhaseSelectCols = append(countPodsPerPhaseSelectCols, col)
countPodsPerPhaseGroupBy = append(countPodsPerPhaseGroupBy, col)
@@ -310,8 +312,8 @@ func (m *module) getPerGroupPodPhaseCounts(
result := make(map[string]podPhaseCounts)
for rows.Next() {
groupVals := make([]string, len(req.GroupBy))
scanPtrs := make([]any, 0, len(req.GroupBy)+5)
groupVals := make([]string, len(groupBy))
scanPtrs := make([]any, 0, len(groupBy)+5)
for i := range groupVals {
scanPtrs = append(scanPtrs, &groupVals[i])
}

View File

@@ -199,7 +199,16 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return q.executeWindowList(ctx)
}
stmt, err := q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
fromMS, toMS := q.fromMS, q.toMS
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
fromMS, toMS, overlap = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
return emptyResultFor(q.kind, q.spec.Name), nil
}
}
stmt, err := q.stmtBuilder.Build(ctx, fromMS, toMS, q.kind, q.spec, q.variables)
if err != nil {
return nil, err
}
@@ -215,6 +224,81 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return result, nil
}
// narrowWindowByTraceID inspects the filter for trace_id predicates and clamps
// [fromMS,toMS] to the time range stored in signoz_traces.distributed_trace_summary.
// Returns the (possibly narrowed) window and overlap=false when the trace lies
// completely outside the query window — callers should short-circuit in that case.
//
// When the trace_id is not present in trace_summary the behaviour differs by
// signal:
// - traces: trace_summary is derived from the spans table, so a missing row
// means no spans exist for that trace_id; we short-circuit to empty.
// - logs: logs can carry a trace_id even when traces are not ingested at all
// (e.g. traces disabled). We must not short-circuit; instead leave the
// window untouched and let the query run.
func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toMS uint64) (uint64, uint64, bool) {
if q.spec.Filter == nil || q.spec.Filter.Expression == "" {
return fromMS, toMS, true
}
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if !found || len(traceIDs) == 0 {
return fromMS, toMS, true
}
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
if !ok {
if q.spec.Signal == telemetrytypes.SignalTraces {
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; short-circuiting traces query to empty",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, false
}
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; leaving time range untouched for logs",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, true
}
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if traceStartMS == 0 || traceEndMS == 0 {
return fromMS, toMS, true
}
if traceStartMS > toMS || traceEndMS < fromMS {
return fromMS, toMS, false
}
if traceStartMS > fromMS {
fromMS = traceStartMS
}
if traceEndMS < toMS {
toMS = traceEndMS
}
q.logger.DebugContext(ctx, "optimized time range using trace_id lookup",
slog.String("signal", q.spec.Signal.StringValue()),
slog.Any("trace_ids", traceIDs),
slog.Uint64("start", fromMS),
slog.Uint64("end", toMS))
return fromMS, toMS, true
}
// emptyResultFor returns an empty result payload appropriate for the given kind.
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
var value any
switch kind {
case qbtypes.RequestTypeTimeSeries:
value = &qbtypes.TimeSeriesData{QueryName: queryName}
case qbtypes.RequestTypeScalar:
value = &qbtypes.ScalarData{QueryName: queryName}
default:
value = &qbtypes.RawData{QueryName: queryName}
}
return &qbtypes.Result{
Type: kind,
Value: value,
}
}
// executeWithContext executes the query with query window and step context for partial value detection.
func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
@@ -310,42 +394,22 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
totalBytes := uint64(0)
start := time.Now()
// Check if filter contains trace_id(s) and optimize time range if needed
if q.spec.Signal == telemetrytypes.SignalTraces &&
q.spec.Filter != nil && q.spec.Filter.Expression != "" {
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if found && len(traceIDs) > 0 {
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if !ok {
q.logger.DebugContext(ctx, "failed to get trace time range", slog.Any("trace_ids", traceIDs))
} else if traceStartMS > 0 && traceEndMS > 0 {
// no overlap — nothing to return
if uint64(traceStartMS) > toMS || uint64(traceEndMS) < fromMS {
return &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}, nil
}
// clamp window to trace time range before bucketing
if uint64(traceStartMS) > fromMS {
fromMS = uint64(traceStartMS)
}
if uint64(traceEndMS) < toMS {
toMS = uint64(traceEndMS)
}
q.logger.DebugContext(ctx, "optimized time range for traces", slog.Any("trace_ids", traceIDs), slog.Uint64("start", fromMS), slog.Uint64("end", toMS))
}
// Check if filter contains trace_id(s) and optimize time range if needed.
// Applies to both traces (the listing this branch was built for) and logs
// (which carry trace_id and benefit from the same clamp before bucketing).
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
fromMS, toMS, overlap = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
return &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}, nil
}
}

View File

@@ -12,8 +12,10 @@ import (
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
)
var (
@@ -22,6 +24,8 @@ var (
// written clickhouse query. The column alias indcate which value is
// to be considered as final result (or target).
legacyReservedColumnTargetAliases = []string{"__result", "__value", "result", "res", "value"}
CodeFailUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
)
// consume reads every row and shapes it into the payload expected for the
@@ -393,11 +397,16 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
// de-reference the typed pointer to any
val := reflect.ValueOf(cellPtr).Elem().Interface()
// Post-process JSON columns: normalize into String value
// Post-process JSON columns: unmarshal bytes into map[string]any
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
switch x := val.(type) {
case []byte:
val = string(x)
var m map[string]any
err := sonic.Unmarshal(x, &m)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailUnmarshalJSONColumn, "failed to unmarshal JSON column %s", name)
}
val = m
default:
// already a structured type (map[string]any, []any, etc.)
}

View File

@@ -12,9 +12,12 @@ import (
"github.com/SigNoz/govaluate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// queryInfo holds common query properties.
@@ -50,7 +53,7 @@ func getQueryName(spec any) string {
return getqueryInfo(spec).Name
}
func (q *querier) postProcessResults(ctx context.Context, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
// Convert results to typed format for processing
typedResults := make(map[string]*qbtypes.Result)
for name, result := range results {
@@ -69,6 +72,7 @@ func (q *querier) postProcessResults(ctx context.Context, results map[string]any
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if result, ok := typedResults[spec.Name]; ok {
result = postProcessBuilderQuery(q, result, spec, req)
result = q.postProcessLogBody(ctx, orgID, result, req)
typedResults[spec.Name] = result
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -1046,3 +1050,33 @@ func (q *querier) calculateFormulaStep(expression string, req *qbtypes.QueryRang
return result
}
// postProcessLogBody removes the "message" key from the body map when it is empty.
// Only runs for raw list queries with the use_json_body feature enabled.
func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, result *qbtypes.Result, req *qbtypes.QueryRangeRequest) *qbtypes.Result {
if req.RequestType != qbtypes.RequestTypeRaw {
return result
}
if !q.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return result
}
rawData, ok := result.Value.(*qbtypes.RawData)
if !ok {
return result
}
for _, row := range rawData.Rows {
bodyMap, ok := row.Data["body"].(map[string]any)
if !ok {
continue
}
if msg, exists := bodyMap["message"]; exists {
switch v := msg.(type) {
case string:
if v == "" {
delete(bodyMap, "message")
}
}
}
}
return result
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -35,6 +36,7 @@ var (
type querier struct {
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
@@ -62,10 +64,12 @@ func New(
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
bucketCache BucketCache,
flagger flagger.Flagger,
) *querier {
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
return &querier{
logger: querierSettings.Logger(),
fl: flagger,
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
@@ -684,7 +688,7 @@ func (q *querier) run(
}
gomaps.Copy(results, preseededResults)
processedResults, err := q.postProcessResults(ctx, results, req)
processedResults, err := q.postProcessResults(ctx, orgID, results, req)
if err != nil {
return nil, err
}

View File

@@ -7,6 +7,7 @@ import (
cmock "github.com/srikanthccv/ClickHouse-go-mock"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
@@ -44,14 +45,15 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
providerSettings,
nil, // telemetryStore
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
)
req := &qbtypes.QueryRangeRequest{
@@ -116,6 +118,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
)
req := &qbtypes.QueryRangeRequest{

View File

@@ -186,5 +186,6 @@ func newProvider(
meterStmtBuilder,
traceOperatorStmtBuilder,
bucketCache,
flagger,
), nil
}

View File

@@ -348,7 +348,8 @@ func (m *Manager) GetInstalledIntegrationDashboardById(
CreatedBy: author,
UpdatedBy: author,
},
OrgID: orgId,
OrgID: orgId,
Source: dashboardtypes.SourceIntegration,
}, nil
}
}
@@ -387,7 +388,8 @@ func (m *Manager) GetDashboardsForInstalledIntegrations(
CreatedBy: author,
UpdatedBy: author,
},
OrgID: orgId,
OrgID: orgId,
Source: dashboardtypes.SourceIntegration,
})
}
}

View File

@@ -53,6 +53,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flagger,
), metadataStore
}
@@ -102,6 +103,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
)
}
@@ -146,5 +148,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
)
}

View File

@@ -204,6 +204,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
sqlmigration.NewAddRoleCRUDTuplesFactory(sqlstore),
sqlmigration.NewAddIntegrationDashboardFactory(sqlstore, sqlschema),
sqlmigration.NewAddSourceToDashboardFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,79 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addSourceToDashboard struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddSourceToDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_source_to_dashboard"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addSourceToDashboard{sqlstore: sqlstore, sqlschema: sqlschema}, nil
},
)
}
func (migration *addSourceToDashboard) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addSourceToDashboard) Up(ctx context.Context, db *bun.DB) error {
// dashboard is referenced by public_dashboard and integration_dashboard;
// FK enforcement must be off for the SQLite recreate-table fallback.
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("dashboard"))
if err != nil {
return err
}
sourceColumn := &sqlschema.Column{
Name: sqlschema.ColumnName("source"),
DataType: sqlschema.DataTypeText,
Nullable: false,
}
// backfill existing rows with 'user' before the NOT NULL flip.
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, sourceColumn, "user")
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, true); err != nil {
return err
}
return nil
}
func (migration *addSourceToDashboard) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -366,6 +366,7 @@ func GetDashboardsFromAssets(
CreatedBy: author,
UpdatedBy: author,
},
Source: dashboardtypes.SourceIntegration,
})
}

View File

@@ -19,6 +19,8 @@ var (
ErrCodeDashboardNotFound = errors.MustNewCode("dashboard_not_found")
ErrCodeDashboardInvalidData = errors.MustNewCode("dashboard_invalid_data")
ErrCodeDashboardInvalidWidgetQuery = errors.MustNewCode("dashboard_invalid_widget_query")
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
)
type StorableDashboard struct {
@@ -30,6 +32,7 @@ type StorableDashboard struct {
Data StorableDashboardData `bun:"data,type:text,notnull"`
Locked bool `bun:"locked,notnull,default:false"`
OrgID valuer.UUID `bun:"org_id,notnull"`
Source Source `bun:"source,type:text,notnull"`
}
type Dashboard struct {
@@ -40,6 +43,7 @@ type Dashboard struct {
Data StorableDashboardData `json:"data"`
Locked bool `json:"locked"`
OrgID valuer.UUID `json:"org_id"`
Source Source `json:"source"`
}
type LockUnlockDashboard struct {
@@ -64,6 +68,10 @@ func NewStorableDashboardFromDashboard(dashboard *Dashboard) (*StorableDashboard
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid")
}
if !dashboard.Source.IsValid() {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidSource, "invalid dashboard source %q, must be one of user, system, integration", dashboard.Source.StringValue())
}
return &StorableDashboard{
Identifiable: types.Identifiable{
ID: dashboardID,
@@ -79,10 +87,15 @@ func NewStorableDashboardFromDashboard(dashboard *Dashboard) (*StorableDashboard
OrgID: dashboard.OrgID,
Data: dashboard.Data,
Locked: dashboard.Locked,
Source: dashboard.Source,
}, nil
}
func NewDashboard(orgID valuer.UUID, createdBy string, storableDashboardData StorableDashboardData) (*Dashboard, error) {
func NewDashboard(orgID valuer.UUID, createdBy string, source Source, storableDashboardData StorableDashboardData) (*Dashboard, error) {
if !source.IsValid() {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidSource, "invalid dashboard source %q, must be one of user, system, integration", source.StringValue())
}
currentTime := time.Now()
return &Dashboard{
@@ -98,6 +111,7 @@ func NewDashboard(orgID valuer.UUID, createdBy string, storableDashboardData Sto
OrgID: orgID,
Data: storableDashboardData,
Locked: false,
Source: source,
}, nil
}
@@ -115,6 +129,7 @@ func NewDashboardFromStorableDashboard(storableDashboard *StorableDashboard) *Da
OrgID: storableDashboard.OrgID,
Data: storableDashboard.Data,
Locked: storableDashboard.Locked,
Source: storableDashboard.Source,
}
}
@@ -147,6 +162,7 @@ func NewGettableDashboardFromDashboard(dashboard *Dashboard) (*GettableDashboard
OrgID: dashboard.OrgID,
Data: dashboard.Data,
Locked: dashboard.Locked,
Source: dashboard.Source,
}, nil
}
@@ -238,6 +254,43 @@ func (storableDashboardData *StorableDashboardData) GetWidgetIds() []string {
return widgetIds
}
func (dashboard *Dashboard) ErrIfNotMutable() error {
if dashboard.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
}
return nil
}
func (dashboard *Dashboard) ErrIfNotDeletable() error {
if err := dashboard.ErrIfNotMutable(); err != nil {
return err
}
if dashboard.Source == SourceSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "system dashboards cannot be deleted")
}
return nil
}
func (dashboard *Dashboard) ErrIfNotLockable() error {
if err := dashboard.ErrIfNotMutable(); err != nil {
return err
}
if dashboard.Source == SourceSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "system dashboards cannot be locked or unlocked")
}
return nil
}
func (dashboard *Dashboard) ErrIfNotPublishable() error {
if err := dashboard.ErrIfNotMutable(); err != nil {
return err
}
if dashboard.Source == SourceSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "system dashboards cannot be made public")
}
return nil
}
func (dashboard *Dashboard) CanUpdate(ctx context.Context, data StorableDashboardData, diff int) error {
if dashboard.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")

View File

@@ -66,7 +66,7 @@ func TestCanUpdate_MultipleDeletions_ByDiff(t *testing.T) {
initial := StorableDashboardData{
"widgets": makeTestWidgets("a", "b", "c"),
}
d, err := NewDashboard(orgID, "tester", initial)
d, err := NewDashboard(orgID, "tester", SourceUser, initial)
assert.NoError(t, err)
updated := StorableDashboardData{

View File

@@ -0,0 +1,60 @@
package dashboardtypes
import (
"database/sql/driver"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Source struct {
s valuer.String
}
var (
SourceUser = Source{s: valuer.NewString("user")}
SourceSystem = Source{s: valuer.NewString("system")}
SourceIntegration = Source{s: valuer.NewString("integration")}
)
func (Source) Enum() []any {
return []any{SourceUser, SourceSystem, SourceIntegration}
}
func (s Source) IsValid() bool {
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
}
func (s Source) IsZero() bool { return s.s.IsZero() }
func (s Source) String() string { return s.s.String() }
func (s Source) StringValue() string { return s.s.StringValue() }
func (s Source) Value() (driver.Value, error) {
if !s.IsValid() {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidSource, "invalid dashboard source %q, must be one of user, system, integration", s.s.StringValue())
}
return s.s.Value()
}
func (s *Source) Scan(src any) error {
return s.s.Scan(src)
}
func (s Source) MarshalJSON() ([]byte, error) {
return s.s.MarshalJSON()
}
func (s *Source) UnmarshalJSON(data []byte) error {
return s.s.UnmarshalJSON(data)
}
func NewSource(source string) (Source, error) {
candidate := Source{s: valuer.NewString(source)}
if !candidate.IsValid() {
return Source{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidSource, "invalid dashboard source %q, must be one of user, system, integration", source)
}
return candidate, nil
}

View File

@@ -0,0 +1,71 @@
package dashboardtypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSourceEnum(t *testing.T) {
t.Run("valid sources round-trip through Value/Scan", func(t *testing.T) {
for _, src := range []Source{SourceUser, SourceSystem, SourceIntegration} {
val, err := src.Value()
require.NoError(t, err)
var got Source
require.NoError(t, got.Scan(val))
assert.Equal(t, src, got)
}
})
t.Run("invalid source is rejected by Value", func(t *testing.T) {
bogus := Source{s: valuer.NewString("hacker")}
_, err := bogus.Value()
assert.Error(t, err)
})
t.Run("Scan tolerates unknown strings, Value still rejects them", func(t *testing.T) {
var got Source
require.NoError(t, got.Scan("future_source"))
assert.Equal(t, "future_source", got.StringValue())
assert.False(t, got.IsValid())
_, err := got.Value()
assert.Error(t, err)
})
t.Run("NewSource validates input", func(t *testing.T) {
s, err := NewSource("USER")
require.NoError(t, err)
assert.Equal(t, SourceUser, s)
_, err = NewSource("nope")
assert.Error(t, err)
})
}
func TestErrIfNotMutable_BySource(t *testing.T) {
cases := []struct {
source Source
mutable bool
deletable bool
lockable bool
publishable bool
}{
{SourceUser, true, true, true, true},
{SourceSystem, true, false, false, false},
{SourceIntegration, false, false, false, false},
}
for _, tc := range cases {
t.Run(tc.source.StringValue(), func(t *testing.T) {
d := &Dashboard{Source: tc.source}
assert.Equal(t, tc.mutable, d.ErrIfNotMutable() == nil)
assert.Equal(t, tc.deletable, d.ErrIfNotDeletable() == nil)
assert.Equal(t, tc.lockable, d.ErrIfNotLockable() == nil)
assert.Equal(t, tc.publishable, d.ErrIfNotPublishable() == nil)
})
}
}

View File

@@ -17,8 +17,6 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
ZEUS_NETWORK_ALIAS = "signoz-zeus"
@pytest.fixture(name="zeus", scope="package")
def zeus(
@@ -33,7 +31,6 @@ def zeus(
def create() -> types.TestContainerDocker:
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
container.with_network(network)
container.with_network_aliases(ZEUS_NETWORK_ALIAS)
container.start()
return types.TestContainerDocker(
@@ -45,7 +42,7 @@ def zeus(
container.get_exposed_port(8080),
)
},
container_configs={"8080": types.TestContainerUrlConfig("http", ZEUS_NETWORK_ALIAS, 8080)},
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
)
def delete(container: types.TestContainerDocker):

View File

@@ -1,19 +1,14 @@
import os
import platform
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass
from http import HTTPStatus
from os import path
from pathlib import Path
import docker
import docker.errors
import pytest
import requests
from testcontainers.core.container import DockerContainer, Network
from testcontainers.core.image import DockerImage
from fixtures import reuse, types
from fixtures.logger import setup_logger
@@ -21,110 +16,6 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@dataclass
class SigNozImageBuild:
process: subprocess.Popen
command: list[str]
cache_path: Path | None = None
next_cache_path: Path | None = None
reader: threading.Thread | None = None
def _stream_build_output(pipe, log) -> None:
try:
for line in iter(pipe.readline, ""):
log.info("buildx: %s", line.rstrip())
finally:
pipe.close()
def start_signoz_image_build(pytestconfig: pytest.Config, dockerfile_path: str, arch: str, zeus_url: str) -> SigNozImageBuild:
root = pytestconfig.rootpath.parent
command = [
"docker",
"buildx",
"build",
"--load",
"--progress",
"plain",
"--tag",
"signoz:integration",
"--file",
dockerfile_path,
"--build-arg",
f"TARGETARCH={arch}",
"--build-arg",
f"ZEUSURL={zeus_url}",
str(root),
]
cache_path = None
next_cache_path = None
if os.environ.get("ACTIONS_RUNTIME_TOKEN"):
# Running in GitHub Actions — use BuildKit's native GHA cache backend.
# Avoids the local-write races and partial exports seen with type=local.
scope = os.environ.get("SIGNOZ_BUILDX_GHA_SCOPE", "signoz-integration")
command.extend(["--cache-from", f"type=gha,scope={scope}"])
command.extend(["--cache-to", f"type=gha,scope={scope},mode=max"])
elif build_cache_dir := os.environ.get("SIGNOZ_INTEGRATION_BUILD_CACHE_DIR"):
# Local cache for developer machines / non-GHA CI.
cache_path = Path(build_cache_dir)
next_cache_path = Path(f"{build_cache_dir}-next")
cache_path.parent.mkdir(parents=True, exist_ok=True)
shutil.rmtree(next_cache_path, ignore_errors=True)
if cache_path.exists():
command.extend(["--cache-from", f"type=local,src={cache_path}"])
command.extend(["--cache-to", f"type=local,dest={next_cache_path},mode=max"])
logger.info("Building SigNoz integration image with %s", " ".join(command))
process = subprocess.Popen(
command,
cwd=root,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
reader = threading.Thread(target=_stream_build_output, args=(process.stdout, logger), daemon=True)
reader.start()
return SigNozImageBuild(
process=process,
command=command,
cache_path=cache_path,
next_cache_path=next_cache_path,
reader=reader,
)
def wait_for_signoz_image_build(build: SigNozImageBuild) -> None:
returncode = build.process.wait()
if build.reader is not None:
build.reader.join(timeout=5)
if returncode != 0:
raise subprocess.CalledProcessError(returncode, build.command)
if build.cache_path and build.next_cache_path and build.next_cache_path.exists():
shutil.rmtree(build.cache_path, ignore_errors=True)
shutil.move(build.next_cache_path, build.cache_path)
def stop_signoz_image_build(build: SigNozImageBuild) -> None:
if build.process.poll() is not None:
return
build.process.terminate()
try:
build.process.wait(timeout=10)
except subprocess.TimeoutExpired:
build.process.kill()
build.process.wait()
if build.reader is not None:
build.reader.join(timeout=5)
def create_signoz(
network: Network,
zeus: types.TestContainerDocker,
@@ -142,6 +33,9 @@ def create_signoz(
"""
def create() -> types.SigNoz:
# Run the migrations for clickhouse
request.getfixturevalue("migrator")
# Get the no-web flag
with_web = pytestconfig.getoption("--with-web")
@@ -154,15 +48,19 @@ def create_signoz(
if with_web:
dockerfile_path = "cmd/enterprise/Dockerfile.with-web.integration"
# The SigNoz image build does not depend on ClickHouse migrations, so
# build it while the migrator container runs.
image_build = start_signoz_image_build(pytestconfig, dockerfile_path, arch, zeus.container_configs["8080"].base())
try:
request.getfixturevalue("migrator")
wait_for_signoz_image_build(image_build)
except Exception: # pylint: disable=broad-exception-caught
stop_signoz_image_build(image_build)
raise
# Docker build context is the repo root — one up from pytest's
# rootdir (tests/).
self = DockerImage(
path=str(pytestconfig.rootpath.parent),
dockerfile_path=dockerfile_path,
tag="signoz:integration",
buildargs={
"TARGETARCH": arch,
"ZEUSURL": zeus.container_configs["8080"].base(),
},
)
self.build()
env = (
{

View File

@@ -20,6 +20,7 @@ from fixtures.querier import (
index_series_by_label,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def test_logs_list(
@@ -2293,3 +2294,333 @@ def test_logs_formula_orderby_and_limit(
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"
def test_logs_list_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that filtering logs by trace_id uses the trace_summary lookup to
narrow the query window before scanning the logs table:
1. Returns the matching log (narrow window, single bucket).
2. Does not return duplicate logs when the query window spans multiple
exponential buckets (>1 h).
3. Returns no results when the query window does not contain the trace.
4. Logs carrying a trace_id whose trace is NOT in trace_summary (e.g.
traces disabled) are still returned — the lookup miss must not
short-circuit logs queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
other_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
other_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-filter-service",
"cloud.provider": "integration",
}
# Populate signoz_traces.distributed_trace_summary by inserting spans for
# the target trace_id. trace_summary records min/max of span timestamps
# (it ignores span duration), so two spans are inserted to give the trace
# a non-trivial recorded window of [now-10s, now-5s].
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Insert logs:
# - one with the target trace_id, at a timestamp within the trace's
# recorded window (now-10s..now-5s, padded ±1s).
# - one with a different trace_id; must never appear in target_trace_id
# results.
# - one with an orphan trace_id whose trace was never ingested — used to
# verify the lookup miss does NOT short-circuit logs queries.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=7),
resources=common_resources,
attributes={"http.method": "GET"},
body="log inside the target trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=3),
resources=common_resources,
attributes={"http.method": "POST"},
body="log with a different trace_id",
severity_text="INFO",
trace_id=other_trace_id,
span_id=other_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={"http.method": "PUT"},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _query(start_ms: int, end_ms: int, trace_id: str) -> list:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
return response.json()["data"]["data"]["results"][0]["rows"] or []
now_ms = int(now.timestamp() * 1000)
# --- Test 1: narrow window (single bucket, <1 h) ---
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
narrow_rows = _query(narrow_start_ms, now_ms, target_trace_id)
assert len(narrow_rows) == 1, f"Expected 1 log for trace_id filter (narrow window), got {len(narrow_rows)}"
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
assert narrow_rows[0]["data"]["span_id"] == target_root_span_id
# --- Test 2: wide window (>1 h, triggers multiple exponential buckets) ---
# Should still return exactly one log — no duplicates from multi-bucket scan.
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_rows = _query(wide_start_ms, now_ms, target_trace_id)
assert len(wide_rows) == 1, f"Expected 1 log for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-log regression"
assert wide_rows[0]["data"]["trace_id"] == target_trace_id
assert wide_rows[0]["data"]["span_id"] == target_root_span_id
# --- Test 3: window that does not contain the trace returns no results ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_rows = _query(past_start_ms, past_end_ms, target_trace_id)
assert len(past_rows) == 0, f"Expected 0 logs for trace_id filter outside time window, got {len(past_rows)}"
# --- Test 4: trace_id not present in trace_summary still returns logs ---
orphan_rows = _query(narrow_start_ms, now_ms, orphan_trace_id)
assert len(orphan_rows) == 1, f"Expected 1 log for orphan trace_id (no trace_summary entry), got {len(orphan_rows)} — logs query may have been incorrectly short-circuited"
assert orphan_rows[0]["data"]["trace_id"] == orphan_trace_id
def test_logs_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) logs queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to an
empty result.
3. A trace_id with no row in trace_summary (e.g. traces disabled) still
returns the matching logs — the lookup miss must not short-circuit
logs aggregation queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-agg-service",
"cloud.provider": "integration",
}
# trace_summary records min/max of span timestamps (it ignores duration),
# so insert two spans to give the trace a recorded window wide enough to
# comfortably contain the log timestamps below.
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Two logs for the target trace_id, both inside the recorded trace window.
# One additional log carries an orphan trace_id with no row in
# trace_summary — used to verify that the lookup miss does not
# short-circuit logs aggregations.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=9),
resources=common_resources,
attributes={},
body="log A inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=6),
resources=common_resources,
attributes={},
body="log B inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> float:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0
series = aggregations[0].get("series") or []
if not series:
return 0
return sum(v["value"] for v in series[0]["values"])
now_ms = int(now.timestamp() * 1000)
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns 2 logs ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
# --- Test 2: window outside the trace short-circuits to empty ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
# --- Test 3: trace_id not present in trace_summary still returns logs ---
orphan_count = _count(narrow_start_ms, now_ms, orphan_trace_id)
assert orphan_count == 1, f"Expected count=1 for orphan trace_id aggregation, got {orphan_count} — query may have been incorrectly short-circuited"

View File

@@ -2123,3 +2123,116 @@ def test_traces_list_filter_by_trace_id(
past_rows = _query(past_start_ms, past_end_ms)
assert len(past_rows) == 0, f"Expected 0 spans for trace_id filter outside time window, got {len(past_rows)}"
def test_traces_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) traces queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to empty.
3. Filter referencing a trace_id with no row in trace_summary
short-circuits to empty (trace_summary is authoritative for traces).
"""
target_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
missing_trace_id = TraceIdGenerator.trace_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "traces-agg-filter-service",
"cloud.provider": "integration",
}
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={"http.request.method": "GET"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> float:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0
series = aggregations[0].get("series") or []
if not series:
return 0
return sum(v["value"] for v in series[0]["values"])
now_ms = int(now.timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns both spans ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
# --- Test 2: window outside the trace short-circuits to empty ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
# --- Test 3: trace_id with no entry in trace_summary short-circuits ---
missing_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
missing_count = _count(missing_start_ms, now_ms, missing_trace_id)
assert missing_count == 0, f"Expected count=0 for trace_id absent from trace_summary, got {missing_count}"

View File

@@ -21,7 +21,7 @@ from fixtures.querier import (
def _get_bodies(response: requests.Response) -> list[dict[str, Any]]:
return [json.loads(row["data"]["body"]) for row in get_rows(response)]
return [row["data"]["body"] for row in get_rows(response)]
def _run_query_case(signoz: types.SigNoz, token: str, now: datetime, case: dict[str, Any]) -> None:
@@ -1188,7 +1188,7 @@ def test_message_searches(
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
def _body_messages(response: requests.Response) -> list[str]:
return [json.loads(row["data"]["body"]).get("message", "") for row in get_rows(response)]
return [row["data"]["body"].get("message", "") for row in get_rows(response)]
payment_messages = {
"Payment processed successfully",

View File

@@ -69,11 +69,11 @@ def _scalar(
def _body_users(response: requests.Response) -> set[str | None]:
return {json.loads(row["data"]["body"]).get("user") for row in get_rows(response)}
return {row["data"]["body"].get("user") for row in get_rows(response)}
def _body_scores(response: requests.Response) -> list[int | None]:
return [json.loads(row["data"]["body"]).get("score") for row in get_rows(response)]
return [row["data"]["body"].get("score") for row in get_rows(response)]
def _services(response: requests.Response) -> list[str]: