Compare commits

...

6 Commits

Author SHA1 Message Date
aks07
0b4a0d0e36 test(data-export): move export tests into __tests__ folders
Aligns with the repo's dominant test layout (__tests__/ subfolders). No test changes beyond import-path depth.
2026-07-08 09:14:38 +05:30
aks07
a542da58e0 feat(data-export): prompt Save-As dialog with a prefilled filename
downloadFile now opens the File System Access API Save-As picker (prefilled with the export filename), falling back to a direct anchor download on browsers without it, and silently no-ops when the user cancels.
2026-07-07 20:20:35 +05:30
aks07
87d0289715 feat(data-export): add useClientExport dispatch hook
Frontend-driven export hook: narrows a V5 queryRange response by request type and routes time_series to the serializer, then formats (csv/jsonl) and downloads. Adds the ExportFormat enum and a scalar-export stub (#5591). Backend-driven export stays in useServerExport.
2026-07-07 20:20:25 +05:30
aks07
5b6a05dcf9 feat(data-export): add csv/jsonl formatters and client download trigger
toCsv/toJsonl turn a SerializedTable into CSV or newline-delimited JSON; downloadFile triggers a client-side blob download. All operate on the format-agnostic SerializedTable so any exporter can reuse them.
2026-07-07 19:43:16 +05:30
aks07
83815d3392 feat(data-export): add time_series LONG/WIDE serializer
Pure serializer that walks the V5 time_series tree (results → aggregations → series) into a format-agnostic SerializedTable. Supports LONG (tidy) and WIDE (pivot) shapes, series naming via the shared getLabelName, queryName/alias qualification only when ambiguous, y-axis unit in headers, and blank gaps.
2026-07-07 19:43:06 +05:30
aks07
9b9c461fbd feat: rename existing export logic to follow the new export data structure 2026-07-06 16:22:42 +05:30
13 changed files with 887 additions and 1 deletions

View File

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

View File

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

View File

@@ -0,0 +1,106 @@
import { message } from 'antd';
import { downloadFile } from 'lib/exportData/downloadFile';
import { exportScalarData } from 'lib/exportData/exportScalarData';
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
import { toCsv } from 'lib/exportData/toCsv';
import { toJsonl } from 'lib/exportData/toJsonl';
import {
ExportFormat,
SerializedTable,
TimeseriesShape,
} from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import {
QueryRangeResponseV5,
ScalarData,
TimeSeriesData,
} from 'types/api/v5/queryRange';
const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
[ExportFormat.Csv]: { mime: 'text/csv;charset=utf-8;', extension: 'csv' },
[ExportFormat.Jsonl]: {
mime: 'application/x-ndjson;charset=utf-8;',
extension: 'jsonl',
},
};
// Picks the serializer for the response's request type. Narrows the results
// union via the response discriminant (raw/trace/distribution aren't client-exported).
function serialize(
response: QueryRangeResponseV5,
shape: TimeseriesShape,
yAxisUnit?: string,
legendMap?: Record<string, string>,
): SerializedTable {
switch (response.type) {
case 'time_series':
return exportTimeseriesData({
data: response.data.results as TimeSeriesData[],
shape,
yAxisUnit,
legendMap,
});
case 'scalar':
return exportScalarData({
data: response.data.results as ScalarData[],
yAxisUnit,
});
default:
throw new Error(`Export is not supported for "${response.type}" results`);
}
}
interface UseClientExportProps {
response?: QueryRangeResponseV5;
yAxisUnit?: string;
fileName?: string;
legendMap?: Record<string, string>;
}
interface ClientExportOptions {
format: ExportFormat;
shape?: TimeseriesShape;
}
interface UseClientExportReturn {
isExporting: boolean;
handleExport: (options: ClientExportOptions) => Promise<void>;
}
// Frontend-driven export: serializes in-memory query results and downloads them
// client-side. Backend-driven export lives in useServerExport.
export function useClientExport({
response,
yAxisUnit,
fileName = 'export',
legendMap,
}: UseClientExportProps): UseClientExportReturn {
const [isExporting, setIsExporting] = useState<boolean>(false);
const handleExport = useCallback(
async ({
format,
shape = TimeseriesShape.Long,
}: ClientExportOptions): Promise<void> => {
if (!response) {
return;
}
setIsExporting(true);
try {
const table = serialize(response, shape, yAxisUnit, legendMap);
const content =
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
const { mime, extension } = FORMAT_META[format];
await downloadFile(content, `${fileName}.${extension}`, mime);
} catch {
message.error('Failed to export data. Please try again.');
} finally {
setIsExporting(false);
}
},
[response, yAxisUnit, fileName, legendMap],
);
return { isExporting, handleExport };
}

