Compare commits

..

8 Commits

Author SHA1 Message Date
aks07
8175c58883 feat(explorer): enable timeseries export in Logs, Traces and Metrics
Logs and Traces pass allowExport + onYAxisUnitChange and drop their external unit-filter headers (one header row: unit left, download right). Metrics passes allowExport only, per chart — its YAxisUnitSelector stays screen-level (persists to the metric, spans N charts). convertDataValueToMs now converts the raw V5 tree too, so Traces duration exports carry the ms values the chart shows instead of raw nanoseconds.
2026-07-10 12:13:24 +05:30
aks07
b2a399b76d feat(data-export): timeseries export menu and opt-in TimeSeriesView header
New TimeseriesExportMenu (csv/jsonl, LONG shape default; same look as DownloadOptionsMenu, which stays raw/list-only) rendered from a new opt-in TimeSeriesView header row: unit selector left (onYAxisUnitChange — rendering only, unit state stays with each view), download right (allowExport, needs the surfaced raw V5). Both default off, so views opt in explicitly. Includes gating + menu tests.
2026-07-10 12:13:24 +05:30
aks07
cb423e6b20 fix(data-export): omit display-only format ids from export headers
'short'/'none' are Grafana-style display formats, not physical units — meaningful on a chart axis but misleading as 'value (short)' in a CSV header. Real units (ms, bytes, ...) are unchanged.
2026-07-10 12:13:24 +05:30
aks07
40f43040f4 feat(data-export): surface raw V5 response and legend map from GetMetricQueryRange
Keeps the pre-conversion V5 queryRange response and the per-query legend map on the returned MetricQueryRangeSuccessResponse so explorer views can feed client-side export without a refetch. Additive optional fields; the legacy V3 render path is untouched. One chokepoint covers Logs, Traces and Metrics.
2026-07-10 12:12:04 +05:30
aks07
dd91f72c5d feat(data-export): add useClientExport dispatch hook
Frontend-driven export hook: narrows a V5 queryRange response by request type, serializes time_series (scalar lands with the next sub-issue), formats as csv/jsonl and downloads. Takes the builder query for chart-parity series naming. Backend-driven export stays in useServerExport.
2026-07-10 12:07:14 +05:30
aks07
098588acc3 feat(data-export): add csv/jsonl formatters and timestamped client download
toCsv/toJsonl turn a SerializedTable into CSV or newline-delimited JSON; downloadFile triggers a client-side blob download with a timestamped filename (base-YYYY-MM-DD_HH-mm-ss.ext) so repeated exports never collide and record when they were taken.
2026-07-10 12:07:14 +05:30
aks07
cb9c39a454 feat(data-export): add time_series serializer
Pure serializer that walks the V5 time_series tree (results → aggregations → series) into a format-agnostic SerializedTable — tidy layout, one row per (series, timestamp), labels as columns, raw values, y-axis unit in the value header. Series names match the chart legend (getLabelName + getLegend resolve legend templates and aggregation aliases/expressions from the builder query).
2026-07-10 12:07:14 +05:30
aks07
9b9c461fbd feat: rename existing export logic to follow the new export data structure 2026-07-06 16:22:42 +05:30
44 changed files with 1353 additions and 466 deletions

View File

@@ -3,7 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
import { useExportRawData } from 'hooks/useExportData/useServerExport';
import { Download, LoaderCircle } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';

View File

@@ -7,13 +7,11 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
// The setup-guide button only exists in the classic form, which is reachable
// via the unadvertised showClassicCreateAlertsPage param.
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
search: 'ruleType=anomaly_rule',
}),
}));
@@ -22,7 +20,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
search: 'ruleType=anomaly_rule',
hash: '',
state: null,
})),

View File

