Compare commits

..

5 Commits

28 changed files with 1279 additions and 259 deletions

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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...',

View File

@@ -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]));
});
});

View File

@@ -0,0 +1,4 @@
export const EXPORT_PAGE_SIZE = 50_000;
/** Pages fetched in parallel.*/
export const EXPORT_CONCURRENCY = 3;

View 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;
// 0100 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],
);
}

View 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');
});
});

View File

@@ -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 {

View 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',
});
}

View File

@@ -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,
});

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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', () => {

View File

@@ -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();
});
});

View File

@@ -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;
// 0100
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();
},
}));

View File

@@ -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: [],
},
],
},
};
}

View File

@@ -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,
};
}

View File

@@ -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';

View File

@@ -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]);

View 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 {};

View File

@@ -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(

View File

@@ -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"))
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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