View File

@@ -0,0 +1,75 @@
import { downloadFile } from '../downloadFile';
// jsdom doesn't implement the object-URL APIs; define stubs so jest.spyOn can wrap them.
if (typeof URL.createObjectURL !== 'function') {
URL.createObjectURL = (): string => '';
}
if (typeof URL.revokeObjectURL !== 'function') {
URL.revokeObjectURL = (): void => undefined;
}
type PickerWindow = { showSaveFilePicker?: unknown };
describe('downloadFile', () => {
afterEach(() => {
jest.restoreAllMocks();
delete (window as unknown as PickerWindow).showSaveFilePicker;
});
it('writes via the Save-As dialog when available, prefilled with the file name', async () => {
const write = jest.fn().mockResolvedValue(undefined);
const close = jest.fn().mockResolvedValue(undefined);
const createWritable = jest.fn().mockResolvedValue({ write, close });
const showSaveFilePicker = jest.fn().mockResolvedValue({ createWritable });
(window as unknown as PickerWindow).showSaveFilePicker = showSaveFilePicker;
const createElement = jest.spyOn(document, 'createElement');
await downloadFile('hello', 'export.csv', 'text/csv');
expect(showSaveFilePicker).toHaveBeenCalledWith({
suggestedName: 'export.csv',
});
expect(write).toHaveBeenCalledWith('hello');
expect(close).toHaveBeenCalledTimes(1);
expect(createElement).not.toHaveBeenCalled();
});
it('does nothing when the user cancels the Save-As dialog', async () => {
const showSaveFilePicker = jest
.fn()
.mockRejectedValue(new DOMException('aborted', 'AbortError'));
(window as unknown as PickerWindow).showSaveFilePicker = showSaveFilePicker;
const createElement = jest.spyOn(document, 'createElement');
await downloadFile('hello', 'export.csv', 'text/csv');
expect(createElement).not.toHaveBeenCalled();
});
it('falls back to an anchor download when the Save-As API is unavailable', async () => {
const click = jest.fn();
const remove = jest.fn();
const anchor = {
href: '',
download: '',
click,
remove,
} as unknown as HTMLAnchorElement;
(
jest.spyOn(document, 'createElement') as unknown as jest.Mock
).mockReturnValue(anchor);
const createObjectURL = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock');
const revokeObjectURL = jest.spyOn(URL, 'revokeObjectURL');
await downloadFile('hello', 'export.csv', 'text/csv');
expect(anchor.download).toBe('export.csv');
expect(anchor.href).toBe('blob:mock');
expect(click).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock');
});
});

View File