@@ -152,7 +152,7 @@ describe('CreateAlertRule', () => {
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
});
it('should render new flow when ruleType is anomaly_rule', () => {
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
@@ -163,8 +163,8 @@ describe('CreateAlertRule', () => {
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {

View File

@@ -100,9 +100,10 @@ function CreateRules(): JSX.Element {
return <SelectAlertType onSelect={handleSelectType} />;
}
// The classic experience is no longer offered in the UI; the query param
// is kept as an unadvertised escape hatch until the flow is removed.
if (showClassicCreateAlertsPageFlag) {
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
return (
<FormAlertRules
alertType={alertType}

View File

@@ -2,9 +2,7 @@ import { useQuery } from 'react-query';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { FeatureKeys } from 'constants/features';
import { Activity, ChartLine } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { ChartLine } from '@signozhq/icons';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
@@ -21,7 +19,6 @@ import './styles.scss';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const { featureFlags } = useAppContext();
const {
data,
@@ -33,15 +30,9 @@ function AlertCondition(): JSX.Element {
});
const channels = data?.data || [];
const isAnomalyDetectionEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
// Anomaly alerts always show both tabs so existing rules stay editable;
// metric alerts only offer the anomaly tab when the feature is enabled.
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
alertType === AlertTypes.METRICS_BASED_ALERT;
const tabs = [
{
@@ -49,15 +40,16 @@ function AlertCondition(): JSX.Element {
icon: <ChartLine size={14} data-testid="threshold-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
...(showMultipleTabs
? [
{
label: 'Anomaly',
icon: <Activity size={14} data-testid="anomaly-view" />,
value: AlertTypes.ANOMALY_BASED_ALERT,
},
]
: []),
// Hide anomaly tab for now
// ...(showMultipleTabs
// ? [
// {
// label: 'Anomaly',
// icon: <Activity size={14} data-testid="anomaly-view" />,
// value: AlertTypes.ANOMALY_BASED_ALERT,
// },
// ]
// : []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {

View File

@@ -188,7 +188,7 @@ function AnomalyThreshold({
}}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
{!notificationSettings.routingPolicies ? (
{notificationSettings.routingPolicies ? (
<>
<Typography.Text
data-testid="seasonality-text"

View File

@@ -1,11 +1,7 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
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 { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
@@ -100,23 +96,6 @@ const createTestQueryClient = (): QueryClient =>
},
});
const ANOMALY_DETECTION_FLAG: FeatureFlagProps = {
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
};
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const mockAppContext = (isAnomalyDetectionEnabled: boolean): void => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState(),
featureFlags: isAnomalyDetectionEnabled ? [ANOMALY_DETECTION_FLAG] : [],
});
};
const renderAlertCondition = (
alertType?: string,
): ReturnType<typeof render> => {
@@ -134,10 +113,6 @@ const renderAlertCondition = (
};
describe('AlertCondition', () => {
beforeEach(() => {
mockAppContext(true);
});
it('renders the stepper with correct step number and label', () => {
renderAlertCondition();
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
@@ -150,9 +125,10 @@ describe('AlertCondition', () => {
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
// ).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -160,7 +136,8 @@ describe('AlertCondition', () => {
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
@@ -175,27 +152,13 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('renders anomaly tab when alert type supports multiple tabs', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
renderAlertCondition();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('does not offer the anomaly tab when anomaly detection is disabled', () => {
mockAppContext(false);
renderAlertCondition();
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
});
it('shows both tabs for anomaly alerts even when anomaly detection is disabled', () => {
mockAppContext(false);
renderAlertCondition('ANOMALY_BASED_ALERT');
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
});
it('shows AlertThreshold component when alert type is not anomaly based', () => {
renderAlertCondition();
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
@@ -204,7 +167,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
@@ -215,7 +179,8 @@ describe('AlertCondition', () => {
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
it('switches between threshold and anomaly tabs', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('switches between threshold and anomaly tabs', () => {
renderAlertCondition();
// Initially shows threshold component
@@ -240,7 +205,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('applies active tab styling correctly', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -261,10 +227,11 @@ describe('AlertCondition', () => {
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
renderAlertCondition('METRIC_BASED_ALERT');
// TODO: uncomment this when anomaly tab is implemented
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
@@ -272,8 +239,9 @@ describe('AlertCondition', () => {
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {

View File

@@ -1,7 +1,14 @@
import { useCallback, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
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';
@@ -15,6 +22,8 @@ function CreateAlertHeader(): JSX.Element {
const alertRuleContext = useAlertRuleOptional();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const groupByLabels = useMemo(() => {
const labels = new Array<string>();
@@ -37,6 +46,14 @@ function CreateAlertHeader(): JSX.Element {
[groupByLabels],
);
const handleSwitchToClassicExperience = useCallback(() => {
logEvent('Alert: Switch to classic experience button clicked', {});
urlQuery.set(QueryParams.showClassicCreateAlertsPage, 'true');
const url = `${ROUTES.ALERTS_NEW}?${urlQuery.toString()}`;
safeNavigate(url, { replace: true });
}, [safeNavigate, urlQuery]);
return (
<div
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
@@ -44,6 +61,15 @@ function CreateAlertHeader(): JSX.Element {
{!isEditMode && (
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
<Button
prefix={<RotateCcw size={12} />}
onClick={handleSwitchToClassicExperience}
variant="solid"
color="secondary"
size="sm"
>
Switch to Classic Experience
</Button>
</div>
)}
<div className="alert-header__content">

View File

@@ -1,12 +1,20 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as rulesHook from '../../../../api/generated/services/rules';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
const mockSafeNavigate = jest.fn();
jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
@@ -98,8 +106,34 @@ describe('CreateAlertHeader', () => {
).toHaveValue('TEST_ALERT');
});
it('should not render "switch to classic experience" button', () => {
it('should navigate to classic experience when button is clicked', () => {
renderCreateAlertHeader();
const switchToClassicExperienceButton = screen.getByText(
'Switch to Classic Experience',
);
expect(switchToClassicExperienceButton).toBeInTheDocument();
fireEvent.click(switchToClassicExperienceButton);
const params = new URLSearchParams();
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
expect(mockSafeNavigate).toHaveBeenCalledWith(
`${ROUTES.ALERTS_NEW}?${params.toString()}`,
{ replace: true },
);
});
it('should not render "switch to classic experience" button when isEditMode is true', () => {
render(
<CreateAlertProvider
isEditMode
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
initialAlertState={getCreateAlertLocalStateFromAlertDef(
defaultPostableAlertRuleV2,
)}
>
<CreateAlertHeader />
</CreateAlertProvider>,
);
expect(
screen.queryByText('Switch to Classic Experience'),
).not.toBeInTheDocument();

View File

@@ -14,7 +14,10 @@ import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { useCreateAlertState } from '../context';
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
import {
buildCreateThresholdAlertRulePayload,
validateCreateAlertState,
} from './utils';
import './styles.scss';
import {
@@ -82,7 +85,7 @@ function Footer(): JSX.Element {
);
const handleTestNotification = useCallback((): void => {
const payload = buildCreateAlertRulePayload({
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
@@ -119,7 +122,7 @@ function Footer(): JSX.Element {
const queryClient = useQueryClient();
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateAlertRulePayload({
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,

View File

@@ -18,8 +18,6 @@ import { EQueryType } from 'types/common/dashboard';
import { BuildCreateAlertRulePayloadArgs } from '../types';
import {
buildCreateAlertRulePayload,
buildCreateAnomalyAlertRulePayload,
buildCreateThresholdAlertRulePayload,
getAlertOnAbsentProps,
getEnforceMinimumDatapointsProps,
@@ -552,115 +550,4 @@ describe('Footer utils', () => {
},
);
});
describe('buildCreateAnomalyAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const ANOMALY_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: {
...mockCreateAlertContextState.thresholdState,
thresholds: [
{
...mockCreateAlertContextState.thresholdState.thresholds[0],
thresholdValue: 3,
},
],
},
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
};
it('builds a v2alpha1 anomaly rule payload', () => {
const props = buildCreateAnomalyAlertRulePayload(ANOMALY_PAYLOAD_ARGS);
expect(props.ruleType).toBe('anomaly_rule');
expect(props.schemaVersion).toBe('v2alpha1');
expect(props.version).toBe('v5');
// The stored alertType is metric based; anomaly is a rule type
expect(props.alertType).toBe('METRIC_BASED_ALERT');
expect(props.condition.algorithm).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.algorithm,
);
expect(props.condition.seasonality).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.seasonality,
);
expect(props.condition.selectedQueryName).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.selectedQuery,
);
// Evaluation comes from the anomaly condition's own window
expect(props.evaluation).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: ANOMALY_PAYLOAD_ARGS.thresholdState.evaluationWindow,
frequency: '1m',
},
});
});
it('keeps the target positive for the above operator', () => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: 'above',
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(3);
expect(props.condition.thresholds?.spec[0].op).toBe('above');
});
it.each([['below'], ['2']])(
'negates the target for the below operator (%s)',
(op) => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: op,
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(-3);
expect(props.condition.thresholds?.spec[0].op).toBe(op);
},
);
it('keeps the target positive for the outside_bounds operator', () => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: 'outside_bounds',
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(3);
});
});
describe('buildCreateAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const args: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: mockCreateAlertContextState.thresholdState,
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: mockCreateAlertContextState.alertType,
};
it('builds an anomaly payload for anomaly based alerts', () => {
const props = buildCreateAlertRulePayload({
...args,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
});
expect(props.ruleType).toBe('anomaly_rule');
});
it('builds a threshold payload for other alert types', () => {
const props = buildCreateAlertRulePayload(args);
expect(props.ruleType).toBe('threshold_rule');
});
});
});

View File

@@ -2,7 +2,6 @@ import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
BasicThreshold,
PostableAlertRuleV2,
@@ -12,11 +11,9 @@ import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelo
import {
AdvancedOptionsState,
AlertThresholdOperator,
EvaluationWindowState,
NotificationSettingsState,
} from '../context/types';
import { normalizeOperator } from '../utils';
import { BuildCreateAlertRulePayloadArgs } from './types';
// Get formatted time/unit pairs for create alert api payload
@@ -291,15 +288,16 @@ export function buildCreateThresholdAlertRulePayload(
}
// Build Create Anomaly Alert Rule Payload
// TODO: Update this function before enabling anomaly alert rule creation
export function buildCreateAnomalyAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
const {
alertType,
basicAlertState,
thresholdState,
query,
notificationSettings,
evaluationWindow,
advancedOptions,
} = args;
@@ -315,55 +313,19 @@ export function buildCreateAnomalyAlertRulePayload(
unit: basicAlertState.yAxisUnit,
});
// v2alpha1 thresholds are literal: "3 deviations below the predicted data"
// means the anomaly z-score must drop under -3, so the target is negated
// for the below operator (the deviations input is always positive).
const isBelowOperator =
normalizeOperator(thresholdState.operator) ===
AlertThresholdOperator.IS_BELOW;
const thresholds: BasicThreshold[] = thresholdState.thresholds.map(
(threshold) => {
const deviations = Math.abs(parseFloat(threshold.thresholdValue.toString()));
return {
name: threshold.label,
target: isBelowOperator ? -deviations : deviations,
matchType: thresholdState.matchType,
op: thresholdState.operator,
channels: threshold.channels,
targetUnit: threshold.unit,
};
},
);
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
const enforceMinimumDatapointsProps =
getEnforceMinimumDatapointsProps(advancedOptions);
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
const notificationSettingsProps =
getNotificationSettingsProps(notificationSettings);
// The anomaly condition carries its own evaluation window
// ("during the last X"), so the evaluation is always a rolling window.
const frequency = getFormattedTimeValue(
advancedOptions.evaluationCadence.default.value,
advancedOptions.evaluationCadence.default.timeUnit,
);
return {
alert: basicAlertState.name,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
alertType:
alertType === AlertTypes.ANOMALY_BASED_ALERT
? AlertTypes.METRICS_BASED_ALERT
: alertType,
alertType,
condition: {
thresholds: {
kind: 'basic',
spec: thresholds,
},
compositeQuery,
selectedQueryName: thresholdState.selectedQuery,
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
},
@@ -373,25 +335,9 @@ export function buildCreateAnomalyAlertRulePayload(
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
evaluation: {
kind: 'rolling',
spec: {
evalWindow: thresholdState.evaluationWindow,
frequency,
},
},
version: 'v5',
schemaVersion: 'v2alpha1',
evaluation: evaluationProps,
version: '',
schemaVersion: '',
source: window?.location.toString(),
};
}
// Build the create/test alert rule payload for the selected alert type
export function buildCreateAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
if (args.alertType === AlertTypes.ANOMALY_BASED_ALERT) {
return buildCreateAnomalyAlertRulePayload(args);
}
return buildCreateThresholdAlertRulePayload(args);
}

View File

@@ -316,60 +316,6 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('getThresholdStateFromAlertDef for anomaly rules', () => {
const anomalyAlertDef: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
ruleType: 'anomaly_rule',
condition: {
...defaultPostableAlertRuleV2.condition,
algorithm: 'standard',
seasonality: 'daily',
selectedQueryName: 'A',
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: -3,
targetUnit: '',
channels: ['email'],
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
op: AlertThresholdOperator.IS_BELOW,
},
],
},
},
evaluation: {
kind: 'rolling',
spec: {
evalWindow: '1h0m0s',
frequency: '1m',
},
},
};
it('shows the absolute deviation value for negative anomaly targets', () => {
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
expect(props.thresholds[0].thresholdValue).toBe(3);
expect(props.operator).toBe(AlertThresholdOperator.IS_BELOW);
});
it('hydrates the anomaly evaluation window, algorithm and seasonality', () => {
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
expect(props.evaluationWindow).toBe('1h0m0s');
expect(props.algorithm).toBe('standard');
expect(props.seasonality).toBe('daily');
});
it('does not touch the target for non-anomaly rules', () => {
const props = getThresholdStateFromAlertDef({
...anomalyAlertDef,
ruleType: 'threshold_rule',
});
expect(props.thresholds[0].thresholdValue).toBe(-3);
});
});
describe('normalizeOperator', () => {
it.each([
['1', AlertThresholdOperator.IS_ABOVE],

View File

@@ -239,39 +239,6 @@ describe('CreateAlertV2 Context Utils', () => {
});
});
it('should set evaluation window', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_EVALUATION_WINDOW',
payload: TimeDuration.ONE_HOUR,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
evaluationWindow: TimeDuration.ONE_HOUR,
});
});
it('should set algorithm', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_ALGORITHM',
payload: Algorithm.STANDARD,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
algorithm: Algorithm.STANDARD,
});
});
it('should set seasonality', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_SEASONALITY',
payload: Seasonality.WEEKLY,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
seasonality: Seasonality.WEEKLY,
});
});
it('should set thresholds', () => {
const newThresholds = [
{

View File

@@ -124,12 +124,6 @@ export const alertThresholdReducer = (
return { ...state, operator: action.payload };
case 'SET_MATCH_TYPE':
return { ...state, matchType: action.payload };
case 'SET_EVALUATION_WINDOW':
return { ...state, evaluationWindow: action.payload };
case 'SET_ALGORITHM':
return { ...state, algorithm: action.payload };
case 'SET_SEASONALITY':
return { ...state, seasonality: action.payload };
case 'SET_THRESHOLDS':
return { ...state, thresholds: action.payload };
case 'RESET':

View File

@@ -4,7 +4,6 @@ import { Spin } from 'antd';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getRandomColor } from 'container/ExplorerOptions/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
@@ -304,19 +303,13 @@ export function normalizeMatchType(
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
// Anomaly targets are stored as literal z-scores (negative for the below
// operator); the deviations select always shows the positive value.
const isAnomalyRule =
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
alertDef.condition.thresholds?.spec.map((threshold) => ({
id: v4(),
label: threshold.name,
thresholdValue: isAnomalyRule
? Math.abs(threshold.target)
: threshold.target,
thresholdValue: threshold.target,
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
@@ -328,18 +321,6 @@ export function getThresholdStateFromAlertDef(
matchType:
alertDef.condition.thresholds?.spec[0].matchType ||
AlertThresholdMatchType.AT_LEAST_ONCE,
...(isAnomalyRule
? {
evaluationWindow:
alertDef.evaluation?.spec?.evalWindow ||
INITIAL_ALERT_THRESHOLD_STATE.evaluationWindow,
algorithm:
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
seasonality:
alertDef.condition.seasonality ||
INITIAL_ALERT_THRESHOLD_STATE.seasonality,
}
: {}),
};
}

View File

@@ -191,14 +191,6 @@
min-height: 0;
overflow-y: visible;
.time-series-view-container-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
flex-shrink: 0;
}
.time-series-view {
flex-shrink: 0;
height: 65vh;

View File

@@ -32,7 +32,6 @@ import {
getListQuery,
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
@@ -461,18 +460,17 @@ function LogsExplorerViewsContainer({
)}
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<div className="time-series-view-container">
<div className="time-series-view-container-header">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
</div>
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
isError={isError}
error={error as APIError}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
setWarning={setWarning}
allowExport
/>
</div>
)}

View File

@@ -311,6 +311,7 @@ function TimeSeries({
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
allowExport
/>
</div>
);

View File

@@ -3,6 +3,14 @@
min-height: 350px;
padding: 0px 12px;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
flex-shrink: 0;
}
.ant-card-body {
height: 50vh;
min-height: 350px;

View File

@@ -16,6 +16,7 @@ import Uplot from 'components/Uplot';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
@@ -41,11 +42,14 @@ import { SuccessResponse, Warning } from 'types/api';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import TimeseriesExportMenu from './TimeseriesExportMenu';
import './TimeSeriesView.styles.scss';
function TimeSeriesView({
@@ -59,6 +63,8 @@ function TimeSeriesView({
setWarning,
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -244,10 +250,33 @@ function TimeSeriesView({
[baseChartOptions, stackedBands],
);
const showExport = allowExport && !!data?.rawV5Response;
const showHeader = showExport || !!onYAxisUnitChange;
return (
<div className="time-series-view">
{isError && error && <ErrorInPlace error={error as APIError} />}
{showHeader && (
<div className="time-series-view__header">
<div>
{onYAxisUnitChange && (
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
)}
</div>
{showExport && data?.rawV5Response && (
<TimeseriesExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
queryResponse={data.rawV5Response}
query={currentQuery}
legendMap={data.legendMap}
fileName={`${dataSource}-timeseries`}
/>
)}
</div>
)}
<div
className="graph-container"
style={{ height: '100%', width: '100%' }}
@@ -295,7 +324,11 @@ function TimeSeriesView({
}
interface TimeSeriesViewProps {
data?: SuccessResponse<MetricRangePayloadProps> & { warning?: Warning };
data?: SuccessResponse<MetricRangePayloadProps> & {
warning?: Warning;
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
};
yAxisUnit?: string;
isLoading: boolean;
isError: boolean;
@@ -305,6 +338,11 @@ interface TimeSeriesViewProps {
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
panelType?: PANEL_TYPES;
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
}
TimeSeriesView.defaultProps = {

View File

@@ -0,0 +1,46 @@
.timeseries-export-popover {
.ant-popover-inner {
border-radius: 4px;
background-color: var(--l2-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0 8px 12px 8px;
margin: 6px 0;
}
.export-options-container {
width: 240px;
border-radius: 4px;
.title {
display: flex;
color: var(--l1-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
margin-bottom: 8px;
}
.export-format {
padding: 12px 4px;
display: flex;
flex-direction: column;
:global(.ant-radio-wrapper) {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
}
}
.export-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
}

View File

@@ -0,0 +1,106 @@
import { Download, LoaderCircle } from '@signozhq/icons';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { Button, Popover, Tooltip } from 'antd';
import { useClientExport } from 'hooks/useExportData/useClientExport';
import { ExportFormat } from 'lib/exportData/types';
import { useCallback, useMemo, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import './TimeseriesExportMenu.styles.scss';
interface TimeseriesExportMenuProps {
dataSource: DataSource;
queryResponse: QueryRangeResponseV5;
query?: Query;
yAxisUnit?: string;
legendMap?: Record<string, string>;
fileName?: string;
}
// Download menu for in-memory timeseries data (client-side serialization).
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
export default function TimeseriesExportMenu({
dataSource,
queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
}: TimeseriesExportMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isExporting, handleExport: handleClientExport } = useClientExport({
response: queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
});
const handleExport = useCallback((): void => {
setIsPopoverOpen(false);
handleClientExport({ format: exportFormat as ExportFormat });
}, [exportFormat, handleClientExport]);
const popoverContent = useMemo(
() => (
<div
className="export-options-container"
role="dialog"
aria-label="Export options"
aria-modal="true"
>
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup value={exportFormat} onChange={setExportFormat}>
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
</RadioGroup>
</div>
<Button
type="primary"
icon={<Download size={16} />}
onClick={handleExport}
className="export-button"
disabled={isExporting}
loading={isExporting}
>
Export
</Button>
</div>
),
[exportFormat, isExporting, handleExport],
);
return (
<Popover
content={popoverContent}
trigger="click"
placement="bottomRight"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="timeseries-export-popover"
>
<Tooltip title="Download" placement="top">
<Button
className="periscope-btn ghost"
icon={
isExporting ? (
<LoaderCircle size={14} className="animate-spin" />
) : (
<Download size={14} />
)
}
data-testid={`timeseries-export-${dataSource}`}
disabled={isExporting}
/>
</Tooltip>
</Popover>
);
}

View File

@@ -0,0 +1,138 @@
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import store from 'store';
import { DataSource } from 'types/common/queryBuilder';
import TimeSeriesView from '../TimeSeriesView';
jest.mock('components/Uplot', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="uplot-chart" />,
}));
jest.mock('../TimeseriesExportMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="timeseries-export-menu" />,
}));
jest.mock('container/QueryBuilder/filters/BuilderUnitsFilter', () => ({
BuilderUnitsFilter: (): JSX.Element => (
<div data-testid="builder-units-filter" />
),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ currentQuery: null }),
}));
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: (): unknown => ({}),
}));
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
getUPlotChartData: (): number[][] => [
[1, 2],
[3, 4],
],
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({ stackSeries: (): unknown => ({ data: [], bands: [] }) }),
);
jest.mock('container/GridCardLayout/GridCard/utils', () => ({
getLocalStorageGraphVisibilityState: (): unknown => ({
graphVisibilityStates: [],
}),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): unknown => ({ timezone: { value: 'UTC' } }),
}));
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): unknown => ({ width: 800, height: 400 }),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockStore = configureStore([])({ ...store.getState() });
const rawV5Response = {
type: 'time_series',
data: { results: [] },
meta: {},
};
function makeData(withRawV5: boolean): any {
return {
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: '' } },
...(withRawV5 ? { rawV5Response, legendMap: {} } : {}),
};
}
function renderView(props: {
allowExport?: boolean;
withRawV5?: boolean;
onYAxisUnitChange?: (value: string) => void;
}): ReturnType<typeof render> {
const { allowExport, withRawV5 = true, onYAxisUnitChange } = props;
return render(
<Provider store={mockStore}>
<MemoryRouter>
<TimeSeriesView
isLoading={false}
isError={false}
isFilterApplied
dataSource={DataSource.LOGS}
data={makeData(withRawV5)}
allowExport={allowExport}
onYAxisUnitChange={onYAxisUnitChange}
/>
</MemoryRouter>
</Provider>,
);
}
describe('TimeSeriesView header gating', () => {
it('renders the export menu when allowExport is set and raw V5 data is present', () => {
const { queryByTestId } = renderView({ allowExport: true });
expect(queryByTestId('timeseries-export-menu')).toBeInTheDocument();
});
it('renders no export menu without allowExport', () => {
const { queryByTestId } = renderView({});
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders no export menu when the raw V5 response is missing', () => {
const { queryByTestId } = renderView({ allowExport: true, withRawV5: false });
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders the unit selector only when onYAxisUnitChange is passed', () => {
const withUnit = renderView({ onYAxisUnitChange: jest.fn() });
expect(withUnit.queryByTestId('builder-units-filter')).toBeInTheDocument();
withUnit.unmount();
const withoutUnit = renderView({ allowExport: true });
expect(
withoutUnit.queryByTestId('builder-units-filter'),
).not.toBeInTheDocument();
});
it('renders no header row when neither export nor unit selector is enabled', () => {
const { container } = renderView({ withRawV5: false });
expect(container.querySelector('.time-series-view__header')).toBeNull();
});
});

View File

@@ -0,0 +1,84 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import TimeseriesExportMenu from '../TimeseriesExportMenu';
const mockHandleExport = jest.fn();
let mockIsExporting = false;
jest.mock('hooks/useExportData/useClientExport', () => ({
useClientExport: (): unknown => ({
isExporting: mockIsExporting,
handleExport: mockHandleExport,
}),
}));
const response = {
type: 'time_series',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
function renderMenu(): void {
render(
<TimeseriesExportMenu
dataSource={DataSource.LOGS}
queryResponse={response}
fileName="logs-timeseries"
/>,
);
}
describe('TimeseriesExportMenu', () => {
beforeEach(() => {
mockHandleExport.mockReset();
mockIsExporting = false;
});
it('renders the download trigger button', () => {
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
});
it('shows only format options — no shape, row-count, or column controls', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'csv' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'jsonl' })).toBeInTheDocument();
expect(screen.queryByText('Number of Rows')).not.toBeInTheDocument();
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'long' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'wide' })).not.toBeInTheDocument();
});
it('exports as csv by default', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledTimes(1);
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'csv' });
});
it('exports as jsonl when selected', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
});
it('disables the trigger while an export is in progress', () => {
mockIsExporting = true;
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
});
});

View File

@@ -1,10 +1,41 @@
import { SuccessResponse } from 'types/api/index';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
type ConvertibleData = SuccessResponse<MetricRangePayloadProps> & {
rawV5Response?: QueryRangeResponseV5;
};
// Applies the same ns→ms conversion to the raw V5 tree, so client-side export
// serializes the values the chart displays (not the original nanoseconds).
function convertRawV5ValuesToMs(
response: QueryRangeResponseV5,
): QueryRangeResponseV5 {
if (response.type !== 'time_series') {
return response;
}
const results = (response.data.results as TimeSeriesData[]).map((result) => ({
...result,
aggregations: (result.aggregations ?? []).map((bucket) => ({
...bucket,
series: (bucket.series ?? []).map((series) => ({
...series,
values: (series.values ?? []).map((value) => ({
...value,
value: value.value / 1000000,
})),
})),
})),
}));
return { ...response, data: { ...response.data, results } };
}
export const convertDataValueToMs = (
data?: SuccessResponse<MetricRangePayloadProps>,
): SuccessResponse<MetricRangePayloadProps> | undefined => {
data?: ConvertibleData,
): ConvertibleData | undefined => {
const convertedData = data;
const convertedResult: QueryData[] = data?.payload?.data?.result
@@ -22,5 +53,11 @@ export const convertDataValueToMs = (
convertedData.payload.data.result = convertedResult;
}
if (convertedData?.rawV5Response) {
convertedData.rawV5Response = convertRawV5ValuesToMs(
convertedData.rawV5Response,
);
}
return convertedData;
};

View File

@@ -0,0 +1,116 @@
import { act, renderHook } from '@testing-library/react';
import { downloadFile } from 'lib/exportData/downloadFile';
import { ExportFormat } from 'lib/exportData/types';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { useClientExport } from '../useClientExport';
jest.mock('lib/exportData/downloadFile', () => ({
...jest.requireActual('lib/exportData/downloadFile'),
downloadFile: jest.fn(),
}));
const mockMessageError = jest.fn();
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: { error: (...args: unknown[]): void => mockMessageError(...args) },
};
});
const mockDownloadFile = downloadFile as jest.Mock;
function timeSeriesResponse(): QueryRangeResponseV5 {
return {
type: 'time_series',
data: {
results: [
{
queryName: 'A',
aggregations: [
{
index: 0,
alias: '',
meta: {},
series: [
{
labels: [{ key: { name: 'service' }, value: 'a' }],
values: [{ timestamp: 1000, value: 12 }],
},
],
},
],
},
],
},
meta: {},
} as unknown as QueryRangeResponseV5;
}
describe('useClientExport', () => {
beforeEach(() => jest.clearAllMocks());
it('exports time_series as CSV to a timestamped <fileName>.csv', () => {
const { result } = renderHook(() =>
useClientExport({
response: timeSeriesResponse(),
fileName: 'chart',
legendMap: { A: '{{service}}' },
}),
);
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
const [content, name, mime] = mockDownloadFile.mock.calls[0];
expect(name).toMatch(/^chart-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.csv$/);
expect(mime).toContain('text/csv');
expect(content).toContain('service');
expect(content).toContain('a');
});
it('exports as JSONL to a timestamped <fileName>.jsonl with the ndjson mime', () => {
const { result } = renderHook(() =>
useClientExport({ response: timeSeriesResponse() }),
);
act(() => {
result.current.handleExport({ format: ExportFormat.Jsonl });
});
const [content, name, mime] = mockDownloadFile.mock.calls[0];
expect(name).toMatch(/^export-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.jsonl$/);
expect(mime).toContain('ndjson');
expect(content).toContain('"series"');
});
it('does nothing when there is no response', () => {
const { result } = renderHook(() => useClientExport({}));
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).not.toHaveBeenCalled();
expect(mockMessageError).not.toHaveBeenCalled();
});
it('shows an error and does not download for unsupported result types', () => {
const raw = {
type: 'raw',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const { result } = renderHook(() => useClientExport({ response: raw }));
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).not.toHaveBeenCalled();
expect(mockMessageError).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,96 @@
import { message } from 'antd';
import {
downloadFile,
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
import { toCsv } from 'lib/exportData/toCsv';
import { toJsonl } from 'lib/exportData/toJsonl';
import { ExportFormat, SerializedTable } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
[ExportFormat.Csv]: { mime: 'text/csv;charset=utf-8;', extension: 'csv' },
[ExportFormat.Jsonl]: {
mime: 'application/x-ndjson;charset=utf-8;',
extension: 'jsonl',
},
};
// Picks the serializer for the response's request type. Narrows the results
// union via the response discriminant. scalar lands with #5591; raw/trace are
// server-exported, distribution is never emitted.
function serialize(
response: QueryRangeResponseV5,
yAxisUnit?: string,
legendMap?: Record<string, string>,
query?: Query,
): SerializedTable {
if (response.type === 'time_series') {
return exportTimeseriesData({
data: response.data.results as TimeSeriesData[],
yAxisUnit,
legendMap,
query,
});
}
throw new Error(`Export is not supported for "${response.type}" results`);
}
interface UseClientExportProps {
response?: QueryRangeResponseV5;
// The builder query behind the response — series names resolve aggregation
// aliases/expressions from it, exactly like the chart legend.
query?: Query;
yAxisUnit?: string;
fileName?: string;
legendMap?: Record<string, string>;
}
interface ClientExportOptions {
format: ExportFormat;
}
interface UseClientExportReturn {
isExporting: boolean;
handleExport: (options: ClientExportOptions) => void;
}
// Frontend-driven export: serializes in-memory query results and downloads them
// client-side. Backend-driven export lives in useServerExport.
export function useClientExport({
response,
query,
yAxisUnit,
fileName = 'export',
legendMap,
}: UseClientExportProps): UseClientExportReturn {
const [isExporting, setIsExporting] = useState<boolean>(false);
const handleExport = useCallback(
({ format }: ClientExportOptions): void => {
if (!response) {
return;
}
setIsExporting(true);
try {
const table = serialize(response, yAxisUnit, legendMap, query);
const content =
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
const { mime, extension } = FORMAT_META[format];
downloadFile(content, getTimestampedFileName(fileName, extension), mime);
} catch {
message.error('Failed to export data. Please try again.');
} finally {
setIsExporting(false);
}
},
[response, query, yAxisUnit, fileName, legendMap],
);
return { isExporting, handleExport };
}

View File

@@ -22,7 +22,11 @@ import { SuccessResponseV2, Warning } from 'types/api';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { ExecStats, MetricRangePayloadV5 } from 'types/api/v5/queryRange';
import {
ExecStats,
MetricRangePayloadV5,
QueryRangeResponseV5,
} from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -192,6 +196,8 @@ export async function GetMetricQueryRange(
| SuccessResponseV2<MetricRangePayloadV5>;
let warning: Warning | undefined;
let meta: ExecStats | undefined;
// Raw V5 response, kept before it's converted to legacy — powers client-side export.
let rawV5Response: QueryRangeResponseV5 | undefined;
const panelType = props.originalGraphType || props.graphType;
@@ -268,6 +274,8 @@ export async function GetMetricQueryRange(
endTime: props.end * 1000,
});
rawV5Response = publicResponse.data.data;
// Convert V5 response to legacy format for components
response = convertV5ResponseToLegacy(
{
@@ -288,6 +296,8 @@ export async function GetMetricQueryRange(
headers,
);
rawV5Response = v5Response.data.data;
// Convert V5 response to legacy format for components
response = convertV5ResponseToLegacy(
{
@@ -366,6 +376,8 @@ export async function GetMetricQueryRange(
...response,
warning,
meta,
rawV5Response,
legendMap,
};
}

View File

@@ -0,0 +1,56 @@
import { downloadFile, getTimestampedFileName } from '../downloadFile';
// jsdom doesn't implement the object-URL APIs; define stubs so jest.spyOn can wrap them.
if (typeof URL.createObjectURL !== 'function') {
URL.createObjectURL = (): string => '';
}
if (typeof URL.revokeObjectURL !== 'function') {
URL.revokeObjectURL = (): void => undefined;
}
describe('downloadFile', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('builds a blob anchor, clicks it, and revokes the object URL', () => {
const click = jest.fn();
const remove = jest.fn();
const anchor = {
href: '',
download: '',
click,
remove,
} as unknown as HTMLAnchorElement;
(
jest.spyOn(document, 'createElement') as unknown as jest.Mock
).mockReturnValue(anchor);
const createObjectURL = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock');
const revokeObjectURL = jest.spyOn(URL, 'revokeObjectURL');
downloadFile('hello', 'export.csv', 'text/csv');
expect(anchor.download).toBe('export.csv');
expect(anchor.href).toBe('blob:mock');
expect(click).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock');
});
});
describe('getTimestampedFileName', () => {
afterEach(() => {
jest.useRealTimers();
});
it('appends a local timestamp between base and extension', () => {
jest.useFakeTimers().setSystemTime(new Date(2026, 6, 8, 14, 32, 5));
expect(getTimestampedFileName('logs-timeseries', 'csv')).toBe(
'logs-timeseries-2026-07-08_14-32-05.csv',
);
});
});

View File

@@ -0,0 +1,200 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { exportTimeseriesData } from '../exportTimeseriesData';
const iso = (ms: number): string => new Date(ms).toISOString();
function makeSeries(
labels: Record<string, string>,
values: [number, number][],
): TimeSeries {
return {
labels: Object.entries(labels).map(([name, value]) => ({
key: { name },
value,
})),
values: values.map(([timestamp, value]) => ({ timestamp, value })),
};
}
function makeQuery(
queryName: string,
buckets: { index?: number; alias?: string; series: TimeSeries[] }[],
): TimeSeriesData {
return {
queryName,
aggregations: buckets.map((bucket, i) => ({
index: bucket.index ?? i,
alias: bucket.alias ?? '',
meta: {},
series: bucket.series,
})),
};
}
describe('exportTimeseriesData', () => {
it('one row per point: query column, label columns, unit in value header, legend naming', () => {
const data = [
makeQuery('A', [
{
series: [
makeSeries({ service_name: 'frontend' }, [
[1000, 12],
[2000, 15],
]),
],
},
]),
];
const table = exportTimeseriesData({
data,
yAxisUnit: 'ms',
legendMap: { A: '{{service_name}}' },
});
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service_name',
'value (ms)',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'frontend', 'frontend', 12],
[iso(2000), 'A', 'frontend', 'frontend', 15],
]);
});
it('no legend falls back to the label-set name from getLabelName', () => {
const data = [
makeQuery('A', [
{ series: [makeSeries({ service_name: 'frontend' }, [[1000, 12]])] },
]),
];
const table = exportTimeseriesData({ data });
expect(table.rows).toStrictEqual([
[iso(1000), 'A', '{service_name="frontend"}', 'frontend', 12],
]);
});
it('omits display-only format ids (short/none) from headers', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'a' }, [[1000, 1]])] }]),
];
const short = exportTimeseriesData({ data, yAxisUnit: 'short' });
expect(short.headers[short.headers.length - 1]).toBe('value');
const none = exportTimeseriesData({ data, yAxisUnit: 'none' });
expect(none.headers[none.headers.length - 1]).toBe('value');
});
it('multi-query: query is its own column; label keys are unioned', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
makeQuery('B', [{ series: [makeSeries({ service: 'y' }, [[1000, 2]])] }]),
];
const table = exportTimeseriesData({
data,
legendMap: { A: '{{service}}', B: '{{service}}' },
});
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service',
'value',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'x', 'x', 1],
[iso(1000), 'B', 'y', 'y', 2],
]);
});
it('multi-aggregation with the builder query: names match the chart legend', () => {
const data = [
makeQuery('A', [
{ index: 0, alias: '__result_0', series: [makeSeries({}, [[1000, 5]])] },
{
index: 1,
alias: '__result_1',
series: [makeSeries({}, [[1000, 300]])],
},
]),
makeQuery('B', [
{
index: 0,
alias: '__result_0',
series: [
makeSeries({ 'cloud.account.id': 'signoz-staging' }, [[1000, 7]]),
],
},
]),
];
const query = {
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'logs',
aggregations: [
{ expression: 'count()' },
{ expression: 'avg(code.lineno)' },
],
groupBy: [],
},
{
queryName: 'B',
dataSource: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ key: 'cloud.account.id' }],
},
],
queryFormulas: [],
},
} as unknown as Query;
const table = exportTimeseriesData({ data, query });
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'count()-A', '', 5],
[iso(1000), 'A', 'avg(code.lineno)-A', '', 300],
[iso(1000), 'B', '{cloud.account.id="signoz-staging"}', 'signoz-staging', 7],
]);
});
it('multi-aggregation without the builder query: falls back to base names', () => {
const data = [
makeQuery('A', [
{ index: 0, alias: '__result_0', series: [makeSeries({}, [[1000, 5]])] },
{
index: 1,
alias: '__result_1',
series: [makeSeries({}, [[1000, 300]])],
},
]),
];
const table = exportTimeseriesData({ data });
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'A', 5],
[iso(1000), 'A', 'A', 300],
]);
});
it('empty data: returns a headers-only table', () => {
expect(exportTimeseriesData({ data: [] })).toStrictEqual({
headers: ['timestamp', 'query', 'series', 'value'],
rows: [],
});
});
});

