diff --git a/static/app/components/droppedData/drawer/droppedDataDrawer.tsx b/static/app/components/droppedData/drawer/droppedDataDrawer.tsx
index abe1fb068559..82e93a1f953b 100644
--- a/static/app/components/droppedData/drawer/droppedDataDrawer.tsx
+++ b/static/app/components/droppedData/drawer/droppedDataDrawer.tsx
@@ -26,13 +26,13 @@ import {useChartInterval} from 'sentry/utils/useChartInterval';
interface DroppedDataDrawerProps {
dataset: DiscoverDatasets;
interval?: string;
- onInvestigate?: (row: ReasonRow) => void;
+ onTroubleshoot?: (row: ReasonRow) => void;
}
export function DroppedDataDrawer({
dataset,
interval,
- onInvestigate,
+ onTroubleshoot,
}: DroppedDataDrawerProps) {
const theme = useTheme();
const [chartInterval] = useChartInterval();
@@ -87,7 +87,7 @@ export function DroppedDataDrawer({
)}
diff --git a/static/app/components/droppedData/drawer/droppedDataOutcomeList.spec.tsx b/static/app/components/droppedData/drawer/droppedDataOutcomeList.spec.tsx
index bf7265f2ee60..04560558ad63 100644
--- a/static/app/components/droppedData/drawer/droppedDataOutcomeList.spec.tsx
+++ b/static/app/components/droppedData/drawer/droppedDataOutcomeList.spec.tsx
@@ -10,17 +10,17 @@ import type {DroppedEventsBucket} from 'sentry/components/droppedData/types';
function OutcomeList({
droppedEvents,
acceptedEvents,
- onInvestigate,
+ onTroubleshoot,
}: {
acceptedEvents: DroppedEventsBucket[];
droppedEvents: DroppedEventsBucket[];
- onInvestigate?: (row: ReasonRow) => void;
+ onTroubleshoot?: (row: ReasonRow) => void;
}) {
return (
);
}
@@ -94,7 +94,7 @@ describe('DroppedDataOutcomeList', () => {
expect(screen.queryByText(/ of /)).not.toBeInTheDocument();
});
- it('renders the short description under the reason title', () => {
+ it('shows the short description when hovering the reason title', async () => {
render(
{
/>
);
- expect(
- screen.getByText("User agent matched Sentry's known crawler list.")
- ).toBeInTheDocument();
+ const description = "User agent matched Sentry's known crawler list.";
+ expect(screen.queryByText(description)).not.toBeInTheDocument();
+
+ await userEvent.hover(screen.getByText('Web crawler filter'));
+
+ expect(await screen.findByText(description)).toBeInTheDocument();
});
- it('fills the data type into the description from the event category', () => {
+ it('fills the data type into the description from the event category', async () => {
render(
{
/>
);
+ await userEvent.hover(screen.getByText('Project abuse limit'));
+
expect(
- screen.getByText('Your log events exceeded the project abuse limit.')
+ await screen.findByText('Your log events exceeded the project abuse limit.')
).toBeInTheDocument();
});
@@ -192,7 +197,8 @@ describe('DroppedDataOutcomeList', () => {
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
- it('reveals the fix options under a reason row via its chevron', async () => {
+ it('opens the reason actions menu with docs and troubleshoot entries', async () => {
+ const onTroubleshoot = jest.fn();
render(
{
}),
]}
acceptedEvents={[]}
+ onTroubleshoot={onTroubleshoot}
/>
);
- // The fix-this affordance is hidden until the reason chevron is clicked.
- expect(screen.queryByRole('button', {name: 'Learn more'})).not.toBeInTheDocument();
-
- await userEvent.click(screen.getByRole('button', {name: 'Toggle fix options'}));
-
- expect(screen.getByRole('button', {name: 'Learn more'})).toBeInTheDocument();
-
- // Collapsing hides it again.
- await userEvent.click(screen.getByRole('button', {name: 'Toggle fix options'}));
- expect(screen.queryByRole('button', {name: 'Learn more'})).not.toBeInTheDocument();
-
- // The toggle is reachable from the keyboard.
- act(() => screen.getByRole('button', {name: 'Toggle fix options'}).focus());
- await userEvent.keyboard('{Enter}');
- expect(screen.getByRole('button', {name: 'Learn more'})).toBeInTheDocument();
- });
-
- it('shows the long description when a reason row is expanded', async () => {
- render(
-
- );
-
- const longDescription =
- 'The event reached Sentry but was rejected with an error code.';
- expect(screen.queryByText(longDescription)).not.toBeInTheDocument();
-
- await userEvent.click(screen.getByRole('button', {name: 'Toggle fix options'}));
-
- expect(screen.getByText(longDescription)).toBeInTheDocument();
- });
-
- it('opens the fix-this menu with investigate, settings, and docs entries', async () => {
- const onInvestigate = jest.fn();
- render(
-
- );
-
- await userEvent.click(screen.getByRole('button', {name: 'Toggle fix options'}));
- await userEvent.click(screen.getByRole('button', {name: 'Learn more'}));
-
- // Project settings deep-links to the usage stats page scoped to the
- // reason's data category.
- expect(screen.getByRole('menuitemradio', {name: 'Project Settings'})).toHaveAttribute(
- 'href',
- expect.stringContaining('/stats/?dataCategory=spans')
- );
+ await userEvent.click(screen.getByRole('button', {name: 'Reason actions'}));
+ expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)).toEqual([
+ 'Troubleshoot',
+ 'Go to Docs',
+ ]);
expect(screen.getByRole('menuitemradio', {name: 'Go to Docs'})).toHaveAttribute(
'href',
expect.stringContaining('docs.sentry.io')
);
- await userEvent.click(screen.getByRole('menuitemradio', {name: 'Investigate'}));
- expect(onInvestigate).toHaveBeenCalledWith(
+ await userEvent.click(screen.getByRole('menuitemradio', {name: 'Troubleshoot'}));
+ expect(onTroubleshoot).toHaveBeenCalledWith(
expect.objectContaining({outcome: 'rate_limited', reason: 'smart_rate_limit'})
);
});
- it('hides investigate when there is no Seer hand-off', async () => {
+ it('hides troubleshoot when there is no Seer hand-off', async () => {
render(
{
/>
);
- await userEvent.click(screen.getByRole('button', {name: 'Toggle fix options'}));
- await userEvent.click(screen.getByRole('button', {name: 'Learn more'}));
+ await userEvent.click(screen.getByRole('button', {name: 'Reason actions'}));
+ expect(screen.getByRole('menuitemradio', {name: 'Go to Docs'})).toBeInTheDocument();
expect(
- screen.getByRole('menuitemradio', {name: 'Project Settings'})
- ).toBeInTheDocument();
- expect(
- screen.queryByRole('menuitemradio', {name: 'Investigate'})
+ screen.queryByRole('menuitemradio', {name: 'Troubleshoot'})
).not.toBeInTheDocument();
});
});
diff --git a/static/app/components/droppedData/drawer/droppedDataOutcomeList.tsx b/static/app/components/droppedData/drawer/droppedDataOutcomeList.tsx
index dac291468223..476c63b71a65 100644
--- a/static/app/components/droppedData/drawer/droppedDataOutcomeList.tsx
+++ b/static/app/components/droppedData/drawer/droppedDataOutcomeList.tsx
@@ -1,9 +1,11 @@
-import {Fragment, useId, useState} from 'react';
+import {useId, useState} from 'react';
import {IconChevron} from '@sentry/icons/chevron';
+import {IconEllipsis} from '@sentry/icons/ellipsis';
import {Button} from '@sentry/scraps/button';
import {useDrawer, useDrawerContentContext} from '@sentry/scraps/drawer';
import {DropdownMenu, type MenuItemProps} from '@sentry/scraps/dropdownMenu';
+import {InfoText} from '@sentry/scraps/info';
import {Flex, Grid, Stack} from '@sentry/scraps/layout';
import {OverlayTrigger} from '@sentry/scraps/overlayTrigger';
import type {TableColumnConfig} from '@sentry/scraps/table';
@@ -15,63 +17,47 @@ import type {
ReasonRow,
} from 'sentry/components/droppedData/drawer/outcomeSections';
import {
- dataCategoryInfo,
formatDroppedShare,
reasonDescription,
- reasonLongDescription,
reasonTitle,
type OutcomeColors,
} from 'sentry/components/droppedData/outcomes';
import {SimpleTable} from 'sentry/components/tables/simpleTable';
import {t} from 'sentry/locale';
-import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
-import {useOrganization} from 'sentry/utils/useOrganization';
-const COLUMN_WIDTHS = {reason: '1fr', dropped: '160px', share: '96px', toggle: '48px'};
+const COLUMN_WIDTHS = {reason: '1fr', dropped: '160px', share: '96px', actions: '48px'};
const TABLE_COLUMNS: TableColumnConfig[] = Object.entries(COLUMN_WIDTHS).map(
([key, width]) => ({key, width})
);
const HEADER_COLUMNS = Object.values(COLUMN_WIDTHS).join(' ');
-function FixThisMenu({
+function ReasonActionsMenu({
row,
- onInvestigate,
+ onTroubleshoot,
}: {
row: ReasonRow;
- onInvestigate?: (row: ReasonRow) => void;
+ onTroubleshoot?: (row: ReasonRow) => void;
}) {
- const organization = useOrganization();
const {onClose: closeDroppedDataDrawer} = useDrawerContentContext();
const {panelRef} = useDrawer();
- const info = dataCategoryInfo(row.category);
-
- const settingsTo = normalizeUrl(
- `/settings/${organization.slug}/stats/${info ? `?dataCategory=${info.plural}` : ''}`
- );
-
// TODO: this will be a dynamic URL once the per-reason docs page
// fully up-to-date.
const docsHref = 'https://docs.sentry.io/';
const items: MenuItemProps[] = [
- ...(onInvestigate
+ ...(onTroubleshoot
? [
{
- key: 'investigate',
- label: t('Investigate'),
+ key: 'troubleshoot',
+ label: t('Troubleshoot'),
onAction: () => {
closeDroppedDataDrawer?.();
- onInvestigate(row);
+ onTroubleshoot(row);
},
},
]
: []),
- {
- key: 'project-settings',
- label: t('Project Settings'),
- to: settingsTo,
- },
{
key: 'docs',
label: t('Go to Docs'),
@@ -86,89 +72,47 @@ function FixThisMenu({
usePortal
portalContainerRef={panelRef}
trigger={(triggerProps, isOpen) => (
- }
+ aria-label={t('Reason actions')}
aria-expanded={isOpen}
- >
- {t('Learn more')}
-
+ />
)}
/>
);
}
-function ReasonExpansion({
- row,
- onInvestigate,
-}: {
- row: ReasonRow;
- onInvestigate?: (row: ReasonRow) => void;
-}) {
- const longDescription = reasonLongDescription(row.reason);
-
- return (
-
-
- {longDescription && {longDescription}}
-
-
-
- );
-}
-
function ReasonTableRow({
row,
- onInvestigate,
+ onTroubleshoot,
}: {
row: ReasonRow;
- onInvestigate?: (row: ReasonRow) => void;
+ onTroubleshoot?: (row: ReasonRow) => void;
}) {
- const [expanded, setExpanded] = useState(false);
- const description = reasonDescription(row.reason, row.category);
-
return (
-
-
-
- {reasonTitle(row.reason)}
- {description && (
-
- {description}
-
- )}
-
-
-
- {row.events.toLocaleString()}
-
-
-
-
- {formatDroppedShare(row.shareRatio)}
-
-
-
- }
- aria-label={t('Toggle fix options')}
- aria-expanded={expanded}
- onClick={() => setExpanded(value => !value)}
- />
-
-
- {expanded && }
-
+
+
+
+ {reasonTitle(row.reason)}
+
+
+
+
+ {row.events.toLocaleString()}
+
+
+
+
+ {formatDroppedShare(row.shareRatio)}
+
+
+
+
+
+
);
}
@@ -183,11 +127,11 @@ function ColumnHeading({children}: {children: React.ReactNode}) {
function ReasonTable({
id,
reasons,
- onInvestigate,
+ onTroubleshoot,
}: {
id: string;
reasons: ReasonRow[];
- onInvestigate?: (row: ReasonRow) => void;
+ onTroubleshoot?: (row: ReasonRow) => void;
}) {
return (
{t('Share')}
-
+
}
>
@@ -212,7 +156,7 @@ function ReasonTable({
))}
@@ -222,11 +166,11 @@ function ReasonTable({
function OutcomeSectionRow({
section,
color,
- onInvestigate,
+ onTroubleshoot,
}: {
color: string | undefined;
section: OutcomeSection;
- onInvestigate?: (row: ReasonRow) => void;
+ onTroubleshoot?: (row: ReasonRow) => void;
}) {
const [expanded, setExpanded] = useState(true);
const reasonTableId = useId();
@@ -270,7 +214,7 @@ function OutcomeSectionRow({
)}
@@ -280,13 +224,13 @@ function OutcomeSectionRow({
interface DroppedDataOutcomeListProps {
colors: OutcomeColors;
sections: OutcomeSection[];
- onInvestigate?: (row: ReasonRow) => void;
+ onTroubleshoot?: (row: ReasonRow) => void;
}
export function DroppedDataOutcomeList({
sections,
colors,
- onInvestigate,
+ onTroubleshoot,
}: DroppedDataOutcomeListProps) {
return (
@@ -295,7 +239,7 @@ export function DroppedDataOutcomeList({
key={section.outcome}
section={section}
color={colors[section.outcome]}
- onInvestigate={onInvestigate}
+ onTroubleshoot={onTroubleshoot}
/>
))}
diff --git a/static/app/components/droppedData/drawer/useDroppedDataDrawer.spec.tsx b/static/app/components/droppedData/drawer/useDroppedDataDrawer.spec.tsx
index 9a26f2fa29df..6b7cf191ff99 100644
--- a/static/app/components/droppedData/drawer/useDroppedDataDrawer.spec.tsx
+++ b/static/app/components/droppedData/drawer/useDroppedDataDrawer.spec.tsx
@@ -323,7 +323,7 @@ describe('useDroppedDataDrawer', () => {
});
});
- it('hands off to Seer Explorer from a reason’s investigate action', async () => {
+ it('hands off to Seer Explorer from a reason’s troubleshoot action', async () => {
const seerOrganization = OrganizationFixture({
openMembership: true,
hideAiFeatures: false,
@@ -372,9 +372,8 @@ describe('useDroppedDataDrawer', () => {
await userEvent.click(screen.getByRole('button', {name: 'Open dropped data'}));
expect(await screen.findByText('10 Dropped Events')).toBeInTheDocument();
- await userEvent.click(screen.getByRole('button', {name: 'Toggle fix options'}));
- await userEvent.click(screen.getByRole('button', {name: 'Learn more'}));
- await userEvent.click(screen.getByRole('menuitemradio', {name: 'Investigate'}));
+ await userEvent.click(screen.getByRole('button', {name: 'Reason actions'}));
+ await userEvent.click(screen.getByRole('menuitemradio', {name: 'Troubleshoot'}));
expect(
await screen.findByRole('complementary', {name: 'Seer Explorer Drawer'})
diff --git a/static/app/components/droppedData/drawer/useDroppedDataDrawer.tsx b/static/app/components/droppedData/drawer/useDroppedDataDrawer.tsx
index 177aa0198b04..af25d889eb3a 100644
--- a/static/app/components/droppedData/drawer/useDroppedDataDrawer.tsx
+++ b/static/app/components/droppedData/drawer/useDroppedDataDrawer.tsx
@@ -5,7 +5,7 @@ import {useDrawer} from '@sentry/scraps/drawer';
import {DroppedDataDrawer} from 'sentry/components/droppedData/drawer/droppedDataDrawer';
import type {ReasonRow} from 'sentry/components/droppedData/drawer/outcomeSections';
-import {buildInvestigatePrompt} from 'sentry/components/droppedData/outcomes';
+import {buildTroubleshootPrompt} from 'sentry/components/droppedData/outcomes';
import {t} from 'sentry/locale';
import type {DiscoverDatasets} from 'sentry/utils/discover/types';
import {decodeScalar} from 'sentry/utils/queryString';
@@ -43,10 +43,10 @@ export function useDroppedDataDrawer(
useEffect(() => {
openSeerExplorerRef.current = openSeerExplorer;
});
- const handleInvestigate = useCallback(
+ const handleTroubleshoot = useCallback(
(row: ReasonRow) => {
openSeerExplorerRef.current({
- initialQuery: buildInvestigatePrompt({
+ initialQuery: buildTroubleshootPrompt({
dataset,
outcome: row.outcome,
reason: row.reason,
@@ -56,8 +56,8 @@ export function useDroppedDataDrawer(
},
[dataset]
);
- const onInvestigate = isSeerExplorerEnabled(organization)
- ? handleInvestigate
+ const onTroubleshoot = isSeerExplorerEnabled(organization)
+ ? handleTroubleshoot
: undefined;
useEffect(() => {
@@ -74,7 +74,7 @@ export function useDroppedDataDrawer(
),
{
@@ -95,7 +95,7 @@ export function useDroppedDataDrawer(
openDrawer,
dataset,
interval,
- onInvestigate,
+ onTroubleshoot,
pathname,
setIsDrawerInUrl,
]);
diff --git a/static/app/components/droppedData/outcomes.spec.tsx b/static/app/components/droppedData/outcomes.spec.tsx
index 46b2a885c545..250df75d7827 100644
--- a/static/app/components/droppedData/outcomes.spec.tsx
+++ b/static/app/components/droppedData/outcomes.spec.tsx
@@ -1,11 +1,6 @@
import {ThemeFixture} from 'sentry-fixture/theme';
-import {
- getOutcomeColors,
- reasonDescription,
- reasonLongDescription,
- reasonTitle,
-} from './outcomes';
+import {getOutcomeColors, reasonDescription, reasonTitle} from './outcomes';
describe('getOutcomeColors', () => {
it('assigns the same colors regardless of input order', () => {
@@ -94,21 +89,3 @@ describe('reasonDescription', () => {
);
});
});
-
-describe('reasonLongDescription', () => {
- it('returns the long description for a known reason', () => {
- expect(reasonLongDescription('send_error')).toBe(
- 'The event reached Sentry but was rejected with an error code.'
- );
- });
-
- it('describes category-prefixed quota reasons', () => {
- expect(reasonLongDescription('span_usage_exceeded')).toBe(
- reasonLongDescription('usage_exceeded')
- );
- });
-
- it('returns undefined for an unknown reason', () => {
- expect(reasonLongDescription('some_new_reason')).toBeUndefined();
- });
-});
diff --git a/static/app/components/droppedData/outcomes.tsx b/static/app/components/droppedData/outcomes.tsx
index 6a830273110e..8b3b47d9207c 100644
--- a/static/app/components/droppedData/outcomes.tsx
+++ b/static/app/components/droppedData/outcomes.tsx
@@ -117,99 +117,7 @@ const REASON_DESCRIPTIONS: Record<
: t('Your organization hit its quota for this event type.'),
};
-const REASON_LONG_DESCRIPTIONS: Record = {
- backpressure: t(
- 'Your SDK detected that it was under load and automatically reduced trace sampling to keep telemetry processing from putting additional pressure on your application. The discarded spans never left your application.'
- ),
- before_send: t(
- 'Your before send function or your ignore spans configuration omitted the event. If you think this is unintentional, double check your Sentry configuration.'
- ),
- buffer_overflow: t(
- 'A fixed-size in-memory buffer in the SDK reached its limit and dropped data. This is most often caused by a session replay exceeding its size cap.'
- ),
- ignore_spans: t(
- 'Spans matching the ignored spans in your SDK configuration were omitted before being sent to Sentry.'
- ),
- network_error: t(
- 'The request failed outright without receiving a response, often caused by ad blockers and browser extensions.'
- ),
- no_parent_span: t(
- 'The SDK only records some spans, such as outgoing requests, when a parent span is active. Without one, the span is never created, so no data was lost.'
- ),
- queue_overflow: t(
- 'Your application is generating events faster than the SDK can buffer and send them. The SDK allows a limited number of operations in flight at once; beyond that, new events are dropped immediately rather than queued.'
- ),
- ratelimit_backoff: t(
- 'Your SDK discarded data locally while backing off, usually because Sentry had earlier told it to pause sending that data type. The real cause is whatever triggered that original limit.'
- ),
- sample_rate: t('Events dropped normally due to your configured client sample rate.'),
- send_error: t('The event reached Sentry but was rejected with an error code.'),
- 'error-message': t(
- 'The event matched a custom glob provided in your error message filter settings.'
- ),
- 'filtered-transaction': t(
- "The transaction name matched Sentry's health check filter, which drops transactions for endpoints like /health, /healthz, /ping, and /ready. This filter is enabled by default."
- ),
- 'ip-address': t(
- 'The event matched a range of IP addresses provided in a custom filter rule.'
- ),
- 'legacy-browsers': t(
- 'The event was filtered because the reporting browser matched the legacy browsers inbound filter. This rule filters out events from older web browsers that are likely to provide broken telemetry.'
- ),
- localhost: t(
- "The event was filtered because it came from a local address such as localhost or 127.0.0.1. This rule is enabled in your project's inbound filter settings."
- ),
- 'release-version': t(
- 'The event matched a custom release filter rule provided in your settings.'
- ),
- 'web-crawlers': t(
- "Data was filtered because the reporting user agent matched Sentry's list of known web crawlers."
- ),
- cors: t(
- "Data was rejected because the request's Origin header, or its Referer header when there is no Origin, did not match any entry in your project's Allowed Domains. Requests that send neither header always pass."
- ),
- internal: t(
- "This indicates a bug or an infrastructure problem on Sentry's ingestion side. During healthy operation this should be zero."
- ),
- invalid_dsc: t(
- 'The trace ID provided in the trace header did not match the trace ID of the spans sent.'
- ),
- invalid_json: t('Sentry could not parse the event that was sent.'),
- invalid_transaction: t(
- 'The transaction was valid JSON but semantically could not be accepted.'
- ),
- missing_dsc: t('The envelope contained spans but no trace header.'),
- timestamp: t(
- "The event's timestamp was outside the range Sentry accepts, either older than the retention window or too far in the future. This is usually caused by an incorrect clock on the device that sent it."
- ),
- 'too_large:event': t('The event exceeded the maximum payload size.'),
- 'too_large:log': t('The log event exceeded the maximum payload size.'),
- 'too_large:span': t('The span event exceeded the maximum payload size.'),
- 'too_large:trace_metric': t(
- 'The application metric event exceeded the maximum payload size.'
- ),
- 'too_large:transaction': t(
- 'The transaction exceeded the maximum payload size. Transactions carry their spans inline, so payload size is usually driven by span count rather than by any single large field.'
- ),
- generic: t(
- "Your requests were throttled by Sentry's edge protection rather than by a quota. This applies to a single DSN key and to all data types, for the duration Sentry indicated in its Retry-After response."
- ),
- project_abuse_limit: t(
- "Your project exceeded Sentry's abuse limit for a data type. This is a rate limit measured over a short window, not your billing quota and not spike protection."
- ),
- smart_rate_limit: t(
- 'Spike protection detected a sudden increase in event volume for this project and began dropping events to stop the spike from consuming your quota.'
- ),
- usage_exceeded: t(
- "Your organization's quota for this data category was fully consumed and no pay-as-you-go budget remained."
- ),
-};
-
-export function reasonLongDescription(reason: string): string | undefined {
- return REASON_LONG_DESCRIPTIONS[normalizeReason(reason)];
-}
-
-export function dataCategoryInfo(category: string) {
+function dataCategoryInfo(category: string) {
return Object.values(DATA_CATEGORY_INFO).find(info => info.name === category);
}
@@ -220,7 +128,7 @@ export function reasonDescription(reason: string, category: string): string | un
: description;
}
-export function buildInvestigatePrompt(args: {
+export function buildTroubleshootPrompt(args: {
category: string;
dataset: DiscoverDatasets;
outcome: string;