@@ -0,0 +1,205 @@
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { exportTimeseriesData } from '../exportTimeseriesData';
import { TimeseriesShape } from '../types';
const iso = (ms: number): string => new Date(ms).toISOString();
function makeSeries(
labels: Record<string, string>,
values: [number, number][],
): TimeSeries {
return {
labels: Object.entries(labels).map(([name, value]) => ({
key: { name },
value,
})),
values: values.map(([timestamp, value]) => ({ timestamp, value })),
};
}
function makeQuery(
queryName: string,
buckets: { index?: number; alias?: string; series: TimeSeries[] }[],
): TimeSeriesData {
return {
queryName,
aggregations: buckets.map((bucket, i) => ({
index: bucket.index ?? i,
alias: bucket.alias ?? '',
meta: {},
series: bucket.series,
})),
};
}
describe('exportTimeseriesData', () => {
it('LONG: query column, label column, unit in value header, legend naming', () => {
const data = [
makeQuery('A', [
{
series: [
makeSeries({ service_name: 'frontend' }, [
[1000, 12],
[2000, 15],
]),
],
},
]),
];
const table = exportTimeseriesData({
data,
shape: TimeseriesShape.Long,
yAxisUnit: 'ms',
legendMap: { A: '{{service_name}}' },
});
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service_name',
'value (ms)',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'frontend', 'frontend', 12],
[iso(2000), 'A', 'frontend', 'frontend', 15],
]);
});
it('LONG: no legend falls back to the label-set name from getLabelName', () => {
const data = [
makeQuery('A', [
{ series: [makeSeries({ service_name: 'frontend' }, [[1000, 12]])] },
]),
];
const table = exportTimeseriesData({ data, shape: TimeseriesShape.Long });
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service_name',
'value',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', '{service_name="frontend"}', 'frontend', 12],
]);
});
it('WIDE: one column per series, sorted timestamps, blank for gaps', () => {
const data = [
makeQuery('A', [
{
series: [
makeSeries({ service: 'a' }, [
[1000, 1],
[2000, 2],
]),
makeSeries({ service: 'b' }, [[2000, 20]]),
],
},
]),
];
const table = exportTimeseriesData({
data,
shape: TimeseriesShape.Wide,
legendMap: { A: '{{service}}' },
});
expect(table.headers).toStrictEqual(['timestamp', 'a', 'b']);
expect(table.rows).toStrictEqual([
[iso(1000), 1, ''],
[iso(2000), 2, 20],
]);
});
it('WIDE: appends the y-axis unit to every series header', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'a' }, [[1000, 1]])] }]),
];
const table = exportTimeseriesData({
data,
shape: TimeseriesShape.Wide,
yAxisUnit: 'ms',
legendMap: { A: '{{service}}' },
});
expect(table.headers).toStrictEqual(['timestamp', 'a (ms)']);
expect(table.rows).toStrictEqual([[iso(1000), 1]]);
});
it('LONG multi-query: series column is not query-prefixed (query has its own column)', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
makeQuery('B', [{ series: [makeSeries({ service: 'y' }, [[1000, 2]])] }]),
];
const table = exportTimeseriesData({
data,
shape: TimeseriesShape.Long,
legendMap: { A: '{{service}}', B: '{{service}}' },
});
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service',
'value',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'x', 'x', 1],
[iso(1000), 'B', 'y', 'y', 2],
]);
});
it('WIDE multi-query: headers are prefixed with queryName', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
makeQuery('B', [{ series: [makeSeries({ service: 'y' }, [[1000, 2]])] }]),
];
const table = exportTimeseriesData({
data,
shape: TimeseriesShape.Wide,
legendMap: { A: '{{service}}', B: '{{service}}' },
});
expect(table.headers).toStrictEqual(['timestamp', 'A: x', 'B: y']);
expect(table.rows).toStrictEqual([[iso(1000), 1, 2]]);
});
it('WIDE multi-aggregation: headers are prefixed with the aggregation alias', () => {
const data = [
makeQuery('A', [
{ index: 0, alias: 'count', series: [makeSeries({}, [[1000, 5]])] },
{ index: 1, alias: 'p99', series: [makeSeries({}, [[1000, 300]])] },
]),
];
const table = exportTimeseriesData({ data, shape: TimeseriesShape.Wide });
expect(table.headers).toStrictEqual(['timestamp', 'count: A', 'p99: A']);
expect(table.rows).toStrictEqual([[iso(1000), 5, 300]]);
});
it('empty data: returns a headers-only table', () => {
expect(
exportTimeseriesData({ data: [], shape: TimeseriesShape.Long }),
).toStrictEqual({
headers: ['timestamp', 'query', 'series', 'value'],
rows: [],
});
expect(
exportTimeseriesData({ data: [], shape: TimeseriesShape.Wide }),
).toStrictEqual({
headers: ['timestamp'],
rows: [],
});
});
});

View File

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

View File

