mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-16 11:20:31 +01:00
Compare commits
5 Commits
feat/rules
...
feat/expor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0854b34dc8 | ||
|
|
8c6b5f0145 | ||
|
|
2170e1f022 | ||
|
|
75dc5f6195 | ||
|
|
8b47513a06 |
@@ -7551,28 +7551,35 @@ components:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
evalWindow:
|
||||
type: string
|
||||
evaluation:
|
||||
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
|
||||
frequency:
|
||||
type: string
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
notificationSettings:
|
||||
$ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
preferredChannels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
ruleType:
|
||||
$ref: '#/components/schemas/RuletypesRuleType'
|
||||
schemaVersion:
|
||||
enum:
|
||||
- v2alpha1
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
required:
|
||||
- alert
|
||||
- alertType
|
||||
- ruleType
|
||||
- condition
|
||||
- schemaVersion
|
||||
- evaluation
|
||||
- notificationSettings
|
||||
type: object
|
||||
RuletypesQueryType:
|
||||
enum:
|
||||
@@ -7622,8 +7629,12 @@ components:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
evalWindow:
|
||||
type: string
|
||||
evaluation:
|
||||
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
|
||||
frequency:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
labels:
|
||||
@@ -7632,11 +7643,15 @@ components:
|
||||
type: object
|
||||
notificationSettings:
|
||||
$ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
preferredChannels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
ruleType:
|
||||
$ref: '#/components/schemas/RuletypesRuleType'
|
||||
schemaVersion:
|
||||
enum:
|
||||
- v2alpha1
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
state:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
@@ -7645,6 +7660,8 @@ components:
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- state
|
||||
@@ -7652,9 +7669,6 @@ components:
|
||||
- alertType
|
||||
- ruleType
|
||||
- condition
|
||||
- schemaVersion
|
||||
- evaluation
|
||||
- notificationSettings
|
||||
type: object
|
||||
RuletypesRuleCondition:
|
||||
properties:
|
||||
@@ -7667,6 +7681,10 @@ components:
|
||||
type: string
|
||||
compositeQuery:
|
||||
$ref: '#/components/schemas/RuletypesAlertCompositeQuery'
|
||||
matchType:
|
||||
$ref: '#/components/schemas/RuletypesMatchType'
|
||||
op:
|
||||
$ref: '#/components/schemas/RuletypesCompareOperator'
|
||||
requireMinPoints:
|
||||
type: boolean
|
||||
requiredNumPoints:
|
||||
@@ -7675,11 +7693,16 @@ components:
|
||||
$ref: '#/components/schemas/RuletypesSeasonality'
|
||||
selectedQueryName:
|
||||
type: string
|
||||
target:
|
||||
format: double
|
||||
nullable: true
|
||||
type: number
|
||||
targetUnit:
|
||||
type: string
|
||||
thresholds:
|
||||
$ref: '#/components/schemas/RuletypesRuleThresholdData'
|
||||
required:
|
||||
- compositeQuery
|
||||
- thresholds
|
||||
type: object
|
||||
RuletypesRuleThresholdData:
|
||||
discriminator:
|
||||
|
||||
@@ -8644,9 +8644,6 @@ export type RuletypesPostableRuleDTOAnnotations = { [key: string]: string };
|
||||
|
||||
export type RuletypesPostableRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
export enum RuletypesPostableRuleDTOSchemaVersion {
|
||||
v2alpha1 = 'v2alpha1',
|
||||
}
|
||||
export enum RuletypesSeasonalityDTO {
|
||||
hourly = 'hourly',
|
||||
daily = 'daily',
|
||||
@@ -8681,6 +8678,8 @@ export interface RuletypesRuleConditionDTO {
|
||||
*/
|
||||
algorithm?: string;
|
||||
compositeQuery: RuletypesAlertCompositeQueryDTO;
|
||||
matchType?: RuletypesMatchTypeDTO;
|
||||
op?: RuletypesCompareOperatorDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -8694,7 +8693,16 @@ export interface RuletypesRuleConditionDTO {
|
||||
* @type string
|
||||
*/
|
||||
selectedQueryName?: string;
|
||||
thresholds: RuletypesRuleThresholdDataDTO;
|
||||
/**
|
||||
* @type number,null
|
||||
* @format double
|
||||
*/
|
||||
target?: number | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
targetUnit?: string;
|
||||
thresholds?: RuletypesRuleThresholdDataDTO;
|
||||
}
|
||||
|
||||
export enum RuletypesRuleTypeDTO {
|
||||
@@ -8721,27 +8729,43 @@ export interface RuletypesPostableRuleDTO {
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
evaluation: RuletypesEvaluationEnvelopeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
evalWindow?: string;
|
||||
evaluation?: RuletypesEvaluationEnvelopeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
frequency?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
labels?: RuletypesPostableRuleDTOLabels;
|
||||
notificationSettings: RuletypesNotificationSettingsDTO;
|
||||
notificationSettings?: RuletypesNotificationSettingsDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
preferredChannels?: string[];
|
||||
ruleType: RuletypesRuleTypeDTO;
|
||||
/**
|
||||
* @enum v2alpha1
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: RuletypesPostableRuleDTOSchemaVersion;
|
||||
schemaVersion?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export type RuletypesRuleDTOAnnotations = { [key: string]: string };
|
||||
|
||||
export type RuletypesRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
export enum RuletypesRuleDTOSchemaVersion {
|
||||
v2alpha1 = 'v2alpha1',
|
||||
}
|
||||
export interface RuletypesRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -8770,7 +8794,15 @@ export interface RuletypesRuleDTO {
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
evaluation: RuletypesEvaluationEnvelopeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
evalWindow?: string;
|
||||
evaluation?: RuletypesEvaluationEnvelopeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
frequency?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -8779,13 +8811,20 @@ export interface RuletypesRuleDTO {
|
||||
* @type object
|
||||
*/
|
||||
labels?: RuletypesRuleDTOLabels;
|
||||
notificationSettings: RuletypesNotificationSettingsDTO;
|
||||
notificationSettings?: RuletypesNotificationSettingsDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
preferredChannels?: string[];
|
||||
ruleType: RuletypesRuleTypeDTO;
|
||||
/**
|
||||
* @enum v2alpha1
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: RuletypesRuleDTOSchemaVersion;
|
||||
schemaVersion?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
state: RuletypesAlertStateDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -8796,6 +8835,10 @@ export interface RuletypesRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData';
|
||||
|
||||
export interface FetchExportDataProps extends ExportRawDataProps {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function postExportRawData({
|
||||
format,
|
||||
body,
|
||||
signal,
|
||||
}: FetchExportDataProps): Promise<AxiosResponse<Blob>> {
|
||||
return axios.post<Blob>(
|
||||
`export_raw_data?format=${encodeURIComponent(format)}`,
|
||||
body,
|
||||
{
|
||||
responseType: 'blob',
|
||||
decompress: true,
|
||||
headers: {
|
||||
Accept: 'application/octet-stream',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 0,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single export_raw_data page and returns it as a Blob.
|
||||
* Callers own retry/cancel/error UX.
|
||||
*/
|
||||
export async function fetchExportData(
|
||||
props: FetchExportDataProps,
|
||||
): Promise<Blob> {
|
||||
const response = await postExportRawData(props);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const downloadExportData = async (
|
||||
props: ExportRawDataProps,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const response = await axios.post<Blob>(
|
||||
`export_raw_data?format=${encodeURIComponent(props.format)}`,
|
||||
props.body,
|
||||
{
|
||||
responseType: 'blob',
|
||||
decompress: true,
|
||||
headers: {
|
||||
Accept: 'application/octet-stream',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 0,
|
||||
},
|
||||
);
|
||||
const response = await postExportRawData(props);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
|
||||
@@ -71,13 +71,12 @@ function ActionsMenu({
|
||||
createRule({
|
||||
...rule,
|
||||
alert: `${rule.alert} - Copy`,
|
||||
} as unknown as RuletypesPostableRuleDTO).then(async (response) => {
|
||||
} as RuletypesPostableRuleDTO).then(async (response) => {
|
||||
await invalidateListRules(queryClient);
|
||||
const newRule = response.data;
|
||||
if (newRule) {
|
||||
onEdit(newRule as AlertRule);
|
||||
}
|
||||
return newRule;
|
||||
}),
|
||||
{
|
||||
loading: 'Cloning alert...',
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'tests/blob-polyfill';
|
||||
|
||||
import { fetchExportData } from 'api/v1/download/downloadExportData';
|
||||
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
|
||||
import { downloadFile } from 'lib/exportData/downloadFile';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { runChunkedExport } from '../runChunkedExport';
|
||||
|
||||
jest.mock('api/v1/download/downloadExportData', () => ({
|
||||
fetchExportData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/v5/v5', () => ({
|
||||
prepareQueryRangePayloadV5: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/exportData/downloadFile', () => ({
|
||||
downloadFile: jest.fn(),
|
||||
getTimestampedFileName: jest.fn(
|
||||
(base: string, ext: string) => `${base}.${ext}`,
|
||||
),
|
||||
}));
|
||||
|
||||
const mockFetch = fetchExportData as jest.Mock;
|
||||
const mockPreparePayload = prepareQueryRangePayloadV5 as jest.Mock;
|
||||
const mockDownloadFile = downloadFile as jest.Mock;
|
||||
|
||||
// Minimal query shape — the runner only maps over builder.queryData.
|
||||
const query = {
|
||||
builder: { queryData: [{}] },
|
||||
} as unknown as Query;
|
||||
|
||||
const baseArgs = {
|
||||
query,
|
||||
timeRange: { start: 100, end: 200 },
|
||||
fileNameBase: 'trace-x',
|
||||
format: ExportFormat.Csv,
|
||||
onProgress: jest.fn(),
|
||||
};
|
||||
|
||||
// EXPORT_PAGE_SIZE is 50_000; rows → parts: 120k → 3, 40k → 1, etc.
|
||||
const rowsForParts = (parts: number): number => parts * 50_000;
|
||||
|
||||
function runArgs(
|
||||
overrides: Partial<Parameters<typeof runChunkedExport>[0]> = {},
|
||||
): Parameters<typeof runChunkedExport>[0] {
|
||||
return {
|
||||
...baseArgs,
|
||||
totalRows: rowsForParts(1),
|
||||
signal: new AbortController().signal,
|
||||
onProgress: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function savedFileText(): Promise<string> {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
|
||||
const [blob] = mockDownloadFile.mock.calls[0];
|
||||
return (blob as Blob).text();
|
||||
}
|
||||
|
||||
describe('runChunkedExport', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPreparePayload.mockImplementation(({ query: pageQuery }) => ({
|
||||
// Echo the page offset so fetch calls can be asserted per page.
|
||||
queryPayload: { offset: pageQuery.builder.queryData[0].offset },
|
||||
}));
|
||||
});
|
||||
|
||||
it('fetches one page for small exports and saves the file', async () => {
|
||||
mockFetch.mockResolvedValueOnce(new Blob(['h\na,b\n']));
|
||||
const onProgress = jest.fn();
|
||||
|
||||
await runChunkedExport(runArgs({ totalRows: 40_000, onProgress }));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch.mock.calls[0][0].body).toStrictEqual({ offset: 0 });
|
||||
expect(onProgress).toHaveBeenCalledWith(100);
|
||||
await expect(savedFileText()).resolves.toBe('h\na,b\n');
|
||||
});
|
||||
|
||||
it('fetches every page with the right offsets and stitches in order', async () => {
|
||||
mockFetch.mockImplementation(({ body }) =>
|
||||
Promise.resolve(new Blob([`h\nrow-${body.offset}\n`])),
|
||||
);
|
||||
|
||||
await runChunkedExport(runArgs({ totalRows: rowsForParts(3) }));
|
||||
|
||||
const offsets = mockFetch.mock.calls.map(([props]) => props.body.offset);
|
||||
expect(offsets.sort((a, b) => a - b)).toStrictEqual([0, 50_000, 100_000]);
|
||||
await expect(savedFileText()).resolves.toBe(
|
||||
'h\nrow-0\nrow-50000\nrow-100000\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps page order even when later pages resolve first', async () => {
|
||||
const resolvers: Record<number, (b: Blob) => void> = {};
|
||||
mockFetch.mockImplementation(
|
||||
({ body }) =>
|
||||
new Promise((resolve) => {
|
||||
resolvers[body.offset] = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const promise = runChunkedExport(runArgs({ totalRows: rowsForParts(3) }));
|
||||
// All 3 pages are in flight (concurrency 3); resolve in reverse order.
|
||||
await Promise.resolve();
|
||||
resolvers[100_000](new Blob(['h\nc\n']));
|
||||
resolvers[50_000](new Blob(['h\nb\n']));
|
||||
resolvers[0](new Blob(['h\na\n']));
|
||||
await promise;
|
||||
|
||||
await expect(savedFileText()).resolves.toBe('h\na\nb\nc\n');
|
||||
});
|
||||
|
||||
it('stops claiming pages once the server runs dry', async () => {
|
||||
// Count says 5 pages but the server only has data for page 0.
|
||||
mockFetch.mockImplementation(({ body }) =>
|
||||
Promise.resolve(body.offset === 0 ? new Blob(['h\na\n']) : new Blob([])),
|
||||
);
|
||||
|
||||
await runChunkedExport(runArgs({ totalRows: rowsForParts(5) }));
|
||||
|
||||
// Workers stop claiming once an empty part is observed — exact count is
|
||||
// timing-dependent, but it must never fetch all 5 pages.
|
||||
expect(mockFetch.mock.calls.length).toBeLessThan(5);
|
||||
await expect(savedFileText()).resolves.toBe('h\na\n');
|
||||
});
|
||||
|
||||
it('rejects on page failure, aborts in-flight siblings, saves nothing', async () => {
|
||||
const seenSignals: AbortSignal[] = [];
|
||||
mockFetch.mockImplementation(({ body, signal }) => {
|
||||
seenSignals.push(signal);
|
||||
return body.offset === 50_000
|
||||
? Promise.reject(new Error('boom'))
|
||||
: new Promise(() => {}); // siblings hang until aborted
|
||||
});
|
||||
|
||||
await expect(
|
||||
runChunkedExport(runArgs({ totalRows: rowsForParts(3) })),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
expect(seenSignals.every((s) => s.aborted)).toBe(true);
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a user abort to the page fetches', async () => {
|
||||
const controller = new AbortController();
|
||||
mockFetch.mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const promise = runChunkedExport(
|
||||
runArgs({ totalRows: rowsForParts(2), signal: controller.signal }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
|
||||
await expect(promise).rejects.toThrow('Aborted');
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports whole-number progress per completed part', async () => {
|
||||
mockFetch.mockImplementation(({ body }) =>
|
||||
Promise.resolve(new Blob([`h\nrow-${body.offset}\n`])),
|
||||
);
|
||||
const onProgress = jest.fn();
|
||||
|
||||
await runChunkedExport(runArgs({ totalRows: rowsForParts(3), onProgress }));
|
||||
|
||||
const reported = onProgress.mock.calls.map(([p]) => p);
|
||||
expect(reported).toHaveLength(3);
|
||||
expect(reported).toStrictEqual(expect.arrayContaining([33, 67, 100]));
|
||||
});
|
||||
});
|
||||
4
frontend/src/hooks/useExportData/constants.ts
Normal file
4
frontend/src/hooks/useExportData/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const EXPORT_PAGE_SIZE = 50_000;
|
||||
|
||||
/** Pages fetched in parallel.*/
|
||||
export const EXPORT_CONCURRENCY = 3;
|
||||
143
frontend/src/hooks/useExportData/runChunkedExport.ts
Normal file
143
frontend/src/hooks/useExportData/runChunkedExport.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { fetchExportData } from 'api/v1/download/downloadExportData';
|
||||
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
downloadFile,
|
||||
getTimestampedFileName,
|
||||
} from 'lib/exportData/downloadFile';
|
||||
import { stitchCsvParts, stitchJsonlParts } from 'lib/exportData/stitchParts';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
import store from 'store';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import { EXPORT_CONCURRENCY, EXPORT_PAGE_SIZE } from './constants';
|
||||
|
||||
export interface ChunkedExportArgs {
|
||||
query: Query;
|
||||
timeRange: { start: number; end: number };
|
||||
// Total rows the query is expected to return; drives the page count.
|
||||
totalRows: number;
|
||||
fileNameBase: string;
|
||||
}
|
||||
|
||||
interface RunChunkedExportProps extends ChunkedExportArgs {
|
||||
format: ExportFormat;
|
||||
signal: AbortSignal;
|
||||
// 0–100 whole number: parts completed over total parts.
|
||||
onProgress: (progress: number) => void;
|
||||
}
|
||||
|
||||
const MIME_BY_FORMAT: Record<ExportFormat, string> = {
|
||||
[ExportFormat.Csv]: 'text/csv',
|
||||
[ExportFormat.Jsonl]: 'application/x-ndjson',
|
||||
};
|
||||
|
||||
/**
|
||||
* Chunked server export: fetches export_raw_data pages through a bounded
|
||||
* worker pool (EXPORT_CONCURRENCY wide — pages are independent since offsets
|
||||
* are precomputable), stitches the parts in page order client-side, and saves
|
||||
* a single file.
|
||||
*/
|
||||
export async function runChunkedExport({
|
||||
query,
|
||||
timeRange,
|
||||
totalRows,
|
||||
fileNameBase,
|
||||
format,
|
||||
signal,
|
||||
onProgress,
|
||||
}: RunChunkedExportProps): Promise<void> {
|
||||
const totalParts = Math.max(1, Math.ceil(totalRows / EXPORT_PAGE_SIZE));
|
||||
const workerCount = Math.min(Math.max(1, EXPORT_CONCURRENCY), totalParts);
|
||||
|
||||
// Internal controller so the first failed page (or a user cancel) stops the
|
||||
// whole fleet instead of letting siblings run to completion for nothing.
|
||||
const fleet = new AbortController();
|
||||
const onExternalAbort = (): void => fleet.abort();
|
||||
signal.addEventListener('abort', onExternalAbort);
|
||||
if (signal.aborted) {
|
||||
fleet.abort();
|
||||
}
|
||||
|
||||
const buildPagePayload = (page: number): QueryRangePayloadV5 => {
|
||||
const pageQuery: Query = {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((qd) => ({
|
||||
...qd,
|
||||
limit: EXPORT_PAGE_SIZE,
|
||||
pageSize: EXPORT_PAGE_SIZE,
|
||||
offset: page * EXPORT_PAGE_SIZE,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
return prepareQueryRangePayloadV5({
|
||||
query: pageQuery,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
// Read directly (not via hooks) — the absolute bounds below take
|
||||
// precedence over the global picker in the payload anyway.
|
||||
globalSelectedInterval: store.getState().globalTime.selectedTime,
|
||||
start: timeRange.start,
|
||||
end: timeRange.end,
|
||||
}).queryPayload;
|
||||
};
|
||||
|
||||
// Indexed by page so out-of-order completion can't scramble the stitch order.
|
||||
const parts = new Array<Blob | undefined>(totalParts);
|
||||
let nextPage = 0;
|
||||
let completedParts = 0;
|
||||
let serverRanDry = false;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
while (!serverRanDry) {
|
||||
const page = nextPage;
|
||||
nextPage += 1;
|
||||
if (page >= totalParts) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const part = await fetchExportData({
|
||||
format,
|
||||
body: buildPagePayload(page),
|
||||
signal: fleet.signal,
|
||||
});
|
||||
|
||||
// Empty part = the row count drifted and the server ran dry early;
|
||||
// stop claiming further pages (later in-flight pages are empty too).
|
||||
if (part.size === 0) {
|
||||
serverRanDry = true;
|
||||
return;
|
||||
}
|
||||
|
||||
parts[page] = part;
|
||||
completedParts += 1;
|
||||
onProgress(Math.round((completedParts / totalParts) * 100));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||
} catch (error) {
|
||||
fleet.abort();
|
||||
throw error;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
|
||||
const orderedParts = parts.filter((part): part is Blob => Boolean(part));
|
||||
const stitched =
|
||||
format === ExportFormat.Csv
|
||||
? await stitchCsvParts(orderedParts)
|
||||
: await stitchJsonlParts(orderedParts);
|
||||
|
||||
downloadFile(
|
||||
stitched,
|
||||
getTimestampedFileName(fileNameBase, format),
|
||||
MIME_BY_FORMAT[format],
|
||||
);
|
||||
}
|
||||
100
frontend/src/lib/exportData/__tests__/stitchParts.test.ts
Normal file
100
frontend/src/lib/exportData/__tests__/stitchParts.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'tests/blob-polyfill';
|
||||
|
||||
import { stitchCsvParts, stitchJsonlParts } from '../stitchParts';
|
||||
|
||||
const blob = (content: string): Blob => new Blob([content]);
|
||||
|
||||
describe('stitchCsvParts', () => {
|
||||
it('keeps a single part as-is', async () => {
|
||||
const out = await stitchCsvParts([blob('h1,h2\na,b\n')]);
|
||||
await expect(out.text()).resolves.toBe('h1,h2\na,b\n');
|
||||
expect(out.type).toBe('text/csv');
|
||||
});
|
||||
|
||||
it('strips the header row from parts after the first', async () => {
|
||||
const out = await stitchCsvParts([
|
||||
blob('h1,h2\na,b\n'),
|
||||
blob('h1,h2\nc,d\n'),
|
||||
blob('h1,h2\ne,f\n'),
|
||||
]);
|
||||
await expect(out.text()).resolves.toBe('h1,h2\na,b\nc,d\ne,f\n');
|
||||
});
|
||||
|
||||
it('handles CRLF line endings (the \\r dies with the header)', async () => {
|
||||
const out = await stitchCsvParts([
|
||||
blob('h1,h2\r\na,b\r\n'),
|
||||
blob('h1,h2\r\nc,d\r\n'),
|
||||
]);
|
||||
await expect(out.text()).resolves.toBe('h1,h2\r\na,b\r\nc,d\r\n');
|
||||
});
|
||||
|
||||
it('inserts a newline at the seam when a part lacks a trailing one', async () => {
|
||||
const out = await stitchCsvParts([blob('h1,h2\na,b'), blob('h1,h2\nc,d')]);
|
||||
await expect(out.text()).resolves.toBe('h1,h2\na,b\nc,d\n');
|
||||
});
|
||||
|
||||
it('preserves multi-byte characters around the header boundary', async () => {
|
||||
const out = await stitchCsvParts([
|
||||
blob('höader,h2\naä,b\n'),
|
||||
blob('höader,h2\ncö,d\n'),
|
||||
]);
|
||||
await expect(out.text()).resolves.toBe('höader,h2\naä,b\ncö,d\n');
|
||||
});
|
||||
|
||||
it('strips headers longer than the initial 8KB sniff window', async () => {
|
||||
// Forces findFirstNewlineEnd through its window-doubling path.
|
||||
const hugeHeader = Array.from({ length: 1200 }, (_, i) => `column_${i}`).join(
|
||||
',',
|
||||
);
|
||||
expect(hugeHeader.length).toBeGreaterThan(8192);
|
||||
const out = await stitchCsvParts([
|
||||
blob(`${hugeHeader}\na,b\n`),
|
||||
blob(`${hugeHeader}\nc,d\n`),
|
||||
]);
|
||||
await expect(out.text()).resolves.toBe(`${hugeHeader}\na,b\nc,d\n`);
|
||||
});
|
||||
|
||||
it('skips empty and header-only parts', async () => {
|
||||
const out = await stitchCsvParts([
|
||||
blob('h1,h2\na,b\n'),
|
||||
blob(''),
|
||||
blob('h1,h2\n'),
|
||||
blob('h1,h2'),
|
||||
blob('h1,h2\nc,d\n'),
|
||||
]);
|
||||
await expect(out.text()).resolves.toBe('h1,h2\na,b\nc,d\n');
|
||||
});
|
||||
|
||||
it('returns an empty blob for no parts', async () => {
|
||||
const out = await stitchCsvParts([]);
|
||||
expect(out.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stitchJsonlParts', () => {
|
||||
it('concatenates parts without stripping anything', async () => {
|
||||
const out = await stitchJsonlParts([
|
||||
blob('{"a":1}\n{"a":2}\n'),
|
||||
blob('{"a":3}\n'),
|
||||
]);
|
||||
await expect(out.text()).resolves.toBe('{"a":1}\n{"a":2}\n{"a":3}\n');
|
||||
expect(out.type).toBe('application/x-ndjson');
|
||||
});
|
||||
|
||||
it('guards the seam when a part lacks a trailing newline', async () => {
|
||||
const out = await stitchJsonlParts([blob('{"a":1}'), blob('{"a":2}')]);
|
||||
const text = await out.text();
|
||||
expect(text).toBe('{"a":1}\n{"a":2}\n');
|
||||
// Every line must stay independently parseable.
|
||||
const lines = text.trim().split('\n');
|
||||
expect(lines.map((line) => JSON.parse(line))).toStrictEqual([
|
||||
{ a: 1 },
|
||||
{ a: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips empty parts', async () => {
|
||||
const out = await stitchJsonlParts([blob(''), blob('{"a":1}\n'), blob('')]);
|
||||
await expect(out.text()).resolves.toBe('{"a":1}\n');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Triggers a browser download of in-memory string content as a file. */
|
||||
/** Triggers a browser download of in-memory content (string or Blob) as a file. */
|
||||
export function downloadFile(
|
||||
content: string,
|
||||
content: string | Blob,
|
||||
fileName: string,
|
||||
mime: string,
|
||||
): void {
|
||||
|
||||
85
frontend/src/lib/exportData/stitchParts.ts
Normal file
85
frontend/src/lib/exportData/stitchParts.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Stitches sequentially-fetched export parts (Blobs) into one downloadable
|
||||
* Blob. Pure blob surgery — parts are never parsed and `Blob.slice`/`new Blob`
|
||||
* are zero-copy, so nothing here scales with the data size except the final
|
||||
* browser-managed blob itself.
|
||||
*/
|
||||
|
||||
const HEADER_SNIFF_BYTES = 8192;
|
||||
const NEWLINE_BYTE = 0x0a;
|
||||
|
||||
/** Byte offset just past the first newline, or -1 when the blob has none. */
|
||||
async function findFirstNewlineEnd(blob: Blob): Promise<number> {
|
||||
let sniffBytes = HEADER_SNIFF_BYTES;
|
||||
for (;;) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const bytes = new Uint8Array(await blob.slice(0, sniffBytes).arrayBuffer());
|
||||
const newlineIdx = bytes.indexOf(NEWLINE_BYTE);
|
||||
if (newlineIdx !== -1) {
|
||||
return newlineIdx + 1;
|
||||
}
|
||||
if (sniffBytes >= blob.size) {
|
||||
return -1;
|
||||
}
|
||||
sniffBytes *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
async function endsWithNewline(blob: Blob): Promise<boolean> {
|
||||
const lastByte = new Uint8Array(await blob.slice(blob.size - 1).arrayBuffer());
|
||||
return lastByte[0] === NEWLINE_BYTE;
|
||||
}
|
||||
|
||||
async function stitchParts(
|
||||
parts: Blob[],
|
||||
{
|
||||
stripHeaderAfterFirst,
|
||||
mime,
|
||||
}: { stripHeaderAfterFirst: boolean; mime: string },
|
||||
): Promise<Blob> {
|
||||
const pieces: (Blob | string)[] = [];
|
||||
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
let piece = parts[index];
|
||||
|
||||
if (stripHeaderAfterFirst && index > 0) {
|
||||
// Every part re-sends the header row; drop it on parts 2..N. Byte-level
|
||||
// search (not text) so multi-byte characters can't skew the offset.
|
||||
// ASSUMPTION: the first newline byte ends the header row — i.e. no
|
||||
// column name contains an embedded (RFC 4180 quoted) newline. Header
|
||||
// cells are telemetry field names, which can't carry newlines; quoted
|
||||
// newlines in DATA rows are fine (we only search from byte 0).
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const headerEnd = await findFirstNewlineEnd(piece);
|
||||
// A part without any newline is header-only — nothing to keep.
|
||||
piece = headerEnd === -1 ? piece.slice(piece.size) : piece.slice(headerEnd);
|
||||
}
|
||||
|
||||
if (piece.size === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pieces.push(piece);
|
||||
// Guard the seam: without a trailing newline the next part's first row
|
||||
// would concatenate onto this part's last row.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await endsWithNewline(piece))) {
|
||||
pieces.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob(pieces, { type: mime });
|
||||
}
|
||||
|
||||
/** Combines CSV parts: part 1 keeps its header, parts 2..N are decapitated. */
|
||||
export async function stitchCsvParts(parts: Blob[]): Promise<Blob> {
|
||||
return stitchParts(parts, { stripHeaderAfterFirst: true, mime: 'text/csv' });
|
||||
}
|
||||
|
||||
/** Combines JSONL parts: plain concatenation with a newline guard per seam. */
|
||||
export async function stitchJsonlParts(parts: Blob[]): Promise<Blob> {
|
||||
return stitchParts(parts, {
|
||||
stripHeaderAfterFirst: false,
|
||||
mime: 'application/x-ndjson',
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesAlertTypeDTO,
|
||||
RuletypesEvaluationRollingDTOKind,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleDTOSchemaVersion,
|
||||
RuletypesRuleTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
@@ -18,10 +16,6 @@ const baseCondition: RuletypesRuleConditionDTO = {
|
||||
panelType: RuletypesPanelTypeDTO.graph,
|
||||
queries: null,
|
||||
},
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [{ name: 'critical', target: 90, matchType: '1', op: '1' }],
|
||||
},
|
||||
} as unknown as RuletypesRuleConditionDTO;
|
||||
|
||||
const make = (
|
||||
@@ -40,12 +34,9 @@ const make = (
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'info' },
|
||||
annotations: {},
|
||||
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
|
||||
evaluation: {
|
||||
kind: RuletypesEvaluationRollingDTOKind.rolling,
|
||||
spec: { evalWindow: '5m0s', frequency: '1m0s' },
|
||||
},
|
||||
notificationSettings: {},
|
||||
source: '',
|
||||
evalWindow: '5m0s',
|
||||
frequency: '1m0s',
|
||||
ruleType: RuletypesRuleTypeDTO.threshold_rule,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import TraceDownloadPanel from './TraceDownloadPanel';
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
@@ -43,6 +44,7 @@ export interface TraceMetadataForHeader {
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
hasMissingSpans: boolean;
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
@@ -168,6 +170,10 @@ function TraceDetailsHeader({
|
||||
showTraceDetails={showTraceDetails}
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
traceId={traceID || ''}
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
totalSpansCount={traceMetadata?.totalSpansCount || 0}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -228,6 +234,8 @@ function TraceDetailsHeader({
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
onTabChange={handleAnalyticsTabChange}
|
||||
/>
|
||||
|
||||
<TraceDownloadPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
.downloadPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--secondary-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loader {
|
||||
color: var(--bg-robin-500);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import { LoaderCircle, X } from '@signozhq/icons';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
|
||||
import { useTraceDownloadStore } from './traceDownloadStore';
|
||||
|
||||
import styles from './TraceDownloadPanel.module.scss';
|
||||
|
||||
const PANEL_WIDTH = 356;
|
||||
const PANEL_HEIGHT = 76;
|
||||
|
||||
function TraceDownloadPanel(): JSX.Element {
|
||||
const isDownloading = useTraceDownloadStore((s) => s.isDownloading);
|
||||
const progress = useTraceDownloadStore((s) => s.progress);
|
||||
const cancelDownload = useTraceDownloadStore((s) => s.cancelDownload);
|
||||
|
||||
useEffect(() => cancelDownload, [cancelDownload]);
|
||||
|
||||
const displayProgress = Math.max(1, progress);
|
||||
|
||||
return (
|
||||
<FloatingPanel
|
||||
isOpen={isDownloading}
|
||||
width={PANEL_WIDTH}
|
||||
height={PANEL_HEIGHT}
|
||||
minWidth={PANEL_WIDTH}
|
||||
minHeight={PANEL_HEIGHT}
|
||||
enableResizing={false}
|
||||
>
|
||||
<div className={styles.downloadPanel} data-testid="trace-download-panel">
|
||||
<div className={`${styles.header} floating-panel__drag-handle`}>
|
||||
<span className={styles.title}>
|
||||
Downloading trace
|
||||
<LoaderCircle size={14} className={`animate-spin ${styles.loader}`} />
|
||||
</span>
|
||||
<span className={styles.percent} data-testid="trace-download-percent">
|
||||
{displayProgress}%
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className={styles.cancelBtn}
|
||||
onClick={cancelDownload}
|
||||
aria-label="Cancel download"
|
||||
data-testid="trace-download-cancel"
|
||||
prefix={<X size={16} />}
|
||||
/>
|
||||
</div>
|
||||
<Progress percent={displayProgress} status="active" showInfo={false} />
|
||||
</div>
|
||||
</FloatingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDownloadPanel;
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import { Settings2 } from '@signozhq/icons';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import { useDownloadTrace } from './useDownloadTrace';
|
||||
|
||||
import styles from './TraceOptionsMenu.module.scss';
|
||||
|
||||
@@ -21,6 +23,10 @@ interface TraceOptionsMenuProps {
|
||||
showTraceDetails: boolean;
|
||||
onToggleTraceDetails: () => void;
|
||||
onOpenPreviewFields: () => void;
|
||||
traceId: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
// Composed from dropdown-menu primitives (instead of DropdownMenuSimple)
|
||||
@@ -30,6 +36,10 @@ function TraceOptionsMenu({
|
||||
showTraceDetails,
|
||||
onToggleTraceDetails,
|
||||
onOpenPreviewFields,
|
||||
traceId,
|
||||
startTime,
|
||||
endTime,
|
||||
totalSpansCount,
|
||||
}: TraceOptionsMenuProps): JSX.Element {
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const setColorByField = useTraceStore((s) => s.setColorByField);
|
||||
@@ -37,6 +47,13 @@ function TraceOptionsMenu({
|
||||
(s) => s.availableColorByOptions,
|
||||
);
|
||||
|
||||
const { isDownloading, isExportDisabled, downloadTrace } = useDownloadTrace({
|
||||
traceId,
|
||||
startTime,
|
||||
endTime,
|
||||
totalSpansCount,
|
||||
});
|
||||
|
||||
const handleColorByChange = (name: string): void => {
|
||||
const next = availableColorByOptions.find((o) => o.field.name === name);
|
||||
if (next) {
|
||||
@@ -81,6 +98,32 @@ function TraceOptionsMenu({
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{!isExportDisabled && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
disabled={isDownloading}
|
||||
data-testid="download-trace-submenu"
|
||||
>
|
||||
Download trace
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={styles.traceOptionsDropdown}>
|
||||
<DropdownMenuItem
|
||||
clickable
|
||||
onSelect={(): void => downloadTrace(ExportFormat.Csv)}
|
||||
testId="download-trace-csv"
|
||||
>
|
||||
CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
clickable
|
||||
onSelect={(): void => downloadTrace(ExportFormat.Jsonl)}
|
||||
testId="download-trace-jsonl"
|
||||
>
|
||||
JSONL
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -141,6 +141,7 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
rootServiceEntryPoint: 'large-trace-root',
|
||||
rootSpanStatusCode: '404',
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 42,
|
||||
};
|
||||
|
||||
it('renders the metadata (service, entry point, duration, status) when provided', () => {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'tests/blob-polyfill';
|
||||
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fetchExportData } from 'api/v1/download/downloadExportData';
|
||||
import { downloadFile } from 'lib/exportData/downloadFile';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import TraceDownloadPanel from '../TraceDownloadPanel';
|
||||
import TraceOptionsMenu from '../TraceOptionsMenu';
|
||||
import { MAX_EXPORT_SPANS } from '../useDownloadTrace';
|
||||
|
||||
// Integration suite: menu → hook → store → runner → stitchers → panel all run
|
||||
// for real; only the true boundaries are mocked (HTTP + file save).
|
||||
jest.mock('api/v1/download/downloadExportData', () => ({
|
||||
fetchExportData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/exportData/downloadFile', () => ({
|
||||
downloadFile: jest.fn(),
|
||||
getTimestampedFileName: jest.fn(
|
||||
(base: string, ext: string) => `${base}.${ext}`,
|
||||
),
|
||||
}));
|
||||
|
||||
const mockFetch = fetchExportData as jest.Mock;
|
||||
const mockDownloadFile = downloadFile as jest.Mock;
|
||||
|
||||
const baseProps = {
|
||||
showTraceDetails: true,
|
||||
onToggleTraceDetails: jest.fn(),
|
||||
onOpenPreviewFields: jest.fn(),
|
||||
traceId: 'trace-123',
|
||||
startTime: 1_000,
|
||||
endTime: 2_000,
|
||||
// 120k spans → 3 export pages of 50k.
|
||||
totalSpansCount: 120_000,
|
||||
};
|
||||
|
||||
function renderFeature(
|
||||
props: Partial<typeof baseProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<>
|
||||
<TraceOptionsMenu {...baseProps} {...props} />
|
||||
<TraceDownloadPanel />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
// skipHover: simulated pointer travel fires pointerleave on the subtrigger and
|
||||
// radix's grace-area math (zero rects in jsdom) closes the submenu.
|
||||
// pointerEventsCheck 0: radix puts pointer-events:none on <body> while open.
|
||||
function setupUser(): ReturnType<typeof userEvent.setup> {
|
||||
return userEvent.setup({
|
||||
delay: null,
|
||||
skipHover: true,
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function startDownload(
|
||||
format: 'csv' | 'jsonl' = 'csv',
|
||||
): Promise<ReturnType<typeof userEvent.setup>> {
|
||||
const user = setupUser();
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(await screen.findByTestId('download-trace-submenu'));
|
||||
await user.click(await screen.findByTestId(`download-trace-${format}`));
|
||||
return user;
|
||||
}
|
||||
|
||||
async function savedFileText(): Promise<string> {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
|
||||
const [blob] = mockDownloadFile.mock.calls[0];
|
||||
return (blob as Blob).text();
|
||||
}
|
||||
|
||||
describe('trace download flow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('downloads a multi-page trace as one stitched CSV', async () => {
|
||||
mockFetch.mockImplementation(({ body }) =>
|
||||
Promise.resolve(
|
||||
new Blob([`h1,h2\nrow-${body.compositeQuery.queries[0].spec.offset}\n`]),
|
||||
),
|
||||
);
|
||||
|
||||
renderFeature();
|
||||
await startDownload('csv');
|
||||
|
||||
await waitFor(() => expect(mockDownloadFile).toHaveBeenCalled());
|
||||
|
||||
// Real query building: trace scoping, page offsets, ±5min window (seconds→ms).
|
||||
const bodies = mockFetch.mock.calls.map(([props]) => props.body);
|
||||
const specs = bodies.map((b) => b.compositeQuery.queries[0].spec);
|
||||
expect(specs[0].filter.expression).toBe("trace_id = 'trace-123'");
|
||||
expect(specs.map((s) => s.offset).sort((a, b) => a - b)).toStrictEqual([
|
||||
0, 50_000, 100_000,
|
||||
]);
|
||||
expect(bodies[0].start).toBe((baseProps.startTime - 300) * 1e3);
|
||||
expect(bodies[0].end).toBe((baseProps.endTime + 300) * 1e3);
|
||||
|
||||
// Stitched in page order, headers deduped, saved under the trace's name.
|
||||
await expect(savedFileText()).resolves.toBe(
|
||||
'h1,h2\nrow-0\nrow-50000\nrow-100000\n',
|
||||
);
|
||||
expect(mockDownloadFile.mock.calls[0][1]).toBe('trace-trace-123.csv');
|
||||
|
||||
// Panel dismissed once the download completed.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads JSONL when that format is chosen', async () => {
|
||||
mockFetch.mockResolvedValue(new Blob(['{"a":1}\n']));
|
||||
|
||||
renderFeature({ totalSpansCount: 10 });
|
||||
await startDownload('jsonl');
|
||||
|
||||
await waitFor(() => expect(mockDownloadFile).toHaveBeenCalled());
|
||||
expect(mockFetch.mock.calls[0][0].format).toBe('jsonl');
|
||||
expect(mockDownloadFile.mock.calls[0][1]).toBe('trace-trace-123.jsonl');
|
||||
});
|
||||
|
||||
it('shows the progress panel while downloading and cancels from its ✕', async () => {
|
||||
// Fetches hang until aborted — the download stays in flight.
|
||||
mockFetch.mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderFeature();
|
||||
const user = await startDownload();
|
||||
|
||||
// Panel appears with the 1% floor (no part finished yet).
|
||||
const panel = await screen.findByTestId('trace-download-panel');
|
||||
expect(panel).toBeInTheDocument();
|
||||
expect(screen.getByTestId('trace-download-percent')).toHaveTextContent('1%');
|
||||
|
||||
await user.click(screen.getByTestId('trace-download-cancel'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables the submenu trigger while a download is running', async () => {
|
||||
mockFetch.mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderFeature();
|
||||
const user = await startDownload();
|
||||
|
||||
// Reopen the menu mid-download: trigger is disabled by REAL store state.
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
const trigger = await screen.findByTestId('download-trace-submenu');
|
||||
expect(trigger).toHaveAttribute('data-disabled');
|
||||
|
||||
// Cleanup: stop the in-flight download so the module-scoped guard resets.
|
||||
await user.click(screen.getByTestId('trace-download-cancel'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the download option entirely above the export cap', async () => {
|
||||
renderFeature({ totalSpansCount: MAX_EXPORT_SPANS + 1 });
|
||||
const user = setupUser();
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByRole('menuitem', { name: /preview fields/i }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('download-trace-submenu'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves nothing and dismisses the panel when a page fails', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('boom'));
|
||||
|
||||
renderFeature();
|
||||
await startDownload();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels the in-flight download when the panel unmounts (leaving the page)', async () => {
|
||||
const seenSignals: AbortSignal[] = [];
|
||||
mockFetch.mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
seenSignals.push(signal);
|
||||
signal.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { unmount } = renderFeature();
|
||||
await startDownload();
|
||||
await screen.findByTestId('trace-download-panel');
|
||||
|
||||
unmount();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(seenSignals.every((signal) => signal.aborted)).toBe(true),
|
||||
);
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
ChunkedExportArgs,
|
||||
runChunkedExport,
|
||||
} from 'hooks/useExportData/runChunkedExport';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** 'skipped' = a download was already in flight; the call was ignored. */
|
||||
export type TraceDownloadOutcome = 'completed' | 'cancelled' | 'skipped';
|
||||
|
||||
interface TraceDownloadState {
|
||||
isDownloading: boolean;
|
||||
// 0–100
|
||||
progress: number;
|
||||
startDownload: (
|
||||
args: ChunkedExportArgs & { format: ExportFormat },
|
||||
) => Promise<TraceDownloadOutcome>;
|
||||
cancelDownload: () => void;
|
||||
}
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
export const useTraceDownloadStore = create<TraceDownloadState>((set) => ({
|
||||
isDownloading: false,
|
||||
progress: 0,
|
||||
|
||||
startDownload: async ({
|
||||
format,
|
||||
...args
|
||||
}: ChunkedExportArgs & {
|
||||
format: ExportFormat;
|
||||
}): Promise<TraceDownloadOutcome> => {
|
||||
if (abortController) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortController = controller;
|
||||
set({ isDownloading: true, progress: 0 });
|
||||
|
||||
try {
|
||||
await runChunkedExport({
|
||||
...args,
|
||||
format,
|
||||
signal: controller.signal,
|
||||
onProgress: (progress: number): void => set({ progress }),
|
||||
});
|
||||
return 'completed';
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return 'cancelled';
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
abortController = null;
|
||||
set({ isDownloading: false });
|
||||
}
|
||||
},
|
||||
|
||||
cancelDownload: (): void => {
|
||||
abortController?.abort();
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,23 @@
|
||||
import { listViewInitialTraceQuery } from 'constants/queryBuilder';
|
||||
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export function getTraceExportQuery(traceId: string): Query {
|
||||
const [baseQueryData] = listViewInitialTraceQuery.builder.queryData;
|
||||
|
||||
return {
|
||||
...listViewInitialTraceQuery,
|
||||
builder: {
|
||||
...listViewInitialTraceQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...baseQueryData,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
filter: { expression: `trace_id = '${traceId}'` },
|
||||
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
|
||||
selectColumns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDownloadStore } from './traceDownloadStore';
|
||||
import { getTraceExportQuery } from './traceExportQuery';
|
||||
|
||||
// Export window pad past the trace bounds so edge spans are never clipped
|
||||
// (mirrors SpanDetailsPanel).
|
||||
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
|
||||
|
||||
// Span-count cap: traces above it hide their download option entirely.
|
||||
export const MAX_EXPORT_SPANS = 500_000;
|
||||
|
||||
interface UseDownloadTraceProps {
|
||||
traceId: string;
|
||||
// Trace start/end in seconds.
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
interface UseDownloadTraceReturn {
|
||||
isDownloading: boolean;
|
||||
isExportDisabled: boolean;
|
||||
downloadTrace: (format: ExportFormat) => void;
|
||||
}
|
||||
|
||||
export function useDownloadTrace({
|
||||
traceId,
|
||||
startTime,
|
||||
endTime,
|
||||
totalSpansCount,
|
||||
}: UseDownloadTraceProps): UseDownloadTraceReturn {
|
||||
const isDownloading = useTraceDownloadStore((s) => s.isDownloading);
|
||||
const startDownload = useTraceDownloadStore((s) => s.startDownload);
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId);
|
||||
|
||||
const query = useMemo(() => getTraceExportQuery(traceId), [traceId]);
|
||||
|
||||
const timeRange = useMemo(
|
||||
() => ({
|
||||
start: startTime - FIVE_MINUTES_IN_SECONDS,
|
||||
end: endTime + FIVE_MINUTES_IN_SECONDS,
|
||||
}),
|
||||
[startTime, endTime],
|
||||
);
|
||||
|
||||
const downloadTrace = useCallback(
|
||||
(format: ExportFormat): void => {
|
||||
logTraceEvent(TraceDetailEvents.DownloadTriggered, {
|
||||
[TraceDetailEventKeys.Format]: format,
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
});
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
try {
|
||||
const outcome = await startDownload({
|
||||
query,
|
||||
timeRange,
|
||||
totalRows: totalSpansCount,
|
||||
fileNameBase: `trace-${traceId}`,
|
||||
format,
|
||||
});
|
||||
if (outcome === 'completed') {
|
||||
toast.success('Export completed successfully');
|
||||
} else if (outcome === 'cancelled') {
|
||||
logTraceEvent(TraceDetailEvents.DownloadCancelled, {
|
||||
[TraceDetailEventKeys.Format]: format,
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
});
|
||||
toast.info('Export cancelled');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to download trace');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
},
|
||||
[query, timeRange, totalSpansCount, traceId, startDownload, logTraceEvent],
|
||||
);
|
||||
|
||||
return {
|
||||
isDownloading,
|
||||
isExportDisabled: totalSpansCount > MAX_EXPORT_SPANS,
|
||||
downloadTrace,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export enum TraceDetailEvents {
|
||||
AnalyticsPanelToggled = 'Trace Detail: Analytics panel toggled',
|
||||
AnalyticsTabChanged = 'Trace Detail: Analytics tab changed',
|
||||
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
|
||||
DownloadTriggered = 'Trace Detail: Download triggered',
|
||||
DownloadCancelled = 'Trace Detail: Download cancelled',
|
||||
}
|
||||
|
||||
export enum TraceDetailEventKeys {
|
||||
@@ -32,6 +34,8 @@ export enum TraceDetailEventKeys {
|
||||
Tab = 'tab',
|
||||
// Span panel tab changed
|
||||
SpanId = 'spanId',
|
||||
// Download triggered (reuses TotalSpansCount for trace size)
|
||||
Format = 'format',
|
||||
}
|
||||
|
||||
export type TraceDetailView = 'v2' | 'v3';
|
||||
|
||||
@@ -346,6 +346,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
rootServiceEntryPoint: payload.rootServiceEntryPoint,
|
||||
rootSpanStatusCode: rootSpan?.response_status_code || '',
|
||||
hasMissingSpans: payload.hasMissingSpans || false,
|
||||
totalSpansCount: payload.totalSpansCount || 0,
|
||||
};
|
||||
}, [traceData?.payload]);
|
||||
|
||||
|
||||
34
frontend/src/tests/blob-polyfill.ts
Normal file
34
frontend/src/tests/blob-polyfill.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* jsdom's Blob lacks the modern read methods (arrayBuffer/text). Polyfill via
|
||||
* FileReader (which jsdom does implement); guarded, so it no-ops once jsdom
|
||||
* catches up. Side-effect module — import it at the top of any test touching
|
||||
* Blob contents:
|
||||
*
|
||||
* import 'tests/blob-polyfill';
|
||||
*/
|
||||
|
||||
if (typeof Blob.prototype.arrayBuffer === 'undefined') {
|
||||
Blob.prototype.arrayBuffer = function arrayBuffer(
|
||||
this: Blob,
|
||||
): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (): void => resolve(reader.result as ArrayBuffer);
|
||||
reader.onerror = (): void => reject(reader.error);
|
||||
reader.readAsArrayBuffer(this);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof Blob.prototype.text === 'undefined') {
|
||||
Blob.prototype.text = function text(this: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (): void => resolve(reader.result as string);
|
||||
reader.onerror = (): void => reject(reader.error);
|
||||
reader.readAsText(this);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -61,22 +61,6 @@ function stripUndefinedLabels(
|
||||
return out;
|
||||
}
|
||||
|
||||
// why: the published DTO carries only the v2alpha1 contract, but the server
|
||||
// still accepts the legacy v1 fields (evalWindow, frequency,
|
||||
// preferredChannels, version, and a free-form schemaVersion) that the v1
|
||||
// editor flows send. LegacyRuleFields keeps those on the wire until
|
||||
// schemaVersion v1 is removed from the backend.
|
||||
type LegacyRuleFields = {
|
||||
evalWindow?: string;
|
||||
frequency?: string;
|
||||
preferredChannels?: string[];
|
||||
version?: string;
|
||||
schemaVersion?: string;
|
||||
description?: string;
|
||||
notificationSettings?: RuletypesPostableRuleDTO['notificationSettings'];
|
||||
evaluation?: RuletypesPostableRuleDTO['evaluation'];
|
||||
};
|
||||
|
||||
// why: local PostableAlertRuleV2/AlertDef diverge from RuletypesPostableRuleDTO
|
||||
// in several spots that match by string value but not by nominal TS type —
|
||||
// condition.{op,matchType}, evaluation.kind, notificationSettings.renotify.alertStates.
|
||||
@@ -85,24 +69,24 @@ type LegacyRuleFields = {
|
||||
export function toPostableRuleDTO(
|
||||
local: PostableAlertRuleV2,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const legacy = local as unknown as LegacyRuleFields;
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
condition: local.condition,
|
||||
annotations: local.annotations,
|
||||
labels: stripUndefinedLabels(local.labels),
|
||||
evalWindow: legacy.evalWindow,
|
||||
frequency: legacy.frequency,
|
||||
preferredChannels: legacy.preferredChannels,
|
||||
evalWindow: (local as unknown as RuletypesPostableRuleDTO).evalWindow,
|
||||
frequency: (local as unknown as RuletypesPostableRuleDTO).frequency,
|
||||
preferredChannels: (local as unknown as RuletypesPostableRuleDTO)
|
||||
.preferredChannels,
|
||||
notificationSettings: local.notificationSettings,
|
||||
evaluation: local.evaluation,
|
||||
schemaVersion: local.schemaVersion,
|
||||
source: local.source,
|
||||
version: local.version,
|
||||
disabled: local.disabled,
|
||||
description: legacy.description,
|
||||
description: (local as unknown as RuletypesPostableRuleDTO).description,
|
||||
};
|
||||
return payload as unknown as RuletypesPostableRuleDTO;
|
||||
}
|
||||
@@ -110,8 +94,7 @@ export function toPostableRuleDTO(
|
||||
export function toPostableRuleDTOFromAlertDef(
|
||||
local: AlertDef,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const legacy = local as unknown as LegacyRuleFields;
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
@@ -121,15 +104,16 @@ export function toPostableRuleDTOFromAlertDef(
|
||||
evalWindow: local.evalWindow,
|
||||
frequency: local.frequency,
|
||||
preferredChannels: local.preferredChannels,
|
||||
notificationSettings: legacy.notificationSettings,
|
||||
evaluation: legacy.evaluation,
|
||||
schemaVersion: legacy.schemaVersion,
|
||||
notificationSettings: (local as unknown as RuletypesPostableRuleDTO)
|
||||
.notificationSettings,
|
||||
evaluation: (local as unknown as RuletypesPostableRuleDTO).evaluation,
|
||||
schemaVersion: (local as unknown as RuletypesPostableRuleDTO).schemaVersion,
|
||||
source: local.source,
|
||||
version: local.version,
|
||||
disabled: local.disabled,
|
||||
description: legacy.description,
|
||||
description: (local as unknown as RuletypesPostableRuleDTO).description,
|
||||
};
|
||||
return payload as unknown as RuletypesPostableRuleDTO;
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function fromRuleDTOToPostableRuleV2(
|
||||
|
||||
@@ -76,12 +76,9 @@ type PostableRule struct {
|
||||
}
|
||||
|
||||
type NotificationSettings struct {
|
||||
GroupBy []string `json:"groupBy,omitempty"`
|
||||
// Renotify is a pointer so that an explicitly disabled renotify
|
||||
// ({"enabled": false}) survives the read path; a value struct with
|
||||
// omitzero would drop it from every response.
|
||||
Renotify *Renotify `json:"renotify,omitempty"`
|
||||
UsePolicy bool `json:"usePolicy,omitempty"`
|
||||
GroupBy []string `json:"groupBy,omitempty"`
|
||||
Renotify Renotify `json:"renotify,omitzero"`
|
||||
UsePolicy bool `json:"usePolicy,omitempty"`
|
||||
// NewGroupEvalDelay is the grace period for new series to be excluded from alerts evaluation
|
||||
NewGroupEvalDelay valuer.TextDuration `json:"newGroupEvalDelay,omitzero"`
|
||||
}
|
||||
@@ -95,7 +92,7 @@ type Renotify struct {
|
||||
func (ns *NotificationSettings) GetAlertManagerNotificationConfig() alertmanagertypes.NotificationConfig {
|
||||
var renotifyInterval time.Duration
|
||||
var noDataRenotifyInterval time.Duration
|
||||
if ns.Renotify != nil && ns.Renotify.Enabled {
|
||||
if ns.Renotify.Enabled {
|
||||
if slices.Contains(ns.Renotify.AlertStates, StateNoData) {
|
||||
noDataRenotifyInterval = ns.Renotify.ReNotifyInterval.Duration()
|
||||
}
|
||||
@@ -207,12 +204,10 @@ func (ns *NotificationSettings) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
// Validate states after unmarshaling
|
||||
if ns.Renotify != nil {
|
||||
for _, state := range ns.Renotify.AlertStates {
|
||||
if state != StateFiring && state != StateNoData {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid alert state: %s", state)
|
||||
for _, state := range ns.Renotify.AlertStates {
|
||||
if state != StateFiring && state != StateNoData {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid alert state: %s", state)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -225,12 +220,6 @@ func (r *PostableRule) processRuleDefaults() {
|
||||
r.SchemaVersion = DefaultSchemaVersion
|
||||
}
|
||||
|
||||
// v5 is the only supported query version; default it so clients
|
||||
// don't have to send it.
|
||||
if r.Version == "" {
|
||||
r.Version = "v5"
|
||||
}
|
||||
|
||||
// v2alpha1 uses the Evaluation envelope for window/frequency;
|
||||
// only default top-level fields for v1.
|
||||
if r.SchemaVersion != SchemaVersionV2Alpha1 {
|
||||
@@ -282,7 +271,7 @@ func (r *PostableRule) processRuleDefaults() {
|
||||
r.RuleCondition.Thresholds = &thresholdData
|
||||
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
|
||||
r.NotificationSettings = &NotificationSettings{
|
||||
Renotify: &Renotify{
|
||||
Renotify: Renotify{
|
||||
Enabled: true,
|
||||
ReNotifyInterval: valuer.MustParseTextDuration("4h"),
|
||||
AlertStates: []AlertState{StateFiring},
|
||||
@@ -568,7 +557,7 @@ func (r *PostableRule) validateV2Alpha1() []error {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"notificationSettings: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
|
||||
} else {
|
||||
if r.NotificationSettings.Renotify != nil && r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
|
||||
if r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"notificationSettings.renotify.interval: must be a positive duration when renotify is enabled"))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package ruletypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -1328,105 +1327,3 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenotifyRoundTrip ensures notificationSettings.renotify survives the
|
||||
// v2 read path (stored JSON -> GettableRule -> NewRule -> marshal) exactly as
|
||||
// stored, including an explicitly disabled renotify with no interval.
|
||||
func TestRenotifyRoundTrip(t *testing.T) {
|
||||
base := `{
|
||||
"alert": "cpu high",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"ruleType": "threshold_rule",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
|
||||
"panelType": "graph",
|
||||
"queryType": "promql"
|
||||
},
|
||||
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "1", "op": "1"}]}
|
||||
},
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
|
||||
"notificationSettings": %s
|
||||
}`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
settings string
|
||||
wantRenotify string
|
||||
}{
|
||||
{
|
||||
name: "absent renotify stays absent",
|
||||
settings: `{"usePolicy": false}`,
|
||||
wantRenotify: "",
|
||||
},
|
||||
{
|
||||
name: "explicitly disabled renotify is echoed",
|
||||
settings: `{"renotify": {"enabled": false}}`,
|
||||
wantRenotify: `{"enabled":false}`,
|
||||
},
|
||||
{
|
||||
name: "enabled renotify with states is echoed",
|
||||
settings: `{"renotify": {"enabled": true, "interval": "30m", "alertStates": ["firing"]}}`,
|
||||
wantRenotify: `{"enabled":true,"interval":"30m","alertStates":["firing"]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stored := strings.Replace(base, "%s", tc.settings, 1)
|
||||
g := GettableRule{}
|
||||
if err := json.Unmarshal([]byte(stored), &g); err != nil {
|
||||
t.Fatalf("unmarshal stored rule: %v", err)
|
||||
}
|
||||
out, err := json.Marshal(NewRule(&g))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal v2 rule: %v", err)
|
||||
}
|
||||
var resp struct {
|
||||
NotificationSettings struct {
|
||||
Renotify json.RawMessage `json:"renotify"`
|
||||
} `json:"notificationSettings"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
got := string(resp.NotificationSettings.Renotify)
|
||||
if got != tc.wantRenotify {
|
||||
t.Errorf("renotify round-trip mismatch: got %q, want %q", got, tc.wantRenotify)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionDefaultsToV5 ensures rules posted without a version parse and
|
||||
// validate with version defaulted to v5.
|
||||
func TestVersionDefaultsToV5(t *testing.T) {
|
||||
content := `{
|
||||
"alert": "cpu high",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"ruleType": "threshold_rule",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
|
||||
"panelType": "graph",
|
||||
"queryType": "promql"
|
||||
},
|
||||
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "1", "op": "1"}]}
|
||||
},
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
|
||||
"notificationSettings": {"usePolicy": false}
|
||||
}`
|
||||
|
||||
rule := PostableRule{}
|
||||
if err := json.Unmarshal([]byte(content), &rule); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if rule.Version != "v5" {
|
||||
t.Errorf("expected version to default to v5, got %q", rule.Version)
|
||||
}
|
||||
if err := rule.Validate(); err != nil {
|
||||
t.Errorf("expected rule without version to validate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
var (
|
||||
_ jsonschema.Preparer = (*PostableRule)(nil)
|
||||
_ jsonschema.Preparer = (*RuleCondition)(nil)
|
||||
)
|
||||
|
||||
// PrepareJSONSchema restricts the published rule schema to the v2alpha1
|
||||
// contract: the v1-only fields (evalWindow, frequency, preferredChannels) and
|
||||
// the query version (always "v5", defaulted by the server) are omitted, and
|
||||
// the fields the server requires for schemaVersion v2alpha1 are marked
|
||||
// required. Runtime JSON handling is unchanged and continues to load stored
|
||||
// v1 rules until schemaVersion v1 is removed.
|
||||
//
|
||||
// Rule and GettableRule embed PostableRule, so this also applies to the
|
||||
// published read models through method promotion.
|
||||
func (r *PostableRule) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
for _, name := range []string{"evalWindow", "frequency", "preferredChannels", "version", "source"} {
|
||||
delete(schema.Properties, name)
|
||||
}
|
||||
|
||||
schema.Required = append(schema.Required, "schemaVersion", "evaluation", "notificationSettings")
|
||||
|
||||
if prop, ok := schema.Properties["schemaVersion"]; ok && prop.TypeObject != nil {
|
||||
prop.TypeObject.WithEnum(SchemaVersionV2Alpha1)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrepareJSONSchema removes the v1-only condition fields (target, op,
|
||||
// matchType, targetUnit — expressed per threshold entry in v2alpha1) from the
|
||||
// published schema and marks thresholds required.
|
||||
func (rc *RuleCondition) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
for _, name := range []string{"target", "op", "matchType", "targetUnit"} {
|
||||
delete(schema.Properties, name)
|
||||
}
|
||||
|
||||
schema.Required = append(schema.Required, "thresholds")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -169,10 +169,12 @@ func TestValidate_PostableRule_Common(t *testing.T) {
|
||||
errSubstr: "alert",
|
||||
},
|
||||
|
||||
// only "v5" is allowed; missing/empty defaults to "v5"
|
||||
// only "v5" is allowed
|
||||
{
|
||||
name: "missing version defaults to v5",
|
||||
json: removeField(validV1Builder(), "version"),
|
||||
name: "missing version",
|
||||
json: removeField(validV1Builder(), "version"),
|
||||
wantErr: true,
|
||||
errSubstr: "version",
|
||||
},
|
||||
{
|
||||
name: "wrong version v4",
|
||||
@@ -187,8 +189,10 @@ func TestValidate_PostableRule_Common(t *testing.T) {
|
||||
errSubstr: "version",
|
||||
},
|
||||
{
|
||||
name: "empty version defaults to v5",
|
||||
json: patchJSON(validV1Builder(), `{"version": ""}`),
|
||||
name: "empty version",
|
||||
json: patchJSON(validV1Builder(), `{"version": ""}`),
|
||||
wantErr: true,
|
||||
errSubstr: "version",
|
||||
},
|
||||
|
||||
// alert type, capital case to avoid breaking changes
|
||||
|
||||
Reference in New Issue
Block a user