View File

@@ -0,0 +1,42 @@
import { toCsv } from '../toCsv';
import { toJsonl } from '../toJsonl';
import { SerializedTable } from '../types';
const table: SerializedTable = {
headers: ['timestamp', 'value'],
rows: [
['t1', 12],
['t2', 15],
],
};
describe('toCsv', () => {
it('emits a header row then one row per record, in column order', () => {
expect(toCsv(table).split(/\r?\n/)).toStrictEqual([
'timestamp,value',
't1,12',
't2,15',
]);
});
it('quotes values containing the delimiter', () => {
const csv = toCsv({ headers: ['name', 'value'], rows: [['a,b', 1]] });
expect(csv.split(/\r?\n/)).toStrictEqual(['name,value', '"a,b",1']);
});
it('emits only the header row when there are no data rows', () => {
expect(toCsv({ headers: ['timestamp'], rows: [] })).toBe('timestamp\r\n');
});
});
describe('toJsonl', () => {
it('emits one JSON object per row keyed by header', () => {
expect(toJsonl(table)).toBe(
'{"timestamp":"t1","value":12}\n{"timestamp":"t2","value":15}',
);
});
it('emits an empty string when there are no rows', () => {
expect(toJsonl({ headers: ['timestamp'], rows: [] })).toBe('');
});
});