@@ -0,0 +1,64 @@
interface FileSystemWritableLike {
write: (data: string) => Promise<void>;
close: () => Promise<void>;
}
interface FileSystemFileHandleLike {
createWritable: () => Promise<FileSystemWritableLike>;
}
interface SaveFilePickerWindow {
showSaveFilePicker: (options: {
suggestedName?: string;
}) => Promise<FileSystemFileHandleLike>;
}
function supportsSaveFilePicker(): boolean {
return (
typeof (window as unknown as Partial<SaveFilePickerWindow>)
.showSaveFilePicker === 'function'
);
}
// Fallback for browsers without the File System Access API (Firefox/Safari):
// download straight to the default Downloads folder.
function anchorDownload(content: string, fileName: string, mime: string): void {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
link.remove();
URL.revokeObjectURL(url);
}
/**
* Prompts a Save-As dialog (prefilled with fileName) via the File System Access
* API, falling back to a direct download where it isn't supported. Silently
* returns if the user cancels the dialog.
*/
export async function downloadFile(
content: string,
fileName: string,
mime: string,
): Promise<void> {
if (supportsSaveFilePicker()) {
try {
const picker = window as unknown as SaveFilePickerWindow;
const handle = await picker.showSaveFilePicker({ suggestedName: fileName });
const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
return;
} catch (error) {
// User dismissed the dialog — nothing to download.
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
// Any other failure: fall back to a direct download.
}
}
anchorDownload(content, fileName, mime);
}

View File

@@ -0,0 +1,16 @@
import { ScalarData } from 'types/api/v5/queryRange';
import { SerializedTable } from './types';
interface ExportScalarDataArgs {
data: ScalarData[];
yAxisUnit?: string;
}
/**
* Serializes a V5 scalar (table/value/pie) result into a table.
* TODO(#5591): implement — stubbed so useClientExport's dispatch compiles.
*/
export function exportScalarData(_args: ExportScalarDataArgs): SerializedTable {
throw new Error('Scalar export is not implemented yet');
}

View File

