From 46cb25a9ae714b4a4fa31782cd82ccf4d966eb3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Fri, 10 Apr 2026 11:01:11 -0400 Subject: [PATCH 01/10] feat(explore): Add logs export modal with row limit Add DataExportWithModal (global modal + scraps form) beside the existing logs export control. Extend useDataExport to send an optional top-level limit to the data-export API. Co-Authored-By: Cursor Made-with: Cursor --- static/app/components/dataExport.tsx | 80 +++++++----- .../components/dataExportWithModal.spec.tsx | 87 +++++++++++++ static/app/components/dataExportWithModal.tsx | 117 ++++++++++++++++++ .../views/explore/logs/logsExport.spec.tsx | 1 + static/app/views/explore/logs/logsExport.tsx | 57 +++++++-- 5 files changed, 300 insertions(+), 42 deletions(-) create mode 100644 static/app/components/dataExportWithModal.spec.tsx create mode 100644 static/app/components/dataExportWithModal.tsx diff --git a/static/app/components/dataExport.tsx b/static/app/components/dataExport.tsx index 5593a2db03d198..5353ffe3c02eeb 100644 --- a/static/app/components/dataExport.tsx +++ b/static/app/components/dataExport.tsx @@ -16,10 +16,14 @@ export enum ExportQueryType { EXPLORE = 'Explore', } -interface DataExportPayload { +export type DataExportPayload = { queryInfo: any; queryType: ExportQueryType; // TODO(ts): Formalize different possible payloads -} +}; + +export type DataExportInvokeOptions = { + limit?: number; +}; interface DataExportProps { payload: DataExportPayload; @@ -43,23 +47,33 @@ export function useDataExport({ const organization = useOrganization(); const api = useApi(); - return useCallback(() => { - inProgressCallback?.(true); - - // This is a fire and forget request. - api - .requestPromise(`/organizations/${organization.slug}/data-export/`, { - includeAllArgs: true, - method: 'POST', - data: { - query_type: payload.queryType, - query_info: payload.queryInfo, - }, - }) - .then(([_data, _, response]) => { - // If component has unmounted, don't do anything + return useCallback( + async (invokeOptions?: DataExportInvokeOptions): Promise => { + inProgressCallback?.(true); + + const data: { + query_info: any; + query_type: ExportQueryType; + limit?: number; + } = { + query_type: payload.queryType, + query_info: payload.queryInfo, + }; + if (typeof invokeOptions?.limit === 'number') { + data.limit = invokeOptions.limit; + } + + try { + const [_data, _, response] = await api.requestPromise( + `/organizations/${organization.slug}/data-export/`, + { + includeAllArgs: true, + method: 'POST', + data, + } + ); if (unmountedRef?.current) { - return; + return false; } addSuccessMessage( @@ -69,29 +83,31 @@ export function useDataExport({ ) : t("It looks like we're already working on it. Sit tight, we'll email you.") ); - }) - .catch(err => { - // If component has unmounted, don't do anything + return true; + } catch (err: unknown) { if (unmountedRef?.current) { - return; + return false; } const message = - err?.responseJSON?.detail ?? + (err as {responseJSON?: {detail?: string}})?.responseJSON?.detail ?? t( "We tried our hardest, but we couldn't export your data. Give it another go." ); addErrorMessage(message); inProgressCallback?.(false); - }); - }, [ - payload.queryInfo, - payload.queryType, - organization.slug, - api, - inProgressCallback, - unmountedRef, - ]); + return false; + } + }, + [ + payload.queryInfo, + payload.queryType, + organization.slug, + api, + inProgressCallback, + unmountedRef, + ] + ); } export function DataExport({ diff --git a/static/app/components/dataExportWithModal.spec.tsx b/static/app/components/dataExportWithModal.spec.tsx new file mode 100644 index 00000000000000..fa0721772f52c3 --- /dev/null +++ b/static/app/components/dataExportWithModal.spec.tsx @@ -0,0 +1,87 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import { + act, + renderGlobalModal, + render, + screen, + userEvent, + within, +} from 'sentry-test/reactTestingLibrary'; + +import {ExportQueryType} from 'sentry/components/dataExport'; +import {DataExportWithModal} from 'sentry/components/dataExportWithModal'; + +describe('DataExportWithModal', () => { + const organization = OrganizationFixture({ + features: ['discover-query'], + }); + + const payload = { + queryType: ExportQueryType.EXPLORE, + queryInfo: { + dataset: 'logs', + field: ['timestamp'], + project: [2], + query: 'severity:error', + sort: ['-timestamp'], + }, + }; + + beforeEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('opens modal and POSTs data export with limit from form', async () => { + const exportMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-export/`, + method: 'POST', + body: {id: 721}, + }); + + renderGlobalModal({organization}); + render(, { + organization, + }); + + await userEvent.click(screen.getByRole('button', {name: 'Export Data (Modal)'})); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Hi Martha!')).toBeInTheDocument(); + + const rowInput = within(dialog).getByRole('spinbutton', {name: 'Number of rows'}); + await userEvent.clear(rowInput); + await userEvent.type(rowInput, '250'); + + await userEvent.click(within(dialog).getByRole('button', {name: 'Export'})); + + await act(async () => { + await Promise.resolve(); + }); + + expect(exportMock).toHaveBeenCalledWith( + `/organizations/${organization.slug}/data-export/`, + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ + query_type: ExportQueryType.EXPLORE, + query_info: payload.queryInfo, + limit: 250, + }), + }) + ); + }); + + it('does not render trigger when organization lacks discover-query and no override', () => { + const orgWithoutDiscover = OrganizationFixture({features: []}); + + renderGlobalModal({organization: orgWithoutDiscover}); + render(, { + organization: orgWithoutDiscover, + }); + + expect( + screen.queryByRole('button', {name: 'Export Data (Modal)'}) + ).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/components/dataExportWithModal.tsx b/static/app/components/dataExportWithModal.tsx new file mode 100644 index 00000000000000..0bca421d8aebd6 --- /dev/null +++ b/static/app/components/dataExportWithModal.tsx @@ -0,0 +1,117 @@ +import type {ReactNode} from 'react'; +import {z} from 'zod'; + +import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {Flex, Stack} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +import type {ModalRenderProps} from 'sentry/actionCreators/modal'; +import {openModal} from 'sentry/actionCreators/modal'; +import Feature from 'sentry/components/acl/feature'; +import type {DataExportPayload} from 'sentry/components/dataExport'; +import {useDataExport} from 'sentry/components/dataExport'; +import {t} from 'sentry/locale'; + +const ROW_COUNT_MAX = 1_000_000; + +const exportModalFormSchema = z.object({ + rowCount: z.number().int().min(1).max(ROW_COUNT_MAX), +}); + +type ExportModalFormValues = z.infer; + +function DataExportFormModal({ + Header, + Body, + Footer, + closeModal, + payload, +}: ModalRenderProps & {payload: DataExportPayload}) { + const runExport = useDataExport({payload}); + + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: { + rowCount: 100, + } satisfies ExportModalFormValues, + validators: { + onDynamic: exportModalFormSchema, + }, + onSubmit: async ({value}) => { + const ok = await runExport({limit: value.rowCount}); + if (ok) { + closeModal(); + } + }, + }); + + return ( + +
+ {t('Export data')} +
+ + + {t('Hi Martha!')} + + {field => ( + + + + )} + + + +
+ + + {t('Export')} + +
+
+ ); +} + +export type DataExportWithModalProps = { + payload: DataExportPayload; + disabled?: boolean; + disabledTooltip?: string; + icon?: ReactNode; + overrideFeatureFlags?: boolean; + size?: 'xs' | 'sm' | 'md'; +}; + +export function DataExportWithModal({ + disabled, + disabledTooltip, + icon, + overrideFeatureFlags, + payload, + size = 'sm', +}: DataExportWithModalProps) { + const handleOpenModal = () => { + openModal(deps => ); + }; + + return ( + + + + ); +} diff --git a/static/app/views/explore/logs/logsExport.spec.tsx b/static/app/views/explore/logs/logsExport.spec.tsx index cd9d21349b4c1f..a65246a602a01d 100644 --- a/static/app/views/explore/logs/logsExport.spec.tsx +++ b/static/app/views/explore/logs/logsExport.spec.tsx @@ -80,6 +80,7 @@ describe('LogsExportButton', () => { expect(screen.getByTestId('export-download-csv')).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Export'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Export Data (Modal)'})).toBeInTheDocument(); }); it('should send correct payload for async export with all LogsQueryInfo parameters', async () => { diff --git a/static/app/views/explore/logs/logsExport.tsx b/static/app/views/explore/logs/logsExport.tsx index f02ee7f05c70ec..919b91a1f4bbe1 100644 --- a/static/app/views/explore/logs/logsExport.tsx +++ b/static/app/views/explore/logs/logsExport.tsx @@ -1,4 +1,10 @@ +import {Fragment} from 'react'; + +import {ExportQueryType} from 'sentry/components/dataExport'; +import {DataExportWithModal} from 'sentry/components/dataExportWithModal'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; +import {IconDownload} from 'sentry/icons'; +import {t} from 'sentry/locale'; import {ExploreExport} from 'sentry/views/explore/components/exploreExport'; import {QUERY_PAGE_LIMIT} from 'sentry/views/explore/logs/constants'; import {downloadLogsAsCsv} from 'sentry/views/explore/logs/logsExportCsv'; @@ -28,6 +34,19 @@ interface LogsQueryInfo { statsPeriod?: string; } +function getLogsExportDisabledTooltip(props: LogsExportButtonProps): string | undefined { + if (props.isLoading) { + return t('Loading...'); + } + if (props.error !== null) { + return t('Unable to export due to an error'); + } + if (!props.tableData || props.tableData.length === 0) { + return t('No data to export'); + } + return undefined; +} + export function LogsExportButton(props: LogsExportButtonProps) { const {selection} = usePageFilters(); const logsSearch = useQueryParamsSearch(); @@ -67,16 +86,34 @@ export function LogsExportButton(props: LogsExportButtonProps) { } }; + const disabledTooltip = getLogsExportDisabledTooltip(props); + return ( - + + + } + /> + ); } From c64879af6d4de42b95301e11673ebd0143accd99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Fri, 10 Apr 2026 13:35:38 -0400 Subject: [PATCH 02/10] wip --- static/app/components/dataExport.tsx | 133 +++++++++--------- static/app/components/dataExportWithModal.tsx | 6 +- static/app/utils/queryClient.tsx | 27 ++++ .../groupDistributions/tagExportDropdown.tsx | 12 +- 4 files changed, 99 insertions(+), 79 deletions(-) diff --git a/static/app/components/dataExport.tsx b/static/app/components/dataExport.tsx index 5353ffe3c02eeb..662a47f948f590 100644 --- a/static/app/components/dataExport.tsx +++ b/static/app/components/dataExport.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; import debounce from 'lodash/debounce'; import {Button} from '@sentry/scraps/button'; @@ -6,7 +6,8 @@ import {Button} from '@sentry/scraps/button'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import Feature from 'sentry/components/acl/feature'; import {t} from 'sentry/locale'; -import {useApi} from 'sentry/utils/useApi'; +import {fetchMutationWithStatus, useMutation} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; import {useOrganization} from 'sentry/utils/useOrganization'; // NOTE: Coordinate with other ExportQueryType (src/sentry/data_export/base.py) @@ -21,7 +22,7 @@ export type DataExportPayload = { queryType: ExportQueryType; // TODO(ts): Formalize different possible payloads }; -export type DataExportInvokeOptions = { +type DataExportInvokeOptions = { limit?: number; }; @@ -35,6 +36,16 @@ interface DataExportProps { size?: 'xs' | 'sm' | 'md'; } +function getDataExportErrorMessage(error: unknown): string { + if (error instanceof RequestError) { + const detail = error.responseJSON?.detail; + if (typeof detail === 'string') { + return detail; + } + } + return t("We tried our hardest, but we couldn't export your data. Give it another go."); +} + export function useDataExport({ payload, inProgressCallback, @@ -45,17 +56,10 @@ export function useDataExport({ unmountedRef?: React.RefObject; }) { const organization = useOrganization(); - const api = useApi(); - return useCallback( - async (invokeOptions?: DataExportInvokeOptions): Promise => { - inProgressCallback?.(true); - - const data: { - query_info: any; - query_type: ExportQueryType; - limit?: number; - } = { + const mutation = useMutation({ + mutationFn: async (invokeOptions?: DataExportInvokeOptions) => { + const data: Record = { query_type: payload.queryType, query_info: payload.queryInfo, }; @@ -63,51 +67,58 @@ export function useDataExport({ data.limit = invokeOptions.limit; } + return fetchMutationWithStatus({ + method: 'POST', + url: `/organizations/${organization.slug}/data-export/`, + data, + }); + }, + onMutate: () => { + inProgressCallback?.(true); + }, + onSuccess: result => { + if (unmountedRef?.current) { + return; + } + addSuccessMessage( + result.statusCode === 201 + ? t("Sit tight. We'll shoot you an email when your data is ready for download.") + : t("It looks like we're already working on it. Sit tight, we'll email you.") + ); + }, + onError: (error: unknown) => { + if (unmountedRef?.current) { + return; + } + addErrorMessage(getDataExportErrorMessage(error)); + inProgressCallback?.(false); + }, + }); + + const {reset} = mutation; + + useEffect(() => { + reset(); + }, [payload.queryInfo, payload.queryType, reset]); + + const runExport = useCallback( + async (invokeOptions?: DataExportInvokeOptions): Promise => { try { - const [_data, _, response] = await api.requestPromise( - `/organizations/${organization.slug}/data-export/`, - { - includeAllArgs: true, - method: 'POST', - data, - } - ); + await mutation.mutateAsync(invokeOptions); if (unmountedRef?.current) { return false; } - - addSuccessMessage( - response?.status === 201 - ? t( - "Sit tight. We'll shoot you an email when your data is ready for download." - ) - : t("It looks like we're already working on it. Sit tight, we'll email you.") - ); return true; - } catch (err: unknown) { - if (unmountedRef?.current) { - return false; - } - const message = - (err as {responseJSON?: {detail?: string}})?.responseJSON?.detail ?? - t( - "We tried our hardest, but we couldn't export your data. Give it another go." - ); - - addErrorMessage(message); - inProgressCallback?.(false); + } catch { return false; } }, - [ - payload.queryInfo, - payload.queryType, - organization.slug, - api, - inProgressCallback, - unmountedRef, - ] + [mutation, unmountedRef] ); + + const isExportWorking = mutation.isPending || mutation.isSuccess; + + return {isExportWorking, runExport}; } export function DataExport({ @@ -120,27 +131,11 @@ export function DataExport({ onClick, }: DataExportProps): React.ReactElement { const unmountedRef = useRef(false); - const [inProgress, setInProgress] = useState(false); - const handleDataExport = useDataExport({ + const {isExportWorking, runExport} = useDataExport({ payload, unmountedRef, - inProgressCallback: setInProgress, }); - // We clear the indicator if export props change so that the user - // can fire another export without having to wait for the previous one to finish. - useEffect(() => { - if (inProgress) { - setInProgress(false); - } - // We are skipping the inProgress dependency because it would have fired on each handleDataExport - // call and would have immediately turned off the value giving users no feedback on their click action. - // An alternative way to handle this would have probably been to key the component by payload/queryType, - // but that seems like it can be a complex object so tracking changes could result in very brittle behavior. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [payload.queryType, payload.queryInfo]); - - // Tracking unmounting of the component to prevent setState call on unmounted component useEffect(() => { return () => { unmountedRef.current = true; @@ -148,13 +143,15 @@ export function DataExport({ }, []); const handleClick = () => { - debounce(handleDataExport, 500)(); + debounce(() => { + void runExport(); + }, 500)(); onClick?.(); }; return ( - {inProgress ? ( + {isExportWorking ? ( @@ -75,7 +75,7 @@ function DataExportFormModal({ ); } -export type DataExportWithModalProps = { +type DataExportWithModalProps = { payload: DataExportPayload; disabled?: boolean; disabledTooltip?: string; diff --git a/static/app/utils/queryClient.tsx b/static/app/utils/queryClient.tsx index 09e5de3c8ae247..9933e6cbdd21de 100644 --- a/static/app/utils/queryClient.tsx +++ b/static/app/utils/queryClient.tsx @@ -298,4 +298,31 @@ export function fetchMutation( }); } +type FetchMutationWithStatusResult = { + data: TData; + statusCode: number; +}; + +/** + * Like {@link fetchMutation} but returns HTTP status for mutations that must + * distinguish responses (e.g. 201 created vs 200 OK). + */ +export function fetchMutationWithStatus( + variables: ApiMutationVariables +): Promise> { + const {method, url, options, data} = variables; + + return QUERY_API_CLIENT.requestPromise(url, { + method, + query: options?.query, + headers: options?.headers, + host: options?.host, + data, + includeAllArgs: true, + }).then(([json, , resp]) => ({ + data: json as TResponseData, + statusCode: resp?.status ?? 0, + })); +} + export * from '@tanstack/react-query'; diff --git a/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx b/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx index 31347894449599..0441e33a282b4e 100644 --- a/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx +++ b/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx @@ -1,5 +1,3 @@ -import {useState} from 'react'; - import {Button} from '@sentry/scraps/button'; import {ExportQueryType, useDataExport} from 'sentry/components/dataExport'; @@ -18,9 +16,8 @@ interface Props { } export function TagExportDropdown({tagKey, group, organization, project}: Props) { - const [isExportDisabled, setIsExportDisabled] = useState(false); const hasDiscoverQuery = organization.features.includes('discover-query'); - const handleDataExport = useDataExport({ + const {isExportWorking, runExport} = useDataExport({ payload: { queryType: ExportQueryType.ISSUES_BY_TAG, queryInfo: { @@ -57,12 +54,11 @@ export function TagExportDropdown({tagKey, group, organization, project}: Props) }, { key: 'export-all', - label: isExportDisabled ? t('Export in progress...') : t('Export All to CSV'), + label: isExportWorking ? t('Export in progress...') : t('Export All to CSV'), onAction: () => { - handleDataExport(); - setIsExportDisabled(true); + void runExport(); }, - disabled: isExportDisabled || !hasDiscoverQuery, + disabled: isExportWorking || !hasDiscoverQuery, tooltip: hasDiscoverQuery ? undefined : t('This feature is not available for your organization'), From 143ce6c4bb9aa5f86542bd3ac57fc7e54ee4d698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Fri, 10 Apr 2026 13:46:02 -0400 Subject: [PATCH 03/10] wip --- .../components/core/form/field/radioField.tsx | 20 +++- .../components/dataExportWithModal.spec.tsx | 58 +++++++++- static/app/components/dataExportWithModal.tsx | 101 +++++++++++------- static/app/views/explore/logs/logsExport.tsx | 39 +++---- .../views/explore/logs/logsExportSession.ts | 24 +++++ 5 files changed, 174 insertions(+), 68 deletions(-) create mode 100644 static/app/views/explore/logs/logsExportSession.ts diff --git a/static/app/components/core/form/field/radioField.tsx b/static/app/components/core/form/field/radioField.tsx index b9fd34fe4acfd2..a7e428515bbf18 100644 --- a/static/app/components/core/form/field/radioField.tsx +++ b/static/app/components/core/form/field/radioField.tsx @@ -80,20 +80,36 @@ interface RadioItemProps { children: React.ReactNode; value: string; description?: React.ReactNode; + /** + * When set, this option is not selectable (e.g. coming soon). Group-level + * `disabled` still applies on top of this. + */ + disabled?: boolean; } -function RadioItem({children, value, description}: RadioItemProps) { +function RadioItem({ + children, + value, + description, + disabled: itemDisabled, +}: RadioItemProps) { const {selectedValue, onChange, ...fieldProps} = useRadioContext(); const descriptionId = useId(); + const disabled = Boolean(itemDisabled || fieldProps.disabled); return ( onChange(value)} + onChange={() => { + if (!disabled) { + onChange(value); + } + }} /> {children} diff --git a/static/app/components/dataExportWithModal.spec.tsx b/static/app/components/dataExportWithModal.spec.tsx index fa0721772f52c3..bbe0067a19ddb5 100644 --- a/static/app/components/dataExportWithModal.spec.tsx +++ b/static/app/components/dataExportWithModal.spec.tsx @@ -9,9 +9,12 @@ import { within, } from 'sentry-test/reactTestingLibrary'; +import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {ExportQueryType} from 'sentry/components/dataExport'; import {DataExportWithModal} from 'sentry/components/dataExportWithModal'; +jest.mock('sentry/actionCreators/indicator'); + describe('DataExportWithModal', () => { const organization = OrganizationFixture({ features: ['discover-query'], @@ -30,9 +33,10 @@ describe('DataExportWithModal', () => { beforeEach(() => { MockApiClient.clearMockResponses(); + jest.mocked(addSuccessMessage).mockClear(); }); - it('opens modal and POSTs data export with limit from form', async () => { + it('opens modal and POSTs data export with limit when async export is required', async () => { const exportMock = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/data-export/`, method: 'POST', @@ -40,9 +44,16 @@ describe('DataExportWithModal', () => { }); renderGlobalModal({organization}); - render(, { - organization, - }); + render( + , + { + organization, + } + ); await userEvent.click(screen.getByRole('button', {name: 'Export Data (Modal)'})); @@ -70,6 +81,45 @@ describe('DataExportWithModal', () => { }), }) ); + expect(jest.mocked(addSuccessMessage)).not.toHaveBeenCalledWith( + expect.stringContaining('download momentarily') + ); + }); + + it('runs in-session export and shows success when canExportInSession', async () => { + const onSessionExport = jest.fn(); + const exportMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-export/`, + method: 'POST', + body: {id: 721}, + }); + + renderGlobalModal({organization}); + render( + , + { + organization, + } + ); + + await userEvent.click(screen.getByRole('button', {name: 'Export Data (Modal)'})); + + const dialog = await screen.findByRole('dialog'); + await userEvent.click(within(dialog).getByRole('button', {name: 'Export'})); + + await act(async () => { + await Promise.resolve(); + }); + + expect(onSessionExport).toHaveBeenCalled(); + expect(exportMock).not.toHaveBeenCalled(); + expect(addSuccessMessage).toHaveBeenCalledWith( + 'Your export has started — the file should download momentarily.' + ); }); it('does not render trigger when organization lacks discover-query and no override', () => { diff --git a/static/app/components/dataExportWithModal.tsx b/static/app/components/dataExportWithModal.tsx index d734c950cea773..39908128c575ed 100644 --- a/static/app/components/dataExportWithModal.tsx +++ b/static/app/components/dataExportWithModal.tsx @@ -1,4 +1,3 @@ -import type {ReactNode} from 'react'; import {z} from 'zod'; import {Button} from '@sentry/scraps/button'; @@ -6,39 +5,66 @@ import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; import {Flex, Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; +import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {openModal} from 'sentry/actionCreators/modal'; -import Feature from 'sentry/components/acl/feature'; import type {DataExportPayload} from 'sentry/components/dataExport'; import {useDataExport} from 'sentry/components/dataExport'; +import {IconDownload} from 'sentry/icons'; import {t} from 'sentry/locale'; const ROW_COUNT_MAX = 1_000_000; const exportModalFormSchema = z.object({ + format: z.enum(['csv', 'json']), rowCount: z.number().int().min(1).max(ROW_COUNT_MAX), }); type ExportModalFormValues = z.infer; +const defaultExportModalValues: ExportModalFormValues = { + format: 'csv', + rowCount: 100, +}; + +type DataExportSessionExportConfig = { + canExportInSession: boolean; + onSessionExport: () => void; +}; + function DataExportFormModal({ Header, Body, Footer, closeModal, payload, -}: ModalRenderProps & {payload: DataExportPayload}) { + sessionExport, +}: ModalRenderProps & { + payload: DataExportPayload; + sessionExport?: DataExportSessionExportConfig; +}) { const {runExport} = useDataExport({payload}); const form = useScrapsForm({ ...defaultFormOptions, - defaultValues: { - rowCount: 100, - } satisfies ExportModalFormValues, + defaultValues: defaultExportModalValues, validators: { onDynamic: exportModalFormSchema, }, onSubmit: async ({value}) => { + if (value.format !== 'csv') { + return; + } + + if (sessionExport?.canExportInSession) { + sessionExport.onSessionExport(); + addSuccessMessage( + t('Your export has started — the file should download momentarily.') + ); + closeModal(); + return; + } + const ok = await runExport({limit: value.rowCount}); if (ok) { closeModal(); @@ -54,6 +80,23 @@ function DataExportFormModal({ {t('Hi Martha!')} + + {field => ( + + field.handleChange(value as ExportModalFormValues['format']) + } + > + + {t('CSV')} + + {t('JSON')} + + + + )} + {field => ( @@ -77,41 +120,27 @@ function DataExportFormModal({ type DataExportWithModalProps = { payload: DataExportPayload; - disabled?: boolean; - disabledTooltip?: string; - icon?: ReactNode; - overrideFeatureFlags?: boolean; - size?: 'xs' | 'sm' | 'md'; + sessionExport?: DataExportSessionExportConfig; }; -export function DataExportWithModal({ - disabled, - disabledTooltip, - icon, - overrideFeatureFlags, - payload, - size = 'sm', -}: DataExportWithModalProps) { +export function DataExportWithModal({payload, sessionExport}: DataExportWithModalProps) { const handleOpenModal = () => { - openModal(deps => ); + openModal(deps => ( + + )); }; return ( - - - + ); } diff --git a/static/app/views/explore/logs/logsExport.tsx b/static/app/views/explore/logs/logsExport.tsx index 919b91a1f4bbe1..559fe4b264d5a6 100644 --- a/static/app/views/explore/logs/logsExport.tsx +++ b/static/app/views/explore/logs/logsExport.tsx @@ -3,11 +3,12 @@ import {Fragment} from 'react'; import {ExportQueryType} from 'sentry/components/dataExport'; import {DataExportWithModal} from 'sentry/components/dataExportWithModal'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; -import {IconDownload} from 'sentry/icons'; -import {t} from 'sentry/locale'; import {ExploreExport} from 'sentry/views/explore/components/exploreExport'; -import {QUERY_PAGE_LIMIT} from 'sentry/views/explore/logs/constants'; import {downloadLogsAsCsv} from 'sentry/views/explore/logs/logsExportCsv'; +import { + canExportLogsInBrowserSession, + hasReachedLogsBrowserExportPageLimit, +} from 'sentry/views/explore/logs/logsExportSession'; import type {OurLogFieldKey, OurLogsResponseItem} from 'sentry/views/explore/logs/types'; import { useQueryParamsFields, @@ -34,19 +35,6 @@ interface LogsQueryInfo { statsPeriod?: string; } -function getLogsExportDisabledTooltip(props: LogsExportButtonProps): string | undefined { - if (props.isLoading) { - return t('Loading...'); - } - if (props.error !== null) { - return t('Unable to export due to an error'); - } - if (!props.tableData || props.tableData.length === 0) { - return t('No data to export'); - } - return undefined; -} - export function LogsExportButton(props: LogsExportButtonProps) { const {selection} = usePageFilters(); const logsSearch = useQueryParamsSearch(); @@ -67,9 +55,6 @@ export function LogsExportButton(props: LogsExportButtonProps) { environment: environments, }; - const isMoreThanOnePage = - props.tableData && props.tableData.length > QUERY_PAGE_LIMIT - 1; - const disabled = props.isLoading || props.error !== null || @@ -86,14 +71,12 @@ export function LogsExportButton(props: LogsExportButtonProps) { } }; - const disabledTooltip = getLogsExportDisabledTooltip(props); - return ( } + sessionExport={{ + canExportInSession: canExportLogsInBrowserSession(props.tableData), + onSessionExport: () => { + if (props.tableData) { + downloadLogsAsCsv(props.tableData, queryInfo.field, 'logs'); + } + }, + }} /> ); diff --git a/static/app/views/explore/logs/logsExportSession.ts b/static/app/views/explore/logs/logsExportSession.ts new file mode 100644 index 00000000000000..0b7ea7f9c2cf15 --- /dev/null +++ b/static/app/views/explore/logs/logsExportSession.ts @@ -0,0 +1,24 @@ +import {QUERY_PAGE_LIMIT} from 'sentry/views/explore/logs/constants'; +import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; + +/** + * Whether the loaded logs table has more rows than a single UI page fetch, so the + * user must use async export instead of downloading CSV from in-memory table data. + * Mirrors the condition used by {@link ExploreExport} for `hasReachedCSVLimit`. + */ +export function hasReachedLogsBrowserExportPageLimit( + tableData: OurLogsResponseItem[] | null | undefined +): boolean { + return !!tableData && tableData.length > QUERY_PAGE_LIMIT - 1; +} + +/** + * Whether we can export the current result set entirely in the browser from data + * already loaded in the table (same threshold as the primary Export button’s + * direct CSV download). + */ +export function canExportLogsInBrowserSession( + tableData: OurLogsResponseItem[] | null | undefined +): boolean { + return !!tableData && tableData.length > 0 && tableData.length <= QUERY_PAGE_LIMIT - 1; +} From 0f368c18822e94cc2f3dc57b91f5369d902e3ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Fri, 10 Apr 2026 15:30:05 -0400 Subject: [PATCH 04/10] wip --- static/app/components/dataExportWithModal.tsx | 2 +- static/app/views/explore/logs/logsExport.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/static/app/components/dataExportWithModal.tsx b/static/app/components/dataExportWithModal.tsx index 39908128c575ed..b87d49dd4a6e10 100644 --- a/static/app/components/dataExportWithModal.tsx +++ b/static/app/components/dataExportWithModal.tsx @@ -120,7 +120,7 @@ function DataExportFormModal({ type DataExportWithModalProps = { payload: DataExportPayload; - sessionExport?: DataExportSessionExportConfig; + sessionExport: DataExportSessionExportConfig; }; export function DataExportWithModal({payload, sessionExport}: DataExportWithModalProps) { diff --git a/static/app/views/explore/logs/logsExport.tsx b/static/app/views/explore/logs/logsExport.tsx index 559fe4b264d5a6..e9f8559ac42e82 100644 --- a/static/app/views/explore/logs/logsExport.tsx +++ b/static/app/views/explore/logs/logsExport.tsx @@ -91,7 +91,6 @@ export function LogsExportButton(props: LogsExportButtonProps) { dataset: TraceItemDataset.LOGS, }, }} - overrideFeatureFlags sessionExport={{ canExportInSession: canExportLogsInBrowserSession(props.tableData), onSessionExport: () => { From b2956c306db636d65816641c70e471b95f901d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 14 Apr 2026 10:11:01 -0400 Subject: [PATCH 05/10] Hooked up modal to API via mutation --- .../components/core/form/field/radioField.tsx | 20 +- .../app/components/core/form/scrapsForm.tsx | 2 + static/app/components/dataExport.spec.tsx | 170 ---------------- static/app/components/dataExport.tsx | 19 +- .../components/dataExportWithModal.spec.tsx | 137 ------------- static/app/components/dataExportWithModal.tsx | 146 -------------- .../explore/components/exploreExport.tsx | 27 +-- .../components/getExportDisabledTooltip.ts | 22 +++ .../views/explore/logs/logsExport.spec.tsx | 182 ------------------ static/app/views/explore/logs/logsExport.tsx | 105 ---------- .../views/explore/logs/logsExportButton.tsx | 43 +++++ .../views/explore/logs/logsExportModal.tsx | 133 +++++++++++++ .../explore/logs/logsExportModalButton.tsx | 46 +++++ .../views/explore/logs/logsExportSession.ts | 24 --- .../views/explore/logs/logsExportSwitch.tsx | 56 ++++++ static/app/views/explore/logs/logsTab.tsx | 4 +- 16 files changed, 325 insertions(+), 811 deletions(-) delete mode 100644 static/app/components/dataExport.spec.tsx delete mode 100644 static/app/components/dataExportWithModal.spec.tsx delete mode 100644 static/app/components/dataExportWithModal.tsx create mode 100644 static/app/views/explore/components/getExportDisabledTooltip.ts delete mode 100644 static/app/views/explore/logs/logsExport.spec.tsx delete mode 100644 static/app/views/explore/logs/logsExport.tsx create mode 100644 static/app/views/explore/logs/logsExportButton.tsx create mode 100644 static/app/views/explore/logs/logsExportModal.tsx create mode 100644 static/app/views/explore/logs/logsExportModalButton.tsx delete mode 100644 static/app/views/explore/logs/logsExportSession.ts create mode 100644 static/app/views/explore/logs/logsExportSwitch.tsx diff --git a/static/app/components/core/form/field/radioField.tsx b/static/app/components/core/form/field/radioField.tsx index a7e428515bbf18..b9fd34fe4acfd2 100644 --- a/static/app/components/core/form/field/radioField.tsx +++ b/static/app/components/core/form/field/radioField.tsx @@ -80,36 +80,20 @@ interface RadioItemProps { children: React.ReactNode; value: string; description?: React.ReactNode; - /** - * When set, this option is not selectable (e.g. coming soon). Group-level - * `disabled` still applies on top of this. - */ - disabled?: boolean; } -function RadioItem({ - children, - value, - description, - disabled: itemDisabled, -}: RadioItemProps) { +function RadioItem({children, value, description}: RadioItemProps) { const {selectedValue, onChange, ...fieldProps} = useRadioContext(); const descriptionId = useId(); - const disabled = Boolean(itemDisabled || fieldProps.disabled); return ( { - if (!disabled) { - onChange(value); - } - }} + onChange={() => onChange(value)} /> {children} diff --git a/static/app/components/core/form/scrapsForm.tsx b/static/app/components/core/form/scrapsForm.tsx index 4230e9f4663bb3..3f5bcf597c7dd1 100644 --- a/static/app/components/core/form/scrapsForm.tsx +++ b/static/app/components/core/form/scrapsForm.tsx @@ -13,6 +13,7 @@ import {FieldMeta} from '@sentry/scraps/form/field/meta'; import {FieldLayout} from '@sentry/scraps/form/layout'; import {FieldGroup} from '@sentry/scraps/form/layout/fieldGroup'; +import {CheckboxField} from 'sentry/components/forms/fields/checkboxField'; import {RequestError} from 'sentry/utils/requestError/requestError'; import {InputField} from './field/inputField'; @@ -42,6 +43,7 @@ export const defaultFormOptions = formOptions({ const fieldComponents = { Base: BaseField, + Checkbox: CheckboxField, Input: InputField, Number: NumberField, Password: PasswordField, diff --git a/static/app/components/dataExport.spec.tsx b/static/app/components/dataExport.spec.tsx deleted file mode 100644 index 8d5f687bb85094..00000000000000 --- a/static/app/components/dataExport.spec.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {DataExport, ExportQueryType} from 'sentry/components/dataExport'; -import type {Organization} from 'sentry/types/organization'; - -jest.mock('sentry/actionCreators/indicator'); - -const mockUnauthorizedOrg = OrganizationFixture({ - features: [], -}); - -const mockAuthorizedOrg = OrganizationFixture({ - features: ['discover-query'], -}); - -const mockPayload = { - queryType: ExportQueryType.ISSUES_BY_TAG, - queryInfo: {project_id: '1', group_id: '1027', key: 'user'}, -}; - -const mockContext = (organization: Organization) => { - return {organization}; -}; - -describe('DataExport', () => { - it('should not render anything for an unauthorized organization', () => { - render(, { - ...mockContext(mockUnauthorizedOrg), - }); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); - - it('should render the button for an authorized organization', () => { - render(, { - ...mockContext(mockAuthorizedOrg), - }); - expect(screen.getByText(/Export All to CSV/)).toBeInTheDocument(); - }); - - it('should render the button for an unauthorized organization using flag override', () => { - render(, { - ...mockContext(mockUnauthorizedOrg), - }); - expect(screen.getByRole('button')).toBeInTheDocument(); - }); - - it('should render custom children if provided', () => { - render(This is an example string, { - ...mockContext(mockAuthorizedOrg), - }); - expect(screen.getByText(/This is an example string/)).toBeInTheDocument(); - }); - - it('should respect the disabled prop and not be clickable', async () => { - const postDataExport = MockApiClient.addMockResponse({ - url: `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - method: 'POST', - body: {id: 721}, - }); - - render(, { - ...mockContext(mockAuthorizedOrg), - }); - - await userEvent.click(screen.getByRole('button')); - expect(postDataExport).not.toHaveBeenCalled(); - expect(screen.getByRole('button')).toBeDisabled(); - }); - - it('should send a request and disable itself when clicked', async () => { - const postDataExport = MockApiClient.addMockResponse({ - url: `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - method: 'POST', - body: {id: 721}, - }); - render(, { - ...mockContext(mockAuthorizedOrg), - }); - - await userEvent.click(screen.getByRole('button')); - - expect(postDataExport).toHaveBeenCalledWith( - `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - { - data: { - query_type: mockPayload.queryType, - query_info: mockPayload.queryInfo, - }, - method: 'POST', - error: expect.anything(), - success: expect.anything(), - } - ); - - await waitFor(() => { - expect(screen.getByRole('button')).toBeDisabled(); - }); - }); - - it('should reset the state when receiving a new payload', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - method: 'POST', - body: {id: 721}, - }); - - const {rerender} = render(, { - ...mockContext(mockAuthorizedOrg), - }); - - await userEvent.click(screen.getByText(/Export All to CSV/)); - await waitFor(() => { - expect(screen.getByRole('button')).toBeDisabled(); - }); - - rerender( - - ); - - await waitFor(() => { - expect(screen.getByRole('button')).toBeEnabled(); - }); - }); - - it('should display default error message if non provided', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - method: 'POST', - statusCode: 400, - }); - - render(, { - ...mockContext(mockAuthorizedOrg), - }); - - await userEvent.click(screen.getByRole('button')); - - await waitFor(() => { - expect(addErrorMessage).toHaveBeenCalledWith( - "We tried our hardest, but we couldn't export your data. Give it another go." - ); - }); - - await waitFor(() => { - expect(screen.getByRole('button')).toBeEnabled(); - }); - }); - - it('should display provided error message', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${mockAuthorizedOrg.slug}/data-export/`, - method: 'POST', - statusCode: 400, - body: {detail: 'uh oh'}, - }); - - render(, { - ...mockContext(mockAuthorizedOrg), - }); - - await userEvent.click(screen.getByRole('button')); - - await waitFor(() => { - expect(addErrorMessage).toHaveBeenCalledWith('uh oh'); - }); - }); -}); diff --git a/static/app/components/dataExport.tsx b/static/app/components/dataExport.tsx index 662a47f948f590..ae8d43034ded41 100644 --- a/static/app/components/dataExport.tsx +++ b/static/app/components/dataExport.tsx @@ -9,6 +9,7 @@ import {t} from 'sentry/locale'; import {fetchMutationWithStatus, useMutation} from 'sentry/utils/queryClient'; import {RequestError} from 'sentry/utils/requestError/requestError'; import {useOrganization} from 'sentry/utils/useOrganization'; +import type {OurLogFieldKey} from 'sentry/views/explore/logs/types'; // NOTE: Coordinate with other ExportQueryType (src/sentry/data_export/base.py) export enum ExportQueryType { @@ -17,7 +18,19 @@ export enum ExportQueryType { EXPLORE = 'Explore', } -export type DataExportPayload = { +export interface LogsQueryInfo { + dataset: 'logs'; + field: OurLogFieldKey[]; + project: number[]; + query: string; + sort: string[]; + end?: string; + environment?: string[]; + start?: string; + statsPeriod?: string; +} + +type DataExportPayload = { queryInfo: any; queryType: ExportQueryType; // TODO(ts): Formalize different possible payloads }; @@ -58,7 +71,7 @@ export function useDataExport({ const organization = useOrganization(); const mutation = useMutation({ - mutationFn: async (invokeOptions?: DataExportInvokeOptions) => { + mutationFn: (invokeOptions?: DataExportInvokeOptions) => { const data: Record = { query_type: payload.queryType, query_info: payload.queryInfo, @@ -118,7 +131,7 @@ export function useDataExport({ const isExportWorking = mutation.isPending || mutation.isSuccess; - return {isExportWorking, runExport}; + return {isExportWorking, mutation, runExport}; } export function DataExport({ diff --git a/static/app/components/dataExportWithModal.spec.tsx b/static/app/components/dataExportWithModal.spec.tsx deleted file mode 100644 index bbe0067a19ddb5..00000000000000 --- a/static/app/components/dataExportWithModal.spec.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import { - act, - renderGlobalModal, - render, - screen, - userEvent, - within, -} from 'sentry-test/reactTestingLibrary'; - -import {addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {ExportQueryType} from 'sentry/components/dataExport'; -import {DataExportWithModal} from 'sentry/components/dataExportWithModal'; - -jest.mock('sentry/actionCreators/indicator'); - -describe('DataExportWithModal', () => { - const organization = OrganizationFixture({ - features: ['discover-query'], - }); - - const payload = { - queryType: ExportQueryType.EXPLORE, - queryInfo: { - dataset: 'logs', - field: ['timestamp'], - project: [2], - query: 'severity:error', - sort: ['-timestamp'], - }, - }; - - beforeEach(() => { - MockApiClient.clearMockResponses(); - jest.mocked(addSuccessMessage).mockClear(); - }); - - it('opens modal and POSTs data export with limit when async export is required', async () => { - const exportMock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/data-export/`, - method: 'POST', - body: {id: 721}, - }); - - renderGlobalModal({organization}); - render( - , - { - organization, - } - ); - - await userEvent.click(screen.getByRole('button', {name: 'Export Data (Modal)'})); - - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText('Hi Martha!')).toBeInTheDocument(); - - const rowInput = within(dialog).getByRole('spinbutton', {name: 'Number of rows'}); - await userEvent.clear(rowInput); - await userEvent.type(rowInput, '250'); - - await userEvent.click(within(dialog).getByRole('button', {name: 'Export'})); - - await act(async () => { - await Promise.resolve(); - }); - - expect(exportMock).toHaveBeenCalledWith( - `/organizations/${organization.slug}/data-export/`, - expect.objectContaining({ - method: 'POST', - data: expect.objectContaining({ - query_type: ExportQueryType.EXPLORE, - query_info: payload.queryInfo, - limit: 250, - }), - }) - ); - expect(jest.mocked(addSuccessMessage)).not.toHaveBeenCalledWith( - expect.stringContaining('download momentarily') - ); - }); - - it('runs in-session export and shows success when canExportInSession', async () => { - const onSessionExport = jest.fn(); - const exportMock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/data-export/`, - method: 'POST', - body: {id: 721}, - }); - - renderGlobalModal({organization}); - render( - , - { - organization, - } - ); - - await userEvent.click(screen.getByRole('button', {name: 'Export Data (Modal)'})); - - const dialog = await screen.findByRole('dialog'); - await userEvent.click(within(dialog).getByRole('button', {name: 'Export'})); - - await act(async () => { - await Promise.resolve(); - }); - - expect(onSessionExport).toHaveBeenCalled(); - expect(exportMock).not.toHaveBeenCalled(); - expect(addSuccessMessage).toHaveBeenCalledWith( - 'Your export has started — the file should download momentarily.' - ); - }); - - it('does not render trigger when organization lacks discover-query and no override', () => { - const orgWithoutDiscover = OrganizationFixture({features: []}); - - renderGlobalModal({organization: orgWithoutDiscover}); - render(, { - organization: orgWithoutDiscover, - }); - - expect( - screen.queryByRole('button', {name: 'Export Data (Modal)'}) - ).not.toBeInTheDocument(); - }); -}); diff --git a/static/app/components/dataExportWithModal.tsx b/static/app/components/dataExportWithModal.tsx deleted file mode 100644 index b87d49dd4a6e10..00000000000000 --- a/static/app/components/dataExportWithModal.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import {z} from 'zod'; - -import {Button} from '@sentry/scraps/button'; -import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; -import {Flex, Stack} from '@sentry/scraps/layout'; -import {Heading, Text} from '@sentry/scraps/text'; - -import {addSuccessMessage} from 'sentry/actionCreators/indicator'; -import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import {openModal} from 'sentry/actionCreators/modal'; -import type {DataExportPayload} from 'sentry/components/dataExport'; -import {useDataExport} from 'sentry/components/dataExport'; -import {IconDownload} from 'sentry/icons'; -import {t} from 'sentry/locale'; - -const ROW_COUNT_MAX = 1_000_000; - -const exportModalFormSchema = z.object({ - format: z.enum(['csv', 'json']), - rowCount: z.number().int().min(1).max(ROW_COUNT_MAX), -}); - -type ExportModalFormValues = z.infer; - -const defaultExportModalValues: ExportModalFormValues = { - format: 'csv', - rowCount: 100, -}; - -type DataExportSessionExportConfig = { - canExportInSession: boolean; - onSessionExport: () => void; -}; - -function DataExportFormModal({ - Header, - Body, - Footer, - closeModal, - payload, - sessionExport, -}: ModalRenderProps & { - payload: DataExportPayload; - sessionExport?: DataExportSessionExportConfig; -}) { - const {runExport} = useDataExport({payload}); - - const form = useScrapsForm({ - ...defaultFormOptions, - defaultValues: defaultExportModalValues, - validators: { - onDynamic: exportModalFormSchema, - }, - onSubmit: async ({value}) => { - if (value.format !== 'csv') { - return; - } - - if (sessionExport?.canExportInSession) { - sessionExport.onSessionExport(); - addSuccessMessage( - t('Your export has started — the file should download momentarily.') - ); - closeModal(); - return; - } - - const ok = await runExport({limit: value.rowCount}); - if (ok) { - closeModal(); - } - }, - }); - - return ( - -
- {t('Export data')} -
- - - {t('Hi Martha!')} - - {field => ( - - field.handleChange(value as ExportModalFormValues['format']) - } - > - - {t('CSV')} - - {t('JSON')} - - - - )} - - - {field => ( - - - - )} - - - -
- - - {t('Export')} - -
-
- ); -} - -type DataExportWithModalProps = { - payload: DataExportPayload; - sessionExport: DataExportSessionExportConfig; -}; - -export function DataExportWithModal({payload, sessionExport}: DataExportWithModalProps) { - const handleOpenModal = () => { - openModal(deps => ( - - )); - }; - - return ( - - ); -} diff --git a/static/app/views/explore/components/exploreExport.tsx b/static/app/views/explore/components/exploreExport.tsx index ab70e73aae5ca4..571437f44b8381 100644 --- a/static/app/views/explore/components/exploreExport.tsx +++ b/static/app/views/explore/components/exploreExport.tsx @@ -1,13 +1,11 @@ -import type React from 'react'; - import {Button} from '@sentry/scraps/button'; import {DataExport, ExportQueryType} from 'sentry/components/dataExport'; import {IconDownload} from 'sentry/icons'; import {t} from 'sentry/locale'; -import type {Organization} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {getExportDisabledTooltip} from 'sentry/views/explore/components/getExportDisabledTooltip'; import {TraceItemDataset} from 'sentry/views/explore/types'; interface QueryInfo { @@ -22,11 +20,11 @@ interface QueryInfo { } type BaseExploreExportProps = { - disabled: boolean; hasReachedCSVLimit: boolean; isDataEmpty: boolean; isDataError: boolean; isDataLoading: boolean; + disabled?: boolean; downloadAsCsv?: () => void; }; @@ -42,28 +40,9 @@ type OtherExploreExportProps = BaseExploreExportProps & { type ExploreExportProps = LogsExploreExportProps | OtherExploreExportProps; -function getDisabledTooltip( - props: ExploreExportProps, - _organization: Organization -): string | undefined { - if (props.isDataLoading) { - return t('Loading...'); - } - if (props.isDataError) { - return t('Unable to export due to an error'); - } - if (props.isDataEmpty) { - return t('No data to export'); - } - return undefined; -} - -export function ExploreExport(props: LogsExploreExportProps): React.ReactElement; -export function ExploreExport(props: OtherExploreExportProps): React.ReactElement; export function ExploreExport(props: ExploreExportProps) { const organization = useOrganization(); - - const disabledTooltip = getDisabledTooltip(props, organization); + const disabledTooltip = getExportDisabledTooltip(props); const disabled = props.disabled || !!disabledTooltip; const handleExport = () => { diff --git a/static/app/views/explore/components/getExportDisabledTooltip.ts b/static/app/views/explore/components/getExportDisabledTooltip.ts new file mode 100644 index 00000000000000..21a3247130025c --- /dev/null +++ b/static/app/views/explore/components/getExportDisabledTooltip.ts @@ -0,0 +1,22 @@ +import {t} from 'sentry/locale'; + +interface ExportDisabledTooltipOptions { + isDataEmpty?: boolean; + isDataError?: boolean; + isDataLoading?: boolean; +} + +export function getExportDisabledTooltip( + props: ExportDisabledTooltipOptions +): string | undefined { + if (props.isDataLoading) { + return t('Loading...'); + } + if (props.isDataError) { + return t('Unable to export due to an error'); + } + if (props.isDataEmpty) { + return t('No data to export'); + } + return undefined; +} diff --git a/static/app/views/explore/logs/logsExport.spec.tsx b/static/app/views/explore/logs/logsExport.spec.tsx deleted file mode 100644 index a65246a602a01d..00000000000000 --- a/static/app/views/explore/logs/logsExport.spec.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import {initializeLogsTest} from 'sentry-fixture/log'; - -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; - -import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; -import {LOGS_QUERY_KEY} from 'sentry/views/explore/contexts/logs/logsPageParams'; -import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys'; -import {QUERY_PAGE_LIMIT} from 'sentry/views/explore/logs/constants'; -import {LogsExportButton} from 'sentry/views/explore/logs/logsExport'; -import {LogsQueryParamsProvider} from 'sentry/views/explore/logs/logsQueryParamsProvider'; -import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; - -jest.mock('sentry/views/explore/logs/logsExportCsv'); - -describe('LogsExportButton', () => { - const {organization, setupPageFilters} = initializeLogsTest({ - organization: {features: ['ourlogs-enabled', 'discover-query']}, - }); - const initialRouterConfig = { - location: { - pathname: `/organizations/${organization.slug}/explore/logs/`, - query: { - [LOGS_SORT_BYS_KEY]: '-timestamp', - [LOGS_QUERY_KEY]: 'severity:error level:warning', - }, - }, - route: '/organizations/:orgId/explore/logs/', - }; - - function ProviderWrapper({children}: {children: React.ReactNode}) { - return ( - - {children} - - ); - } - - const mockTableData = [ - { - [OurLogKnownFieldKey.ID]: '019621262d117e03bce898cb8f4f6ff7', - [OurLogKnownFieldKey.PROJECT_ID]: '1', - [OurLogKnownFieldKey.ORGANIZATION_ID]: 1, - [OurLogKnownFieldKey.TRACE_ID]: '17cc0bae407042eaa4bf6d798c37d026', - [OurLogKnownFieldKey.SEVERITY_NUMBER]: 9, - [OurLogKnownFieldKey.SEVERITY]: 'info', - [OurLogKnownFieldKey.TIMESTAMP]: '2025-04-10T19:21:12+00:00', - [OurLogKnownFieldKey.MESSAGE]: 'some log message1', - [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: 1.7443128722090732e18, - [OurLogKnownFieldKey.OBSERVED_TIMESTAMP_PRECISE]: 1.7443128722090732e18, - }, - { - [OurLogKnownFieldKey.ID]: '0196212624a17144aa392d01420256a2', - [OurLogKnownFieldKey.PROJECT_ID]: '1', - [OurLogKnownFieldKey.ORGANIZATION_ID]: 1, - [OurLogKnownFieldKey.TRACE_ID]: 'c331c2df93d846f5a2134203416d40bb', - [OurLogKnownFieldKey.SEVERITY_NUMBER]: 9, - [OurLogKnownFieldKey.SEVERITY]: 'info', - [OurLogKnownFieldKey.TIMESTAMP]: '2025-04-10T19:21:10+00:00', - [OurLogKnownFieldKey.MESSAGE]: 'some log message2', - [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: 1.744312870049196e18, - [OurLogKnownFieldKey.OBSERVED_TIMESTAMP_PRECISE]: 1.744312870049196e18, - }, - ]; - - beforeEach(() => { - MockApiClient.clearMockResponses(); - setupPageFilters(); - }); - - it('should render browser export button for small datasets', () => { - render( - - - , - {initialRouterConfig, organization} - ); - - expect(screen.getByTestId('export-download-csv')).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Export'})).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Export Data (Modal)'})).toBeInTheDocument(); - }); - - it('should send correct payload for async export with all LogsQueryInfo parameters', async () => { - const largeTableData = new Array(QUERY_PAGE_LIMIT).fill(mockTableData[0]); - - const exportMock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/data-export/`, - method: 'POST', - body: {id: 721}, - }); - - render( - - - , - {initialRouterConfig, organization} - ); - - await userEvent.click(screen.getByRole('button', {name: 'Export'})); - - expect(exportMock).toHaveBeenCalledWith('/organizations/org-slug/data-export/', { - data: { - query_info: { - dataset: 'logs', - end: undefined, - environment: [], - field: ['timestamp', 'message'], - project: [2], - query: 'severity:error level:warning', - sort: ['-timestamp'], - start: undefined, - statsPeriod: '14d', - }, - query_type: 'Explore', - }, - error: expect.any(Function), - method: 'POST', - success: expect.any(Function), - }); - }); - - it('should handle CSV export for small datasets', async () => { - const mockDownloadLogsAsCsv = jest.mocked( - require('sentry/views/explore/logs/logsExportCsv').downloadLogsAsCsv - ); - - render( - - - , - {initialRouterConfig, organization} - ); - - await userEvent.click(screen.getByTestId('export-download-csv')); - - expect(mockDownloadLogsAsCsv).toHaveBeenCalledWith( - mockTableData, - ['timestamp', 'message'], - 'logs' - ); - }); - - it('should disable button when loading', () => { - render( - - - , - {initialRouterConfig, organization} - ); - - expect(screen.getByRole('button', {name: 'Export'})).toBeDisabled(); - }); - - it('should disable button when there is an error', () => { - render( - - - , - {initialRouterConfig, organization} - ); - - expect(screen.getByRole('button', {name: 'Export'})).toBeDisabled(); - }); - - it('should disable button when table data is empty', () => { - render( - - - , - {initialRouterConfig, organization} - ); - - expect(screen.getByRole('button', {name: 'Export'})).toBeDisabled(); - }); -}); diff --git a/static/app/views/explore/logs/logsExport.tsx b/static/app/views/explore/logs/logsExport.tsx deleted file mode 100644 index e9f8559ac42e82..00000000000000 --- a/static/app/views/explore/logs/logsExport.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import {Fragment} from 'react'; - -import {ExportQueryType} from 'sentry/components/dataExport'; -import {DataExportWithModal} from 'sentry/components/dataExportWithModal'; -import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; -import {ExploreExport} from 'sentry/views/explore/components/exploreExport'; -import {downloadLogsAsCsv} from 'sentry/views/explore/logs/logsExportCsv'; -import { - canExportLogsInBrowserSession, - hasReachedLogsBrowserExportPageLimit, -} from 'sentry/views/explore/logs/logsExportSession'; -import type {OurLogFieldKey, OurLogsResponseItem} from 'sentry/views/explore/logs/types'; -import { - useQueryParamsFields, - useQueryParamsSearch, - useQueryParamsSortBys, -} from 'sentry/views/explore/queryParams/context'; -import {TraceItemDataset} from 'sentry/views/explore/types'; - -type LogsExportButtonProps = { - isLoading: boolean; - tableData: OurLogsResponseItem[] | null | undefined; - error?: Error | null; -}; - -interface LogsQueryInfo { - dataset: 'logs'; - field: OurLogFieldKey[]; - project: number[]; - query: string; - sort: string[]; - end?: string; - environment?: string[]; - start?: string; - statsPeriod?: string; -} - -export function LogsExportButton(props: LogsExportButtonProps) { - const {selection} = usePageFilters(); - const logsSearch = useQueryParamsSearch(); - const fields = useQueryParamsFields(); - const sortBys = useQueryParamsSortBys(); - const {start, end, period: statsPeriod} = selection.datetime; - const {environments, projects} = selection; - - const queryInfo: LogsQueryInfo = { - dataset: 'logs', - field: [...fields], - query: logsSearch.formatString(), - project: projects, - sort: sortBys.map(sort => `${sort.kind === 'desc' ? '-' : ''}${sort.field}`), - start: start ? new Date(start).toISOString() : undefined, - end: end ? new Date(end).toISOString() : undefined, - statsPeriod: statsPeriod || undefined, - environment: environments, - }; - - const disabled = - props.isLoading || - props.error !== null || - !props.tableData || - props.tableData.length === 0; - - const isDataEmpty = !props.tableData || props.tableData.length === 0; - const isDataLoading = props.isLoading; - const isDataError = props.error !== null; - - const handleDownloadAsCsv = () => { - if (props.tableData) { - downloadLogsAsCsv(props.tableData, queryInfo.field, 'logs'); - } - }; - - return ( - - - { - if (props.tableData) { - downloadLogsAsCsv(props.tableData, queryInfo.field, 'logs'); - } - }, - }} - /> - - ); -} diff --git a/static/app/views/explore/logs/logsExportButton.tsx b/static/app/views/explore/logs/logsExportButton.tsx new file mode 100644 index 00000000000000..fb60de4c973488 --- /dev/null +++ b/static/app/views/explore/logs/logsExportButton.tsx @@ -0,0 +1,43 @@ +import {type LogsQueryInfo} from 'sentry/components/dataExport'; +import {ExploreExport} from 'sentry/views/explore/components/exploreExport'; +import {QUERY_PAGE_LIMIT} from 'sentry/views/explore/logs/constants'; +import {downloadLogsAsCsv} from 'sentry/views/explore/logs/logsExportCsv'; +import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; +import {TraceItemDataset} from 'sentry/views/explore/types'; + +type LogsExportButtonProps = { + isLoading: boolean; + queryInfo: LogsQueryInfo; + tableData: OurLogsResponseItem[] | null | undefined; + error?: Error | null; +}; + +export function LogsExportButton({ + isLoading, + tableData, + error, + queryInfo, +}: LogsExportButtonProps) { + const isDataEmpty = !tableData?.length; + const isDataError = error !== null; + + const handleDownloadAsCsv = () => { + if (tableData) { + downloadLogsAsCsv(tableData, queryInfo.field, 'logs'); + } + }; + + const isMoreThanOnePage = !!tableData && tableData.length > QUERY_PAGE_LIMIT - 1; + + return ( + + ); +} diff --git a/static/app/views/explore/logs/logsExportModal.tsx b/static/app/views/explore/logs/logsExportModal.tsx new file mode 100644 index 00000000000000..4f702e936133f4 --- /dev/null +++ b/static/app/views/explore/logs/logsExportModal.tsx @@ -0,0 +1,133 @@ +import {useMemo} from 'react'; +import {z} from 'zod'; + +import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {Flex, Stack} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +import type {ModalRenderProps} from 'sentry/actionCreators/modal'; +import type {LogsQueryInfo} from 'sentry/components/dataExport'; +import {ExportQueryType, useDataExport} from 'sentry/components/dataExport'; +import {t} from 'sentry/locale'; +import {TraceItemDataset} from 'sentry/views/explore/types'; + +const ROW_COUNT_VALUE_DEFAULT = 100; +const ROW_COUNT_VALUES = [ROW_COUNT_VALUE_DEFAULT, 500, 1_000, 5_000, 10_000] as const; + +const exportModalFormSchema = z.object({ + allColumns: z.boolean(), + format: z.enum(['csv', 'json']), + rowCount: z.union(ROW_COUNT_VALUES.map(option => z.literal(option))), +}); + +type ExportModalFormValues = z.infer; + +const defaultExportModalValues: ExportModalFormValues = { + allColumns: false, + format: 'csv', + rowCount: 100, +}; + +type LogsExportModalProps = ModalRenderProps & { + queryInfo: LogsQueryInfo; +}; + +export function LogsExportModal({ + Body, + Footer, + Header, + closeModal, + queryInfo, +}: LogsExportModalProps) { + const payload = useMemo( + () => ({ + queryType: ExportQueryType.EXPLORE, + queryInfo: { + ...queryInfo, + dataset: TraceItemDataset.LOGS, + }, + }), + [queryInfo] + ); + const {mutation} = useDataExport({payload}); + + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: defaultExportModalValues, + validators: { + onDynamic: exportModalFormSchema, + }, + onSubmit: async ({value}) => { + try { + const {data, statusCode} = await mutation.mutateAsync({limit: value.rowCount}); + // eslint-disable-next-line no-console -- TODO, Josh is testing :) + console.log({data, statusCode}); + } finally { + closeModal(); + } + }, + }); + + const rowOptions = ROW_COUNT_VALUES.map(value => ({label: value, value})); + + return ( + +
+ {t('Export logs')} +
+ + + {t("Hi! Let's export some logs!")} + + {field => ( + + field.handleChange(value as ExportModalFormValues['format']) + } + > + + {t('CSV')} + {t('JSON')} + + + )} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + +
+ + + {t('Export')} + +
+
+ ); +} diff --git a/static/app/views/explore/logs/logsExportModalButton.tsx b/static/app/views/explore/logs/logsExportModalButton.tsx new file mode 100644 index 00000000000000..48f10c7ec15254 --- /dev/null +++ b/static/app/views/explore/logs/logsExportModalButton.tsx @@ -0,0 +1,46 @@ +import {Button} from '@sentry/scraps/button'; + +import {openModal} from 'sentry/actionCreators/modal'; +import {type LogsQueryInfo} from 'sentry/components/dataExport'; +import {IconDownload} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {getExportDisabledTooltip} from 'sentry/views/explore/components/getExportDisabledTooltip'; +import {LogsExportModal} from 'sentry/views/explore/logs/logsExportModal'; +import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; + +type LogsExportModalButtonProps = { + isLoading: boolean; + queryInfo: LogsQueryInfo; + tableData: OurLogsResponseItem[] | null | undefined; + error?: Error | null; +}; + +export function LogsExportModalButton(props: LogsExportModalButtonProps) { + const {isLoading, tableData, error, queryInfo} = props; + const isDataEmpty = !tableData?.length; + const isDataError = error !== null; + + const disabledTooltip = getExportDisabledTooltip({ + isDataEmpty, + isDataError, + isDataLoading: isLoading, + }); + + return ( + + ); +} diff --git a/static/app/views/explore/logs/logsExportSession.ts b/static/app/views/explore/logs/logsExportSession.ts deleted file mode 100644 index 0b7ea7f9c2cf15..00000000000000 --- a/static/app/views/explore/logs/logsExportSession.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {QUERY_PAGE_LIMIT} from 'sentry/views/explore/logs/constants'; -import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; - -/** - * Whether the loaded logs table has more rows than a single UI page fetch, so the - * user must use async export instead of downloading CSV from in-memory table data. - * Mirrors the condition used by {@link ExploreExport} for `hasReachedCSVLimit`. - */ -export function hasReachedLogsBrowserExportPageLimit( - tableData: OurLogsResponseItem[] | null | undefined -): boolean { - return !!tableData && tableData.length > QUERY_PAGE_LIMIT - 1; -} - -/** - * Whether we can export the current result set entirely in the browser from data - * already loaded in the table (same threshold as the primary Export button’s - * direct CSV download). - */ -export function canExportLogsInBrowserSession( - tableData: OurLogsResponseItem[] | null | undefined -): boolean { - return !!tableData && tableData.length > 0 && tableData.length <= QUERY_PAGE_LIMIT - 1; -} diff --git a/static/app/views/explore/logs/logsExportSwitch.tsx b/static/app/views/explore/logs/logsExportSwitch.tsx new file mode 100644 index 00000000000000..7570dad80590c4 --- /dev/null +++ b/static/app/views/explore/logs/logsExportSwitch.tsx @@ -0,0 +1,56 @@ +import {type LogsQueryInfo} from 'sentry/components/dataExport'; +import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {LogsExportButton} from 'sentry/views/explore/logs/logsExportButton'; +import {LogsExportModalButton} from 'sentry/views/explore/logs/logsExportModalButton'; +import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; +import { + useQueryParamsFields, + useQueryParamsSearch, + useQueryParamsSortBys, +} from 'sentry/views/explore/queryParams/context'; + +type LogsExportSwitchProps = { + isLoading: boolean; + tableData: OurLogsResponseItem[] | null | undefined; + error?: Error | null; +}; + +export function LogsExportSwitch({isLoading, tableData, error}: LogsExportSwitchProps) { + const organization = useOrganization(); + const location = useLocation(); + const showModalExport = + organization.features.includes('ourlogs-modal-export') || + location.query.logsModalExport === 'true'; + + const {selection} = usePageFilters(); + const logsSearch = useQueryParamsSearch(); + const fields = useQueryParamsFields(); + const sortBys = useQueryParamsSortBys(); + const {start, end, period: statsPeriod} = selection.datetime; + const {environments, projects} = selection; + + const queryInfo: LogsQueryInfo = { + dataset: 'logs', + field: [...fields], + query: logsSearch.formatString(), + project: projects, + sort: sortBys.map(sort => `${sort.kind === 'desc' ? '-' : ''}${sort.field}`), + start: start ? new Date(start).toISOString() : undefined, + end: end ? new Date(end).toISOString() : undefined, + statsPeriod: statsPeriod || undefined, + environment: environments, + }; + + const ButtonComponent = showModalExport ? LogsExportModalButton : LogsExportButton; + + return ( + + ); +} diff --git a/static/app/views/explore/logs/logsTab.tsx b/static/app/views/explore/logs/logsTab.tsx index a2c10489ad57c0..bed6aa241cb133 100644 --- a/static/app/views/explore/logs/logsTab.tsx +++ b/static/app/views/explore/logs/logsTab.tsx @@ -55,7 +55,7 @@ import { } from 'sentry/views/explore/logs/constants'; import {AutorefreshToggle} from 'sentry/views/explore/logs/logsAutoRefresh'; import {LogsDownSamplingAlert} from 'sentry/views/explore/logs/logsDownsamplingAlert'; -import {LogsExportButton} from 'sentry/views/explore/logs/logsExport'; +import {LogsExportSwitch} from 'sentry/views/explore/logs/logsExportSwitch'; import {LogsGraph} from 'sentry/views/explore/logs/logsGraph'; import {LogsTabSeerComboBox} from 'sentry/views/explore/logs/logsTabSeerComboBox'; import {LogsToolbar} from 'sentry/views/explore/logs/logsToolbar'; @@ -455,7 +455,7 @@ export function LogsTabContent({datePageFilterProps, tableExpando}: LogsTabProps > {sidebarOpen ? null : t('Advanced')} - Date: Tue, 14 Apr 2026 10:48:12 -0400 Subject: [PATCH 06/10] boom, phrasing --- .../views/explore/logs/logsExportButton.tsx | 3 ++ .../views/explore/logs/logsExportModal.tsx | 36 +++++++++++++++---- .../explore/logs/logsExportModalButton.tsx | 14 ++++++-- .../views/explore/logs/logsExportSwitch.tsx | 12 ++++++- static/app/views/explore/logs/logsTab.tsx | 8 +++++ 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/static/app/views/explore/logs/logsExportButton.tsx b/static/app/views/explore/logs/logsExportButton.tsx index fb60de4c973488..3b74b19213a65e 100644 --- a/static/app/views/explore/logs/logsExportButton.tsx +++ b/static/app/views/explore/logs/logsExportButton.tsx @@ -9,7 +9,10 @@ type LogsExportButtonProps = { isLoading: boolean; queryInfo: LogsQueryInfo; tableData: OurLogsResponseItem[] | null | undefined; + /** Passed through from LogsExportSwitch for the modal path only */ + downloadLocally?: boolean; error?: Error | null; + threshold?: number; }; export function LogsExportButton({ diff --git a/static/app/views/explore/logs/logsExportModal.tsx b/static/app/views/explore/logs/logsExportModal.tsx index 4f702e936133f4..939a349b1b1b9d 100644 --- a/static/app/views/explore/logs/logsExportModal.tsx +++ b/static/app/views/explore/logs/logsExportModal.tsx @@ -10,6 +10,7 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import type {LogsQueryInfo} from 'sentry/components/dataExport'; import {ExportQueryType, useDataExport} from 'sentry/components/dataExport'; import {t} from 'sentry/locale'; +import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; import {TraceItemDataset} from 'sentry/views/explore/types'; const ROW_COUNT_VALUE_DEFAULT = 100; @@ -30,7 +31,10 @@ const defaultExportModalValues: ExportModalFormValues = { }; type LogsExportModalProps = ModalRenderProps & { + downloadLocally: boolean; queryInfo: LogsQueryInfo; + tableData: OurLogsResponseItem[] | null | undefined; + threshold: number; }; export function LogsExportModal({ @@ -38,7 +42,10 @@ export function LogsExportModal({ Footer, Header, closeModal, + downloadLocally, queryInfo, + tableData: _tableData, + threshold, }: LogsExportModalProps) { const payload = useMemo( () => ({ @@ -60,9 +67,7 @@ export function LogsExportModal({ }, onSubmit: async ({value}) => { try { - const {data, statusCode} = await mutation.mutateAsync({limit: value.rowCount}); - // eslint-disable-next-line no-console -- TODO, Josh is testing :) - console.log({data, statusCode}); + await mutation.mutateAsync({limit: value.rowCount}); } finally { closeModal(); } @@ -74,11 +79,25 @@ export function LogsExportModal({ return (
- {t('Export logs')} + {t('Logs Export')}
- {t("Hi! Let's export some logs!")} + + {t( + 'Export the contents of your logs so you can look at them closely yourself.' + )} + + + {downloadLocally + ? t( + 'You can download these logs immediately in the format of your choosing.' + ) + : t( + "To export more than %s logs, we'll queue up an export that will be emailed to you soon.", + threshold + )} + {field => ( {field => ( - + } onClick={() => { - openModal(deps => ); + openModal(deps => ( + + )); }} tooltipProps={{ title: diff --git a/static/app/views/explore/logs/logsExportSwitch.tsx b/static/app/views/explore/logs/logsExportSwitch.tsx index 7570dad80590c4..87703003aac194 100644 --- a/static/app/views/explore/logs/logsExportSwitch.tsx +++ b/static/app/views/explore/logs/logsExportSwitch.tsx @@ -12,12 +12,20 @@ import { } from 'sentry/views/explore/queryParams/context'; type LogsExportSwitchProps = { + downloadLocally: boolean; isLoading: boolean; tableData: OurLogsResponseItem[] | null | undefined; + threshold: number; error?: Error | null; }; -export function LogsExportSwitch({isLoading, tableData, error}: LogsExportSwitchProps) { +export function LogsExportSwitch({ + isLoading, + tableData, + error, + downloadLocally, + threshold, +}: LogsExportSwitchProps) { const organization = useOrganization(); const location = useLocation(); const showModalExport = @@ -51,6 +59,8 @@ export function LogsExportSwitch({isLoading, tableData, error}: LogsExportSwitch isLoading={isLoading} error={error} tableData={tableData} + downloadLocally={downloadLocally} + threshold={threshold} /> ); } diff --git a/static/app/views/explore/logs/logsTab.tsx b/static/app/views/explore/logs/logsTab.tsx index bed6aa241cb133..d1a46d21be278f 100644 --- a/static/app/views/explore/logs/logsTab.tsx +++ b/static/app/views/explore/logs/logsTab.tsx @@ -52,6 +52,8 @@ import {useLogAnalytics} from 'sentry/views/explore/hooks/useAnalytics'; import { HiddenColumnEditorLogFields, HiddenLogSearchFields, + QUERY_PAGE_LIMIT, + QUERY_PAGE_LIMIT_WITH_AUTO_REFRESH, } from 'sentry/views/explore/logs/constants'; import {AutorefreshToggle} from 'sentry/views/explore/logs/logsAutoRefresh'; import {LogsDownSamplingAlert} from 'sentry/views/explore/logs/logsDownsamplingAlert'; @@ -253,6 +255,10 @@ export function LogsTabContent({datePageFilterProps, tableExpando}: LogsTabProps const setFields = useSetQueryParamsFields(); const tableData = useLogsPageDataQueryResult(); const autorefreshEnabled = useLogsAutoRefreshEnabled(); + const logsExportThreshold = autorefreshEnabled + ? QUERY_PAGE_LIMIT_WITH_AUTO_REFRESH + : QUERY_PAGE_LIMIT; + const logsExportDownloadLocally = !tableData.isPending && !tableData.hasNextPage; const [timeseriesIngestDelay, setTimeseriesIngestDelay] = useState( getMaxIngestDelayTimestamp() @@ -459,6 +465,8 @@ export function LogsTabContent({datePageFilterProps, tableExpando}: LogsTabProps isLoading={tableData.isPending} tableData={tableData.data} error={tableData.error} + downloadLocally={logsExportDownloadLocally} + threshold={logsExportThreshold} /> )} From 120ca984172c4283b8812b6b9a49dea5c727debf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 14 Apr 2026 14:10:55 -0400 Subject: [PATCH 07/10] updated draft to latest designs --- static/app/components/dataExport.tsx | 4 ++- .../views/explore/logs/logsExportModal.tsx | 34 ++++++------------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/static/app/components/dataExport.tsx b/static/app/components/dataExport.tsx index ae8d43034ded41..049a5a0316c901 100644 --- a/static/app/components/dataExport.tsx +++ b/static/app/components/dataExport.tsx @@ -36,7 +36,9 @@ type DataExportPayload = { }; type DataExportInvokeOptions = { - limit?: number; + allColumns: boolean; + format: 'csv' | 'json'; + limit: number; }; interface DataExportProps { diff --git a/static/app/views/explore/logs/logsExportModal.tsx b/static/app/views/explore/logs/logsExportModal.tsx index 939a349b1b1b9d..ea547ab220fa83 100644 --- a/static/app/views/explore/logs/logsExportModal.tsx +++ b/static/app/views/explore/logs/logsExportModal.tsx @@ -42,10 +42,7 @@ export function LogsExportModal({ Footer, Header, closeModal, - downloadLocally, queryInfo, - tableData: _tableData, - threshold, }: LogsExportModalProps) { const payload = useMemo( () => ({ @@ -67,7 +64,11 @@ export function LogsExportModal({ }, onSubmit: async ({value}) => { try { - await mutation.mutateAsync({limit: value.rowCount}); + await mutation.mutateAsync({ + limit: value.rowCount, + format: value.format, + allColumns: value.allColumns, + }); } finally { closeModal(); } @@ -85,19 +86,9 @@ export function LogsExportModal({ {t( - 'Export the contents of your logs so you can look at them closely yourself.' + 'If you select more than 1000 rows or to export all columns of data your file will be sent to your email address.' )} - - {downloadLocally - ? t( - 'You can download these logs immediately in the format of your choosing.' - ) - : t( - "To export more than %s logs, we'll queue up an export that will be emailed to you soon.", - threshold - )} - {field => ( - + {t('CSV')} {t('JSON')} - +
)}
@@ -129,17 +120,12 @@ export function LogsExportModal({ {field => ( - + - + )}
From 441e31d5eb932c55a7c6abc560438207b67aa478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 14 Apr 2026 16:36:28 -0400 Subject: [PATCH 08/10] support local filename downloads and json too --- pyproject.toml | 48 +++---- static/app/components/dataExport.tsx | 118 +---------------- static/app/components/useDataExport.ts | 123 ++++++++++++++++++ static/app/utils/downloadFromHref.ts | 8 ++ static/app/utils/downloadObjectAsJson.tsx | 17 +-- .../views/dataExport/dataDownload.spec.tsx | 2 +- static/app/views/dataExport/dataDownload.tsx | 2 +- .../app/views/discover/table/tableActions.tsx | 3 +- .../explore/components/exploreExport.tsx | 3 +- .../explore/logs/createLogDownloadFilename.ts | 6 + static/app/views/explore/logs/downloadLogs.ts | 26 ++++ ...logsExportCsv.tsx => downloadLogsAsCsv.ts} | 17 +-- .../views/explore/logs/downloadLogsAsJson.ts | 10 ++ .../views/explore/logs/logsExportButton.tsx | 4 +- .../views/explore/logs/logsExportModal.tsx | 80 +++++++++--- .../explore/logs/logsExportModalButton.tsx | 2 +- .../views/explore/logs/logsExportSwitch.tsx | 2 +- .../groupDistributions/tagExportDropdown.tsx | 2 +- 18 files changed, 281 insertions(+), 192 deletions(-) create mode 100644 static/app/components/useDataExport.ts create mode 100644 static/app/utils/downloadFromHref.ts create mode 100644 static/app/views/explore/logs/createLogDownloadFilename.ts create mode 100644 static/app/views/explore/logs/downloadLogs.ts rename static/app/views/explore/logs/{logsExportCsv.tsx => downloadLogsAsCsv.ts} (68%) create mode 100644 static/app/views/explore/logs/downloadLogsAsJson.ts diff --git a/pyproject.toml b/pyproject.toml index 0f899185c505d7..3b0c4d075c49ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -375,32 +375,32 @@ ignore_missing_imports = true # - python3 -m tools.mypy_helpers.find_easiest_modules [[tool.mypy.overrides]] module = [ - "sentry.api.endpoints.organization_releases", - "sentry.api.paginator", - "sentry.db.postgres.base", - "sentry.middleware.auth", - "sentry.middleware.ratelimit", - "sentry.net.http", - "sentry.release_health.metrics_sessions_v2", - "sentry.search.events.builder.errors", - "sentry.search.events.builder.metrics", - "sentry.search.events.datasets.filter_aliases", - "sentry.search.events.filter", - "sentry.search.snuba.executors", - "sentry.services.eventstore.models", - "sentry.snuba.metrics.query_builder", - "sentry.testutils.cases", - "tests.sentry.api.helpers.test_group_index", - "tests.sentry.issues.test_utils", + "sentry.api.endpoints.organization_releases", + "sentry.api.paginator", + "sentry.db.postgres.base", + "sentry.middleware.auth", + "sentry.middleware.ratelimit", + "sentry.net.http", + "sentry.release_health.metrics_sessions_v2", + "sentry.search.events.builder.errors", + "sentry.search.events.builder.metrics", + "sentry.search.events.datasets.filter_aliases", + "sentry.search.events.filter", + "sentry.search.snuba.executors", + "sentry.services.eventstore.models", + "sentry.snuba.metrics.query_builder", + "sentry.testutils.cases", + "tests.sentry.api.helpers.test_group_index", + "tests.sentry.issues.test_utils", ] disable_error_code = [ - "arg-type", - "assignment", - "attr-defined", - "call-overload", - "misc", - "override", - "union-attr", + "arg-type", + "assignment", + "attr-defined", + "call-overload", + "misc", + "override", + "union-attr", ] # end: sentry modules with typing issues diff --git a/static/app/components/dataExport.tsx b/static/app/components/dataExport.tsx index 049a5a0316c901..859792927c1ed8 100644 --- a/static/app/components/dataExport.tsx +++ b/static/app/components/dataExport.tsx @@ -1,46 +1,17 @@ -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; import debounce from 'lodash/debounce'; import {Button} from '@sentry/scraps/button'; -import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import Feature from 'sentry/components/acl/feature'; +import {ExportQueryType, useDataExport} from 'sentry/components/useDataExport'; import {t} from 'sentry/locale'; -import {fetchMutationWithStatus, useMutation} from 'sentry/utils/queryClient'; -import {RequestError} from 'sentry/utils/requestError/requestError'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import type {OurLogFieldKey} from 'sentry/views/explore/logs/types'; - -// NOTE: Coordinate with other ExportQueryType (src/sentry/data_export/base.py) -export enum ExportQueryType { - ISSUES_BY_TAG = 'Issues-by-Tag', - DISCOVER = 'Discover', - EXPLORE = 'Explore', -} - -export interface LogsQueryInfo { - dataset: 'logs'; - field: OurLogFieldKey[]; - project: number[]; - query: string; - sort: string[]; - end?: string; - environment?: string[]; - start?: string; - statsPeriod?: string; -} type DataExportPayload = { queryInfo: any; queryType: ExportQueryType; // TODO(ts): Formalize different possible payloads }; -type DataExportInvokeOptions = { - allColumns: boolean; - format: 'csv' | 'json'; - limit: number; -}; - interface DataExportProps { payload: DataExportPayload; children?: React.ReactNode; @@ -51,91 +22,6 @@ interface DataExportProps { size?: 'xs' | 'sm' | 'md'; } -function getDataExportErrorMessage(error: unknown): string { - if (error instanceof RequestError) { - const detail = error.responseJSON?.detail; - if (typeof detail === 'string') { - return detail; - } - } - return t("We tried our hardest, but we couldn't export your data. Give it another go."); -} - -export function useDataExport({ - payload, - inProgressCallback, - unmountedRef, -}: { - payload: DataExportPayload; - inProgressCallback?: (inProgress: boolean) => void; - unmountedRef?: React.RefObject; -}) { - const organization = useOrganization(); - - const mutation = useMutation({ - mutationFn: (invokeOptions?: DataExportInvokeOptions) => { - const data: Record = { - query_type: payload.queryType, - query_info: payload.queryInfo, - }; - if (typeof invokeOptions?.limit === 'number') { - data.limit = invokeOptions.limit; - } - - return fetchMutationWithStatus({ - method: 'POST', - url: `/organizations/${organization.slug}/data-export/`, - data, - }); - }, - onMutate: () => { - inProgressCallback?.(true); - }, - onSuccess: result => { - if (unmountedRef?.current) { - return; - } - addSuccessMessage( - result.statusCode === 201 - ? t("Sit tight. We'll shoot you an email when your data is ready for download.") - : t("It looks like we're already working on it. Sit tight, we'll email you.") - ); - }, - onError: (error: unknown) => { - if (unmountedRef?.current) { - return; - } - addErrorMessage(getDataExportErrorMessage(error)); - inProgressCallback?.(false); - }, - }); - - const {reset} = mutation; - - useEffect(() => { - reset(); - }, [payload.queryInfo, payload.queryType, reset]); - - const runExport = useCallback( - async (invokeOptions?: DataExportInvokeOptions): Promise => { - try { - await mutation.mutateAsync(invokeOptions); - if (unmountedRef?.current) { - return false; - } - return true; - } catch { - return false; - } - }, - [mutation, unmountedRef] - ); - - const isExportWorking = mutation.isPending || mutation.isSuccess; - - return {isExportWorking, mutation, runExport}; -} - export function DataExport({ children, disabled, diff --git a/static/app/components/useDataExport.ts b/static/app/components/useDataExport.ts new file mode 100644 index 00000000000000..c67b6c0b0a7909 --- /dev/null +++ b/static/app/components/useDataExport.ts @@ -0,0 +1,123 @@ +import {useCallback, useEffect} from 'react'; + +import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; +import {t} from 'sentry/locale'; +import {fetchMutationWithStatus, useMutation} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import type {OurLogFieldKey} from 'sentry/views/explore/logs/types'; + +// NOTE: Coordinate with other ExportQueryType (src/sentry/data_export/base.py) +export enum ExportQueryType { + ISSUES_BY_TAG = 'Issues-by-Tag', + DISCOVER = 'Discover', + EXPLORE = 'Explore', +} + +export interface LogsQueryInfo { + dataset: 'logs'; + field: OurLogFieldKey[]; + project: number[]; + query: string; + sort: string[]; + end?: string; + environment?: string[]; + start?: string; + statsPeriod?: string; +} + +type DataExportPayload = { + queryInfo: any; + queryType: ExportQueryType; // TODO(ts): Formalize different possible payloads +}; + +type DataExportInvokeOptions = { + allColumns: boolean; + format: 'csv' | 'json'; + limit: number; +}; + +function getDataExportErrorMessage(error: unknown): string { + if (error instanceof RequestError) { + const detail = error.responseJSON?.detail; + if (typeof detail === 'string') { + return detail; + } + } + return t("We tried our hardest, but we couldn't export your data. Give it another go."); +} + +export function useDataExport({ + payload, + inProgressCallback, + unmountedRef, +}: { + payload: DataExportPayload; + inProgressCallback?: (inProgress: boolean) => void; + unmountedRef?: React.RefObject; +}) { + const organization = useOrganization(); + + const mutation = useMutation({ + mutationFn: (invokeOptions?: DataExportInvokeOptions) => { + const data: Record = { + query_type: payload.queryType, + query_info: payload.queryInfo, + }; + if (typeof invokeOptions?.limit === 'number') { + data.limit = invokeOptions.limit; + } + + return fetchMutationWithStatus({ + method: 'POST', + url: `/organizations/${organization.slug}/data-export/`, + data, + }); + }, + onMutate: () => { + inProgressCallback?.(true); + }, + onSuccess: result => { + if (unmountedRef?.current) { + return; + } + addSuccessMessage( + result.statusCode === 201 + ? t("Sit tight. We'll shoot you an email when your data is ready for download.") + : t("It looks like we're already working on it. Sit tight, we'll email you.") + ); + }, + onError: (error: unknown) => { + if (unmountedRef?.current) { + return; + } + addErrorMessage(getDataExportErrorMessage(error)); + inProgressCallback?.(false); + }, + }); + + const {reset} = mutation; + + useEffect(() => { + reset(); + }, [payload.queryInfo, payload.queryType, reset]); + + const runExport = useCallback( + async (invokeOptions?: DataExportInvokeOptions): Promise => { + try { + await mutation.mutateAsync(invokeOptions); + if (unmountedRef?.current) { + return false; + } + return true; + } catch { + return false; + } + }, + [mutation, unmountedRef] + ); + + const isExportWorking = mutation.isPending || mutation.isSuccess; + + return {isExportWorking, mutation, runExport}; +} diff --git a/static/app/utils/downloadFromHref.ts b/static/app/utils/downloadFromHref.ts new file mode 100644 index 00000000000000..1e8446bc81ff8c --- /dev/null +++ b/static/app/utils/downloadFromHref.ts @@ -0,0 +1,8 @@ +export function downloadFromHref(filename: string, href: string) { + const link = document.createElement('a'); + link.setAttribute('href', href); + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); +} diff --git a/static/app/utils/downloadObjectAsJson.tsx b/static/app/utils/downloadObjectAsJson.tsx index de09d8e97d230d..edf50ae716f509 100644 --- a/static/app/utils/downloadObjectAsJson.tsx +++ b/static/app/utils/downloadObjectAsJson.tsx @@ -1,11 +1,8 @@ -export function downloadObjectAsJson(exportObj: any, exportName: any) { - const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent( - JSON.stringify(exportObj) - )}`; - const downloadAnchorNode = document.createElement('a'); - downloadAnchorNode.setAttribute('href', dataStr); - downloadAnchorNode.setAttribute('download', `${exportName}.json`); - document.body.appendChild(downloadAnchorNode); // required for firefox - downloadAnchorNode.click(); - downloadAnchorNode.remove(); +import {downloadFromHref} from 'sentry/utils/downloadFromHref'; + +export function downloadObjectAsJson(exportObj: unknown, exportName: string) { + downloadFromHref( + `${exportName}.json`, + `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(exportObj))}` + ); } diff --git a/static/app/views/dataExport/dataDownload.spec.tsx b/static/app/views/dataExport/dataDownload.spec.tsx index e36a78cb829ff4..97677c7f781759 100644 --- a/static/app/views/dataExport/dataDownload.spec.tsx +++ b/static/app/views/dataExport/dataDownload.spec.tsx @@ -3,7 +3,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary'; import {textWithMarkupMatcher} from 'sentry-test/utils'; -import {ExportQueryType} from 'sentry/components/dataExport'; +import {ExportQueryType} from 'sentry/components/useDataExport'; import DataDownload, {DownloadStatus} from 'sentry/views/dataExport/dataDownload'; describe('DataDownload', () => { diff --git a/static/app/views/dataExport/dataDownload.tsx b/static/app/views/dataExport/dataDownload.tsx index 6863befdd30cd6..401155205fc5d6 100644 --- a/static/app/views/dataExport/dataDownload.tsx +++ b/static/app/views/dataExport/dataDownload.tsx @@ -3,10 +3,10 @@ import styled from '@emotion/styled'; import {Button, LinkButton} from '@sentry/scraps/button'; -import {ExportQueryType} from 'sentry/components/dataExport'; import {DateTime} from 'sentry/components/dateTime'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; +import {ExportQueryType} from 'sentry/components/useDataExport'; import {IconDownload} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; diff --git a/static/app/views/discover/table/tableActions.tsx b/static/app/views/discover/table/tableActions.tsx index 8b0f6c315994d8..94250d93312578 100644 --- a/static/app/views/discover/table/tableActions.tsx +++ b/static/app/views/discover/table/tableActions.tsx @@ -6,8 +6,9 @@ import {Button} from '@sentry/scraps/button'; import Feature from 'sentry/components/acl/feature'; import {FeatureDisabled} from 'sentry/components/acl/featureDisabled'; import {GuideAnchor} from 'sentry/components/assistant/guideAnchor'; -import {DataExport, ExportQueryType} from 'sentry/components/dataExport'; +import {DataExport} from 'sentry/components/dataExport'; import {Hovercard} from 'sentry/components/hovercard'; +import {ExportQueryType} from 'sentry/components/useDataExport'; import {IconDownload, IconSliders, IconTag} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {OrganizationSummary} from 'sentry/types/organization'; diff --git a/static/app/views/explore/components/exploreExport.tsx b/static/app/views/explore/components/exploreExport.tsx index 571437f44b8381..dcd8c93373a143 100644 --- a/static/app/views/explore/components/exploreExport.tsx +++ b/static/app/views/explore/components/exploreExport.tsx @@ -1,6 +1,7 @@ import {Button} from '@sentry/scraps/button'; -import {DataExport, ExportQueryType} from 'sentry/components/dataExport'; +import {DataExport} from 'sentry/components/dataExport'; +import {ExportQueryType} from 'sentry/components/useDataExport'; import {IconDownload} from 'sentry/icons'; import {t} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; diff --git a/static/app/views/explore/logs/createLogDownloadFilename.ts b/static/app/views/explore/logs/createLogDownloadFilename.ts new file mode 100644 index 00000000000000..deabb682221055 --- /dev/null +++ b/static/app/views/explore/logs/createLogDownloadFilename.ts @@ -0,0 +1,6 @@ +import {getUtcDateString} from 'sentry/utils/dates'; + +export function createLogDownloadFilename(base: string, format: string) { + const now = new Date(); + return `${base} ${getUtcDateString(now)}.${format}`; +} diff --git a/static/app/views/explore/logs/downloadLogs.ts b/static/app/views/explore/logs/downloadLogs.ts new file mode 100644 index 00000000000000..b7dd3116a97552 --- /dev/null +++ b/static/app/views/explore/logs/downloadLogs.ts @@ -0,0 +1,26 @@ +import {downloadLogsAsCsv} from 'sentry/views/explore/logs/downloadLogsAsCsv'; +import {downloadLogsAsJson} from 'sentry/views/explore/logs/downloadLogsAsJson'; +import type {OurLogFieldKey, OurLogsResponseItem} from 'sentry/views/explore/logs/types'; + +interface DownloadLogsOptions { + fields: OurLogFieldKey[]; + filename: string; + format: 'csv' | 'json'; + limit: number; + tableData: OurLogsResponseItem[]; +} + +export function downloadLogs({ + format, + tableData, + fields, + filename, + limit, +}: DownloadLogsOptions) { + switch (format) { + case 'csv': + return downloadLogsAsCsv(tableData.slice(0, limit), fields, filename); + case 'json': + return downloadLogsAsJson(tableData, filename); + } +} diff --git a/static/app/views/explore/logs/logsExportCsv.tsx b/static/app/views/explore/logs/downloadLogsAsCsv.ts similarity index 68% rename from static/app/views/explore/logs/logsExportCsv.tsx rename to static/app/views/explore/logs/downloadLogsAsCsv.ts index 5db23dc8d2c7e6..cc26492096f55d 100644 --- a/static/app/views/explore/logs/logsExportCsv.tsx +++ b/static/app/views/explore/logs/downloadLogsAsCsv.ts @@ -1,6 +1,7 @@ import Papa from 'papaparse'; -import {getUtcDateString} from 'sentry/utils/dates'; +import {downloadFromHref} from 'sentry/utils/downloadFromHref'; +import {createLogDownloadFilename} from 'sentry/views/explore/logs/createLogDownloadFilename'; import type {OurLogFieldKey, OurLogsResponseItem} from 'sentry/views/explore/logs/types'; function disableMacros(value: string | null | boolean | number | undefined) { @@ -35,17 +36,5 @@ export function downloadLogsAsCsv( const encodedDataUrl = `data:text/csv;charset=utf8,${encodeURIComponent(csvContent)}`; - const now = new Date(); - emptyClickCSV(encodedDataUrl, filename, now); -} - -function emptyClickCSV(encodedDataUrl: string, filename: string, now: Date) { - const link = document.createElement('a'); - - link.setAttribute('href', encodedDataUrl); - link.setAttribute('download', `${filename} ${getUtcDateString(now)}.csv`); - link.click(); - link.remove(); - - return encodedDataUrl; + downloadFromHref(createLogDownloadFilename(filename, 'csv'), encodedDataUrl); } diff --git a/static/app/views/explore/logs/downloadLogsAsJson.ts b/static/app/views/explore/logs/downloadLogsAsJson.ts new file mode 100644 index 00000000000000..ddbb5c3b4d64e9 --- /dev/null +++ b/static/app/views/explore/logs/downloadLogsAsJson.ts @@ -0,0 +1,10 @@ +import {downloadFromHref} from 'sentry/utils/downloadFromHref'; +import {createLogDownloadFilename} from 'sentry/views/explore/logs/createLogDownloadFilename'; +import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; + +export function downloadLogsAsJson(tableData: OurLogsResponseItem[], filename: string) { + const jsonContent = JSON.stringify(tableData); + const encodedDataUrl = `data:application/json;charset=utf8,${encodeURIComponent(jsonContent)}`; + + downloadFromHref(createLogDownloadFilename(filename, 'json'), encodedDataUrl); +} diff --git a/static/app/views/explore/logs/logsExportButton.tsx b/static/app/views/explore/logs/logsExportButton.tsx index 3b74b19213a65e..e05c2f98949ebb 100644 --- a/static/app/views/explore/logs/logsExportButton.tsx +++ b/static/app/views/explore/logs/logsExportButton.tsx @@ -1,7 +1,7 @@ -import {type LogsQueryInfo} from 'sentry/components/dataExport'; +import {type LogsQueryInfo} from 'sentry/components/useDataExport'; import {ExploreExport} from 'sentry/views/explore/components/exploreExport'; import {QUERY_PAGE_LIMIT} from 'sentry/views/explore/logs/constants'; -import {downloadLogsAsCsv} from 'sentry/views/explore/logs/logsExportCsv'; +import {downloadLogsAsCsv} from 'sentry/views/explore/logs/downloadLogsAsCsv'; import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; import {TraceItemDataset} from 'sentry/views/explore/types'; diff --git a/static/app/views/explore/logs/logsExportModal.tsx b/static/app/views/explore/logs/logsExportModal.tsx index ea547ab220fa83..ce747a614ffc11 100644 --- a/static/app/views/explore/logs/logsExportModal.tsx +++ b/static/app/views/explore/logs/logsExportModal.tsx @@ -6,20 +6,42 @@ import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; import {Flex, Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; +import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import type {LogsQueryInfo} from 'sentry/components/dataExport'; -import {ExportQueryType, useDataExport} from 'sentry/components/dataExport'; +import { + ExportQueryType, + useDataExport, + type LogsQueryInfo, +} from 'sentry/components/useDataExport'; import {t} from 'sentry/locale'; +import {downloadFromHref} from 'sentry/utils/downloadFromHref'; +import {QUERY_PAGE_LIMIT} from 'sentry/views/explore/logs/constants'; +import {createLogDownloadFilename} from 'sentry/views/explore/logs/createLogDownloadFilename'; +import {downloadLogs} from 'sentry/views/explore/logs/downloadLogs'; import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; import {TraceItemDataset} from 'sentry/views/explore/types'; const ROW_COUNT_VALUE_DEFAULT = 100; -const ROW_COUNT_VALUES = [ROW_COUNT_VALUE_DEFAULT, 500, 1_000, 5_000, 10_000] as const; + +/** + * Keep this in sync with data_export.py on the backend + * (TODO: Saraj is looking into updating this) + */ +const ROW_COUNT_VALUE_SYNC_LIMIT = QUERY_PAGE_LIMIT; + +const ROW_COUNT_VALUES = [ + ROW_COUNT_VALUE_DEFAULT, + 500, + ROW_COUNT_VALUE_SYNC_LIMIT, + 10_000, + 50_000, + 100_000, +] as const; const exportModalFormSchema = z.object({ allColumns: z.boolean(), format: z.enum(['csv', 'json']), - rowCount: z.union(ROW_COUNT_VALUES.map(option => z.literal(option))), + limit: z.union(ROW_COUNT_VALUES.map(option => z.literal(option))), }); type ExportModalFormValues = z.infer; @@ -27,13 +49,13 @@ type ExportModalFormValues = z.infer; const defaultExportModalValues: ExportModalFormValues = { allColumns: false, format: 'csv', - rowCount: 100, + limit: 100, }; type LogsExportModalProps = ModalRenderProps & { downloadLocally: boolean; queryInfo: LogsQueryInfo; - tableData: OurLogsResponseItem[] | null | undefined; + tableData: OurLogsResponseItem[]; threshold: number; }; @@ -43,6 +65,7 @@ export function LogsExportModal({ Header, closeModal, queryInfo, + tableData, }: LogsExportModalProps) { const payload = useMemo( () => ({ @@ -55,6 +78,11 @@ export function LogsExportModal({ [queryInfo] ); const {mutation} = useDataExport({payload}); + const rowOptions = ROW_COUNT_VALUES.map(value => ({label: value, value})); + const rowOptionsAvailable = + tableData.length < QUERY_PAGE_LIMIT + ? rowOptions.filter(({value}) => value <= tableData.length) + : rowOptions; const form = useScrapsForm({ ...defaultFormOptions, @@ -63,20 +91,33 @@ export function LogsExportModal({ onDynamic: exportModalFormSchema, }, onSubmit: async ({value}) => { - try { - await mutation.mutateAsync({ - limit: value.rowCount, + if (!value.allColumns && value.limit < ROW_COUNT_VALUE_SYNC_LIMIT) { + downloadLogs({ + tableData, + fields: queryInfo.field, + filename: 'logs', format: value.format, - allColumns: value.allColumns, + limit: value.limit, }); - } finally { - closeModal(); + addSuccessMessage(t('Downloading file to your browser.')); + return; + } + + // TODO: How should we type this? + const {data} = (await mutation.mutateAsync(value)) as { + data: {fileName: string | null; id: string}; + }; + if (data.fileName) { + const filename = createLogDownloadFilename(data.fileName, value.format); + downloadFromHref( + filename, + `/api/0/organizations/sentry/data-export/${data.id}/?download=true` + ); + addSuccessMessage(t("Downloading '%s' to your browser.", filename)); } }, }); - const rowOptions = ROW_COUNT_VALUES.map(value => ({label: value, value})); - return (
@@ -86,7 +127,8 @@ export function LogsExportModal({ {t( - 'If you select more than 1000 rows or to export all columns of data your file will be sent to your email address.' + 'If you select more than %s rows or to export all columns of data your file will be sent to your email address.', + ROW_COUNT_VALUE_SYNC_LIMIT )} @@ -104,16 +146,16 @@ export function LogsExportModal({ )} - + {field => ( )} diff --git a/static/app/views/explore/logs/logsExportModalButton.tsx b/static/app/views/explore/logs/logsExportModalButton.tsx index 6ca2464d385495..2c05329404b86d 100644 --- a/static/app/views/explore/logs/logsExportModalButton.tsx +++ b/static/app/views/explore/logs/logsExportModalButton.tsx @@ -1,7 +1,7 @@ import {Button} from '@sentry/scraps/button'; import {openModal} from 'sentry/actionCreators/modal'; -import {type LogsQueryInfo} from 'sentry/components/dataExport'; +import {type LogsQueryInfo} from 'sentry/components/useDataExport'; import {IconDownload} from 'sentry/icons'; import {t} from 'sentry/locale'; import {getExportDisabledTooltip} from 'sentry/views/explore/components/getExportDisabledTooltip'; diff --git a/static/app/views/explore/logs/logsExportSwitch.tsx b/static/app/views/explore/logs/logsExportSwitch.tsx index 87703003aac194..31399a864efe76 100644 --- a/static/app/views/explore/logs/logsExportSwitch.tsx +++ b/static/app/views/explore/logs/logsExportSwitch.tsx @@ -1,5 +1,5 @@ -import {type LogsQueryInfo} from 'sentry/components/dataExport'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; +import {type LogsQueryInfo} from 'sentry/components/useDataExport'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; import {LogsExportButton} from 'sentry/views/explore/logs/logsExportButton'; diff --git a/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx b/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx index 0441e33a282b4e..c5034ca47cebeb 100644 --- a/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx +++ b/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx @@ -1,7 +1,7 @@ import {Button} from '@sentry/scraps/button'; -import {ExportQueryType, useDataExport} from 'sentry/components/dataExport'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import {ExportQueryType, useDataExport} from 'sentry/components/useDataExport'; import {IconDownload} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; From 0de928eeb2d350088470172afe06e41de0af4699 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:41:09 +0000 Subject: [PATCH 09/10] :knife: regenerate mypy module blocklist --- pyproject.toml | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3b0c4d075c49ba..0f899185c505d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -375,32 +375,32 @@ ignore_missing_imports = true # - python3 -m tools.mypy_helpers.find_easiest_modules [[tool.mypy.overrides]] module = [ - "sentry.api.endpoints.organization_releases", - "sentry.api.paginator", - "sentry.db.postgres.base", - "sentry.middleware.auth", - "sentry.middleware.ratelimit", - "sentry.net.http", - "sentry.release_health.metrics_sessions_v2", - "sentry.search.events.builder.errors", - "sentry.search.events.builder.metrics", - "sentry.search.events.datasets.filter_aliases", - "sentry.search.events.filter", - "sentry.search.snuba.executors", - "sentry.services.eventstore.models", - "sentry.snuba.metrics.query_builder", - "sentry.testutils.cases", - "tests.sentry.api.helpers.test_group_index", - "tests.sentry.issues.test_utils", + "sentry.api.endpoints.organization_releases", + "sentry.api.paginator", + "sentry.db.postgres.base", + "sentry.middleware.auth", + "sentry.middleware.ratelimit", + "sentry.net.http", + "sentry.release_health.metrics_sessions_v2", + "sentry.search.events.builder.errors", + "sentry.search.events.builder.metrics", + "sentry.search.events.datasets.filter_aliases", + "sentry.search.events.filter", + "sentry.search.snuba.executors", + "sentry.services.eventstore.models", + "sentry.snuba.metrics.query_builder", + "sentry.testutils.cases", + "tests.sentry.api.helpers.test_group_index", + "tests.sentry.issues.test_utils", ] disable_error_code = [ - "arg-type", - "assignment", - "attr-defined", - "call-overload", - "misc", - "override", - "union-attr", + "arg-type", + "assignment", + "attr-defined", + "call-overload", + "misc", + "override", + "union-attr", ] # end: sentry modules with typing issues From 8dab7b44283b98dabc958b6a9fb5f4a53d7c2bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 14 Apr 2026 16:46:09 -0400 Subject: [PATCH 10/10] git checkout master -- static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx --- .../groupDistributions/tagExportDropdown.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx b/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx index c5034ca47cebeb..31347894449599 100644 --- a/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx +++ b/static/app/views/issueDetails/groupDistributions/tagExportDropdown.tsx @@ -1,7 +1,9 @@ +import {useState} from 'react'; + import {Button} from '@sentry/scraps/button'; +import {ExportQueryType, useDataExport} from 'sentry/components/dataExport'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {ExportQueryType, useDataExport} from 'sentry/components/useDataExport'; import {IconDownload} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; @@ -16,8 +18,9 @@ interface Props { } export function TagExportDropdown({tagKey, group, organization, project}: Props) { + const [isExportDisabled, setIsExportDisabled] = useState(false); const hasDiscoverQuery = organization.features.includes('discover-query'); - const {isExportWorking, runExport} = useDataExport({ + const handleDataExport = useDataExport({ payload: { queryType: ExportQueryType.ISSUES_BY_TAG, queryInfo: { @@ -54,11 +57,12 @@ export function TagExportDropdown({tagKey, group, organization, project}: Props) }, { key: 'export-all', - label: isExportWorking ? t('Export in progress...') : t('Export All to CSV'), + label: isExportDisabled ? t('Export in progress...') : t('Export All to CSV'), onAction: () => { - void runExport(); + handleDataExport(); + setIsExportDisabled(true); }, - disabled: isExportWorking || !hasDiscoverQuery, + disabled: isExportDisabled || !hasDiscoverQuery, tooltip: hasDiscoverQuery ? undefined : t('This feature is not available for your organization'),