mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-21 17:30:32 +01:00
Compare commits
10 Commits
issue_8965
...
issue_5015
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
887dc9de16 | ||
|
|
d0ab1b6301 | ||
|
|
1bfe614b92 | ||
|
|
592cc02df4 | ||
|
|
bf07f74185 | ||
|
|
a9bb25e085 | ||
|
|
99267e5e91 | ||
|
|
08320f5173 | ||
|
|
8562049bfe | ||
|
|
e3a22cd7cf |
6
.github/workflows/e2eci.yaml
vendored
6
.github/workflows/e2eci.yaml
vendored
@@ -45,9 +45,15 @@ 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:
|
||||
|
||||
6
.github/workflows/integrationci.yaml
vendored
6
.github/workflows/integrationci.yaml
vendored
@@ -69,9 +69,15 @@ 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:
|
||||
|
||||
16
Makefile
16
Makefile
@@ -34,6 +34,7 @@ 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
|
||||
@@ -229,6 +230,21 @@ 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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# syntax=docker/dockerfile:1.13
|
||||
|
||||
FROM golang:1.25-bookworm
|
||||
|
||||
ARG OS="linux"
|
||||
@@ -21,7 +23,8 @@ RUN set -eux; \
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
@@ -29,7 +32,9 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates/email /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
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 mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# syntax=docker/dockerfile:1.13
|
||||
|
||||
FROM node:22-bookworm AS build
|
||||
|
||||
WORKDIR /opt/
|
||||
@@ -30,7 +32,8 @@ RUN set -eux; \
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
@@ -38,7 +41,9 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates/email /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
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 mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
COPY --from=build /opt/build ./web/
|
||||
|
||||
@@ -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.1
|
||||
image: signoz/signoz:v0.125.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -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.1
|
||||
image: signoz/signoz:v0.125.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -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.1}
|
||||
image: signoz/signoz:${VERSION:-v0.125.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -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.1}
|
||||
image: signoz/signoz:${VERSION:-v0.125.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -2342,8 +2342,6 @@ components:
|
||||
type: boolean
|
||||
org_id:
|
||||
type: string
|
||||
source:
|
||||
$ref: '#/components/schemas/DashboardtypesSource'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -2373,12 +2371,6 @@ components:
|
||||
timeRangeEnabled:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
- system
|
||||
- integration
|
||||
type: object
|
||||
DashboardtypesStorableDashboardData:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
|
||||
@@ -49,14 +49,6 @@ 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
|
||||
@@ -137,14 +129,6 @@ 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))
|
||||
}
|
||||
|
||||
@@ -154,10 +138,6 @@ 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")
|
||||
}
|
||||
@@ -188,14 +168,6 @@ 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
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
const BANNED_COMPONENTS = {
|
||||
Typography: 'Use @signozhq/ui Typography instead of antd Typography.',
|
||||
Badge: 'Use @signozhq/ui/badge instead of antd Badge.',
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -47,6 +47,7 @@ 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'
|
||||
@@ -312,6 +313,13 @@ 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'),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AIAssistantPage,
|
||||
AlertHistory,
|
||||
AlertOverview,
|
||||
AlertTypeSelectionPage,
|
||||
AllAlertChannels,
|
||||
AllErrors,
|
||||
ApiMonitoring,
|
||||
@@ -212,6 +213,13 @@ 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,
|
||||
@@ -525,6 +533,18 @@ 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',
|
||||
@@ -535,9 +555,7 @@ 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,
|
||||
|
||||
@@ -2999,11 +2999,6 @@ export interface CoretypesPatchableObjectsDTO {
|
||||
deletions: CoretypesObjectGroupDTO[] | null;
|
||||
}
|
||||
|
||||
export enum DashboardtypesSourceDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
integration = 'integration',
|
||||
}
|
||||
export interface DashboardtypesDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3027,7 +3022,6 @@ export interface DashboardtypesDashboardDTO {
|
||||
* @type string
|
||||
*/
|
||||
org_id?: string;
|
||||
source?: DashboardtypesSourceDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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;
|
||||
@@ -1,9 +0,0 @@
|
||||
.item {
|
||||
--button-padding: 0;
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.itemLast {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
@@ -1,6 +0,0 @@
|
||||
export { default } from './AlertBreadcrumb';
|
||||
export {
|
||||
default as BreadcrumbItem,
|
||||
type BreadcrumbItemConfig,
|
||||
} from './BreadcrumbItem';
|
||||
export type { AlertBreadcrumbProps } from './AlertBreadcrumb';
|
||||
@@ -50,7 +50,6 @@ 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';
|
||||
@@ -218,17 +217,20 @@ function LogDetailInner({
|
||||
|
||||
const logBody = useMemo(() => {
|
||||
if (!isBodyJsonQueryEnabled) {
|
||||
return (log?.body as string) ?? '';
|
||||
return log?.body || '';
|
||||
}
|
||||
// Feature enabled: body is always a map; message is always a string
|
||||
const bodyObj = log?.body as ILogBody;
|
||||
if (!bodyObj) {
|
||||
return '';
|
||||
|
||||
try {
|
||||
const json = JSON.parse(log?.body || '');
|
||||
|
||||
if (typeof json?.message === 'string' && json.message !== '') {
|
||||
return json.message;
|
||||
}
|
||||
|
||||
return log?.body || '';
|
||||
} catch {
|
||||
return log?.body || '';
|
||||
}
|
||||
if (bodyObj.message) {
|
||||
return bodyObj.message;
|
||||
}
|
||||
return JSON.stringify(bodyObj);
|
||||
}, [isBodyJsonQueryEnabled, log?.body]);
|
||||
|
||||
const htmlBody = useMemo(
|
||||
|
||||
@@ -9,10 +9,7 @@ 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 {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
// hooks
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -102,7 +99,7 @@ function RawLogView({
|
||||
// Check if body is selected
|
||||
const showBody = selectedFields.some((field) => field.name === 'body');
|
||||
if (showBody) {
|
||||
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
|
||||
parts.push(`${attributesText} ${data.body}`);
|
||||
} else {
|
||||
parts.push(attributesText);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ import type { ReactElement } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
@@ -90,7 +87,7 @@ export function useLogsTableColumns({
|
||||
? {
|
||||
id: 'body',
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => getBodyDisplayString(log.body),
|
||||
accessorFn: (log): string => log.body,
|
||||
canBeHidden: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
cell: ({ value, isActive }): ReactElement => (
|
||||
|
||||
@@ -626,10 +626,6 @@ function TanStackTableInner<TData>(
|
||||
onChange={(value): void => {
|
||||
setLimit(+value);
|
||||
pagination.onLimitChange?.(+value);
|
||||
if (page !== 1) {
|
||||
setPage(1);
|
||||
pagination.onPageChange?.(1);
|
||||
}
|
||||
}}
|
||||
items={paginationPageSizeItems}
|
||||
/>
|
||||
|
||||
@@ -401,62 +401,6 @@ 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', () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ 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',
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
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({
|
||||
@@ -65,13 +54,20 @@ 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(
|
||||
<CreateAlertPage />,
|
||||
<AlertTypeSelectionPage />,
|
||||
{},
|
||||
{
|
||||
initialRoute: ROUTES.ALERTS_NEW,
|
||||
initialRoute: ROUTES.ALERT_TYPE_SELECTION,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,18 +15,6 @@ 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(() => ({
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +1,13 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
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({
|
||||
@@ -72,14 +48,10 @@ 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';
|
||||
@@ -91,13 +63,8 @@ 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', () => {
|
||||
@@ -105,53 +72,18 @@ 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 when alertType is provided', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.alertType) {
|
||||
return AlertTypes.METRICS_BASED_ALERT;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
it('should render new flow by default', () => {
|
||||
mockGetUrlQuery.mockReturnValue(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) {
|
||||
@@ -179,13 +111,8 @@ describe('CreateAlertRule', () => {
|
||||
expect(screen.getByText(AlertTypes.LOGS_BASED_ALERT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alertType from URL over compositeQuery dataSource', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.alertType) {
|
||||
return AlertTypes.LOGS_BASED_ALERT;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
it('should use alertType from compositeQuery dataSource when alertType is not in URL', () => {
|
||||
mockGetUrlQuery.mockReturnValue(null);
|
||||
useCompositeQueryParamSpy.mockReturnValue({
|
||||
...initialQueriesMap.metrics,
|
||||
builder: {
|
||||
@@ -200,123 +127,14 @@ describe('CreateAlertRule', () => {
|
||||
});
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(AlertTypes.LOGS_BASED_ALERT)).toBeInTheDocument();
|
||||
expect(screen.getByText(AlertTypes.TRACES_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();
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,11 +208,3 @@ 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',
|
||||
};
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
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 { useMemo } from 'react';
|
||||
import { Form } from 'antd';
|
||||
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, ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
|
||||
import SelectAlertType from './SelectAlertType';
|
||||
|
||||
import './CreateAlertRule.styles.scss';
|
||||
import { ALERTS_VALUES_MAP } from './defaults';
|
||||
|
||||
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);
|
||||
@@ -36,15 +23,6 @@ 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;
|
||||
@@ -67,142 +45,22 @@ function CreateRules(): JSX.Element {
|
||||
[alertType, version],
|
||||
);
|
||||
|
||||
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],
|
||||
);
|
||||
// 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 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
return <CreateAlertV2 alertType={alertType} />;
|
||||
}
|
||||
|
||||
export default CreateRules;
|
||||
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
@@ -19,7 +18,6 @@ import './styles.scss';
|
||||
|
||||
function CreateAlertHeader(): JSX.Element {
|
||||
const { alertState, setAlertState, isEditMode } = useCreateAlertState();
|
||||
const alertRuleContext = useAlertRuleOptional();
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -76,13 +74,9 @@ function CreateAlertHeader(): JSX.Element {
|
||||
<Input
|
||||
type="text"
|
||||
value={alertState.name}
|
||||
onChange={(e): void => {
|
||||
const newName = e.target.value;
|
||||
setAlertState({ type: 'SET_ALERT_NAME', payload: newName });
|
||||
if (isEditMode && alertRuleContext?.setAlertRuleName) {
|
||||
alertRuleContext.setAlertRuleName(newName);
|
||||
}
|
||||
}}
|
||||
onChange={(e): void =>
|
||||
setAlertState({ type: 'SET_ALERT_NAME', payload: e.target.value })
|
||||
}
|
||||
className="alert-header__input title"
|
||||
placeholder="Enter alert rule name"
|
||||
data-testid="alert-name-input"
|
||||
|
||||
@@ -20,11 +20,6 @@ import {
|
||||
} from './utils';
|
||||
|
||||
import './styles.scss';
|
||||
import {
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
function Footer(): JSX.Element {
|
||||
const {
|
||||
@@ -120,7 +115,6 @@ function Footer(): JSX.Element {
|
||||
testAlertRule,
|
||||
]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleSaveAlert = useCallback((): void => {
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
alertType,
|
||||
@@ -139,9 +133,6 @@ function Footer(): JSX.Element {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
void invalidateListRules(queryClient);
|
||||
|
||||
toast.success('Alert rule updated successfully');
|
||||
safeNavigate('/alerts');
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ 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', () => ({
|
||||
@@ -65,12 +64,6 @@ const mockAlertContextState = createMockAlertContextState({
|
||||
},
|
||||
});
|
||||
|
||||
const WrappedFooter = (): JSX.Element => (
|
||||
<MockQueryClientProvider>
|
||||
<Footer />
|
||||
</MockQueryClientProvider>
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(createAlertState, 'useCreateAlertState')
|
||||
.mockReturnValue(mockAlertContextState);
|
||||
@@ -104,20 +97,20 @@ describe('Footer', () => {
|
||||
});
|
||||
|
||||
it('should render the component with 3 buttons', () => {
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
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(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
fireEvent.click(screen.getByText(DISCARD_TEXT));
|
||||
expect(mockDiscardAlertRule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save alert rule action works correctly', () => {
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
|
||||
expect(mockCreateAlertRule).toHaveBeenCalled();
|
||||
});
|
||||
@@ -127,13 +120,13 @@ describe('Footer', () => {
|
||||
...mockAlertContextState,
|
||||
isEditMode: true,
|
||||
});
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
|
||||
expect(mockUpdateAlertRule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('test notification action works correctly', () => {
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
fireEvent.click(screen.getByText(TEST_NOTIFICATION_TEXT));
|
||||
expect(mockTestAlertRule).toHaveBeenCalled();
|
||||
});
|
||||
@@ -143,7 +136,7 @@ describe('Footer', () => {
|
||||
...mockAlertContextState,
|
||||
isCreatingAlertRule: true,
|
||||
});
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save alert rule/i }),
|
||||
@@ -159,7 +152,7 @@ describe('Footer', () => {
|
||||
...mockAlertContextState,
|
||||
isUpdatingAlertRule: true,
|
||||
});
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
|
||||
// Target the button elements directly instead of the text spans inside them
|
||||
expect(
|
||||
@@ -176,7 +169,7 @@ describe('Footer', () => {
|
||||
...mockAlertContextState,
|
||||
isTestingAlertRule: true,
|
||||
});
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
|
||||
// Target the button elements directly instead of the text spans inside them
|
||||
expect(
|
||||
@@ -196,7 +189,7 @@ describe('Footer', () => {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save alert rule/i }),
|
||||
@@ -224,7 +217,7 @@ describe('Footer', () => {
|
||||
},
|
||||
});
|
||||
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save alert rule/i }),
|
||||
@@ -252,7 +245,7 @@ describe('Footer', () => {
|
||||
},
|
||||
});
|
||||
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save alert rule/i }),
|
||||
@@ -268,7 +261,7 @@ describe('Footer', () => {
|
||||
...mockAlertContextState,
|
||||
isTestingAlertRule: true,
|
||||
});
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
|
||||
// When testing alert rule, the play icon is replaced with a loader icon
|
||||
expect(
|
||||
@@ -283,7 +276,7 @@ describe('Footer', () => {
|
||||
...mockAlertContextState,
|
||||
isUpdatingAlertRule: true,
|
||||
});
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
|
||||
// When updating alert rule, the check icon is replaced with a loader icon
|
||||
expect(
|
||||
@@ -298,7 +291,7 @@ describe('Footer', () => {
|
||||
...mockAlertContextState,
|
||||
isCreatingAlertRule: true,
|
||||
});
|
||||
render(<WrappedFooter />);
|
||||
render(<Footer />);
|
||||
|
||||
// When creating alert rule, the check icon is replaced with a loader icon
|
||||
expect(
|
||||
|
||||
@@ -38,7 +38,6 @@ 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';
|
||||
@@ -93,6 +92,7 @@ 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,32 +160,6 @@ 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);
|
||||
@@ -706,7 +680,7 @@ function FormAlertRules({
|
||||
const renderBasicInfo = (): JSX.Element => (
|
||||
<BasicInfo
|
||||
alertDef={alertDef}
|
||||
setAlertDef={handleSetAlertDef}
|
||||
setAlertDef={setAlertDef}
|
||||
isNewRule={isNewRule}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -111,7 +111,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
number: allAlertRules?.length,
|
||||
layout: 'new',
|
||||
});
|
||||
safeNavigate(ROUTES.ALERTS_NEW, {
|
||||
safeNavigate(ROUTES.ALERT_TYPE_SELECTION, {
|
||||
newTab: isModifierKeyPressed(e),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -22,9 +22,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Divider } from 'antd';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Divider, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { TagContainer, TagLabel, TagValue } from './FieldRenderer.styles';
|
||||
@@ -8,10 +7,6 @@ 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);
|
||||
|
||||
@@ -19,16 +14,11 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
|
||||
<span className="field-renderer-container">
|
||||
{dataType && newField && logType ? (
|
||||
<>
|
||||
<TooltipSimple
|
||||
title={newField}
|
||||
side="left"
|
||||
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
|
||||
arrow
|
||||
>
|
||||
<Tooltip placement="left" title={newField} mouseLeaveDelay={0}>
|
||||
<Typography.Text truncate={1} className="label">
|
||||
{newField}{' '}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
|
||||
<div className="tags">
|
||||
<TagContainer>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { ActionItemProps } from './ActionItem';
|
||||
import TableView from './TableView';
|
||||
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
|
||||
import { removeEscapeCharacters } from './utils';
|
||||
|
||||
import './Overview.styles.scss';
|
||||
|
||||
@@ -112,7 +112,7 @@ function Overview({
|
||||
children: (
|
||||
<div className="logs-body-content">
|
||||
<MEditor
|
||||
value={removeEscapeCharacters(getBodyDisplayString(logData.body))}
|
||||
value={removeEscapeCharacters(logData.body)}
|
||||
language="json"
|
||||
options={options}
|
||||
onChange={(): void => {}}
|
||||
|
||||
@@ -106,20 +106,10 @@ function TableView({
|
||||
isListViewPanel,
|
||||
]);
|
||||
|
||||
// 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 flattenLogData: Record<string, string> | null = useMemo(
|
||||
() => (logData ? flattenObject(logData) : null),
|
||||
[logData],
|
||||
);
|
||||
|
||||
const handleClick = (
|
||||
operator: string,
|
||||
|
||||
@@ -10,7 +10,7 @@ const MAX_BODY_BYTES = 100 * 1024; // 100 KB
|
||||
|
||||
// Hook for async JSON processing
|
||||
const useAsyncJSONProcessing = (
|
||||
value: string | Record<string, unknown>,
|
||||
value: string,
|
||||
shouldProcess: boolean,
|
||||
handleChangeSelectedView?: ChangeViewFunctionType,
|
||||
): {
|
||||
@@ -40,17 +40,11 @@ const useAsyncJSONProcessing = (
|
||||
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);
|
||||
};
|
||||
// Avoid processing if the json is too large
|
||||
const byteSize = new Blob([value]).size;
|
||||
if (byteSize > MAX_BODY_BYTES) {
|
||||
return (): void => {};
|
||||
}
|
||||
|
||||
processingRef.current = true;
|
||||
setJsonState({ isLoading: true, treeData: null, error: null });
|
||||
@@ -59,8 +53,8 @@ const useAsyncJSONProcessing = (
|
||||
const processAsync = (): void => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const parsedBody = parseBody();
|
||||
if (parsedBody && !isEmpty(parsedBody)) {
|
||||
const parsedBody = recursiveParseJSON(value);
|
||||
if (!isEmpty(parsedBody)) {
|
||||
const treeData = jsonToDataNodes(parsedBody, {
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
@@ -88,8 +82,8 @@ const useAsyncJSONProcessing = (
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
(): void => {
|
||||
try {
|
||||
const parsedBody = parseBody();
|
||||
if (parsedBody && !isEmpty(parsedBody)) {
|
||||
const parsedBody = recursiveParseJSON(value);
|
||||
if (!isEmpty(parsedBody)) {
|
||||
const treeData = jsonToDataNodes(parsedBody, {
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
|
||||
@@ -4,11 +4,7 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import dompurify from 'dompurify';
|
||||
import { uniqueId } from 'lodash-es';
|
||||
import {
|
||||
ILog,
|
||||
ILogAggregateAttributesResources,
|
||||
ILogBody,
|
||||
} from 'types/api/logs/log';
|
||||
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
|
||||
@@ -437,8 +433,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Spin } from 'antd';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Button, Spin, Tooltip } from 'antd';
|
||||
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';
|
||||
@@ -14,10 +11,6 @@ import {
|
||||
formatTimestampToReadableDate,
|
||||
} from './utils';
|
||||
|
||||
const TOOLTIP_CONTENT_PROPS = {
|
||||
className: 'metric-highlights-tooltip-content',
|
||||
};
|
||||
|
||||
function Highlights({ metricName }: HighlightsProps): JSX.Element {
|
||||
const {
|
||||
data: metricHighlightsData,
|
||||
@@ -46,13 +39,6 @@ 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 (
|
||||
@@ -104,42 +90,27 @@ function Highlights({ metricName }: HighlightsProps): JSX.Element {
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-data-points"
|
||||
>
|
||||
<TooltipSimple
|
||||
title={metricHighlights?.dataPoints?.toLocaleString()}
|
||||
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
|
||||
arrow
|
||||
>
|
||||
<span>
|
||||
{formatNumberIntoHumanReadableFormat(
|
||||
metricHighlights?.dataPoints ?? 0,
|
||||
)}
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-time-series-total"
|
||||
>
|
||||
<TooltipSimple
|
||||
title="Active time series are those that have received data points in the last 1 hour."
|
||||
side="top"
|
||||
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
|
||||
arrow
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-last-received"
|
||||
>
|
||||
<TooltipSimple
|
||||
title={lastReceivedTooltipText}
|
||||
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
|
||||
arrow
|
||||
>
|
||||
<span>{lastReceivedText}</span>
|
||||
</TooltipSimple>
|
||||
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -510,12 +510,6 @@
|
||||
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;
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
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',
|
||||
@@ -28,7 +16,7 @@ describe('Highlights', () => {
|
||||
});
|
||||
|
||||
it('should render all highlights data correctly', () => {
|
||||
renderHighlights(MOCK_METRIC_NAME);
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
const dataPoints = screen.getByTestId('metric-highlights-data-points');
|
||||
const timeSeriesTotal = screen.getByTestId(
|
||||
@@ -53,7 +41,7 @@ describe('Highlights', () => {
|
||||
),
|
||||
);
|
||||
|
||||
renderHighlights(MOCK_METRIC_NAME);
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('metric-highlights-error-state'),
|
||||
@@ -70,7 +58,7 @@ describe('Highlights', () => {
|
||||
),
|
||||
);
|
||||
|
||||
renderHighlights(MOCK_METRIC_NAME);
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(screen.getByText('SAMPLES')).toBeInTheDocument();
|
||||
expect(screen.getByText('TIME SERIES')).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
.new-explorer-cta-with-badge {
|
||||
display: inline-flex;
|
||||
.new-explorer-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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',
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Badge, Button } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Undo } from '@signozhq/icons';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { buttonText } from './config';
|
||||
import { buttonText, RIBBON_STYLES } from './config';
|
||||
|
||||
import './NewExplorerCTA.styles.scss';
|
||||
|
||||
@@ -71,12 +70,9 @@ function NewExplorerCTA(): JSX.Element | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="new-explorer-cta-with-badge">
|
||||
<Badge.Ribbon style={RIBBON_STYLES} text="New">
|
||||
{button}
|
||||
<Badge color="robin" variant="default">
|
||||
New
|
||||
</Badge>
|
||||
</span>
|
||||
</Badge.Ribbon>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -27,9 +26,7 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
DATE_TIME_FORMATS.UTC_MONTH_SHORT,
|
||||
)}
|
||||
</div>
|
||||
<div className="logs-preview-list-item-body">
|
||||
{getBodyDisplayString(log.body)}
|
||||
</div>
|
||||
<div className="logs-preview-list-item-body">{log.body}</div>
|
||||
<div
|
||||
className="logs-preview-list-item-expand"
|
||||
onClick={makeLogDetailsHandler(log)}
|
||||
|
||||
@@ -33,12 +33,14 @@ function TopNav(): JSX.Element | null {
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
const isAlertCreationPage = useMemo(
|
||||
() => matchPath(location.pathname, { path: ROUTES.ALERTS_NEW, exact: true }),
|
||||
[location.pathname],
|
||||
const isNewAlertsLandingPage = useMemo(
|
||||
() =>
|
||||
matchPath(location.pathname, { path: ROUTES.ALERTS_NEW, exact: true }) &&
|
||||
!location.search,
|
||||
[location.pathname, location.search],
|
||||
);
|
||||
|
||||
if (isSignUpPage || isDisabled || isRouteToSkip || isAlertCreationPage) {
|
||||
if (isSignUpPage || isDisabled || isRouteToSkip || isNewAlertsLandingPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
}
|
||||
|
||||
.alert-details {
|
||||
margin-top: 10px;
|
||||
.divider {
|
||||
border-color: var(--l1-border);
|
||||
margin: 16px 0;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Divider } from 'antd';
|
||||
import { Breadcrumb, Button, 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';
|
||||
@@ -11,12 +10,13 @@ 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,11 +24,42 @@ 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();
|
||||
@@ -38,7 +69,7 @@ function AlertDetails(): JSX.Element {
|
||||
}, [params]);
|
||||
|
||||
const getDocumentTitle = useMemo(() => {
|
||||
const alertTitle = alertRuleName ?? alertDetailsResponse?.data?.alert;
|
||||
const alertTitle = alertDetailsResponse?.data?.alert;
|
||||
if (alertTitle) {
|
||||
return alertTitle;
|
||||
}
|
||||
@@ -49,7 +80,7 @@ function AlertDetails(): JSX.Element {
|
||||
return document.title;
|
||||
}
|
||||
return 'Alert Not Found';
|
||||
}, [alertRuleName, alertDetailsResponse?.data?.alert, isTestAlert, isLoading]);
|
||||
}, [alertDetailsResponse?.data?.alert, isTestAlert, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = getDocumentTitle;
|
||||
@@ -95,13 +126,20 @@ function AlertDetails(): JSX.Element {
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
>
|
||||
<AlertBreadcrumb
|
||||
<Breadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
items={[
|
||||
{ title: 'Alert Rules', route: ROUTES.LIST_ALL_ALERT },
|
||||
{ title: ruleId, isLast: true },
|
||||
{
|
||||
title: (
|
||||
<BreadCrumbItem title="Alert Rules" route={ROUTES.LIST_ALL_ALERT} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <BreadCrumbItem title={ruleId} isLast />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Divider className="divider breadcrumb-divider" />
|
||||
|
||||
{alertRuleDetails && <AlertHeader alertDetails={alertRuleDetails} />}
|
||||
<Divider className="divider" />
|
||||
|
||||
@@ -33,12 +33,13 @@ const menuItemStyleV2: CSSProperties = {
|
||||
function AlertActionButtons({
|
||||
ruleId,
|
||||
alertDetails,
|
||||
setUpdatedName,
|
||||
}: {
|
||||
ruleId: string;
|
||||
alertDetails: AlertHeaderProps['alertDetails'];
|
||||
setUpdatedName: (name: string) => void;
|
||||
}): JSX.Element {
|
||||
const { alertRuleState, setAlertRuleState, alertRuleName, setAlertRuleName } =
|
||||
useAlertRule();
|
||||
const { alertRuleState, setAlertRuleState } = useAlertRule();
|
||||
const [intermediateName, setIntermediateName] = useState<string>(
|
||||
alertDetails.alert,
|
||||
);
|
||||
@@ -52,7 +53,7 @@ function AlertActionButtons({
|
||||
const { handleAlertDelete } = useAlertRuleDelete({ ruleId });
|
||||
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
|
||||
alertDetails: alertDetails as unknown as AlertDef,
|
||||
setAlertRuleName,
|
||||
setUpdatedName,
|
||||
intermediateName,
|
||||
});
|
||||
|
||||
@@ -112,12 +113,6 @@ 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), []);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMemo, useState } 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,17 +20,8 @@ export type AlertHeaderProps = {
|
||||
};
|
||||
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
const { state, alert: alertName, labels } = alertDetails;
|
||||
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 { alertRuleState } = useAlertRule();
|
||||
const [updatedName, setUpdatedName] = useState(alertName);
|
||||
|
||||
const labelsWithoutSeverity = useMemo(() => {
|
||||
if (labels) {
|
||||
@@ -49,7 +40,7 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
<div className="alert-title-wrapper">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
<div className="alert-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
<LineClampedText text={updatedName || alertName} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,6 +64,7 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
<AlertActionButtons
|
||||
alertDetails={alertDetails}
|
||||
ruleId={alertDetails?.id || ''}
|
||||
setUpdatedName={setUpdatedName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,9 +12,7 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
createRule,
|
||||
deleteRuleByID,
|
||||
getGetRuleByIDQueryKey,
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useListRules,
|
||||
@@ -492,11 +490,11 @@ export const useAlertRuleDuplicate = ({
|
||||
};
|
||||
export const useAlertRuleUpdate = ({
|
||||
alertDetails,
|
||||
setAlertRuleName,
|
||||
setUpdatedName,
|
||||
intermediateName,
|
||||
}: {
|
||||
alertDetails: AlertDef;
|
||||
setAlertRuleName: (name: string | undefined) => void;
|
||||
setUpdatedName: (name: string) => void;
|
||||
intermediateName: string;
|
||||
}): {
|
||||
handleAlertUpdate: () => void;
|
||||
@@ -504,29 +502,17 @@ 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: () => 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' });
|
||||
},
|
||||
onMutate: () => setUpdatedName(intermediateName),
|
||||
onSuccess: () =>
|
||||
notifications.success({ message: 'Alert renamed successfully' }),
|
||||
onError: (error) => {
|
||||
setAlertRuleName(alertDetails.alert);
|
||||
setUpdatedName(alertDetails.alert);
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
@@ -565,6 +551,7 @@ 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,
|
||||
|
||||
@@ -1,44 +1,15 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-tabs-nav {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.configuration-tabs {
|
||||
margin-top: -16px;
|
||||
|
||||
.ant-tabs-nav {
|
||||
.ant-tabs-nav-wrap {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-rules-container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
56
frontend/src/pages/AlertTypeSelection/AlertTypeSelection.tsx
Normal file
56
frontend/src/pages/AlertTypeSelection/AlertTypeSelection.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
@@ -0,0 +1,189 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
3
frontend/src/pages/AlertTypeSelection/index.tsx
Normal file
3
frontend/src/pages/AlertTypeSelection/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import AlertTypeSelectionPage from './AlertTypeSelection';
|
||||
|
||||
export default AlertTypeSelectionPage;
|
||||
@@ -526,7 +526,7 @@ function SpanDetailsPanel({
|
||||
|
||||
const PANEL_WIDTH = 500;
|
||||
const PANEL_MARGIN_RIGHT = 20;
|
||||
const PANEL_MARGIN_TOP = 50;
|
||||
const PANEL_MARGIN_TOP = 25;
|
||||
const PANEL_MARGIN_BOTTOM = 25;
|
||||
|
||||
const content = (
|
||||
|
||||
@@ -580,9 +580,10 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Backend mode: trigger refetch via interestedSpanId
|
||||
// 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.
|
||||
setInterestedSpanId({
|
||||
spanId,
|
||||
isUncollapsed: !collapse,
|
||||
@@ -781,26 +782,19 @@ 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 (isFullDataLoaded || interestedSpanId.spanId === '') {
|
||||
return;
|
||||
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]);
|
||||
}
|
||||
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,
|
||||
]);
|
||||
}, [interestedSpanId, setSelectedSpan, spans, scrollSpanIntoView]);
|
||||
|
||||
// Covers URL-driven navigation to an already-loaded span (flamegraph /
|
||||
// filter / browser back) that the interestedSpanId-keyed effect doesn't see.
|
||||
|
||||
@@ -199,12 +199,10 @@ const mockSpans = [
|
||||
createMockSpan('span-3', 1),
|
||||
];
|
||||
|
||||
// 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.
|
||||
// Shared TestComponent for all tests
|
||||
function TestComponent(): JSX.Element {
|
||||
const [selectedSpan, setSelectedSpan] = React.useState<SpanV3 | undefined>(
|
||||
mockSpans[0],
|
||||
undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -75,7 +75,6 @@ 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.
|
||||
@@ -176,36 +175,12 @@ function TraceDetailsV3(): JSX.Element {
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
// Frontend mode: auto-expand ancestors of the selected span so it becomes visible
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
if (!isFullDataLoaded || !interestedSpanId.spanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
const ancestors = getAncestorSpanIds(allSpans, interestedSpanId.spanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -228,7 +203,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
}, [isFullDataLoaded, interestedSpanId.spanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
@@ -242,7 +217,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DIALOG,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED,
|
||||
);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
|
||||
@@ -9,8 +9,6 @@ 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>(
|
||||
@@ -25,18 +23,13 @@ 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, alertRuleName],
|
||||
[alertRuleState],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -54,7 +47,4 @@ export const useAlertRule = (): AlertRuleContextType => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useAlertRuleOptional = (): AlertRuleContextType | undefined =>
|
||||
useContext(AlertRuleContext);
|
||||
|
||||
export default AlertRuleProvider;
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export interface ILogBody {
|
||||
message?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ILog {
|
||||
date: string;
|
||||
timestamp: number | string;
|
||||
@@ -13,7 +8,7 @@ export interface ILog {
|
||||
traceFlags: number;
|
||||
severityText: string;
|
||||
severityNumber: number;
|
||||
body: string | ILogBody;
|
||||
body: string;
|
||||
resources_string: Record<string, never>;
|
||||
scope_string: Record<string, never>;
|
||||
attributesString: Record<string, never>;
|
||||
|
||||
@@ -132,6 +132,7 @@ 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
2
go.mod
@@ -11,7 +11,6 @@ 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
|
||||
@@ -113,6 +112,7 @@ 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
|
||||
|
||||
@@ -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, dashboardtypes.SourceUser, postableDashboard)
|
||||
dashboard, err := dashboardtypes.NewDashboard(orgID, createdBy, postableDashboard)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -72,16 +72,7 @@ func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboard
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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
|
||||
return dashboardtypes.NewDashboardsFromStorableDashboards(storableDashboards), nil
|
||||
}
|
||||
|
||||
func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatableDashboard dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error) {
|
||||
@@ -90,10 +81,6 @@ 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
|
||||
@@ -118,10 +105,6 @@ 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
|
||||
@@ -145,10 +128,6 @@ 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")
|
||||
}
|
||||
|
||||
@@ -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.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -314,12 +314,19 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -402,7 +409,14 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return nil, err
|
||||
}
|
||||
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -484,14 +498,27 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -662,7 +689,14 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
return nil, err
|
||||
}
|
||||
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -750,9 +784,16 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -840,9 +881,16 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -930,9 +978,16 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -170,29 +170,27 @@ 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,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
req *inframonitoringtypes.PostableNodes,
|
||||
pageGroups []map[string]string,
|
||||
) (map[string]nodeConditionCounts, error) {
|
||||
if len(pageGroups) == 0 || len(groupBy) == 0 {
|
||||
if len(pageGroups) == 0 || len(req.GroupBy) == 0 {
|
||||
return map[string]nodeConditionCounts{}, nil
|
||||
}
|
||||
|
||||
// Merge user filter with page-groups IN clauses.
|
||||
userFilterExpr := ""
|
||||
if filter != nil {
|
||||
userFilterExpr = filter.Expression
|
||||
// Merged filter expression (user filter + page-groups IN clauses).
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, pageGroupsFilterExpr)
|
||||
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
|
||||
|
||||
// Resolve tables. Same convention as pods.
|
||||
adjustedStart, adjustedEnd, _, localTimeSeriesTable := telemetrymetrics.WhichTSTableToUse(
|
||||
uint64(start), uint64(end), nil,
|
||||
uint64(req.Start), uint64(req.End), nil,
|
||||
)
|
||||
samplesTable := telemetrymetrics.WhichSamplesTableToUse(
|
||||
uint64(start), uint64(end),
|
||||
uint64(req.Start), uint64(req.End),
|
||||
metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil,
|
||||
)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(samplesTable)
|
||||
@@ -203,7 +201,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
"fingerprint",
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS node_name", timeSeriesFPs.Var(nodeNameAttrKey)),
|
||||
}
|
||||
for _, key := range groupBy {
|
||||
for _, key := range req.GroupBy {
|
||||
timeSeriesFPsSelectCols = append(timeSeriesFPsSelectCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", timeSeriesFPs.Var(key.Name), quoteIdentifier(key.Name)),
|
||||
)
|
||||
@@ -215,8 +213,8 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
timeSeriesFPs.GE("unix_milli", adjustedStart),
|
||||
timeSeriesFPs.L("unix_milli", adjustedEnd),
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if filterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -225,7 +223,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
}
|
||||
}
|
||||
timeSeriesFPsGroupBy := []string{"fingerprint", "node_name"}
|
||||
for _, key := range groupBy {
|
||||
for _, key := range req.GroupBy {
|
||||
timeSeriesFPsGroupBy = append(timeSeriesFPsGroupBy, quoteIdentifier(key.Name))
|
||||
}
|
||||
timeSeriesFPs.GroupBy(timeSeriesFPsGroupBy...)
|
||||
@@ -235,7 +233,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
latestConditionPerNode := sqlbuilder.NewSelectBuilder()
|
||||
latestConditionPerNodeSelectCols := []string{"tsfp.node_name AS node_name"}
|
||||
latestConditionPerNodeGroupBy := []string{"node_name"}
|
||||
for _, key := range groupBy {
|
||||
for _, key := range req.GroupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
latestConditionPerNodeSelectCols = append(latestConditionPerNodeSelectCols, fmt.Sprintf("tsfp.%s AS %s", col, col))
|
||||
latestConditionPerNodeGroupBy = append(latestConditionPerNodeGroupBy, col)
|
||||
@@ -250,17 +248,17 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
))
|
||||
latestConditionPerNode.Where(
|
||||
latestConditionPerNode.E("samples.metric_name", nodeConditionMetricName),
|
||||
latestConditionPerNode.GE("samples.unix_milli", start),
|
||||
latestConditionPerNode.L("samples.unix_milli", end),
|
||||
latestConditionPerNode.GE("samples.unix_milli", req.Start),
|
||||
latestConditionPerNode.L("samples.unix_milli", req.End),
|
||||
"tsfp.node_name != ''",
|
||||
)
|
||||
latestConditionPerNode.GroupBy(latestConditionPerNodeGroupBy...)
|
||||
latestConditionPerNodeSQL, latestConditionPerNodeArgs := latestConditionPerNode.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- countNodesPerCondition (outer SELECT) -----
|
||||
countNodesPerConditionSelectCols := make([]string, 0, len(groupBy)+2)
|
||||
countNodesPerConditionGroupBy := make([]string, 0, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
countNodesPerConditionSelectCols := make([]string, 0, len(req.GroupBy)+2)
|
||||
countNodesPerConditionGroupBy := make([]string, 0, len(req.GroupBy))
|
||||
for _, key := range req.GroupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
countNodesPerConditionSelectCols = append(countNodesPerConditionSelectCols, col)
|
||||
countNodesPerConditionGroupBy = append(countNodesPerConditionGroupBy, col)
|
||||
@@ -291,8 +289,8 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
|
||||
result := make(map[string]nodeConditionCounts)
|
||||
for rows.Next() {
|
||||
groupVals := make([]string, len(groupBy))
|
||||
scanPtrs := make([]any, 0, len(groupBy)+2)
|
||||
groupVals := make([]string, len(req.GroupBy))
|
||||
scanPtrs := make([]any, 0, len(req.GroupBy)+2)
|
||||
for i := range groupVals {
|
||||
scanPtrs = append(scanPtrs, &groupVals[i])
|
||||
}
|
||||
|
||||
@@ -189,29 +189,27 @@ 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,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
req *inframonitoringtypes.PostablePods,
|
||||
pageGroups []map[string]string,
|
||||
) (map[string]podPhaseCounts, error) {
|
||||
if len(pageGroups) == 0 || len(groupBy) == 0 {
|
||||
if len(pageGroups) == 0 || len(req.GroupBy) == 0 {
|
||||
return map[string]podPhaseCounts{}, nil
|
||||
}
|
||||
|
||||
// Merge user filter with page-groups IN clauses.
|
||||
userFilterExpr := ""
|
||||
if filter != nil {
|
||||
userFilterExpr = filter.Expression
|
||||
// Merged filter expression (user filter + page-groups IN clauses).
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, pageGroupsFilterExpr)
|
||||
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
|
||||
|
||||
// Resolve tables. Same convention as hosts (distributed names from helpers).
|
||||
adjustedStart, adjustedEnd, _, localTimeSeriesTable := telemetrymetrics.WhichTSTableToUse(
|
||||
uint64(start), uint64(end), nil,
|
||||
uint64(req.Start), uint64(req.End), nil,
|
||||
)
|
||||
samplesTable := telemetrymetrics.WhichSamplesTableToUse(
|
||||
uint64(start), uint64(end),
|
||||
uint64(req.Start), uint64(req.End),
|
||||
metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil,
|
||||
)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(samplesTable)
|
||||
@@ -222,7 +220,7 @@ func (m *module) getPerGroupPodPhaseCounts(
|
||||
"fingerprint",
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", timeSeriesFPs.Var(podUIDAttrKey)),
|
||||
}
|
||||
for _, key := range groupBy {
|
||||
for _, key := range req.GroupBy {
|
||||
timeSeriesFPsSelectCols = append(timeSeriesFPsSelectCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", timeSeriesFPs.Var(key.Name), quoteIdentifier(key.Name)),
|
||||
)
|
||||
@@ -234,8 +232,8 @@ func (m *module) getPerGroupPodPhaseCounts(
|
||||
timeSeriesFPs.GE("unix_milli", adjustedStart),
|
||||
timeSeriesFPs.L("unix_milli", adjustedEnd),
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if filterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -244,7 +242,7 @@ func (m *module) getPerGroupPodPhaseCounts(
|
||||
}
|
||||
}
|
||||
timeSeriesFPsGroupBy := []string{"fingerprint", "pod_uid"}
|
||||
for _, key := range groupBy {
|
||||
for _, key := range req.GroupBy {
|
||||
timeSeriesFPsGroupBy = append(timeSeriesFPsGroupBy, quoteIdentifier(key.Name))
|
||||
}
|
||||
timeSeriesFPs.GroupBy(timeSeriesFPsGroupBy...)
|
||||
@@ -253,7 +251,7 @@ func (m *module) getPerGroupPodPhaseCounts(
|
||||
latestPhasePerPod := sqlbuilder.NewSelectBuilder()
|
||||
latestPhasePerPodSelectCols := []string{"tsfp.pod_uid AS pod_uid"}
|
||||
latestPhasePerPodGroupBy := []string{"pod_uid"}
|
||||
for _, key := range groupBy {
|
||||
for _, key := range req.GroupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
latestPhasePerPodSelectCols = append(latestPhasePerPodSelectCols, fmt.Sprintf("tsfp.%s AS %s", col, col))
|
||||
latestPhasePerPodGroupBy = append(latestPhasePerPodGroupBy, col)
|
||||
@@ -268,17 +266,17 @@ func (m *module) getPerGroupPodPhaseCounts(
|
||||
))
|
||||
latestPhasePerPod.Where(
|
||||
latestPhasePerPod.E("samples.metric_name", podPhaseMetricName),
|
||||
latestPhasePerPod.GE("samples.unix_milli", start),
|
||||
latestPhasePerPod.L("samples.unix_milli", end),
|
||||
latestPhasePerPod.GE("samples.unix_milli", req.Start),
|
||||
latestPhasePerPod.L("samples.unix_milli", req.End),
|
||||
"tsfp.pod_uid != ''",
|
||||
)
|
||||
latestPhasePerPod.GroupBy(latestPhasePerPodGroupBy...)
|
||||
latestPhasePerPodSQL, latestPhasePerPodArgs := latestPhasePerPod.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- countPodsPerPhase (outer SELECT) -----
|
||||
countPodsPerPhaseSelectCols := make([]string, 0, len(groupBy)+5)
|
||||
countPodsPerPhaseGroupBy := make([]string, 0, len(groupBy))
|
||||
for _, key := range groupBy {
|
||||
countPodsPerPhaseSelectCols := make([]string, 0, len(req.GroupBy)+5)
|
||||
countPodsPerPhaseGroupBy := make([]string, 0, len(req.GroupBy))
|
||||
for _, key := range req.GroupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
countPodsPerPhaseSelectCols = append(countPodsPerPhaseSelectCols, col)
|
||||
countPodsPerPhaseGroupBy = append(countPodsPerPhaseGroupBy, col)
|
||||
@@ -312,8 +310,8 @@ func (m *module) getPerGroupPodPhaseCounts(
|
||||
|
||||
result := make(map[string]podPhaseCounts)
|
||||
for rows.Next() {
|
||||
groupVals := make([]string, len(groupBy))
|
||||
scanPtrs := make([]any, 0, len(groupBy)+5)
|
||||
groupVals := make([]string, len(req.GroupBy))
|
||||
scanPtrs := make([]any, 0, len(req.GroupBy)+5)
|
||||
for i := range groupVals {
|
||||
scanPtrs = append(scanPtrs, &groupVals[i])
|
||||
}
|
||||
|
||||
@@ -199,16 +199,7 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
|
||||
return q.executeWindowList(ctx)
|
||||
}
|
||||
|
||||
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)
|
||||
stmt, err := q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -224,81 +215,6 @@ 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{
|
||||
@@ -394,22 +310,42 @@ 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.
|
||||
// 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
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,8 @@ 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 (
|
||||
@@ -24,8 +22,6 @@ 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
|
||||
@@ -397,16 +393,11 @@ 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: unmarshal bytes into map[string]any
|
||||
// Post-process JSON columns: normalize into String value
|
||||
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
|
||||
switch x := val.(type) {
|
||||
case []byte:
|
||||
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
|
||||
val = string(x)
|
||||
default:
|
||||
// already a structured type (map[string]any, []any, etc.)
|
||||
}
|
||||
|
||||
@@ -12,12 +12,9 @@ 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.
|
||||
@@ -53,7 +50,7 @@ func getQueryName(spec any) string {
|
||||
return getqueryInfo(spec).Name
|
||||
}
|
||||
|
||||
func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
|
||||
func (q *querier) postProcessResults(ctx context.Context, 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 {
|
||||
@@ -72,7 +69,6 @@ func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, res
|
||||
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]:
|
||||
@@ -1050,33 +1046,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
@@ -36,7 +35,6 @@ var (
|
||||
|
||||
type querier struct {
|
||||
logger *slog.Logger
|
||||
fl flagger.Flagger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
@@ -64,12 +62,10 @@ 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,
|
||||
@@ -688,7 +684,7 @@ func (q *querier) run(
|
||||
}
|
||||
|
||||
gomaps.Copy(results, preseededResults)
|
||||
processedResults, err := q.postProcessResults(ctx, orgID, results, req)
|
||||
processedResults, err := q.postProcessResults(ctx, results, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
@@ -45,15 +44,14 @@ 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
|
||||
flaggertest.New(t), // flagger
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
@@ -118,7 +116,6 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flaggertest.New(t), // flagger
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
|
||||
@@ -186,6 +186,5 @@ func newProvider(
|
||||
meterStmtBuilder,
|
||||
traceOperatorStmtBuilder,
|
||||
bucketCache,
|
||||
flagger,
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -348,8 +348,7 @@ func (m *Manager) GetInstalledIntegrationDashboardById(
|
||||
CreatedBy: author,
|
||||
UpdatedBy: author,
|
||||
},
|
||||
OrgID: orgId,
|
||||
Source: dashboardtypes.SourceIntegration,
|
||||
OrgID: orgId,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -388,8 +387,7 @@ func (m *Manager) GetDashboardsForInstalledIntegrations(
|
||||
CreatedBy: author,
|
||||
UpdatedBy: author,
|
||||
},
|
||||
OrgID: orgId,
|
||||
Source: dashboardtypes.SourceIntegration,
|
||||
OrgID: orgId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flagger,
|
||||
), metadataStore
|
||||
}
|
||||
|
||||
@@ -103,7 +102,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,6 +146,5 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -204,7 +204,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddRoleCRUDTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddIntegrationDashboardFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddSourceToDashboardFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -366,7 +366,6 @@ func GetDashboardsFromAssets(
|
||||
CreatedBy: author,
|
||||
UpdatedBy: author,
|
||||
},
|
||||
Source: dashboardtypes.SourceIntegration,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ 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 {
|
||||
@@ -32,7 +30,6 @@ 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 {
|
||||
@@ -43,7 +40,6 @@ type Dashboard struct {
|
||||
Data StorableDashboardData `json:"data"`
|
||||
Locked bool `json:"locked"`
|
||||
OrgID valuer.UUID `json:"org_id"`
|
||||
Source Source `json:"source"`
|
||||
}
|
||||
|
||||
type LockUnlockDashboard struct {
|
||||
@@ -68,10 +64,6 @@ 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,
|
||||
@@ -87,15 +79,10 @@ 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, 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())
|
||||
}
|
||||
|
||||
func NewDashboard(orgID valuer.UUID, createdBy string, storableDashboardData StorableDashboardData) (*Dashboard, error) {
|
||||
currentTime := time.Now()
|
||||
|
||||
return &Dashboard{
|
||||
@@ -111,7 +98,6 @@ func NewDashboard(orgID valuer.UUID, createdBy string, source Source, storableDa
|
||||
OrgID: orgID,
|
||||
Data: storableDashboardData,
|
||||
Locked: false,
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -129,7 +115,6 @@ func NewDashboardFromStorableDashboard(storableDashboard *StorableDashboard) *Da
|
||||
OrgID: storableDashboard.OrgID,
|
||||
Data: storableDashboard.Data,
|
||||
Locked: storableDashboard.Locked,
|
||||
Source: storableDashboard.Source,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +147,6 @@ func NewGettableDashboardFromDashboard(dashboard *Dashboard) (*GettableDashboard
|
||||
OrgID: dashboard.OrgID,
|
||||
Data: dashboard.Data,
|
||||
Locked: dashboard.Locked,
|
||||
Source: dashboard.Source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -254,43 +238,6 @@ 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")
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestCanUpdate_MultipleDeletions_ByDiff(t *testing.T) {
|
||||
initial := StorableDashboardData{
|
||||
"widgets": makeTestWidgets("a", "b", "c"),
|
||||
}
|
||||
d, err := NewDashboard(orgID, "tester", SourceUser, initial)
|
||||
d, err := NewDashboard(orgID, "tester", initial)
|
||||
assert.NoError(t, err)
|
||||
|
||||
updated := StorableDashboardData{
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
5
tests/fixtures/http.py
vendored
5
tests/fixtures/http.py
vendored
@@ -17,6 +17,8 @@ from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
ZEUS_NETWORK_ALIAS = "signoz-zeus"
|
||||
|
||||
|
||||
@pytest.fixture(name="zeus", scope="package")
|
||||
def zeus(
|
||||
@@ -31,6 +33,7 @@ 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(
|
||||
@@ -42,7 +45,7 @@ def zeus(
|
||||
container.get_exposed_port(8080),
|
||||
)
|
||||
},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", ZEUS_NETWORK_ALIAS, 8080)},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker):
|
||||
|
||||
136
tests/fixtures/signoz.py
vendored
136
tests/fixtures/signoz.py
vendored
@@ -1,14 +1,19 @@
|
||||
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
|
||||
@@ -16,6 +21,110 @@ 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,
|
||||
@@ -33,9 +142,6 @@ 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")
|
||||
|
||||
@@ -48,19 +154,15 @@ def create_signoz(
|
||||
if with_web:
|
||||
dockerfile_path = "cmd/enterprise/Dockerfile.with-web.integration"
|
||||
|
||||
# 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()
|
||||
# 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
|
||||
|
||||
env = (
|
||||
{
|
||||
|
||||
@@ -20,7 +20,6 @@ from fixtures.querier import (
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
def test_logs_list(
|
||||
@@ -2294,333 +2293,3 @@ 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"
|
||||
|
||||
@@ -2123,116 +2123,3 @@ 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}"
|
||||
|
||||
@@ -21,7 +21,7 @@ from fixtures.querier import (
|
||||
|
||||
|
||||
def _get_bodies(response: requests.Response) -> list[dict[str, Any]]:
|
||||
return [row["data"]["body"] for row in get_rows(response)]
|
||||
return [json.loads(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 [row["data"]["body"].get("message", "") for row in get_rows(response)]
|
||||
return [json.loads(row["data"]["body"]).get("message", "") for row in get_rows(response)]
|
||||
|
||||
payment_messages = {
|
||||
"Payment processed successfully",
|
||||
|
||||
@@ -69,11 +69,11 @@ def _scalar(
|
||||
|
||||
|
||||
def _body_users(response: requests.Response) -> set[str | None]:
|
||||
return {row["data"]["body"].get("user") for row in get_rows(response)}
|
||||
return {json.loads(row["data"]["body"]).get("user") for row in get_rows(response)}
|
||||
|
||||
|
||||
def _body_scores(response: requests.Response) -> list[int | None]:
|
||||
return [row["data"]["body"].get("score") for row in get_rows(response)]
|
||||
return [json.loads(row["data"]["body"]).get("score") for row in get_rows(response)]
|
||||
|
||||
|
||||
def _services(response: requests.Response) -> list[str]:
|
||||
|
||||
Reference in New Issue
Block a user