Compare commits

...

5 Commits

19 changed files with 1152 additions and 16 deletions

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

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

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