View File

@@ -0,0 +1,29 @@
/** Triggers a browser download of in-memory string content as a file. */
export function downloadFile(
content: string,
fileName: string,
mime: string,
): void {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
link.remove();
URL.revokeObjectURL(url);
}
/** `base` + local timestamp + extension, e.g. `logs-timeseries-2026-07-08_14-32-05.csv`.
* Keeps repeated exports from colliding and records when the export was taken. */
export function getTimestampedFileName(
base: string,
extension: string,
): string {
const now = new Date();
const pad = (value: number): string => String(value).padStart(2, '0');
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
now.getDate(),
)}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
return `${base}-${stamp}.${extension}`;
}

View File

@@ -0,0 +1,161 @@
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { SerializedTable } from './types';
interface ExportTimeseriesDataArgs {
data: TimeSeriesData[];
yAxisUnit?: string;
legendMap?: Record<string, string>;
// The builder query that produced the data — lets series names resolve
// aggregation aliases/expressions exactly like the chart legend does.
query?: Query;
}
// One row of the flattened V5 tree: a single (query, aggregation, label-set) series.
interface FlatSeries {
queryName: string;
labels: Record<string, string>;
name: string;
values: { timestamp: number; value: number }[];
}
// V5 labels [{key:{name}, value}] → {name: value} (the getLabelName contract).
function foldLabels(labels: TimeSeries['labels']): Record<string, string> {
const record: Record<string, string> = {};
(labels ?? []).forEach((label) => {
if (label.key?.name) {
record[label.key.name] = String(label.value);
}
});
return record;
}
// Series display name, matching the chart legend: getLabelName for the base
// (legend template / label-set), then getLegend to resolve the aggregation
// alias/expression from the builder query (the response only carries
// auto-generated `__result_N` aliases). Same chain the uPlot layer uses.
function seriesName(args: {
labels: Record<string, string>;
queryName: string;
legend: string;
aggIndex: number;
alias: string;
query?: Query;
}): string {
const { labels, queryName, legend, aggIndex, alias, query } = args;
const baseName = getLabelName(labels, queryName, legend);
if (!query) {
return baseName;
}
const legacySeries = {
queryName,
metric: labels,
values: [],
metaData: { alias, index: aggIndex, queryName },
} as QueryData;
return getLegend(legacySeries, query, baseName);
}
// Walk results → aggregations → series into a flat, named list.
function flatten(
data: TimeSeriesData[],
legendMap?: Record<string, string>,
query?: Query,
): FlatSeries[] {
const flat: FlatSeries[] = [];
data.forEach((result) => {
const queryName = result.queryName ?? '';
const legend = legendMap?.[queryName] ?? '';
(result.aggregations ?? []).forEach((bucket) => {
(bucket.series ?? []).forEach((series) => {
const labels = foldLabels(series.labels);
flat.push({
queryName,
labels,
name: seriesName({
labels,
queryName,
legend,
aggIndex: bucket.index ?? 0,
alias: bucket.alias ?? '',
query,
}),
values: (series.values ?? []).map((value) => ({
timestamp: value.timestamp,
value: value.value,
})),
});
});
});
});
return flat;
}
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
function withUnit(header: string, yAxisUnit?: string): string {
if (!yAxisUnit || DISPLAY_ONLY_UNITS.has(yAxisUnit)) {
return header;
}
return `${header} (${yAxisUnit})`;
}
function toIso(timestamp: number): string {
return new Date(timestamp).toISOString();
}
// Tidy (LONG) layout: one row per (series, timestamp). query is its own column.
function buildTable(flat: FlatSeries[], yAxisUnit?: string): SerializedTable {
const labelKeySet = new Set<string>();
flat.forEach((series) => {
Object.keys(series.labels).forEach((key) => labelKeySet.add(key));
});
const labelKeys = Array.from(labelKeySet).sort();
const headers = [
'timestamp',
'query',
'series',
...labelKeys,
withUnit('value', yAxisUnit),
];
const rows: (string | number)[][] = [];
flat.forEach((series) => {
series.values.forEach(({ timestamp, value }) => {
rows.push([
toIso(timestamp),
series.queryName,
series.name,
...labelKeys.map((key) => series.labels[key] ?? ''),
value,
]);
});
});
return { headers, rows };
}
/**
* Serializes a V5 time_series result into a format-agnostic tidy table — one
* row per (series, timestamp), labels as columns, raw values.
* Pure — walks the V5 tree directly; series names match the chart legend.
*/
export function exportTimeseriesData({
data,
yAxisUnit,
legendMap,
query,
}: ExportTimeseriesDataArgs): SerializedTable {
return buildTable(flatten(data, legendMap, query), yAxisUnit);
}

