diff --git a/static/app/components/performanceOnboarding/sidebar.tsx b/static/app/components/performanceOnboarding/sidebar.tsx index 8d10ce0ba9f913..cda4b218e270ad 100644 --- a/static/app/components/performanceOnboarding/sidebar.tsx +++ b/static/app/components/performanceOnboarding/sidebar.tsx @@ -33,11 +33,10 @@ import OnboardingDrawerStore, { import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator'; import type {Project} from 'sentry/types/project'; -import EventWaiter from 'sentry/utils/eventWaiter'; import useApi from 'sentry/utils/useApi'; +import {useEventWaiter} from 'sentry/utils/useEventWaiter'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; -import usePrevious from 'sentry/utils/usePrevious'; import useProjects from 'sentry/utils/useProjects'; import {filterProjects} from './utils'; @@ -233,15 +232,12 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { const organization = useOrganization(); const {isSelfHosted, urlPrefix} = useLegacyStore(ConfigStore); const copyEnabled = useCopySetupInstructionsEnabled(); - const [received, setReceived] = useState(false); - - const previousProject = usePrevious(currentProject); - - useEffect(() => { - if (previousProject.id !== currentProject.id) { - setReceived(false); - } - }, [previousProject.id, currentProject.id]); + const firstIssue = useEventWaiter({ + eventType: 'transaction', + organization, + project: currentProject, + }); + const received = !!firstIssue; const currentPlatform = currentProject.platform ? platforms.find(p => p.id === currentProject.platform) @@ -362,17 +358,7 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { ); })} - { - setReceived(true); - }} - > - {() => (received ? : )} - + {received ? : } ); } diff --git a/static/app/components/updatedEmptyState.tsx b/static/app/components/updatedEmptyState.tsx index 86b38d9a0b9af0..f6e38c71701757 100644 --- a/static/app/components/updatedEmptyState.tsx +++ b/static/app/components/updatedEmptyState.tsx @@ -33,9 +33,9 @@ import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator'; import type {PlatformIntegration, Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; -import EventWaiter from 'sentry/utils/eventWaiter'; import {decodeInteger} from 'sentry/utils/queryString'; import useApi from 'sentry/utils/useApi'; +import {useEventWaiter} from 'sentry/utils/useEventWaiter'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; @@ -56,32 +56,31 @@ export function SetupTitle({project}: {project: Project}) { function WaitingIndicator({project}: {project: Project}) { const organization = useOrganization(); + const firstIssue = useEventWaiter({ + eventType: 'error', + organization, + project, + }); + + if (!firstIssue) { + return ; + } return ( - - {({firstIssue}) => - firstIssue ? ( - - trackAnalytics('growth.onboarding_take_to_error', { - organization, - platform: project.platform, - }) - } - to={`/organizations/${organization.slug}/issues/${ - firstIssue && firstIssue !== true && 'id' in firstIssue - ? `${firstIssue.id}/` - : '' - }?referrer=onboarding-first-event-indicator`} - priority="primary" - > - {t('Take me to my error')} - - ) : ( - - ) + + trackAnalytics('growth.onboarding_take_to_error', { + organization, + platform: project.platform, + }) } - + to={`/organizations/${organization.slug}/issues/${ + firstIssue !== true && 'id' in firstIssue ? `${firstIssue.id}/` : '' + }?referrer=onboarding-first-event-indicator`} + priority="primary" + > + {t('Take me to my error')} + ); } diff --git a/static/app/utils/eventWaiter.spec.tsx b/static/app/utils/eventWaiter.spec.tsx deleted file mode 100644 index 0b32e5598e5452..00000000000000 --- a/static/app/utils/eventWaiter.spec.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {ProjectFixture} from 'sentry-fixture/project'; - -import {act, render} from 'sentry-test/reactTestingLibrary'; - -import EventWaiter from 'sentry/utils/eventWaiter'; - -jest.useFakeTimers(); - -describe('EventWaiter', () => { - it('waits for the first projet event', async () => { - const org = OrganizationFixture(); - const project = ProjectFixture({ - firstEvent: null, - }); - - // Start with a project *without* a first event - const projectApiMock = MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/`, - method: 'GET', - body: project, - }); - - const child = jest.fn().mockReturnValue(null); - - render( - - {child} - - ); - expect(child).toHaveBeenCalledWith({firstIssue: null}); - - // Add the first events and associated responses and tick the timer - project.firstEvent = '2019-05-01T00:00:00.000Z'; - - const events = [ - { - id: 1, - firstSeen: project.firstEvent, - }, - { - id: 2, - firstSeen: null, - }, - ]; - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/issues/`, - method: 'GET', - body: events, - }); - - child.mockClear(); - - // Advanced time for the first setInterval tick to occur - act(() => jest.advanceTimersByTime(1)); - - // We have to await *two* API calls. We could normally do this using tick(), - // however since we have enabled fake timers, we cannot tick. - await act(() => Promise.resolve()); - await act(() => Promise.resolve()); - - expect(child).toHaveBeenCalledWith({firstIssue: events[0]}); - - // Check that the polling has stopped - projectApiMock.mockClear(); - - act(() => jest.advanceTimersByTime(10)); - expect(projectApiMock).not.toHaveBeenCalled(); - }); - - it('receives a first event of `true` when first even has expired', async () => { - const org = OrganizationFixture(); - const project = ProjectFixture({ - firstEvent: '2019-05-01T00:00:00.000Z', - }); - - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/`, - method: 'GET', - body: project, - }); - - // No events to list - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/issues/`, - method: 'GET', - body: [], - }); - - const child = jest.fn().mockReturnValue(null); - - render( - - {child} - - ); - - // We have to await *two* API calls. We could normally do this using tick(), - // however since we have enabled fake timers, we cannot tick. - await act(() => Promise.resolve()); - await act(() => Promise.resolve()); - - expect(child).toHaveBeenCalledWith({firstIssue: true}); - }); - - it('does not poll when disabled', () => { - const org = OrganizationFixture(); - const project = ProjectFixture(); - - const projectApiMock = MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/`, - method: 'GET', - body: project, - }); - - // No events to list - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/issues/`, - method: 'GET', - body: [], - }); - - const child = jest.fn().mockReturnValue(null); - - render( - - {child} - - ); - - expect(child).toHaveBeenCalledWith({firstIssue: null}); - - // Ensure we do not call it again - projectApiMock.mockClear(); - jest.advanceTimersByTime(10); - expect(projectApiMock).not.toHaveBeenCalled(); - }); -}); diff --git a/static/app/utils/eventWaiter.tsx b/static/app/utils/eventWaiter.tsx deleted file mode 100644 index 4b980d85842641..00000000000000 --- a/static/app/utils/eventWaiter.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import {Component} from 'react'; -import * as Sentry from '@sentry/react'; - -import type {Client} from 'sentry/api'; -import type {Group} from 'sentry/types/group'; -import type {Organization} from 'sentry/types/organization'; -import type {Project} from 'sentry/types/project'; -import withApi from 'sentry/utils/withApi'; - -const DEFAULT_POLL_INTERVAL = 5000; - -/** - * When no event has been received this will be set to null or false. - * Otherwise it will be the Group of the issue that was received. - * Or in the case of transactions & replay the value will be set to true. - * The `group.id` value is used to generate links directly into the event. - */ -type FirstIssue = null | boolean | Group; - -interface EventWaiterProps { - api: Client; - children: (props: {firstIssue: FirstIssue}) => React.ReactNode; - eventType: 'error' | 'transaction' | 'replay' | 'profile' | 'log'; - organization: Organization; - project: Project; - disabled?: boolean; - onIssueReceived?: (props: {firstIssue: FirstIssue}) => void; - pollInterval?: number; -} - -type EventWaiterState = { - firstIssue: FirstIssue; -}; - -function getFirstEvent(eventType: EventWaiterProps['eventType'], resp: Project) { - switch (eventType) { - case 'error': - return resp.firstEvent; - case 'transaction': - return resp.firstTransactionEvent; - case 'replay': - return resp.hasReplays; - case 'profile': - return resp.hasProfiles; - case 'log': - return resp.hasLogs; - default: - return null; - } -} - -/** - * This is a render prop component that can be used to wait for the first event - * of a project to be received via polling. - */ -class EventWaiter extends Component { - state: EventWaiterState = { - firstIssue: null, - }; - - componentDidMount() { - this.pollHandler(); - this.startPolling(); - } - - componentDidUpdate() { - this.stopPolling(); - this.startPolling(); - } - - componentWillUnmount() { - this.stopPolling(); - } - - pollingInterval: number | null = null; - - pollHandler = async () => { - const {api, organization, project, eventType, onIssueReceived} = this.props; - let firstEvent: string | boolean | null = null; - let firstIssue: Group | boolean | null = null; - - try { - const resp = await api.requestPromise( - `/projects/${organization.slug}/${project.slug}/` - ); - firstEvent = getFirstEvent(eventType, resp); - } catch (resp: any) { - if (!resp) { - return; - } - - // This means org or project does not exist, we need to stop polling - // Also stop polling on auth-related errors (403/401) - if ([404, 403, 401, 0].includes(resp.status)) { - // TODO: Add some UX around this... redirect? error message? - this.stopPolling(); - return; - } - - Sentry.setExtras({ - status: resp.status, - detail: resp.responseJSON?.detail, - }); - Sentry.captureException(new Error(`Error polling for first ${eventType} event`)); - } - - if (firstEvent === null || firstEvent === false) { - return; - } - - if (eventType === 'error') { - // Locate the projects first issue group. The project.firstEvent field will - // *not* include sample events, while just looking at the issues list will. - // We will wait until the project.firstEvent is set and then locate the - // event given that event datetime - const issues: Group[] = await api.requestPromise( - `/projects/${organization.slug}/${project.slug}/issues/` - ); - - // The event may have expired, default to true - firstIssue = issues.find((issue: Group) => issue.firstSeen === firstEvent) || true; - } else if (eventType === 'transaction') { - firstIssue = Boolean(firstEvent); - } else if (eventType === 'replay') { - firstIssue = Boolean(firstEvent); - } else if (eventType === 'profile') { - firstIssue = Boolean(firstEvent); - } else if (eventType === 'log') { - firstIssue = Boolean(firstEvent); - } - - if (onIssueReceived) { - onIssueReceived({firstIssue}); - } - - this.stopPolling(); - this.setState({firstIssue}); - }; - - startPolling() { - const {disabled, organization, project} = this.props; - - if (disabled || !organization || !project || this.state.firstIssue) { - return; - } - - // Proactively clear interval just in case stopPolling was not called - if (this.pollingInterval) { - window.clearInterval(this.pollingInterval); - } - - this.pollingInterval = window.setInterval( - this.pollHandler, - this.props.pollInterval || DEFAULT_POLL_INTERVAL - ); - } - - stopPolling() { - if (this.pollingInterval) { - clearInterval(this.pollingInterval); - } - } - - render() { - return this.props.children({firstIssue: this.state.firstIssue}); - } -} - -export default withApi(EventWaiter); diff --git a/static/app/utils/useEventWaiter.spec.tsx b/static/app/utils/useEventWaiter.spec.tsx new file mode 100644 index 00000000000000..bb681b0a62d887 --- /dev/null +++ b/static/app/utils/useEventWaiter.spec.tsx @@ -0,0 +1,188 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {useEventWaiter} from 'sentry/utils/useEventWaiter'; + +describe('useEventWaiter', () => { + it('waits for the first project event and resolves the matching issue', async () => { + const org = OrganizationFixture(); + const project = ProjectFixture({firstEvent: null}); + + // Start with a project *without* a first event + const projectApiMock = MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: project, + }); + + const {result} = renderHookWithProviders( + () => + useEventWaiter({ + eventType: 'error', + organization: org, + project, + pollInterval: 100, + }), + {organization: org} + ); + + // Initially null + expect(result.current).toBeNull(); + + // Simulate first event arriving on subsequent poll + const events = [ + {id: 1, firstSeen: '2019-05-01T00:00:00.000Z'}, + {id: 2, firstSeen: null}, + ]; + + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: ProjectFixture({firstEvent: '2019-05-01T00:00:00.000Z'}), + }); + + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/issues/`, + method: 'GET', + body: events, + }); + + // Wait for the hook to resolve the first issue + await waitFor(() => { + expect(result.current).toEqual(events[0]); + }); + + // Verify polling stops after resolution + projectApiMock.mockClear(); + }); + + it('returns true when first event has expired (no matching issue)', async () => { + const org = OrganizationFixture(); + const project = ProjectFixture({firstEvent: '2019-05-01T00:00:00.000Z'}); + + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: project, + }); + + // No matching issues + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/issues/`, + method: 'GET', + body: [], + }); + + const {result} = renderHookWithProviders( + () => + useEventWaiter({ + eventType: 'error', + organization: org, + project, + pollInterval: 100, + }), + {organization: org} + ); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it('returns true for transaction events', async () => { + const org = OrganizationFixture(); + const project = ProjectFixture({firstTransactionEvent: true}); + + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: project, + }); + + const {result} = renderHookWithProviders( + () => + useEventWaiter({ + eventType: 'transaction', + organization: org, + project, + pollInterval: 100, + }), + {organization: org} + ); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it('does not poll when disabled', () => { + const org = OrganizationFixture(); + const project = ProjectFixture(); + + const projectApiMock = MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: project, + }); + + const {result} = renderHookWithProviders( + () => + useEventWaiter({ + eventType: 'error', + organization: org, + project, + disabled: true, + pollInterval: 100, + }), + {organization: org} + ); + + expect(result.current).toBeNull(); + expect(projectApiMock).not.toHaveBeenCalled(); + }); + + it('stops polling after first event is detected', async () => { + jest.useFakeTimers(); + + const org = OrganizationFixture(); + const project = ProjectFixture({firstEvent: null}); + + // API returns a project with firstTransactionEvent already set + const projectApiMock = MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/`, + method: 'GET', + body: ProjectFixture({firstTransactionEvent: true}), + }); + + const {result} = renderHookWithProviders( + () => + useEventWaiter({ + eventType: 'transaction', + organization: org, + project, + pollInterval: 100, + }), + {organization: org} + ); + + // Flush the initial fetch + await act(async () => { + await jest.advanceTimersByTimeAsync(1); + }); + + expect(result.current).toBe(true); + expect(projectApiMock).toHaveBeenCalledTimes(1); + + // Advance well past multiple poll intervals + await act(async () => { + await jest.advanceTimersByTimeAsync(1000); + }); + + // Polling should have stopped — no calls beyond the initial fetch + expect(projectApiMock).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); +}); diff --git a/static/app/utils/useEventWaiter.tsx b/static/app/utils/useEventWaiter.tsx new file mode 100644 index 00000000000000..03eb5aeb083b03 --- /dev/null +++ b/static/app/utils/useEventWaiter.tsx @@ -0,0 +1,157 @@ +import {useEffect} from 'react'; +import * as Sentry from '@sentry/react'; + +import type {Group} from 'sentry/types/group'; +import type {Organization} from 'sentry/types/organization'; +import type {Project} from 'sentry/types/project'; +import getApiUrl from 'sentry/utils/api/getApiUrl'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import RequestError from 'sentry/utils/requestError/requestError'; + +const DEFAULT_POLL_INTERVAL = 5000; + +/** + * When no event has been received this will be set to null or false. + * Otherwise it will be the Group of the issue that was received. + * Or in the case of transactions & replay the value will be set to true. + * The `group.id` value is used to generate links directly into the event. + */ +type FirstEvent = null | boolean | Group; + +type EventType = 'error' | 'transaction' | 'replay' | 'profile' | 'log'; + +interface UseEventWaiterOptions { + eventType: EventType; + organization: Organization; + project: Project; + disabled?: boolean; + pollInterval?: number; +} + +function getFirstEvent(eventType: EventType, resp: Project) { + switch (eventType) { + case 'error': + return resp.firstEvent; + case 'transaction': + return resp.firstTransactionEvent; + case 'replay': + return resp.hasReplays; + case 'profile': + return resp.hasProfiles; + case 'log': + return resp.hasLogs; + default: + return null; + } +} + +/** + * Hook that polls for the first event of a project. + * Returns null until the first event is detected, then returns the + * resolved FirstEvent (a Group for errors, or true for other event types). + * Once resolved, polling stops automatically. + */ +export function useEventWaiter({ + eventType, + organization, + project, + disabled, + pollInterval = DEFAULT_POLL_INTERVAL, +}: UseEventWaiterOptions): FirstEvent { + const shouldPoll = !disabled && !!organization && !!project; + + const projectUrl = getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/', { + path: { + organizationIdOrSlug: organization.slug, + projectIdOrSlug: project.slug, + }, + }); + + const issuesUrl = getApiUrl( + '/projects/$organizationIdOrSlug/$projectIdOrSlug/issues/', + { + path: { + organizationIdOrSlug: organization.slug, + projectIdOrSlug: project.slug, + }, + } + ); + + // Poll the project endpoint to detect when the first event arrives + const projectQuery = useApiQuery([projectUrl], { + refetchInterval: query => { + if (!shouldPoll) { + return false; + } + // Stop polling once the first event has been detected + if (query.state.data) { + const [projectData] = query.state.data; + if (getFirstEvent(eventType, projectData)) { + return false; + } + } + return pollInterval; + }, + enabled: shouldPoll, + staleTime: 0, + retry: (_, error) => { + if (error instanceof RequestError) { + // Stop retrying for auth/not-found errors + if (error.status && [401, 403, 404, 0].includes(error.status)) { + return false; + } + } + return true; + }, + }); + + const firstEvent = projectQuery.data + ? getFirstEvent(eventType, projectQuery.data) + : null; + + // For errors, fetch the first issue group once we know the firstEvent exists + const issuesQuery = useApiQuery([issuesUrl], { + enabled: eventType === 'error' && !!firstEvent, + staleTime: 0, + }); + + // Report errors to Sentry (matching original behavior) + useEffect(() => { + if (!projectQuery.error) { + return; + } + + const err = projectQuery.error; + if (err instanceof RequestError) { + if (err.status !== undefined && [401, 403, 404, 0].includes(err.status)) { + return; + } + + Sentry.setExtras({ + status: err.status, + detail: err.responseJSON?.detail, + }); + } + + Sentry.captureException( + new Error(`Error polling for first ${eventType} event`, {cause: err}) + ); + }, [projectQuery.error, eventType]); + + if (firstEvent === null || firstEvent === false) { + return null; + } + + if (eventType === 'error') { + if (!issuesQuery.data) { + return null; + } + // The event may have expired, default to true + return ( + issuesQuery.data.find((issue: Group) => issue.firstSeen === firstEvent) || true + ); + } + + // transaction, replay, profile, log + return Boolean(firstEvent); +} diff --git a/static/app/views/performance/onboarding.tsx b/static/app/views/performance/onboarding.tsx index af75e15f695d04..068ee564e26d67 100644 --- a/static/app/views/performance/onboarding.tsx +++ b/static/app/views/performance/onboarding.tsx @@ -1,4 +1,4 @@ -import {Fragment, useEffect, useState} from 'react'; +import {Fragment, useEffect} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; @@ -65,10 +65,10 @@ import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls'; -import EventWaiter from 'sentry/utils/eventWaiter'; import {decodeInteger} from 'sentry/utils/queryString'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import useApi from 'sentry/utils/useApi'; +import {useEventWaiter} from 'sentry/utils/useEventWaiter'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import useProjects from 'sentry/utils/useProjects'; @@ -420,7 +420,19 @@ export function Onboarding({organization, project}: OnboardingProps) { const navigate = useNavigate(); const {isSelfHosted, urlPrefix} = useLegacyStore(ConfigStore); const copyEnabled = useCopySetupInstructionsEnabled(); - const [received, setReceived] = useState(false); + + const doesNotSupportPerformance = project.platform + ? withoutPerformanceSupport.has(project.platform) + : false; + + const firstIssue = useEventWaiter({ + eventType: 'transaction', + organization, + project, + disabled: doesNotSupportPerformance, + }); + const received = !!firstIssue; + const isEAPTraceEnabled = organization.features.includes('trace-spans-format'); const tracesQuery = useTraces({ enabled: received, @@ -447,10 +459,6 @@ export function Onboarding({organization, project}: OnboardingProps) { const {isPending: isLoadingRegistry, data: registryData} = useSourcePackageRegistries(organization); - const doesNotSupportPerformance = project.platform - ? withoutPerformanceSupport.has(project.platform) - : false; - useEffect(() => { if (isLoading || !currentPlatform || !dsn || !projectKeyId) { return; @@ -565,20 +573,10 @@ export function Onboarding({organization, project}: OnboardingProps) { const steps = [...installSteps, ...configureSteps, ...verifySteps]; - const eventWaitingIndicator = ( - { - setReceived(true); - }} - > - {({firstIssue}) => - firstIssue ? : - } - + const eventWaitingIndicator = received ? ( + + ) : ( + ); return ( diff --git a/static/app/views/profiling/onboarding.tsx b/static/app/views/profiling/onboarding.tsx index 00b2ee7aab56ad..dbd0d2e606a845 100644 --- a/static/app/views/profiling/onboarding.tsx +++ b/static/app/views/profiling/onboarding.tsx @@ -39,11 +39,11 @@ import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator'; import {space} from 'sentry/styles/space'; import type {Project} from 'sentry/types/project'; -import EventWaiter from 'sentry/utils/eventWaiter'; import {useProfileEvents} from 'sentry/utils/profiling/hooks/useProfileEvents'; import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes'; import {getSelectedProjectList} from 'sentry/utils/project/useSelectedProjectsHaveField'; import useApi from 'sentry/utils/useApi'; +import {useEventWaiter} from 'sentry/utils/useEventWaiter'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; @@ -110,8 +110,13 @@ function StepRenderer({ }) { const theme = useTheme(); const {type, title} = step; - const api = useApi(); const organization = useOrganization(); + const firstIssue = useEventWaiter({ + eventType: 'profile', + organization, + project, + disabled: !isLastStep, + }); return ( - {isLastStep && ( - - {({firstIssue}) => ( - - )} - - )} + {isLastStep && } {/* This spacer ensures the whole pulse effect is visible, as the parent has overflow: hidden */} {isLastStep && }