mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-08 19:40:30 +01:00
Compare commits
8 Commits
test/flaky
...
postproces
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
297ff0a1d6 | ||
|
|
e99ac3dd76 | ||
|
|
757c4a84c0 | ||
|
|
0ad2a49b5b | ||
|
|
bcaccff2eb | ||
|
|
71d27b7022 | ||
|
|
7ed9627ae5 | ||
|
|
2a747df764 |
@@ -327,6 +327,11 @@ function App(): JSX.Element {
|
||||
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
|
||||
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||
beforeSend(event) {
|
||||
// Drop the event if its level is 'warning' or 'info'
|
||||
if (event.level === 'warning' || event.level === 'info') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionReplayUrl = posthog.get_session_replay_url?.({
|
||||
withTimestamp: true,
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -217,20 +218,17 @@ function LogDetailInner({
|
||||
|
||||
const logBody = useMemo(() => {
|
||||
if (!isBodyJsonQueryEnabled) {
|
||||
return log?.body || '';
|
||||
return (log?.body as string) ?? '';
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(log?.body || '');
|
||||
|
||||
if (typeof json?.message === 'string' && json.message !== '') {
|
||||
return json.message;
|
||||
}
|
||||
|
||||
return log?.body || '';
|
||||
} catch (error) {
|
||||
return log?.body || '';
|
||||
// Feature enabled: body is always a map; message is always a string
|
||||
const bodyObj = log?.body as ILogBody;
|
||||
if (!bodyObj) {
|
||||
return '';
|
||||
}
|
||||
if (bodyObj.message) {
|
||||
return bodyObj.message;
|
||||
}
|
||||
return JSON.stringify(bodyObj);
|
||||
}, [isBodyJsonQueryEnabled, log?.body]);
|
||||
|
||||
const htmlBody = useMemo(
|
||||
|
||||
@@ -9,7 +9,10 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { Tooltip } from 'antd';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
// hooks
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -99,7 +102,7 @@ function RawLogView({
|
||||
// Check if body is selected
|
||||
const showBody = selectedFields.some((field) => field.name === 'body');
|
||||
if (showBody) {
|
||||
parts.push(`${attributesText} ${data.body}`);
|
||||
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
|
||||
} else {
|
||||
parts.push(attributesText);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { ReactElement } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
@@ -87,7 +90,7 @@ export function useLogsTableColumns({
|
||||
? {
|
||||
id: 'body',
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => log.body,
|
||||
accessorFn: (log): string => getBodyDisplayString(log.body),
|
||||
canBeHidden: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
cell: ({ value, isActive }): ReactElement => (
|
||||
|
||||
@@ -56,4 +56,5 @@ export enum QueryParams {
|
||||
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
|
||||
isTestAlert = 'isTestAlert',
|
||||
yAxisUnit = 'yAxisUnit',
|
||||
ruleName = 'ruleName',
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
AlertThresholdOperator,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -410,6 +411,7 @@ export function RoutingPolicyBanner({
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
}: RoutingPolicyBannerProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
return (
|
||||
<div className="routing-policies-info-banner">
|
||||
<Typography.Text>
|
||||
@@ -427,10 +429,10 @@ export function RoutingPolicyBanner({
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
href={ROUTING_POLICIES_ROUTE}
|
||||
type="link"
|
||||
className="view-routing-policies-button"
|
||||
data-testid="view-routing-policies-button"
|
||||
onClick={(): void => safeNavigate(ROUTING_POLICIES_ROUTE)}
|
||||
>
|
||||
View Routing Policies
|
||||
<ArrowRight size={14} />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useCreateAlertState } from 'container/CreateAlertV2/context';
|
||||
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
|
||||
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
|
||||
@@ -39,14 +41,22 @@ function ChartPreview({
|
||||
|
||||
const yAxisUnit = alertState.yAxisUnit || '';
|
||||
|
||||
// Only update automatically when creating a new metrics-based alert rule
|
||||
const location = useLocation();
|
||||
const yAxisUnitFromURL = new URLSearchParams(location.search).get(
|
||||
QueryParams.yAxisUnit,
|
||||
);
|
||||
|
||||
// Only update automatically when creating a new metrics-based alert rule.
|
||||
// Skip when yAxisUnit was explicitly provided via URL (e.g. from ingestion settings).
|
||||
const shouldUpdateYAxisUnit = useMemo(() => {
|
||||
// Do not update if we are coming to the page from dashboards (we still show warning)
|
||||
if (source === YAxisSource.DASHBOARDS) {
|
||||
return false;
|
||||
}
|
||||
if (yAxisUnitFromURL) {
|
||||
return false;
|
||||
}
|
||||
return !isEditMode && alertType === AlertTypes.METRICS_BASED_ALERT;
|
||||
}, [isEditMode, alertType, source]);
|
||||
}, [isEditMode, alertType, source, yAxisUnitFromURL]);
|
||||
|
||||
const selectedQueryName = thresholdState.selectedQuery;
|
||||
const { yAxisUnit: initialYAxisUnit, isLoading } =
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
@@ -123,6 +124,8 @@ export function CreateAlertProvider(
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const thresholdsFromURL = queryParams.get(QueryParams.thresholds);
|
||||
const ruleNameFromURL = queryParams.get(QueryParams.ruleName);
|
||||
const yAxisUnitFromURL = queryParams.get(QueryParams.yAxisUnit);
|
||||
|
||||
const [alertType, setAlertType] = useState<AlertTypes>(() => {
|
||||
if (isEditMode) {
|
||||
@@ -154,6 +157,9 @@ export function CreateAlertProvider(
|
||||
[redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const ruleNameAppliedRef = useRef(false);
|
||||
const yAxisUnitAppliedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
@@ -191,7 +197,29 @@ export function CreateAlertProvider(
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [alertType, thresholdsFromURL]);
|
||||
|
||||
if (ruleNameFromURL && !ruleNameAppliedRef.current) {
|
||||
ruleNameAppliedRef.current = true;
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.BASIC,
|
||||
action: {
|
||||
type: 'SET_ALERT_NAME',
|
||||
payload: ruleNameFromURL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (yAxisUnitFromURL && !yAxisUnitAppliedRef.current) {
|
||||
yAxisUnitAppliedRef.current = true;
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.BASIC,
|
||||
action: {
|
||||
type: 'SET_Y_AXIS_UNIT',
|
||||
payload: yAxisUnitFromURL,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [alertType, thresholdsFromURL, ruleNameFromURL, yAxisUnitFromURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && initialAlertState) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { isArray } from 'lodash-es';
|
||||
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -173,7 +174,7 @@ export default function Events({
|
||||
(event): EventDataType => ({
|
||||
timestamp: event.timestamp,
|
||||
severity: event.data.severity_text,
|
||||
body: event.data.body,
|
||||
body: getBodyDisplayString(event.data.body),
|
||||
id: event.data.id,
|
||||
key: event.data.id,
|
||||
resources_string: event.data.resources_string,
|
||||
|
||||
@@ -443,7 +443,25 @@
|
||||
|
||||
.signal-limit-save-discard {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: var(--spacing-4);
|
||||
|
||||
.signal-limit-save-discard-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.signal-limit-alert-helper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
color: var(--l2-foreground);
|
||||
border-bottom: 1px dashed var(--l2-foreground);
|
||||
padding-bottom: 1px;
|
||||
font-style: italic;
|
||||
margin-left: var(--spacing-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,6 +493,7 @@
|
||||
.ant-modal-footer {
|
||||
padding: 16px;
|
||||
margin-top: 0;
|
||||
gap: 8px;
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Badge, Button } from '@signozhq/ui';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Collapse,
|
||||
DatePicker,
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { AxiosError } from 'axios';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import Tags from 'components/Tags/Tags';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -98,9 +99,30 @@ const COUNT_MULTIPLIER = {
|
||||
};
|
||||
|
||||
const SIGNALS_CONFIG = [
|
||||
{ name: 'logs', usesSize: true, usesCount: false },
|
||||
{ name: 'traces', usesSize: true, usesCount: false },
|
||||
{ name: 'metrics', usesSize: false, usesCount: true },
|
||||
{
|
||||
name: 'logs',
|
||||
usesSize: true,
|
||||
usesCount: false,
|
||||
metricName: 'signoz.meter.log.size',
|
||||
yAxisUnit: UniversalYAxisUnit.BYTES_IEC,
|
||||
thresholdUnit: UniversalYAxisUnit.GIBIBYTES,
|
||||
},
|
||||
{
|
||||
name: 'traces',
|
||||
usesSize: true,
|
||||
usesCount: false,
|
||||
metricName: 'signoz.meter.span.size',
|
||||
yAxisUnit: UniversalYAxisUnit.BYTES_IEC,
|
||||
thresholdUnit: UniversalYAxisUnit.GIBIBYTES,
|
||||
},
|
||||
{
|
||||
name: 'metrics',
|
||||
usesSize: false,
|
||||
usesCount: true,
|
||||
metricName: 'signoz.meter.metric.datapoint.count',
|
||||
yAxisUnit: UniversalYAxisUnit.COUNT,
|
||||
thresholdUnit: UniversalYAxisUnit.COUNT,
|
||||
},
|
||||
];
|
||||
|
||||
// Using any type here because antd's DatePicker expects its own internal Dayjs type
|
||||
@@ -394,7 +416,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
notifications.success({
|
||||
message: 'Ingestion key deleted successfully',
|
||||
});
|
||||
refetchAPIKeys();
|
||||
void refetchAPIKeys();
|
||||
setIsDeleteModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -426,7 +448,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
notifications.success({
|
||||
message: 'Ingestion key updated successfully',
|
||||
});
|
||||
refetchAPIKeys();
|
||||
void refetchAPIKeys();
|
||||
setIsEditModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -466,7 +488,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
setActiveAPIKey(null);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
void refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
@@ -630,13 +652,14 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Limit created successfully',
|
||||
description: "Set up an alert to know when you're close to hitting it.",
|
||||
});
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
void refetchAPIKeys();
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
@@ -733,13 +756,14 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Limit updated successfully',
|
||||
description: "Set up an alert to know when you're close to hitting it.",
|
||||
});
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
void refetchAPIKeys();
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
@@ -824,7 +848,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
setIsDeleteLimitModalOpen(false);
|
||||
refetchAPIKeys();
|
||||
void refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
@@ -840,29 +864,22 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
let metricName = '';
|
||||
|
||||
switch (signal.signal) {
|
||||
case 'metrics':
|
||||
metricName = 'signoz.meter.metric.datapoint.count';
|
||||
break;
|
||||
case 'traces':
|
||||
metricName = 'signoz.meter.span.size';
|
||||
break;
|
||||
case 'logs':
|
||||
metricName = 'signoz.meter.log.size';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
const signalCfg = SIGNALS_CONFIG.find((cfg) => cfg.name === signal.signal);
|
||||
if (!signalCfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threshold =
|
||||
signal.signal === 'metrics'
|
||||
? signal.config?.day?.count || 0
|
||||
: signal.config?.day?.size || 0;
|
||||
const { metricName, yAxisUnit, thresholdUnit } = signalCfg;
|
||||
|
||||
// Size signals store the limit in bytes but the user entered GiB; pass the GiB
|
||||
// value so the threshold reads "400 GiB" while the chart Y-axis stays in bytes.
|
||||
const thresholdValue = signalCfg.usesCount
|
||||
? signal.config?.day?.count || 0
|
||||
: bytesToGb(signal.config?.day?.size);
|
||||
|
||||
const query = {
|
||||
...initialQueryMeterWithType,
|
||||
unit: yAxisUnit,
|
||||
builder: {
|
||||
...initialQueryMeterWithType.builder,
|
||||
queryData: [
|
||||
@@ -887,13 +904,23 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const stringifiedQuery = JSON.stringify(query);
|
||||
|
||||
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
|
||||
thresholds[0].thresholdValue = threshold;
|
||||
thresholds[0].thresholdValue = thresholdValue;
|
||||
thresholds[0].unit = thresholdUnit;
|
||||
|
||||
const keyName = APIKey.name?.trim();
|
||||
const ruleName = keyName
|
||||
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
|
||||
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
|
||||
|
||||
const URL = `${ROUTES.ALERTS_NEW}?${
|
||||
QueryParams.compositeQuery
|
||||
}=${encodeURIComponent(stringifiedQuery)}&${
|
||||
QueryParams.thresholds
|
||||
}=${encodeURIComponent(JSON.stringify(thresholds))}`;
|
||||
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
|
||||
QueryParams.ruleName
|
||||
}=${encodeURIComponent(ruleName)}&${
|
||||
QueryParams.yAxisUnit
|
||||
}=${encodeURIComponent(yAxisUnit)}`;
|
||||
|
||||
history.push(URL);
|
||||
};
|
||||
@@ -980,13 +1007,18 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
</div>
|
||||
<div className="action-btn">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<PenLine size={14} />}
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
suffix={<PenLine size={14} />}
|
||||
aria-label="Edit ingestion key"
|
||||
onClick={onEditKey}
|
||||
/>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="destructive"
|
||||
suffix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
onClick={onDeleteKey}
|
||||
/>
|
||||
</div>
|
||||
@@ -1092,16 +1124,22 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
{hasLimits(signalName) ? (
|
||||
<>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<PenLine size={14} />}
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
prefix={<PenLine size={14} />}
|
||||
aria-label={`Edit ${signalName} limit`}
|
||||
disabled={
|
||||
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
|
||||
}
|
||||
onClick={onEditSignalLimit}
|
||||
/>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="destructive"
|
||||
prefix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
aria-label={`Delete ${signalName} limit`}
|
||||
disabled={
|
||||
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
|
||||
}
|
||||
@@ -1110,10 +1148,10 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="periscope-btn"
|
||||
size="small"
|
||||
shape="round"
|
||||
icon={<PlusIcon size={14} />}
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<PlusIcon size={12} />}
|
||||
disabled={!!(activeAPIKey?.id === APIKey?.id && activeSignal)}
|
||||
onClick={onAddSignalLimit}
|
||||
>
|
||||
@@ -1344,31 +1382,35 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
activeSignal.signal === signalName &&
|
||||
isEditAddLimitOpen && (
|
||||
<div className="signal-limit-save-discard">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
size="small"
|
||||
disabled={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
loading={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
onClick={onSaveSignalLimit}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn"
|
||||
size="small"
|
||||
disabled={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
onClick={handleDiscardSaveLimit}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<div className="signal-limit-save-discard-actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
disabled={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
loading={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
onClick={onSaveSignalLimit}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
onClick={handleDiscardSaveLimit}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<span className="signal-limit-alert-helper">
|
||||
You can set up an alert after saving
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
@@ -1425,19 +1467,18 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
limit?.config?.day?.size !== undefined) ||
|
||||
(signalCfg.usesCount &&
|
||||
limit?.config?.day?.count !== undefined)) && (
|
||||
<Tooltip
|
||||
title="Set alert on this limit"
|
||||
placement="top"
|
||||
arrow={false}
|
||||
<Badge
|
||||
asChild
|
||||
color="cherry"
|
||||
variant="outline"
|
||||
testId={`set-alert-btn-${signalName}`}
|
||||
className="set-alert-btn"
|
||||
>
|
||||
<Button
|
||||
icon={<BellPlus size={14} color={Color.BG_CHERRY_400} />}
|
||||
className="set-alert-btn periscope-btn ghost"
|
||||
type="text"
|
||||
data-testid={`set-alert-btn-${signalName}`}
|
||||
onClick={onCreateSignalAlert}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button onClick={onCreateSignalAlert} size="sm">
|
||||
<BellPlus size={12} />
|
||||
Set alert
|
||||
</Button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1617,7 +1658,13 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
}
|
||||
placement="topLeft"
|
||||
>
|
||||
<Button type="text" icon={<TriangleAlert size={14} />} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
prefix={<TriangleAlert size={14} />}
|
||||
aria-label="Ingestion URL error details"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -1633,11 +1680,12 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
className="add-new-ingestion-key-btn"
|
||||
type="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={showAddModal}
|
||||
>
|
||||
<Plus size={14} /> New Ingestion key
|
||||
New Ingestion key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1670,15 +1718,19 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<X size={16} />}
|
||||
onClick={hideDeleteViewModal}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
icon={<Trash2 size={16} />}
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={16} />}
|
||||
loading={isDeleteingAPIKey}
|
||||
onClick={onDeleteHandler}
|
||||
className="delete-btn"
|
||||
@@ -1706,15 +1758,19 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<X size={16} />}
|
||||
onClick={hideDeleteLimitModal}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
icon={<Trash2 size={16} />}
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={16} />}
|
||||
loading={isDeletingLimit}
|
||||
onClick={onDeleteLimitHandler}
|
||||
className="delete-btn"
|
||||
@@ -1745,18 +1801,18 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<X size={16} />}
|
||||
onClick={hideEditViewModal}
|
||||
className="periscope-btn cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
className="periscope-btn primary"
|
||||
key="submit"
|
||||
type="primary"
|
||||
variant="solid"
|
||||
prefix={<Check size={14} />}
|
||||
loading={isLoadingUpdateAPIKey}
|
||||
icon={<Check size={14} />}
|
||||
onClick={onUpdateApiKey}
|
||||
>
|
||||
Update Ingestion Key
|
||||
@@ -1813,18 +1869,18 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<X size={16} />}
|
||||
onClick={hideAddViewModal}
|
||||
className="periscope-btn cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
className="periscope-btn primary"
|
||||
test-id="create-new-key"
|
||||
key="submit"
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
variant="solid"
|
||||
testId="create-new-key"
|
||||
prefix={<Check size={14} />}
|
||||
loading={isLoadingCreateAPIKey}
|
||||
onClick={onCreateIngestionKey}
|
||||
>
|
||||
@@ -1858,7 +1914,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
]}
|
||||
validateTrigger="onBlur"
|
||||
>
|
||||
<Input placeholder="Enter Ingestion Key name" autoFocus />
|
||||
<Input placeholder="Enter Ingestion Key name" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { LimitProps } from 'types/api/ingestionKeys/limits/types';
|
||||
import {
|
||||
AllIngestionKeyProps,
|
||||
IngestionKeyProps,
|
||||
} from 'types/api/ingestionKeys/types';
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import MultiIngestionSettings from '../MultiIngestionSettings';
|
||||
|
||||
// Extend the existing types to include limits with proper structure
|
||||
interface TestIngestionKeyProps extends Omit<IngestionKeyProps, 'limits'> {
|
||||
limits?: LimitProps[];
|
||||
}
|
||||
|
||||
interface TestAllIngestionKeyProps extends Omit<AllIngestionKeyProps, 'data'> {
|
||||
data: TestIngestionKeyProps[];
|
||||
}
|
||||
|
||||
// Gateway API response type (uses actual schema types for contract safety)
|
||||
interface TestGatewayIngestionKeysResponse {
|
||||
status: string;
|
||||
@@ -40,6 +32,16 @@ const TEST_EXPIRES_AT = '2030-01-01T00:00:00Z';
|
||||
const TEST_WORKSPACE_ID = 'w1';
|
||||
const INGESTION_SETTINGS_ROUTE = '/ingestion-settings';
|
||||
|
||||
const GLOBAL_CONFIG_RESPONSE = {
|
||||
status: 'success',
|
||||
data: {
|
||||
external_url: '',
|
||||
ingestion_url: 'http://ingest.example.com',
|
||||
ai_assistant_url: null,
|
||||
mcp_url: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe('MultiIngestionSettings Page', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear();
|
||||
@@ -71,11 +73,6 @@ describe('MultiIngestionSettings Page', () => {
|
||||
});
|
||||
|
||||
it('navigates to create alert with metrics count threshold', async () => {
|
||||
// Increase timeout - test involves multiple user interactions and async waits
|
||||
// that can exceed 5s default under heavy load (full test suite)
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Arrange API response with a metrics daily count limit so the alert button is visible
|
||||
const response: TestGatewayIngestionKeysResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
@@ -103,95 +100,174 @@ describe('MultiIngestionSettings Page', () => {
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get('*/api/v1/global/config*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(GLOBAL_CONFIG_RESPONSE)),
|
||||
),
|
||||
rest.get('*/api/v2/gateway/ingestion_keys*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(response)),
|
||||
),
|
||||
);
|
||||
|
||||
// Render with initial route to test navigation
|
||||
render(<MultiIngestionSettings />, undefined, {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
// Wait for ingestion key to load and expand the row to show limits
|
||||
await screen.findByText('Key One');
|
||||
const expandButton = screen.getByRole('button', { name: /right Key One/i });
|
||||
await user.click(expandButton);
|
||||
fireEvent.click(screen.getByRole('button', { name: /right Key One/i }));
|
||||
|
||||
// Wait for limits section to render and click metrics alert button by test id
|
||||
await screen.findByText('LIMITS');
|
||||
const metricsAlertBtn = (await screen.findByTestId(
|
||||
'set-alert-btn-metrics',
|
||||
)) as HTMLButtonElement;
|
||||
await user.click(metricsAlertBtn);
|
||||
fireEvent.click(
|
||||
(await screen.findByTestId('set-alert-btn-metrics')) as HTMLButtonElement,
|
||||
);
|
||||
|
||||
// Wait for navigation to occur
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Assert: navigation occurred with correct query parameters
|
||||
const navigationCall = mockPush.mock.calls[0][0] as string;
|
||||
|
||||
// Check URL contains alerts/new route
|
||||
expect(navigationCall).toContain('/alerts/new');
|
||||
|
||||
// Parse query parameters
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
const thresholds = JSON.parse(urlParams.get(QueryParams.thresholds) || '{}');
|
||||
expect(thresholds).toBeDefined();
|
||||
expect(thresholds[0].thresholdValue).toBe(1000);
|
||||
expect(thresholds[0].unit).toBe('{count}');
|
||||
|
||||
// Verify compositeQuery parameter exists and contains correct data
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
expect(compositeQuery.unit).toBe('{count}');
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
|
||||
// Check that the query contains the correct filter expression for the key
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"signoz.workspace.key.id='k1'",
|
||||
);
|
||||
|
||||
// Verify metric name for metrics signal
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
'signoz.meter.metric.datapoint.count',
|
||||
);
|
||||
}, 15000);
|
||||
|
||||
// skipping the flaky test
|
||||
it.skip('navigates to create alert for logs with size threshold', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('{count}');
|
||||
expect(urlParams.get(QueryParams.ruleName)).toContain('metrics');
|
||||
});
|
||||
|
||||
// Arrange API response with a logs daily size limit so the alert button is visible
|
||||
const response: TestAllIngestionKeyProps = {
|
||||
it('navigates to create alert for logs with GiB threshold and bytes yAxisUnit', async () => {
|
||||
const GIB = 1073741824;
|
||||
const sizeInBytes = 400 * GIB;
|
||||
|
||||
const response: TestGatewayIngestionKeysResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
name: 'Key Two',
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k2',
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l2',
|
||||
signal: 'logs',
|
||||
config: { day: { size: 2048 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
data: {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key Logs',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k2',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l2',
|
||||
signal: 'logs',
|
||||
config: { day: { size: sizeInBytes } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get('*/workspaces/me/keys*', (_req, res, ctx) =>
|
||||
rest.get('*/api/v1/global/config*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(GLOBAL_CONFIG_RESPONSE)),
|
||||
),
|
||||
rest.get('*/api/v2/gateway/ingestion_keys*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(response)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<MultiIngestionSettings />, undefined, {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
await screen.findByText('Key Logs');
|
||||
fireEvent.click(screen.getByRole('button', { name: /right Key Logs/i }));
|
||||
|
||||
await screen.findByText('LIMITS');
|
||||
fireEvent.click(
|
||||
(await screen.findByTestId('set-alert-btn-logs')) as HTMLButtonElement,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const navigationCall = mockPush.mock.calls[0][0] as string;
|
||||
expect(navigationCall).toContain('/alerts/new');
|
||||
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
const thresholds = JSON.parse(urlParams.get(QueryParams.thresholds) || '{}');
|
||||
expect(thresholds[0].thresholdValue).toBe(400);
|
||||
expect(thresholds[0].unit).toBe('GiBy');
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.unit).toBe('bytes');
|
||||
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"signoz.workspace.key.id='k2'",
|
||||
);
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
'signoz.meter.log.size',
|
||||
);
|
||||
|
||||
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
|
||||
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');
|
||||
});
|
||||
|
||||
it('shows alert CTAs in view mode and helper text in edit mode for configured limits', async () => {
|
||||
const KEY_NAME = 'Key With Limits';
|
||||
const response: TestGatewayIngestionKeysResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
keys: [
|
||||
{
|
||||
name: KEY_NAME,
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l1',
|
||||
signal: 'metrics',
|
||||
config: { day: { count: 1000 } },
|
||||
},
|
||||
{
|
||||
id: 'l2',
|
||||
signal: 'logs',
|
||||
config: { day: { size: 1073741824 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get('*/api/v1/global/config*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(GLOBAL_CONFIG_RESPONSE)),
|
||||
),
|
||||
rest.get('*/api/v2/gateway/ingestion_keys*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(response)),
|
||||
),
|
||||
);
|
||||
@@ -200,54 +276,18 @@ describe('MultiIngestionSettings Page', () => {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
|
||||
// Wait for ingestion key to load and expand the row to show limits
|
||||
await screen.findByText('Key Two');
|
||||
const expandButton = screen.getByRole('button', { name: /right Key Two/i });
|
||||
await user.click(expandButton);
|
||||
|
||||
// Wait for limits section to render and click logs alert button by test id
|
||||
await screen.findByText(KEY_NAME);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: new RegExp(`right ${KEY_NAME}`, 'i') }),
|
||||
);
|
||||
await screen.findByText('LIMITS');
|
||||
const logsAlertBtn = (await screen.findByTestId(
|
||||
'set-alert-btn-logs',
|
||||
)) as HTMLButtonElement;
|
||||
await user.click(logsAlertBtn);
|
||||
|
||||
// Wait for navigation to occur
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.getAllByText('Set alert').length).toBeGreaterThan(0);
|
||||
|
||||
// Assert: navigation occurred with correct query parameters
|
||||
const navigationCall = mockPush.mock.calls[0][0] as string;
|
||||
|
||||
// Check URL contains alerts/new route
|
||||
expect(navigationCall).toContain('/alerts/new');
|
||||
|
||||
// Parse query parameters
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
// Verify thresholds parameter
|
||||
const thresholds = JSON.parse(urlParams.get(QueryParams.thresholds) || '{}');
|
||||
expect(thresholds).toBeDefined();
|
||||
expect(thresholds[0].thresholdValue).toBe(2048);
|
||||
|
||||
// Verify compositeQuery parameter exists and contains correct data
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
|
||||
// Check that the query contains the correct filter expression for the key
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"signoz.workspace.key.id='k2'",
|
||||
);
|
||||
|
||||
// Verify metric name for logs signal
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
'signoz.meter.log.size',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit logs limit' }));
|
||||
expect(
|
||||
screen.getByText('You can set up an alert after saving'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to search API when search text is entered', async () => {
|
||||
@@ -297,6 +337,9 @@ describe('MultiIngestionSettings Page', () => {
|
||||
const searchHandler = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.get('*/api/v1/global/config*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(GLOBAL_CONFIG_RESPONSE)),
|
||||
),
|
||||
rest.get('*/api/v2/gateway/ingestion_keys', (req, res, ctx) => {
|
||||
if (req.url.pathname.endsWith('/search')) {
|
||||
return undefined;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { ActionItemProps } from './ActionItem';
|
||||
import TableView from './TableView';
|
||||
import { removeEscapeCharacters } from './utils';
|
||||
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
|
||||
|
||||
import './Overview.styles.scss';
|
||||
|
||||
@@ -113,7 +113,7 @@ function Overview({
|
||||
children: (
|
||||
<div className="logs-body-content">
|
||||
<MEditor
|
||||
value={removeEscapeCharacters(logData.body)}
|
||||
value={removeEscapeCharacters(getBodyDisplayString(logData.body))}
|
||||
language="json"
|
||||
options={options}
|
||||
onChange={(): void => {}}
|
||||
|
||||
@@ -10,7 +10,7 @@ const MAX_BODY_BYTES = 100 * 1024; // 100 KB
|
||||
|
||||
// Hook for async JSON processing
|
||||
const useAsyncJSONProcessing = (
|
||||
value: string,
|
||||
value: string | Record<string, unknown>,
|
||||
shouldProcess: boolean,
|
||||
handleChangeSelectedView?: ChangeViewFunctionType,
|
||||
): {
|
||||
@@ -40,11 +40,17 @@ const useAsyncJSONProcessing = (
|
||||
return (): void => {};
|
||||
}
|
||||
|
||||
// Avoid processing if the json is too large
|
||||
const byteSize = new Blob([value]).size;
|
||||
if (byteSize > MAX_BODY_BYTES) {
|
||||
return (): void => {};
|
||||
}
|
||||
// When value is already a parsed object skip the size check and JSON parsing
|
||||
const parseBody = (): Record<string, unknown> | null => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
const byteSize = new Blob([value as string]).size;
|
||||
if (byteSize > MAX_BODY_BYTES) {
|
||||
return null;
|
||||
}
|
||||
return recursiveParseJSON(value as string);
|
||||
};
|
||||
|
||||
processingRef.current = true;
|
||||
setJsonState({ isLoading: true, treeData: null, error: null });
|
||||
@@ -53,8 +59,8 @@ const useAsyncJSONProcessing = (
|
||||
const processAsync = (): void => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const parsedBody = recursiveParseJSON(value);
|
||||
if (!isEmpty(parsedBody)) {
|
||||
const parsedBody = parseBody();
|
||||
if (parsedBody && !isEmpty(parsedBody)) {
|
||||
const treeData = jsonToDataNodes(parsedBody, {
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
@@ -82,8 +88,8 @@ const useAsyncJSONProcessing = (
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
(): void => {
|
||||
try {
|
||||
const parsedBody = recursiveParseJSON(value);
|
||||
if (!isEmpty(parsedBody)) {
|
||||
const parsedBody = parseBody();
|
||||
if (parsedBody && !isEmpty(parsedBody)) {
|
||||
const treeData = jsonToDataNodes(parsedBody, {
|
||||
isBodyJsonQueryEnabled,
|
||||
handleChangeSelectedView,
|
||||
|
||||
@@ -4,7 +4,11 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import dompurify from 'dompurify';
|
||||
import { uniqueId } from 'lodash-es';
|
||||
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
|
||||
import {
|
||||
ILog,
|
||||
ILogAggregateAttributesResources,
|
||||
ILogBody,
|
||||
} from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
|
||||
@@ -433,3 +437,24 @@ 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);
|
||||
}
|
||||
|
||||
// Returns the primary "message" text for compact log row previews.
|
||||
export function getBodyMessage(
|
||||
body: string | ILogBody,
|
||||
isBodyJsonEnabled: boolean,
|
||||
): string {
|
||||
if (!isBodyJsonEnabled) {
|
||||
return (body as string) ?? '';
|
||||
}
|
||||
// Feature enabled: body is always a map; message is always a string
|
||||
const msg = (body as ILogBody).message;
|
||||
if (msg) {
|
||||
return msg;
|
||||
}
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ExpandAltOutlined } from '@ant-design/icons';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -26,7 +27,9 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
DATE_TIME_FORMATS.UTC_MONTH_SHORT,
|
||||
)}
|
||||
</div>
|
||||
<div className="logs-preview-list-item-body">{log.body}</div>
|
||||
<div className="logs-preview-list-item-body">
|
||||
{getBodyDisplayString(log.body)}
|
||||
</div>
|
||||
<div
|
||||
className="logs-preview-list-item-expand"
|
||||
onClick={makeLogDetailsHandler(log)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Typography } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import NoData from '../NoData/NoData';
|
||||
|
||||
@@ -26,11 +27,13 @@ function LinkedSpans(props: LinkedSpansProps): JSX.Element {
|
||||
if (!item.traceId || !item.spanId) {
|
||||
return null;
|
||||
}
|
||||
return `${ROUTES.TRACE}/${item.traceId}${formUrlParams({
|
||||
spanId: item.spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`;
|
||||
return withBasePath(
|
||||
`${ROUTES.TRACE}/${item.traceId}${formUrlParams({
|
||||
spanId: item.spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Filter out CHILD_OF references as they are parent-child relationships
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export interface ILogBody {
|
||||
message?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ILog {
|
||||
date: string;
|
||||
timestamp: number | string;
|
||||
@@ -8,7 +13,7 @@ export interface ILog {
|
||||
traceFlags: number;
|
||||
severityText: string;
|
||||
severityNumber: number;
|
||||
body: string;
|
||||
body: string | ILogBody;
|
||||
resources_string: Record<string, never>;
|
||||
scope_string: Record<string, never>;
|
||||
attributesString: Record<string, never>;
|
||||
|
||||
2
go.mod
2
go.mod
@@ -11,6 +11,7 @@ require (
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.3
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1
|
||||
github.com/antonmedv/expr v1.15.3
|
||||
github.com/bytedance/sonic v1.14.1
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||
@@ -112,7 +113,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // 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
|
||||
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -22,6 +24,8 @@ var (
|
||||
// written clickhouse query. The column alias indcate which value is
|
||||
// to be considered as final result (or target).
|
||||
legacyReservedColumnTargetAliases = []string{"__result", "__value", "result", "res", "value"}
|
||||
|
||||
CodeFailUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
|
||||
)
|
||||
|
||||
// consume reads every row and shapes it into the payload expected for the
|
||||
@@ -393,11 +397,16 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
|
||||
// de-reference the typed pointer to any
|
||||
val := reflect.ValueOf(cellPtr).Elem().Interface()
|
||||
// Post-process JSON columns: normalize into String value
|
||||
// Post-process JSON columns: unmarshal bytes into map[string]any
|
||||
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
|
||||
switch x := val.(type) {
|
||||
case []byte:
|
||||
val = string(x)
|
||||
var m map[string]any
|
||||
err := sonic.Unmarshal(x, &m)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, CodeFailUnmarshalJSONColumn, "failed to unmarshal JSON column %s", name)
|
||||
}
|
||||
val = m
|
||||
default:
|
||||
// already a structured type (map[string]any, []any, etc.)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@ import (
|
||||
"github.com/SigNoz/govaluate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// queryInfo holds common query properties.
|
||||
@@ -50,7 +53,7 @@ func getQueryName(spec any) string {
|
||||
return getqueryInfo(spec).Name
|
||||
}
|
||||
|
||||
func (q *querier) postProcessResults(ctx context.Context, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
|
||||
func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
|
||||
// Convert results to typed format for processing
|
||||
typedResults := make(map[string]*qbtypes.Result)
|
||||
for name, result := range results {
|
||||
@@ -69,6 +72,7 @@ func (q *querier) postProcessResults(ctx context.Context, results map[string]any
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
if result, ok := typedResults[spec.Name]; ok {
|
||||
result = postProcessBuilderQuery(q, result, spec, req)
|
||||
result = q.postProcessLogBody(ctx, orgID, result, req)
|
||||
typedResults[spec.Name] = result
|
||||
}
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
@@ -1035,3 +1039,33 @@ func (q *querier) calculateFormulaStep(expression string, req *qbtypes.QueryRang
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// postProcessLogBody removes the "message" key from the body map when it is empty.
|
||||
// Only runs for raw list queries with the use_json_body feature enabled.
|
||||
func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, result *qbtypes.Result, req *qbtypes.QueryRangeRequest) *qbtypes.Result {
|
||||
if req.RequestType != qbtypes.RequestTypeRaw {
|
||||
return result
|
||||
}
|
||||
if !q.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
return result
|
||||
}
|
||||
rawData, ok := result.Value.(*qbtypes.RawData)
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
for _, row := range rawData.Rows {
|
||||
bodyMap, ok := row.Data["body"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if msg, exists := bodyMap["message"]; exists {
|
||||
switch v := msg.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
delete(bodyMap, "message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
@@ -35,6 +36,7 @@ var (
|
||||
|
||||
type querier struct {
|
||||
logger *slog.Logger
|
||||
fl flagger.Flagger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
@@ -62,10 +64,12 @@ func New(
|
||||
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
|
||||
bucketCache BucketCache,
|
||||
flagger flagger.Flagger,
|
||||
) *querier {
|
||||
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
|
||||
return &querier{
|
||||
logger: querierSettings.Logger(),
|
||||
fl: flagger,
|
||||
telemetryStore: telemetryStore,
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
@@ -684,7 +688,7 @@ func (q *querier) run(
|
||||
}
|
||||
|
||||
gomaps.Copy(results, preseededResults)
|
||||
processedResults, err := q.postProcessResults(ctx, results, req)
|
||||
processedResults, err := q.postProcessResults(ctx, orgID, results, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
cmock "github.com/srikanthccv/ClickHouse-go-mock"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
@@ -44,14 +45,15 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
providerSettings,
|
||||
nil, // telemetryStore
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flaggertest.New(t), // flagger
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
@@ -116,6 +118,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flaggertest.New(t), // flagger
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
|
||||
@@ -186,5 +186,6 @@ func newProvider(
|
||||
meterStmtBuilder,
|
||||
traceOperatorStmtBuilder,
|
||||
bucketCache,
|
||||
flagger,
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flagger,
|
||||
), metadataStore
|
||||
}
|
||||
|
||||
@@ -102,6 +103,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,5 +148,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ from fixtures.querier import (
|
||||
|
||||
|
||||
def _get_bodies(response: requests.Response) -> list[dict[str, Any]]:
|
||||
return [json.loads(row["data"]["body"]) for row in get_rows(response)]
|
||||
return [row["data"]["body"] for row in get_rows(response)]
|
||||
|
||||
|
||||
def _run_query_case(signoz: types.SigNoz, token: str, now: datetime, case: dict[str, Any]) -> None:
|
||||
@@ -1183,7 +1183,7 @@ def test_message_searches(
|
||||
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
|
||||
|
||||
def _body_messages(response: requests.Response) -> list[str]:
|
||||
return [json.loads(row["data"]["body"]).get("message", "") for row in get_rows(response)]
|
||||
return [row["data"]["body"].get("message", "") for row in get_rows(response)]
|
||||
|
||||
payment_messages = {
|
||||
"Payment processed successfully",
|
||||
|
||||
Reference in New Issue
Block a user