View File

@@ -0,0 +1,8 @@
import { unparse } from 'papaparse';
import { SerializedTable } from './types';
/** Serializes a table to CSV. `fields` pins column order regardless of row keys. */
export function toCsv(table: SerializedTable): string {
return unparse({ fields: table.headers, data: table.rows });
}

View File

@@ -0,0 +1,12 @@
import { SerializedTable } from './types';
/** Serializes a table to newline-delimited JSON: one object per row, keyed by header. */
export function toJsonl(table: SerializedTable): string {
return table.rows
.map((row) =>
JSON.stringify(
Object.fromEntries(table.headers.map((header, i) => [header, row[i]])),
),
)
.join('\n');
}

View File

@@ -0,0 +1,13 @@
/** Format-agnostic tabular result produced by every exporter. Consumed by the
* CSV/JSONL formatters */
export interface SerializedTable {
headers: string[];
// One entry per header, in header order. Empty string marks a gap.
rows: (string | number)[][];
}
/** File formats a client-side export can be downloaded as. */
export enum ExportFormat {
Csv = 'csv',
Jsonl = 'jsonl',
}

View File

@@ -11,7 +11,6 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useAlertRule } from 'providers/Alert';
@@ -86,18 +85,11 @@ function AlertDetails(): JSX.Element {
return <Spinner />;
}
// Anomaly rules are stored with a metric alertType; the editor's alert
// type is derived from the ruleType so the anomaly condition is shown.
const initialAlertType =
alertRuleDetails?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT
? AlertTypes.ANOMALY_BASED_ALERT
: (alertRuleDetails?.alertType as AlertTypes);
return (
<CreateAlertProvider
ruleId={ruleId || ''}
isEditMode
initialAlertType={initialAlertType}
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
initialAlertState={initialAlertState}
>
<div

View File

@@ -10,7 +10,6 @@ import { useSelector } from 'react-redux';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
@@ -116,9 +115,6 @@ function TimeSeriesViewContainer({
return (
<div className="trace-explorer-time-series-view-container">
<div className="trace-explorer-time-series-view-container-header">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
</div>
<TimeSeriesView
isFilterApplied={isFilterApplied}
isError={isError}
@@ -126,8 +122,10 @@ function TimeSeriesViewContainer({
isLoading={isLoading || isFetching}
data={responseData}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
dataSource={dataSource}
setWarning={setWarning}
allowExport
/>
</div>
);

View File

@@ -28,8 +28,6 @@ export interface PostableAlertRuleV2 {
absentFor?: number;
requireMinPoints?: boolean;
requiredNumPoints?: number;
algorithm?: string;
seasonality?: string;
};
evaluation?: {
kind?: 'rolling' | 'cumulative';

View File

@@ -8,7 +8,11 @@ import {
IClickHouseQuery,
IPromQLQuery,
} from '../queryBuilder/queryBuilderData';
import { ExecStats, QueryRangeRequestV5 } from '../v5/queryRange';
import {
ExecStats,
QueryRangeRequestV5,
QueryRangeResponseV5,
} from '../v5/queryRange';
import { QueryData, QueryDataV3 } from '../widgets/getQuery';
export type QueryRangePayload = {
@@ -48,6 +52,9 @@ export interface MetricQueryRangeSuccessResponse extends SuccessResponse<
> {
warning?: Warning;
meta?: ExecStats;
// Raw V5 response (pre-legacy-conversion) + per-query legend map, for client-side export.
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
}
export interface MetricRangePayloadV3 {

View File

@@ -221,15 +221,16 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
{
Name: "metric_anomaly",
Summary: "Metric anomaly rule",
Description: "Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Thresholds compare against the anomaly z-score and are literal: a drop of more than 2 deviations below the forecast is expressed as `op: below` with `target: -2`. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
Summary: "Metric anomaly rule (v1 only)",
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
Value: map[string]any{
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"evalWindow": "24h",
"frequency": "3h",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
@@ -255,29 +256,17 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
},
},
"selectedQueryName": "A",
"op": "below",
"matchType": "all_the_times",
"target": 2,
"algorithm": "standard",
"seasonality": "daily",
"selectedQueryName": "A",
"requireMinPoints": true,
"requiredNumPoints": 3,
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "warning",
"op": "below",
"matchType": "all_the_times",
"target": -2,
"channels": []any{"slack-ingestion"},
},
},
},
},
"evaluation": rolling("24h", "3h"),
"notificationSettings": map[string]any{
"renotify": renotify("4h", "firing"),
},
"labels": map[string]any{"severity": "warning"},
"labels": map[string]any{"severity": "warning"},
"preferredChannels": []any{"slack-ingestion"},
"annotations": map[string]any{
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
"summary": "Span ingestion anomaly",

View File

@@ -712,39 +712,6 @@ func TestValidate_V2Alpha1(t *testing.T) {
json: validV2Alpha1Builder(),
},
// anomaly rules are supported under v2alpha1; thresholds are literal,
// so a drop of 2 deviations below the forecast carries target -2
{
name: "valid v2alpha1 anomaly rule",
json: `{
"alert": "Test", "version": "v5", "schemaVersion": "v2alpha1", "ruleType": "anomaly_rule",
"condition": {
"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}], "stepInterval": "5m"}}]},
"algorithm": "standard",
"seasonality": "daily",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": -2.0, "matchType": "2", "op": "2", "channels": ["slack"]}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "1h", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`,
},
{
name: "v2alpha1 anomaly rule with invalid seasonality",
json: `{
"alert": "Test", "version": "v5", "schemaVersion": "v2alpha1", "ruleType": "anomaly_rule",
"condition": {
"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}], "stepInterval": "5m"}}]},
"algorithm": "standard",
"seasonality": "yearly",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": -2.0, "matchType": "2", "op": "2", "channels": ["slack"]}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "1h", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`,
wantErr: true,
errSubstr: "seasonality",
},
// missing required fields
{
name: "missing thresholds",