diff --git a/static/app/components/profiling/flamegraph/aggregateFlamegraph.tsx b/static/app/components/profiling/flamegraph/aggregateFlamegraph.tsx index a97d98cd2d6df7..4ff8ff370deef8 100644 --- a/static/app/components/profiling/flamegraph/aggregateFlamegraph.tsx +++ b/static/app/components/profiling/flamegraph/aggregateFlamegraph.tsx @@ -9,6 +9,7 @@ import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {ContinuousFlamegraphContextMenu} from 'sentry/components/profiling/flamegraph/flamegraphContextMenu'; import {FlamegraphWarnings} from 'sentry/components/profiling/flamegraph/flamegraphOverlays/FlamegraphWarnings'; import {FlamegraphZoomView} from 'sentry/components/profiling/flamegraph/flamegraphZoomView'; +import {t} from 'sentry/locale'; import {defined} from 'sentry/utils'; import type { AggregateProfileSource, @@ -34,8 +35,11 @@ import { import {FlamegraphRenderer2D} from 'sentry/utils/profiling/renderers/flamegraphRenderer2D'; import {FlamegraphRendererWebGL} from 'sentry/utils/profiling/renderers/flamegraphRendererWebGL'; import {Rect} from 'sentry/utils/profiling/speedscope'; +import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage'; +import type {RequestError} from 'sentry/utils/requestError/requestError'; import {useFlamegraph} from 'sentry/views/explore/profiling/flamegraphProvider'; import {useProfileGroup} from 'sentry/views/explore/profiling/profileGroupProvider'; + interface AggregateFlamegraphProps { canvasPoolManager: CanvasPoolManager; filter: 'application' | 'system' | 'all'; @@ -43,6 +47,7 @@ interface AggregateFlamegraphProps { profileType: ProfileSource | AggregateProfileSource; scheduler: CanvasScheduler; status: QueryStatus; + queryError?: RequestError | null; } export function AggregateFlamegraph(props: AggregateFlamegraphProps): ReactElement { @@ -217,7 +222,13 @@ export function AggregateFlamegraph(props: AggregateFlamegraphProps): ReactEleme props.status === 'pending' ? {type: 'loading'} : props.status === 'error' - ? {type: 'errored', error: 'error'} + ? { + type: 'errored', + error: getRequestErrorUserMessage( + props.queryError, + t('Failed to load profile') + ), + } : {type: 'resolved', data: null} } onResetFilter={props.onResetFilter} diff --git a/static/app/components/profiling/flamegraph/flamegraph.spec.tsx b/static/app/components/profiling/flamegraph/flamegraph.spec.tsx index 111277056aa635..4305b4568061df 100644 --- a/static/app/components/profiling/flamegraph/flamegraph.spec.tsx +++ b/static/app/components/profiling/flamegraph/flamegraph.spec.tsx @@ -117,10 +117,11 @@ describe('Flamegraph', () => { }, }); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Error loading flamegraph' + ); expect( - await screen.findByText( - 'RequestError: GET /projects/{orgSlug}/{projectSlug}/profiling/profiles/profile-id/' - ) + await screen.findByText('The requested data could not be found.') ).toBeInTheDocument(); }); diff --git a/static/app/components/profiling/flamegraph/flamegraphOverlays/FlamegraphWarnings.tsx b/static/app/components/profiling/flamegraph/flamegraphOverlays/FlamegraphWarnings.tsx index f677b80fa6de56..617b932510d5b5 100644 --- a/static/app/components/profiling/flamegraph/flamegraphOverlays/FlamegraphWarnings.tsx +++ b/static/app/components/profiling/flamegraph/flamegraphOverlays/FlamegraphWarnings.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; +import {Text} from '@sentry/scraps/text'; import {ExportProfileButton} from 'sentry/components/profiling/exportProfileButton'; import {t, tct} from 'sentry/locale'; @@ -33,8 +34,9 @@ export function FlamegraphWarnings(props: FlamegraphWarningProps) { if (props.requestState.type === 'errored') { return ( - -

{props.requestState.error || t('Failed to load profile')}

+ + {t('Error loading flamegraph')} + {props.requestState.error || t('Failed to load profile')} ); } @@ -102,6 +104,7 @@ const Overlay = styled('div')` height: 100%; display: grid; grid: auto/50%; + gap: ${p => p.theme.space.md}; place-content: center; z-index: ${p => p.theme.zIndex.initial}; text-align: center; diff --git a/static/app/utils/requestError/getRequestErrorUserMessage.spec.tsx b/static/app/utils/requestError/getRequestErrorUserMessage.spec.tsx new file mode 100644 index 00000000000000..8f32bef06b363f --- /dev/null +++ b/static/app/utils/requestError/getRequestErrorUserMessage.spec.tsx @@ -0,0 +1,46 @@ +import type {ResponseMeta} from 'sentry/api'; +import {RequestError} from 'sentry/utils/requestError/requestError'; + +import {getRequestErrorUserMessage} from './getRequestErrorUserMessage'; + +describe('getRequestErrorUserMessage', () => { + it('returns string detail from the API when present', () => { + const err = new RequestError('GET', '/api/', new Error('x'), { + status: 500, + responseJSON: {detail: 'Custom server message'}, + } as ResponseMeta); + expect(getRequestErrorUserMessage(err)).toBe('Custom server message'); + }); + + it('returns message from object detail when present', () => { + const err = new RequestError('GET', '/api/', new Error('x'), { + status: 400, + responseJSON: {detail: {message: 'Structured detail'}}, + } as ResponseMeta); + expect(getRequestErrorUserMessage(err)).toBe('Structured detail'); + }); + + it('maps 429 to rate-limit copy', () => { + const err = new RequestError('GET', '/api/', new Error('x'), { + status: 429, + } as ResponseMeta); + expect(getRequestErrorUserMessage(err)).toBe( + 'API requests have been temporarily rate-limited. Please wait a moment and try again.' + ); + }); + + it('uses the provided fallback for unknown RequestError shapes', () => { + const err = new RequestError('GET', '/api/', new Error('x'), { + status: 418, + } as ResponseMeta); + expect(getRequestErrorUserMessage(err, 'fallback')).toBe('fallback'); + }); + + it('returns message for generic Error instances', () => { + expect(getRequestErrorUserMessage(new Error('oops'))).toBe('oops'); + }); + + it('returns fallback for non-errors', () => { + expect(getRequestErrorUserMessage(null, 'nope')).toBe('nope'); + }); +}); diff --git a/static/app/utils/requestError/getRequestErrorUserMessage.tsx b/static/app/utils/requestError/getRequestErrorUserMessage.tsx new file mode 100644 index 00000000000000..1911670843d30d --- /dev/null +++ b/static/app/utils/requestError/getRequestErrorUserMessage.tsx @@ -0,0 +1,56 @@ +import {t} from 'sentry/locale'; + +import {RequestError} from './requestError'; + +const DEFAULT_FALLBACK = t('Something went wrong. Please try again.'); + +/** + * User-facing copy for failed API requests. Prefer server `detail` when present, + * otherwise map common HTTP statuses to friendly text. + */ +export function getRequestErrorUserMessage( + err: unknown, + fallback: string = DEFAULT_FALLBACK +): string { + if (!(err instanceof RequestError)) { + if (err instanceof Error && err.message) { + return err.message; + } + return fallback; + } + + const detail = err.responseJSON?.detail; + if (typeof detail === 'string' && detail.trim()) { + return detail; + } + if (detail && typeof detail === 'object' && 'message' in detail) { + const message = (detail as {message?: unknown}).message; + if (typeof message === 'string' && message.trim()) { + return message; + } + } + + switch (err.status) { + case 429: + return t( + 'API requests have been temporarily rate-limited. Please wait a moment and try again.' + ); + case 401: + return t('Authentication is required to load this data.'); + case 403: + return t('You do not have permission to load this data.'); + case 404: + return t('The requested data could not be found.'); + case 500: + return t('The server encountered an error while processing this request.'); + case 502: + case 503: + return t( + 'The server is temporarily unavailable. Please try again in a few moments.' + ); + case 504: + return t('The request timed out. Please try again.'); + default: + return fallback; + } +} diff --git a/static/app/views/explore/profiling/landingAggregateFlamegraph.tsx b/static/app/views/explore/profiling/landingAggregateFlamegraph.tsx index 5c08dd0d129f26..1439dc495836bd 100644 --- a/static/app/views/explore/profiling/landingAggregateFlamegraph.tsx +++ b/static/app/views/explore/profiling/landingAggregateFlamegraph.tsx @@ -6,6 +6,7 @@ import {CompactSelect} from '@sentry/scraps/compactSelect'; import type {SelectOption} from '@sentry/scraps/compactSelect'; import {Flex, Stack} from '@sentry/scraps/layout'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; +import {Text} from '@sentry/scraps/text'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {AggregateFlamegraph} from 'sentry/components/profiling/flamegraph/aggregateFlamegraph'; @@ -26,6 +27,7 @@ import {FlamegraphStateProvider} from 'sentry/utils/profiling/flamegraph/flamegr import {FlamegraphThemeProvider} from 'sentry/utils/profiling/flamegraph/flamegraphThemeProvider'; import type {Frame} from 'sentry/utils/profiling/frame'; import {useAggregateFlamegraphQuery} from 'sentry/utils/profiling/hooks/useAggregateFlamegraphQuery'; +import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -177,6 +179,7 @@ export function LandingAggregateFlamegraph({ const { data, + error, isPending: isLoading, isError, status, @@ -303,15 +306,24 @@ export function LandingAggregateFlamegraph({ - ) : status === 'error' ? ( + ) : status === 'error' && visualization !== 'flamegraph' ? ( - {t('There was an error loading the flamegraph.')} + + {t('Error loading flamegraph')} + + {getRequestErrorUserMessage( + error, + t('There was an error loading the flamegraph.') + )} + + ) : null} {visualization === 'flamegraph' ? ( { - const message = String(err); - - setProfile({type: 'errored', error: message}); + setProfile({ + type: 'errored', + error: getRequestErrorUserMessage(err, t('Failed to load profile')), + }); Sentry.captureException(err); }); @@ -217,7 +220,10 @@ export function ContinuousProfileProvider({ setProfile({type: 'resolved', data: p}); }) .catch(err => { - setProfile({type: 'errored', error: 'Failed to fetch profiles'}); + setProfile({ + type: 'errored', + error: getRequestErrorUserMessage(err, t('Failed to fetch profiles')), + }); Sentry.captureException(err); }); diff --git a/static/app/views/performance/transactionSummary/transactionProfiles/content.tsx b/static/app/views/performance/transactionSummary/transactionProfiles/content.tsx index 6b25f53bd218af..510e0ef2e457c9 100644 --- a/static/app/views/performance/transactionSummary/transactionProfiles/content.tsx +++ b/static/app/views/performance/transactionSummary/transactionProfiles/content.tsx @@ -4,8 +4,9 @@ import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; import type {SelectOption} from '@sentry/scraps/compactSelect'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; +import {Text} from '@sentry/scraps/text'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {AggregateFlamegraph} from 'sentry/components/profiling/flamegraph/aggregateFlamegraph'; @@ -27,6 +28,7 @@ import {FlamegraphThemeProvider} from 'sentry/utils/profiling/flamegraph/flamegr import type {Frame} from 'sentry/utils/profiling/frame'; import {isEventedProfile, isSampledProfile} from 'sentry/utils/profiling/guards/profile'; import {useAggregateFlamegraphQuery} from 'sentry/utils/profiling/hooks/useAggregateFlamegraphQuery'; +import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -93,7 +95,7 @@ export function TransactionProfilesContent(props: TransactionProfilesContentProp return search.formatString(); }, [props.query, isEAP]); - const {data, status} = useAggregateFlamegraphQuery({ + const {data, error, status} = useAggregateFlamegraphQuery({ query, ...(isEAP ? {dataSource: 'spans' as const} : {}), }); @@ -181,6 +183,7 @@ export function TransactionProfilesContent(props: TransactionProfilesContentProp {visualization === 'flamegraph' ? ( - ) : status === 'error' ? ( + ) : status === 'error' && visualization !== 'flamegraph' ? ( - {t('There was an error loading the flamegraph.')} + + {t('Error loading flamegraph')} + + {getRequestErrorUserMessage( + error, + t('There was an error loading the flamegraph.') + )} + + - ) : isEmpty(data) && visualization !== 'flamegraph' ? ( + ) : data && isEmpty(data) && visualization !== 'flamegraph' ? ( {t('No profiling data found')} diff --git a/static/app/views/performance/transactionSummary/transactionThresholdButton.tsx b/static/app/views/performance/transactionSummary/transactionThresholdButton.tsx index 1b0ca686e5a363..4e356c48d697f9 100644 --- a/static/app/views/performance/transactionSummary/transactionThresholdButton.tsx +++ b/static/app/views/performance/transactionSummary/transactionThresholdButton.tsx @@ -11,6 +11,7 @@ import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {defined} from 'sentry/utils'; import type {EventView} from 'sentry/utils/discover/eventView'; +import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage'; import {RequestError} from 'sentry/utils/requestError/requestError'; import {withApi} from 'sentry/utils/withApi'; import {withProjects} from 'sentry/utils/withProjects'; @@ -84,9 +85,16 @@ function TransactionThresholdButton({ }) .catch(err => { setLoadingThreshold(false); - const errorMessage = - err instanceof RequestError ? err.responseJSON?.threshold : null; - addErrorMessage(errorMessage as string); + const thresholdMessage = + err instanceof RequestError ? err.responseJSON?.threshold : undefined; + const message = + typeof thresholdMessage === 'string' + ? thresholdMessage + : getRequestErrorUserMessage( + err, + t('Failed to load transaction threshold settings.') + ); + addErrorMessage(message); }); }); }, [api, project, organization.slug, transactionName]);