Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,15 +35,19 @@ 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';
onResetFilter: () => void;
profileType: ProfileSource | AggregateProfileSource;
scheduler: CanvasScheduler;
status: QueryStatus;
queryError?: RequestError | null;
}

export function AggregateFlamegraph(props: AggregateFlamegraphProps): ReactElement {
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,8 +34,9 @@ export function FlamegraphWarnings(props: FlamegraphWarningProps) {

if (props.requestState.type === 'errored') {
return (
<Overlay data-test-id="flamegraph-warning-overlay">
<p>{props.requestState.error || t('Failed to load profile')}</p>
<Overlay data-test-id="flamegraph-warning-overlay" role="alert">
<Text bold>{t('Error loading flamegraph')}</Text>
<Text>{props.requestState.error || t('Failed to load profile')}</Text>
</Overlay>
);
}
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
56 changes: 56 additions & 0 deletions static/app/utils/requestError/getRequestErrorUserMessage.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -177,6 +179,7 @@ export function LandingAggregateFlamegraph({

const {
data,
error,
isPending: isLoading,
isError,
status,
Expand Down Expand Up @@ -303,15 +306,24 @@ export function LandingAggregateFlamegraph({
<RequestStateMessageContainer>
<LoadingIndicator />
</RequestStateMessageContainer>
) : status === 'error' ? (
) : status === 'error' && visualization !== 'flamegraph' ? (
<RequestStateMessageContainer>
{t('There was an error loading the flamegraph.')}
<Stack align="center" gap="md" role="alert">
<Text bold>{t('Error loading flamegraph')}</Text>
<Text>
{getRequestErrorUserMessage(
error,
t('There was an error loading the flamegraph.')
)}
</Text>
</Stack>
</RequestStateMessageContainer>
) : null}
{visualization === 'flamegraph' ? (
<AggregateFlamegraph
filter={frameFilter}
status={status}
queryError={status === 'error' ? error : null}
onResetFilter={onResetFrameFilter}
canvasPoolManager={canvasPoolManager}
scheduler={scheduler}
Expand Down
14 changes: 10 additions & 4 deletions static/app/views/explore/profiling/profilesProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import {createContext, useContext, useLayoutEffect, useState} from 'react';
import * as Sentry from '@sentry/react';

import type {Client} from 'sentry/api';
import {t} from 'sentry/locale';
import type {RequestState} from 'sentry/types/core';
import type {Organization} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
import type {TransactionResult} from 'sentry/utils/profiling/hooks/useTransactionAsSpans';
import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage';
import {useApi} from 'sentry/utils/useApi';
import {useProjects} from 'sentry/utils/useProjects';

Expand Down Expand Up @@ -147,9 +149,10 @@ export function TransactionProfileProvider({
setProfile({type: 'resolved', data: p});
})
.catch(err => {
const message = String(err);

setProfile({type: 'errored', error: message});
setProfile({
type: 'errored',
error: getRequestErrorUserMessage(err, t('Failed to load profile')),
});
Sentry.captureException(err);
});

Expand Down Expand Up @@ -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);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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} : {}),
});
Expand Down Expand Up @@ -181,6 +183,7 @@ export function TransactionProfilesContent(props: TransactionProfilesContentProp
{visualization === 'flamegraph' ? (
<AggregateFlamegraph
status={status}
queryError={status === 'error' ? error : null}
filter={frameFilter}
onResetFilter={onResetFrameFilter}
canvasPoolManager={canvasPoolManager}
Expand All @@ -202,11 +205,19 @@ export function TransactionProfilesContent(props: TransactionProfilesContentProp
<RequestStateMessageContainer>
<LoadingIndicator />
</RequestStateMessageContainer>
) : status === 'error' ? (
) : status === 'error' && visualization !== 'flamegraph' ? (
<RequestStateMessageContainer>
{t('There was an error loading the flamegraph.')}
<Stack align="center" gap="md" role="alert">
<Text bold>{t('Error loading flamegraph')}</Text>
<Text>
{getRequestErrorUserMessage(
error,
t('There was an error loading the flamegraph.')
)}
</Text>
</Stack>
</RequestStateMessageContainer>
) : isEmpty(data) && visualization !== 'flamegraph' ? (
) : data && isEmpty(data) && visualization !== 'flamegraph' ? (
<RequestStateMessageContainer>
{t('No profiling data found')}
</RequestStateMessageContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);
Expand Down
Loading