Browse Source

feat(profiling): add all profiles tab to transactions (#42141)

Adds profiles table to transactions
(https://github.com/getsentry/team-profiling/issues/123)


https://user-images.githubusercontent.com/9317857/206269801-3b175862-e7bb-4892-9a75-5508e44016d4.mp4
Jonas 2 years ago
parent
commit
a71acf8600

+ 4 - 0
static/app/components/profiling/ProfilingOnboarding/util.ts

@@ -35,6 +35,10 @@ const platformToDocsPlatform: Record<
   'python-tornado': 'python',
 };
 
+export function isProfilingSupportedForProject(project: Project): boolean {
+  return !!(project.platform && platformToDocsPlatform[project.platform]);
+}
+
 export const profilingOnboardingDocKeys = [
   '0-alert',
   '1-install',

+ 7 - 0
static/app/routes.tsx

@@ -1591,6 +1591,13 @@ function buildRoutes() {
               import('sentry/views/performance/transactionSummary/transactionAnomalies')
           )}
         />
+        <Route
+          path="profiles/"
+          component={make(
+            () =>
+              import('sentry/views/performance/transactionSummary/transactionProfiles')
+          )}
+        />
         <Route path="spans/">
           <IndexRoute
             component={make(

+ 10 - 0
static/app/views/performance/transactionSummary/header.tsx

@@ -23,6 +23,7 @@ import HasMeasurementsQuery from 'sentry/utils/performance/vitals/hasMeasurement
 import projectSupportsReplay from 'sentry/utils/replays/projectSupportsReplay';
 import Breadcrumb from 'sentry/views/performance/breadcrumb';
 
+import {isProfilingSupportedForProject} from '../../../components/profiling/ProfilingOnboarding/util';
 import {getCurrentLandingDisplay, LandingDisplayField} from '../landing/utils';
 
 import Tab from './tabs';
@@ -72,6 +73,11 @@ function TransactionHeader({
   const hasSessionReplay =
     organization.features.includes('session-replay-ui') && projectSupportsReplay(project);
 
+  const hasProfiling =
+    project &&
+    organization.features.includes('profiling') &&
+    isProfilingSupportedForProject(project);
+
   const getWebVitals = useCallback(
     (hasMeasurements: boolean) => {
       switch (hasWebVitals) {
@@ -210,6 +216,10 @@ function TransactionHeader({
                 <ReplayCountBadge count={replaysCount} />
                 <ReplaysFeatureBadge noTooltip />
               </Item>
+              <Item key={Tab.Profiling} textValue={t('Profiling')} hidden={!hasProfiling}>
+                {t('Profiling')}
+                <FeatureBadge type="beta" noTooltip />
+              </Item>
             </TabList>
           );
         }}

+ 4 - 0
static/app/views/performance/transactionSummary/pageLayout.tsx

@@ -25,6 +25,7 @@ import {getSelectedProjectPlatforms, getTransactionName} from '../utils';
 
 import {anomaliesRouteWithQuery} from './transactionAnomalies/utils';
 import {eventsRouteWithQuery} from './transactionEvents/utils';
+import {profilesRouteWithQuery} from './transactionProfiles/utils';
 import {replaysRouteWithQuery} from './transactionReplays/utils';
 import {spansRouteWithQuery} from './transactionSpans/utils';
 import {tagsRouteWithQuery} from './transactionTags/utils';
@@ -135,6 +136,9 @@ function PageLayout(props: Props) {
           return anomaliesRouteWithQuery(routeQuery);
         case Tab.Replays:
           return replaysRouteWithQuery(routeQuery);
+        case Tab.Profiling: {
+          return profilesRouteWithQuery(routeQuery);
+        }
         case Tab.WebVitals:
           return vitalsRouteWithQuery({
             orgSlug: organization.slug,

+ 1 - 0
static/app/views/performance/transactionSummary/tabs.tsx

@@ -6,6 +6,7 @@ enum Tab {
   Spans = 'spans',
   Anomalies = 'anomalies',
   Replays = 'replays',
+  Profiling = 'profiling',
 }
 
 export default Tab;

+ 140 - 0
static/app/views/performance/transactionSummary/transactionProfiles/index.tsx

@@ -0,0 +1,140 @@
+import {useCallback, useMemo, useState} from 'react';
+import {browserHistory} from 'react-router';
+import styled from '@emotion/styled';
+
+import DatePageFilter from 'sentry/components/datePageFilter';
+import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
+import * as Layout from 'sentry/components/layouts/thirds';
+import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import {ProfileEventsTable} from 'sentry/components/profiling/profileEventsTable';
+import SmartSearchBar, {SmartSearchBarProps} from 'sentry/components/smartSearchBar';
+import {MAX_QUERY_LENGTH} from 'sentry/constants';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {defined} from 'sentry/utils';
+import EventView from 'sentry/utils/discover/eventView';
+import {
+  formatSort,
+  useProfileEvents,
+} from 'sentry/utils/profiling/hooks/useProfileEvents';
+import {useProfileFilters} from 'sentry/utils/profiling/hooks/useProfileFilters';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import useProjects from 'sentry/utils/useProjects';
+import Tab from 'sentry/views/performance/transactionSummary/tabs';
+import {
+  getProfilesTableFields,
+  ProfilingFieldType,
+} from 'sentry/views/profiling/profileSummary/content';
+
+import PageLayout from '../pageLayout';
+
+function Profiles(): React.ReactElement {
+  const location = useLocation();
+  const organization = useOrganization();
+  const projects = useProjects();
+  const {selection} = usePageFilters();
+
+  const profilesCursor = useMemo(
+    () => decodeScalar(location.query.cursor),
+    [location.query.cursor]
+  );
+
+  const project = projects.projects.find(p => p.id === location.query.project);
+  const fields = getProfilesTableFields(project?.platform);
+
+  const sort = formatSort<ProfilingFieldType>(decodeScalar(location.query.sort), fields, {
+    key: 'timestamp',
+    order: 'desc',
+  });
+
+  const [query, setQuery] = useState(() => {
+    // The search fields from the URL differ between profiling and
+    // events dataset. For now, just drop everything except transaction
+    const search = new MutableSearch('');
+    const transaction = decodeScalar(location.query.transaction);
+
+    if (defined(transaction)) {
+      search.setFilterValues('transaction', [transaction]);
+    }
+
+    return search;
+  });
+
+  const profiles = useProfileEvents<ProfilingFieldType>({
+    cursor: profilesCursor,
+    fields,
+    query: query.formatString(),
+    sort,
+    limit: 30,
+    referrer: 'api.profiling.transactions-profiles-table',
+  });
+
+  const handleSearch: SmartSearchBarProps['onSearch'] = useCallback(
+    (searchQuery: string) => {
+      setQuery(new MutableSearch(searchQuery));
+      browserHistory.push({
+        ...location,
+        query: {
+          ...location.query,
+          cursor: undefined,
+          query: searchQuery || undefined,
+        },
+      });
+    },
+    [location]
+  );
+
+  const profileFilters = useProfileFilters({query: '', selection});
+  const transaction = decodeScalar(location.query.transaction);
+  return (
+    <PageLayout
+      location={location}
+      organization={organization}
+      projects={projects.projects}
+      tab={Tab.Profiling}
+      generateEventView={() => EventView.fromLocation(location)}
+      getDocumentTitle={() => t(`Profile: %s`, transaction)}
+      childComponent={() => {
+        return (
+          <Layout.Main fullWidth>
+            <FilterActions>
+              <PageFilterBar condensed>
+                <EnvironmentPageFilter />
+                <DatePageFilter alignDropdown="left" />
+              </PageFilterBar>
+              <SmartSearchBar
+                organization={organization}
+                hasRecentSearches
+                searchSource="profile_landing"
+                supportedTags={profileFilters}
+                query={query.formatString()}
+                onSearch={handleSearch}
+                maxQueryLength={MAX_QUERY_LENGTH}
+              />
+            </FilterActions>
+            <ProfileEventsTable
+              columns={fields}
+              data={profiles.status === 'success' ? profiles.data[0] : null}
+              error={profiles.status === 'error' ? t('Unable to load profiles') : null}
+              isLoading={profiles.status === 'loading'}
+              sort={sort}
+            />
+          </Layout.Main>
+        );
+      }}
+    />
+  );
+}
+
+const FilterActions = styled('div')`
+  margin-bottom: ${space(2)};
+  gap: ${space(2)};
+  display: grid;
+  grid-template-columns: min-content 1fr;
+`;
+
+export default Profiles;

+ 28 - 0
static/app/views/performance/transactionSummary/transactionProfiles/utils.ts

@@ -0,0 +1,28 @@
+import {Query} from 'history';
+
+export function profilesRouteWithQuery({
+  orgSlug,
+  transaction,
+  projectID,
+  query,
+}: {
+  orgSlug: string;
+  query: Query;
+  transaction: string;
+  projectID?: string | string[];
+}) {
+  const pathname = `/organizations/${orgSlug}/performance/summary/profiles/`;
+
+  return {
+    pathname,
+    query: {
+      transaction,
+      project: projectID,
+      environment: query.environment,
+      statsPeriod: query.statsPeriod,
+      start: query.start,
+      end: query.end,
+      query: query.query,
+    },
+  };
+}

+ 23 - 15
static/app/views/profiling/profileSummary/content.tsx

@@ -30,13 +30,10 @@ interface ProfileSummaryContentProps {
 }
 
 function ProfileSummaryContent(props: ProfileSummaryContentProps) {
-  const fields = useMemo(() => {
-    if (mobile.includes(props.project.platform as any)) {
-      return MOBILE_FIELDS;
-    }
-
-    return DEFAULT_FIELDS;
-  }, [props.project]);
+  const fields = useMemo(
+    () => getProfilesTableFields(props.project.platform),
+    [props.project]
+  );
 
   const profilesCursor = useMemo(
     () => decodeScalar(props.location.query.cursor),
@@ -53,12 +50,16 @@ function ProfileSummaryContent(props: ProfileSummaryContentProps) {
     [props.location.query.functionsSort]
   );
 
-  const sort = formatSort<FieldType>(decodeScalar(props.location.query.sort), fields, {
-    key: 'timestamp',
-    order: 'desc',
-  });
+  const sort = formatSort<ProfilingFieldType>(
+    decodeScalar(props.location.query.sort),
+    fields,
+    {
+      key: 'timestamp',
+      order: 'desc',
+    }
+  );
 
-  const profiles = useProfileEvents<FieldType>({
+  const profiles = useProfileEvents<ProfilingFieldType>({
     cursor: profilesCursor,
     fields,
     query: props.query,
@@ -170,11 +171,18 @@ const ALL_FIELDS = [
   'profile.duration',
 ] as const;
 
-type FieldType = typeof ALL_FIELDS[number];
+export type ProfilingFieldType = typeof ALL_FIELDS[number];
 
-const MOBILE_FIELDS: FieldType[] = [...ALL_FIELDS];
+export function getProfilesTableFields(platform: Project['platform']) {
+  if (mobile.includes(platform as any)) {
+    return MOBILE_FIELDS;
+  }
+
+  return DEFAULT_FIELDS;
+}
 
-const DEFAULT_FIELDS: FieldType[] = [
+const MOBILE_FIELDS: ProfilingFieldType[] = [...ALL_FIELDS];
+const DEFAULT_FIELDS: ProfilingFieldType[] = [
   'id',
   'timestamp',
   'release',