Browse Source

feat(profiling): link to flamechart view with spanId query (#42352)

## Summary
Provides the `profileId` related to a transaction event to the whole
page for use in the `[Go to Profile]` btn and the `[View Profile]` btn
with a `?spanId=` querystring.

This featured is guarded by `organizations:profiling-flamechart-spans`.
- https://flagr.getsentry.net/#/flags/289


![image](https://user-images.githubusercontent.com/7349258/207947388-e903522d-6115-4701-8b29-67f16a2c164a.png)


Relates to: https://github.com/getsentry/team-profiling/issues/78
Elias Hussary 2 years ago
parent
commit
a146d721d2

+ 25 - 0
static/app/components/events/interfaces/spans/spanDetail.tsx

@@ -21,6 +21,8 @@ import {
 } from 'sentry/components/performance/waterfall/rowDetails';
 import Pill from 'sentry/components/pill';
 import Pills from 'sentry/components/pills';
+import {useTransactionProfileId} from 'sentry/components/profiling/transactionProfileIdProvider';
+import {TransactionToProfileButton} from 'sentry/components/profiling/transactionToProfileButton';
 import {
   generateIssueEventTarget,
   generateTraceTarget,
@@ -79,6 +81,7 @@ type Props = {
 function SpanDetail(props: Props) {
   const [errorsOpened, setErrorsOpened] = useState(false);
   const location = useLocation();
+  const profileId = useTransactionProfileId();
 
   useEffect(() => {
     // Run on mount.
@@ -366,6 +369,10 @@ function SpanDetail(props: Props) {
       value => value === 0
     );
 
+    const flamechartSpanFeatureEnabled = organization.features.includes(
+      'profiling-flamechart-spans'
+    );
+
     return (
       <Fragment>
         {renderOrphanSpanMessage()}
@@ -407,6 +414,24 @@ function SpanDetail(props: Props) {
               <Row title="Trace ID" extra={renderTraceButton()}>
                 {span.trace_id}
               </Row>
+              {flamechartSpanFeatureEnabled && profileId && event.projectSlug && (
+                <Row
+                  title="Profile ID"
+                  extra={
+                    <TransactionToProfileButton
+                      size="xs"
+                      projectSlug={event.projectSlug}
+                      query={{
+                        spanId: span.span_id,
+                      }}
+                    >
+                      {t('View Profile')}
+                    </TransactionToProfileButton>
+                  }
+                >
+                  {profileId}
+                </Row>
+              )}
               <Row title="Description" extra={renderViewSimilarSpansButton()}>
                 {span?.description ?? ''}
               </Row>

+ 11 - 6
static/app/components/events/interfaces/spans/traceView.spec.tsx

@@ -17,6 +17,7 @@ import * as AnchorLinkManager from 'sentry/components/events/interfaces/spans/sp
 import TraceView from 'sentry/components/events/interfaces/spans/traceView';
 import {spanTargetHash} from 'sentry/components/events/interfaces/spans/utils';
 import WaterfallModel from 'sentry/components/events/interfaces/spans/waterfallModel';
+import {TransactionProfileIdProvider} from 'sentry/components/profiling/transactionProfileIdProvider';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import {QuickTraceContext} from 'sentry/utils/performance/quickTrace/quickTraceContext';
 import QuickTraceQuery from 'sentry/utils/performance/quickTrace/quickTraceQuery';
@@ -415,9 +416,11 @@ describe('TraceView', () => {
       const waterfallModel = new WaterfallModel(builder.getEvent());
 
       render(
-        <AnchorLinkManager.Provider>
-          <TraceView organization={data.organization} waterfallModel={waterfallModel} />
-        </AnchorLinkManager.Provider>
+        <TransactionProfileIdProvider transactionId={undefined} timestamp={undefined}>
+          <AnchorLinkManager.Provider>
+            <TraceView organization={data.organization} waterfallModel={waterfallModel} />
+          </AnchorLinkManager.Provider>
+        </TransactionProfileIdProvider>
       );
 
       expect(await screen.findByText(/0000000000000003/i)).toBeInTheDocument();
@@ -441,9 +444,11 @@ describe('TraceView', () => {
       const waterfallModel = new WaterfallModel(builder.getEvent());
 
       render(
-        <AnchorLinkManager.Provider>
-          <TraceView organization={data.organization} waterfallModel={waterfallModel} />
-        </AnchorLinkManager.Provider>
+        <TransactionProfileIdProvider transactionId={undefined} timestamp={undefined}>
+          <AnchorLinkManager.Provider>
+            <TraceView organization={data.organization} waterfallModel={waterfallModel} />
+          </AnchorLinkManager.Provider>
+        </TransactionProfileIdProvider>
       );
 
       expect(await screen.findByText(/0000000000000003/i)).toBeInTheDocument();

+ 78 - 0
static/app/components/profiling/transactionProfileIdProvider.tsx

@@ -0,0 +1,78 @@
+import {createContext, useContext, useEffect, useMemo} from 'react';
+import * as Sentry from '@sentry/react';
+
+import {PageFilters} from 'sentry/types';
+import {useProfileEvents} from 'sentry/utils/profiling/hooks/useProfileEvents';
+
+const TransactionProfileContext = createContext<string | null | undefined>(undefined);
+
+interface TransactionToProfileIdProviderProps {
+  children: React.ReactNode;
+  timestamp: string | undefined;
+  transactionId: string | undefined;
+}
+
+export function TransactionProfileIdProvider({
+  timestamp,
+  transactionId,
+  children,
+}: TransactionToProfileIdProviderProps) {
+  // create a 24h timeframe relative from the transaction timestamp to use for
+  // the profile events query
+  const datetime: PageFilters['datetime'] | undefined = useMemo(() => {
+    if (!timestamp) {
+      return undefined;
+    }
+    const ts = new Date(timestamp);
+    const start = new Date(new Date(ts).setHours(ts.getHours() - 12));
+    const end = new Date(new Date(ts).setHours(ts.getHours() + 12));
+
+    return {
+      start,
+      end,
+      period: null,
+      utc: true,
+    };
+  }, [timestamp]);
+
+  const {status, data, error} = useProfileEvents({
+    fields: ['id'],
+    referrer: 'transactionToProfileProvider',
+    limit: 1,
+    sort: {
+      key: 'id',
+      order: 'asc',
+    },
+    query: `trace.transaction:${transactionId}`,
+    enabled: Boolean(transactionId),
+    datetime,
+  });
+
+  useEffect(() => {
+    if (status !== 'error') {
+      return;
+    }
+
+    if (error.status !== 404) {
+      Sentry.captureException(error);
+    }
+  }, [status, error]);
+
+  const profileId = (data?.[0].data[0]?.id as string | undefined) ?? null;
+
+  return (
+    <TransactionProfileContext.Provider value={profileId}>
+      {children}
+    </TransactionProfileContext.Provider>
+  );
+}
+TransactionProfileIdProvider.Context = TransactionProfileContext;
+
+export function useTransactionProfileId() {
+  const ctx = useContext(TransactionProfileContext);
+  if (typeof ctx === 'undefined') {
+    throw new Error(`useTransactionProfile called outside of TransactionProfileProvider`);
+  }
+
+  return ctx;
+}

+ 24 - 55
static/app/components/profiling/transactionToProfileButton.tsx

@@ -1,44 +1,30 @@
-import {useEffect, useState} from 'react';
-import * as Sentry from '@sentry/react';
+import {Location} from 'history';
 
-import {Client} from 'sentry/api';
-import Button from 'sentry/components/button';
+import Button, {ButtonProps} from 'sentry/components/button';
 import {t} from 'sentry/locale';
-import {RequestState} from 'sentry/types/core';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
-import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
-import useApi from 'sentry/utils/useApi';
+import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes';
 import useOrganization from 'sentry/utils/useOrganization';
 
+import {useTransactionProfileId} from './transactionProfileIdProvider';
+
 interface Props {
-  orgId: string;
-  projectId: string;
-  transactionId: string;
+  projectSlug: string;
+  children?: React.ReactNode;
+  query?: Location['query'];
+  size?: ButtonProps['size'];
 }
 
-function TransactionToProfileButton({transactionId, orgId, projectId}: Props) {
-  const api = useApi();
+function TransactionToProfileButton({
+  projectSlug,
+  query,
+  children = t('Go to Profile'),
+  size = 'sm',
+}: Props) {
+  const profileId = useTransactionProfileId();
   const organization = useOrganization();
 
-  const [profileIdState, setProfileIdState] = useState<RequestState<string>>({
-    type: 'initial',
-  });
-
-  useEffect(() => {
-    fetchProfileId(api, transactionId, orgId, projectId)
-      .then((profileId: ProfileId) => {
-        setProfileIdState({type: 'resolved', data: profileId.profile_id});
-      })
-      .catch(err => {
-        // If there isn't a matching profile, we get a 404. No need to raise an error
-        // in this case, but we should otherwise.
-        if (err.status !== 404) {
-          Sentry.captureException(err);
-        }
-      });
-  }, [api, transactionId, orgId, projectId]);
-
-  if (profileIdState.type !== 'resolved') {
+  if (!profileId) {
     return null;
   }
 
@@ -49,35 +35,18 @@ function TransactionToProfileButton({transactionId, orgId, projectId}: Props) {
     });
   }
 
-  const target = generateProfileFlamechartRoute({
-    orgSlug: orgId,
-    projectSlug: projectId,
-    profileId: profileIdState.data,
+  const target = generateProfileFlamechartRouteWithQuery({
+    orgSlug: organization.slug,
+    projectSlug,
+    profileId,
+    query,
   });
 
   return (
-    <Button size="sm" onClick={handleGoToProfile} to={target}>
-      {t('Go to Profile')}
+    <Button size={size} onClick={handleGoToProfile} to={target}>
+      {children}
     </Button>
   );
 }
 
-type ProfileId = {
-  profile_id: string;
-};
-
-function fetchProfileId(
-  api: Client,
-  transactionId: string,
-  orgId: string,
-  projectId: string
-): Promise<ProfileId> {
-  return api.requestPromise(
-    `/projects/${orgId}/${projectId}/profiling/transactions/${transactionId}/`,
-    {
-      method: 'GET',
-    }
-  );
-}
-
 export {TransactionToProfileButton};

+ 106 - 0
static/app/components/profiling/transactonProfileIdProvider.spec.tsx

@@ -0,0 +1,106 @@
+import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import * as useProfileEventsModule from 'sentry/utils/profiling/hooks/useProfileEvents';
+import * as useApiModule from 'sentry/utils/useApi';
+
+import * as TransactionProfileIdProviderModule from './transactionProfileIdProvider';
+
+const useApiSpy = jest.spyOn(useApiModule, 'default');
+
+// this order matters; create the spy before dereferencing below
+const useTransactionProfileIdSpy = jest.spyOn(
+  TransactionProfileIdProviderModule,
+  'useTransactionProfileId'
+);
+
+const {TransactionProfileIdProvider, useTransactionProfileId} =
+  TransactionProfileIdProviderModule;
+
+const useProfileEventsSpy = jest.spyOn(useProfileEventsModule, 'useProfileEvents');
+
+function MockComponent() {
+  const profileId = useTransactionProfileId();
+  return <div data-test-id={profileId} />;
+}
+
+const MOCK_TRX_ID = '123';
+const MOCK_PROFILE_ID = '456';
+
+describe('TransactionProfileIdProvider', () => {
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+  it('provides default profileId state as null', () => {
+    render(
+      <TransactionProfileIdProvider transactionId={undefined} timestamp={undefined}>
+        <MockComponent />
+      </TransactionProfileIdProvider>
+    );
+
+    expect(useProfileEventsSpy).toHaveBeenCalledWith(
+      expect.objectContaining({
+        enabled: false,
+      })
+    );
+    expect(useTransactionProfileIdSpy).toHaveReturnedWith(null);
+  });
+
+  it('does not query the events endpoint when transactionId is undefined', () => {
+    const requestPromiseMock = jest.fn();
+    // @ts-ignore
+    useApiSpy.mockReturnValueOnce({
+      requestPromise: requestPromiseMock,
+    });
+    render(
+      <TransactionProfileIdProvider transactionId={undefined} timestamp={undefined}>
+        <MockComponent />
+      </TransactionProfileIdProvider>
+    );
+
+    expect(useProfileEventsSpy).toHaveBeenCalledWith(
+      expect.objectContaining({
+        enabled: false,
+      })
+    );
+    expect(requestPromiseMock).not.toHaveBeenCalled();
+    expect(useTransactionProfileIdSpy).toHaveReturnedWith(null);
+  });
+
+  it('queries the events endpoint for a profile id when given a transactionId', async () => {
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/events/',
+      body: {
+        data: [
+          {
+            id: MOCK_PROFILE_ID,
+          },
+        ],
+      },
+    });
+
+    render(
+      <TransactionProfileIdProvider
+        transactionId={MOCK_TRX_ID}
+        timestamp="2022-12-19T16:00:00.000Z"
+      >
+        <MockComponent />
+      </TransactionProfileIdProvider>
+    );
+
+    await waitFor(() => screen.findAllByTestId(MOCK_PROFILE_ID));
+
+    expect(useProfileEventsSpy).toHaveBeenCalledWith(
+      expect.objectContaining({
+        query: 'trace.transaction:' + MOCK_TRX_ID,
+        datetime: {
+          end: new Date('2022-12-20T04:00:00.000Z'),
+          period: null,
+          start: new Date('2022-12-19T04:00:00.000Z'),
+          utc: true,
+        },
+      })
+    );
+    expect(useTransactionProfileIdSpy).toHaveReturnedWith(MOCK_PROFILE_ID);
+  });
+});

+ 12 - 2
static/app/utils/profiling/hooks/useProfileEvents.tsx

@@ -3,9 +3,11 @@ import {useQuery} from '@tanstack/react-query';
 import {ResponseMeta} from 'sentry/api';
 import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import {t} from 'sentry/locale';
+import {PageFilters} from 'sentry/types';
 import {defined} from 'sentry/utils';
 import {DURATION_UNITS, SIZE_UNITS} from 'sentry/utils/discover/fieldRenderers';
 import {FieldValueType} from 'sentry/utils/fields';
+import RequestError from 'sentry/utils/requestError/requestError';
 import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
@@ -20,6 +22,8 @@ interface UseProfileEventsOptions<F> {
   referrer: string;
   sort: Sort<F>;
   cursor?: string;
+  datetime?: PageFilters['datetime'];
+  enabled?: boolean;
   limit?: number;
   query?: string;
 }
@@ -47,6 +51,8 @@ export function useProfileEvents<F extends string>({
   query,
   sort,
   cursor,
+  enabled = true,
+  datetime,
 }: UseProfileEventsOptions<F>) {
   const api = useApi();
   const organization = useOrganization();
@@ -59,7 +65,7 @@ export function useProfileEvents<F extends string>({
       referrer,
       project: selection.projects,
       environment: selection.environments,
-      ...normalizeDateTimeParams(selection.datetime),
+      ...normalizeDateTimeParams(datetime ?? selection.datetime),
       field: fields,
       per_page: limit,
       query,
@@ -77,11 +83,15 @@ export function useProfileEvents<F extends string>({
       query: endpointOptions.query,
     });
 
-  return useQuery<[EventsResults<F>, string | undefined, ResponseMeta | undefined]>({
+  return useQuery<
+    [EventsResults<F>, string | undefined, ResponseMeta | undefined],
+    RequestError
+  >({
     queryKey,
     queryFn,
     refetchOnWindowFocus: false,
     retry: false,
+    enabled,
   });
 }
 

+ 7 - 8
static/app/views/eventsV2/eventDetails/content.tsx

@@ -1,4 +1,3 @@
-import {Fragment} from 'react';
 import {RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
 
@@ -17,6 +16,7 @@ import FileSize from 'sentry/components/fileSize';
 import * as Layout from 'sentry/components/layouts/thirds';
 import LoadingError from 'sentry/components/loadingError';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {TransactionProfileIdProvider} from 'sentry/components/profiling/transactionProfileIdProvider';
 import {TransactionToProfileButton} from 'sentry/components/profiling/transactionToProfileButton';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {TagsTable} from 'sentry/components/tagsTable';
@@ -152,7 +152,10 @@ class EventDetailsContent extends AsyncComponent<Props, State> {
       results?: QuickTraceQueryChildrenProps,
       metaResults?: TraceMetaQueryChildrenProps
     ) => (
-      <Fragment>
+      <TransactionProfileIdProvider
+        transactionId={event.type === 'transaction' ? event.id : undefined}
+        timestamp={event.dateReceived}
+      >
         <Layout.Header>
           <Layout.HeaderContent>
             <DiscoverBreadcrumb
@@ -186,11 +189,7 @@ class EventDetailsContent extends AsyncComponent<Props, State> {
                 {t('JSON')} (<FileSize bytes={event.size} />)
               </Button>
               {hasProfilingFeature && event.type === 'transaction' && (
-                <TransactionToProfileButton
-                  orgId={organization.slug}
-                  projectId={this.projectId}
-                  transactionId={event.eventID}
-                />
+                <TransactionToProfileButton projectSlug={this.projectId} />
               )}
               {transactionSummaryTarget && (
                 <Feature organization={organization} features={['performance-view']}>
@@ -281,7 +280,7 @@ class EventDetailsContent extends AsyncComponent<Props, State> {
             </Layout.Side>
           )}
         </Layout.Body>
-      </Fragment>
+      </TransactionProfileIdProvider>
     );
 
     const hasQuickTraceView = organization.features.includes('performance-view');

+ 7 - 7
static/app/views/performance/transactionDetails/content.tsx

@@ -16,6 +16,7 @@ import RootSpanStatus from 'sentry/components/events/rootSpanStatus';
 import FileSize from 'sentry/components/fileSize';
 import * as Layout from 'sentry/components/layouts/thirds';
 import LoadingError from 'sentry/components/loadingError';
+import {TransactionProfileIdProvider} from 'sentry/components/profiling/transactionProfileIdProvider';
 import {TransactionToProfileButton} from 'sentry/components/profiling/transactionToProfileButton';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {TagsTable} from 'sentry/components/tagsTable';
@@ -155,7 +156,10 @@ class EventDetailsContent extends AsyncComponent<Props, State> {
         {metaResults => (
           <QuickTraceQuery event={event} location={location} orgSlug={organization.slug}>
             {results => (
-              <Fragment>
+              <TransactionProfileIdProvider
+                transactionId={event.type === 'transaction' ? event.id : undefined}
+                timestamp={event.dateReceived}
+              >
                 <Layout.Header>
                   <Layout.HeaderContent>
                     <Breadcrumb
@@ -185,11 +189,7 @@ class EventDetailsContent extends AsyncComponent<Props, State> {
                         </Button>
                       )}
                       {hasProfilingFeature && (
-                        <TransactionToProfileButton
-                          orgId={organization.slug}
-                          projectId={this.projectId}
-                          transactionId={event.eventID}
-                        />
+                        <TransactionToProfileButton projectSlug={this.projectId} />
                       )}
                     </ButtonBar>
                   </Layout.HeaderActions>
@@ -268,7 +268,7 @@ class EventDetailsContent extends AsyncComponent<Props, State> {
                     </Layout.Side>
                   )}
                 </Layout.Body>
-              </Fragment>
+              </TransactionProfileIdProvider>
             )}
           </QuickTraceQuery>
         )}