@@ -0,0 +1,223 @@
import getLabelName from 'lib/getLabelName';
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { SerializedTable, TimeseriesShape } from './types';
interface ExportTimeseriesDataArgs {
data: TimeSeriesData[];
shape: TimeseriesShape;
yAxisUnit?: string;
legendMap?: Record<string, string>;
}
// One row of the flattened V5 tree: a single (query, aggregation, label-set) series.
interface FlatSeries {
queryName: string;
aggIndex: number;
alias: string;
labels: Record<string, string>;
baseName: string;
values: { timestamp: number; value: number }[];
}
// V5 labels [{key:{name}, value}] → {name: value} (the getLabelName contract).
function foldLabels(labels: TimeSeries['labels']): Record<string, string> {
const record: Record<string, string> = {};
(labels ?? []).forEach((label) => {
if (label.key?.name) {
record[label.key.name] = String(label.value);
}
});
return record;
}
// Walk results → aggregations → series into a flat, named list.
function flatten(
data: TimeSeriesData[],
legendMap?: Record<string, string>,
): FlatSeries[] {
const flat: FlatSeries[] = [];
data.forEach((query) => {
const queryName = query.queryName ?? '';
const legend = legendMap?.[queryName] ?? '';
(query.aggregations ?? []).forEach((bucket) => {
(bucket.series ?? []).forEach((series) => {
const labels = foldLabels(series.labels);
flat.push({
queryName,
aggIndex: bucket.index ?? 0,
alias: bucket.alias ?? '',
labels,
baseName: getLabelName(labels, queryName, legend),
values: (series.values ?? []).map((value) => ({
timestamp: value.timestamp,
value: value.value,
})),
});
});
});
});
return flat;
}
interface Qualifiers {
manyQueries: boolean;
multiAggByQuery: Record<string, boolean>;
}
// A series name only needs a query/aggregation prefix when that dimension is
// ambiguous — >1 query overall, or >1 aggregation within its query.
function computeQualifiers(flat: FlatSeries[]): Qualifiers {
const queries = new Set<string>();
const aggsByQuery: Record<string, Set<number>> = {};
flat.forEach((series) => {
queries.add(series.queryName);
if (!aggsByQuery[series.queryName]) {
aggsByQuery[series.queryName] = new Set<number>();
}
aggsByQuery[series.queryName].add(series.aggIndex);
});
const multiAggByQuery: Record<string, boolean> = {};
Object.keys(aggsByQuery).forEach((query) => {
multiAggByQuery[query] = aggsByQuery[query].size > 1;
});
return { manyQueries: queries.size > 1, multiAggByQuery };
}
function qualifiedName(
series: FlatSeries,
opts: { includeQuery: boolean; includeAlias: boolean },
): string {
let prefix = '';
if (opts.includeQuery) {
prefix += `${series.queryName}: `;
}
if (opts.includeAlias) {
const aggLabel = series.alias || `agg[${series.aggIndex}]`;
prefix += `${aggLabel}: `;
}
return `${prefix}${series.baseName}`;
}
// Guarantee unique column headers if two series still collide after qualifying.
function dedupeHeaders(names: string[]): string[] {
const counts: Record<string, number> = {};
return names.map((name) => {
if (counts[name] === undefined) {
counts[name] = 1;
return name;
}
counts[name] += 1;
return `${name} (${counts[name]})`;
});
}
// Appends the y-axis unit to a header: `value` → `value (ms)`, `frontend` →
// `frontend (ms)`. Shared by LONG's value column and every WIDE series column.
function withUnit(header: string, yAxisUnit?: string): string {
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
}
function toIso(timestamp: number): string {
return new Date(timestamp).toISOString();
}
// LONG (tidy): one row per (series, timestamp). query is its own column.
function buildLong(
flat: FlatSeries[],
qualifiers: Qualifiers,
yAxisUnit?: string,
): SerializedTable {
const labelKeySet = new Set<string>();
flat.forEach((series) => {
Object.keys(series.labels).forEach((key) => labelKeySet.add(key));
});
const labelKeys = Array.from(labelKeySet).sort();
const headers = [
'timestamp',
'query',
'series',
...labelKeys,
withUnit('value', yAxisUnit),
];
const rows: (string | number)[][] = [];
flat.forEach((series) => {
const seriesName = qualifiedName(series, {
includeQuery: false,
includeAlias: qualifiers.multiAggByQuery[series.queryName],
});
series.values.forEach(({ timestamp, value }) => {
rows.push([
toIso(timestamp),
series.queryName,
seriesName,
...labelKeys.map((key) => series.labels[key] ?? ''),
value,
]);
});
});
return { headers, rows };
}
// WIDE (pivot): one row per timestamp, one column per series.
function buildWide(
flat: FlatSeries[],
qualifiers: Qualifiers,
yAxisUnit?: string,
): SerializedTable {
const timestampSet = new Set<number>();
flat.forEach((series) => {
series.values.forEach((value) => timestampSet.add(value.timestamp));
});
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
const valueBySeries = flat.map((series) => {
const map = new Map<number, number>();
series.values.forEach((value) => map.set(value.timestamp, value.value));
return map;
});
const seriesHeaders = dedupeHeaders(
flat.map((series) =>
qualifiedName(series, {
includeQuery: qualifiers.manyQueries,
includeAlias: qualifiers.multiAggByQuery[series.queryName],
}),
),
).map((header) => withUnit(header, yAxisUnit));
const headers = ['timestamp', ...seriesHeaders];
const rows: (string | number)[][] = timestamps.map((timestamp) => {
const row: (string | number)[] = [toIso(timestamp)];
valueBySeries.forEach((map) => {
const value = map.get(timestamp);
row.push(value === undefined ? '' : value);
});
return row;
});
return { headers, rows };
}
/**
* Serializes a V5 time_series result into a format-agnostic table (LONG or WIDE).
* Pure — walks the V5 tree directly and names series via the shared getLabelName.
*/
export function exportTimeseriesData({
data,
shape,
yAxisUnit,
legendMap,
}: ExportTimeseriesDataArgs): SerializedTable {
const flat = flatten(data, legendMap);
const qualifiers = computeQualifiers(flat);
return shape === TimeseriesShape.Wide
? buildWide(flat, qualifiers, yAxisUnit)
: buildLong(flat, qualifiers, yAxisUnit);
}

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
/** Format-agnostic tabular result produced by every exporter. Consumed by the
* CSV/JSONL formatters */
export interface SerializedTable {
headers: string[];
// One entry per header, in header order. Empty string marks a gap.
rows: (string | number)[][];
}
/** TimeSeries export layouts: LONG (tidy, one row per point) or WIDE (pivot). */
export enum TimeseriesShape {
Long = 'long',
Wide = 'wide',
}
/** File formats a client-side export can be downloaded as. */
export enum ExportFormat {
Csv = 'csv',
Jsonl = 'jsonl',
}