mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-14 18:40:26 +01:00
Compare commits
5 Commits
issue-1000
...
feat/expor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0854b34dc8 | ||
|
|
8c6b5f0145 | ||
|
|
2170e1f022 | ||
|
|
75dc5f6195 | ||
|
|
8b47513a06 |
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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 {};
|
||||
Reference in New Issue
Block a user