Repository: getsentry/sentry
Pull request: feat(replays): Conditionally render Replay touch points by project platform
Adds a new project based condition for rendering replay touch points. Replays are only supported by front-end JavaScript projects. Looking through the defined platform categories in `platformCategories.tsx` it appears that any project with a platform containing 'javascript' in the name is a front-end project in Sentry and can support Replays. 

### Changes
- [x] adds `projectSupportsReplay` utility function
- [x] Adds project support check to issue list items
- [x] Adds project support check to transaction summary replay tab
- [x] Adds project support check to transaction events table
- [x] Adds project support check to transaction overview mini table
- [x] Adds project support check to issue replay tab
- [x] Adds project support check to issue events table

### Notes
Ryan mentioned possibly also checking the `platforms` array on a project. It looks like in a fair amount of cases only a `MinimalProject` typed value is immediately available. Since the `platform` field on `MinimalProject` seems to be enough, I decided to just use that one check, for now, to keep the `projectSupportsReplay` logic simple and easy to replace. 

Closes #40349 


<!--

  Sentry employees and contractors can delete or ignore the following.

-->

### Legal Boilerplate

Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.


Problem statement:
Only display Replay touchpoints in Issue/Performance/Discovery views for applicable projects
**Problem:**

We’ve made many improvements to the Performance views (i.e., Transaction Summary and the tabs on this page) such as the Replay ID column on mini-table, “Replays” tab, Replay ID column on “All Events” page, etc. 

Right now, these touch points are shown even for mobile or backend projects that are not Session Replay compatible, which can be confusing and/or misleading.

**Solution:**

Make these touch points only display for transactions associated with JavaScript/equivalent framework projects.

Hints from issue discussion:

Commit message: fix(replays): add conditional chain back for tests

Patch to review:
diff --git a/static/app/components/eventOrGroupExtraDetails.tsx b/static/app/components/eventOrGroupExtraDetails.tsx
index 05e319bb541c9c..fb66f725f611c4 100644
--- a/static/app/components/eventOrGroupExtraDetails.tsx
+++ b/static/app/components/eventOrGroupExtraDetails.tsx
@@ -17,6 +17,7 @@ import {tct} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import {Group, Organization} from 'sentry/types';
 import {Event} from 'sentry/types/event';
+import projectSupportsReplay from 'sentry/utils/replays/projectSupportsReplay';
 import withOrganization from 'sentry/utils/withOrganization';
 
 type Props = WithRouterProps<{orgId: string}> & {
@@ -50,7 +51,9 @@ function EventOrGroupExtraDetails({
   } = data as Group;
 
   const issuesPath = `/organizations/${params.orgId}/issues/`;
-  const isReplayEnabled = organization.features.includes('session-replay-ui');
+
+  const showReplayCount =
+    organization.features.includes('session-replay-ui') && projectSupportsReplay(project);
 
   return (
     <GroupExtra>
@@ -84,7 +87,7 @@ function EventOrGroupExtraDetails({
           <span>{numComments}</span>
         </CommentsLink>
       )}
-      {isReplayEnabled && <ReplayCount groupId={id} orgId={params.orgId} />}
+      {showReplayCount && <ReplayCount groupId={id} orgId={params.orgId} />}
       {logger && (
         <LoggerAnnotation>
           <GlobalSelectionLink
diff --git a/static/app/components/eventsTable/eventsTable.spec.jsx b/static/app/components/eventsTable/eventsTable.spec.jsx
index 1d4e63a5557177..d941a84261ec0a 100644
--- a/static/app/components/eventsTable/eventsTable.spec.jsx
+++ b/static/app/components/eventsTable/eventsTable.spec.jsx
@@ -1,21 +1,32 @@
 import {render, screen} from 'sentry-test/reactTestingLibrary';
 
 import EventsTable from 'sentry/components/eventsTable/eventsTable';
+import ProjectsStore from 'sentry/stores/projectsStore';
 
 jest.mock('sentry/utils/useRoutes', () => ({
   useRoutes: jest.fn(() => []),
 }));
 
 describe('EventsTable', function () {
+  beforeEach(() => {
+    ProjectsStore.loadInitialData([
+      {
+        ...TestStubs.Project(),
+        platform: 'javascript',
+      },
+    ]);
+  });
+
   it('renders', function () {
     const {container} = render(
       <EventsTable
         tagList={[]}
         orgId="orgId"
-        projectId="projectId"
+        projectId="project-slug"
         groupId="groupId"
         orgFeatures={[]}
         events={TestStubs.DetailedEvents()}
+        projects={[TestStubs.Project()]}
       />
     );
     expect(container).toSnapshot();
@@ -32,10 +43,35 @@ describe('EventsTable', function () {
           },
         ]}
         orgId="orgId"
-        projectId="projectId"
+        projectId="project-slug"
+        groupId="groupId"
+        orgFeatures={[]}
+        events={TestStubs.DetailedEvents()}
+        projects={[TestStubs.Project()]}
+      />
+    );
+
+    expect(screen.queryByRole('columnheader', {name: 'Replay'})).not.toBeInTheDocument();
+  });
+
+  it('does not show the replay column when the project does not support it', () => {
+    ProjectsStore.loadInitialData([TestStubs.Project()]);
+
+    render(
+      <EventsTable
+        tagList={[
+          {
+            key: 'replayId',
+            name: 'Replayid',
+            totalValues: 5,
+          },
+        ]}
+        orgId="orgId"
+        projectId="project-slug"
         groupId="groupId"
         orgFeatures={[]}
         events={TestStubs.DetailedEvents()}
+        projects={[TestStubs.Project()]}
       />
     );
 
@@ -53,10 +89,11 @@ describe('EventsTable', function () {
           },
         ]}
         orgId="orgId"
-        projectId="projectId"
+        projectId="project-slug"
         groupId="groupId"
         orgFeatures={['session-replay-ui']}
         events={TestStubs.DetailedEvents()}
+        projects={[TestStubs.Project()]}
       />
     );
 
diff --git a/static/app/components/eventsTable/eventsTable.tsx b/static/app/components/eventsTable/eventsTable.tsx
index eb3b479eca267c..1c4c3f79a9d030 100644
--- a/static/app/components/eventsTable/eventsTable.tsx
+++ b/static/app/components/eventsTable/eventsTable.tsx
@@ -2,8 +2,10 @@ import {Component} from 'react';
 
 import EventsTableRow from 'sentry/components/eventsTable/eventsTableRow';
 import {t} from 'sentry/locale';
-import {Tag} from 'sentry/types';
+import {Project, Tag} from 'sentry/types';
 import {Event} from 'sentry/types/event';
+import projectSupportsReplay from 'sentry/utils/replays/projectSupportsReplay';
+import withProjects from 'sentry/utils/withProjects';
 
 type Props = {
   events: Event[];
@@ -11,15 +13,20 @@ type Props = {
   orgFeatures: string[];
   orgId: string;
   projectId: string;
+  projects: Project[];
   tagList: Tag[];
 };
+
 class EventsTable extends Component<Props> {
   render() {
-    const {events, tagList, orgId, projectId, groupId, orgFeatures} = this.props;
+    const {events, tagList, orgId, projectId, groupId, orgFeatures, projects} =
+      this.props;
 
     const hasUser = !!events.find(event => event.user);
+    const project = projects.find(p => p.slug === projectId);
 
-    const isReplayEnabled = orgFeatures.includes('session-replay-ui');
+    const isReplayEnabled =
+      orgFeatures.includes('session-replay-ui') && projectSupportsReplay(project);
     const filteredTagList = tagList.filter(tag =>
       tag.key === 'replayId' ? isReplayEnabled : true
     );
@@ -57,4 +64,4 @@ class EventsTable extends Component<Props> {
   }
 }
 
-export default EventsTable;
+export default withProjects(EventsTable);
diff --git a/static/app/utils/replays/projectSupportsReplay.tsx b/static/app/utils/replays/projectSupportsReplay.tsx
new file mode 100644
index 00000000000000..b4023e8f15394e
--- /dev/null
+++ b/static/app/utils/replays/projectSupportsReplay.tsx
@@ -0,0 +1,7 @@
+import type {MinimalProject} from 'sentry/types';
+
+function projectSupportsReplay(project?: MinimalProject) {
+  return Boolean(project?.platform?.includes('javascript'));
+}
+
+export default projectSupportsReplay;
diff --git a/static/app/views/organizationGroupDetails/header.tsx b/static/app/views/organizationGroupDetails/header.tsx
index dba563bf7d6c39..4980ca5ec9e92d 100644
--- a/static/app/views/organizationGroupDetails/header.tsx
+++ b/static/app/views/organizationGroupDetails/header.tsx
@@ -30,6 +30,7 @@ import {Event, Group, IssueCategory, Organization, Project, User} from 'sentry/t
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import {getUtcDateString} from 'sentry/utils/dates';
 import {getMessage} from 'sentry/utils/events';
+import projectSupportsReplay from 'sentry/utils/replays/projectSupportsReplay';
 import useApi from 'sentry/utils/useApi';
 import {useLocation} from 'sentry/utils/useLocation';
 
@@ -155,7 +156,8 @@ function GroupHeader({
     const hasGroupingTreeUI = organizationFeatures.has('grouping-tree-ui');
     const hasSimilarView = projectFeatures.has('similarity-view');
     const hasEventAttachments = organizationFeatures.has('event-attachments');
-    const hasSessionReplay = organizationFeatures.has('session-replay-ui');
+    const hasSessionReplay =
+      organizationFeatures.has('session-replay-ui') && projectSupportsReplay(project);
 
     const analyticsData = event
       ? event.tags
diff --git a/static/app/views/performance/transactionSummary/header.tsx b/static/app/views/performance/transactionSummary/header.tsx
index 2c19848d84dc6a..f3691b04b00d73 100644
--- a/static/app/views/performance/transactionSummary/header.tsx
+++ b/static/app/views/performance/transactionSummary/header.tsx
@@ -18,6 +18,7 @@ import {trackAnalyticsEvent} from 'sentry/utils/analytics';
 import EventView from 'sentry/utils/discover/eventView';
 import {MetricsCardinalityContext} from 'sentry/utils/performance/contexts/metricsCardinality';
 import HasMeasurementsQuery from 'sentry/utils/performance/vitals/hasMeasurementsQuery';
+import projectSupportsReplay from 'sentry/utils/replays/projectSupportsReplay';
 import Breadcrumb from 'sentry/views/performance/breadcrumb';
 
 import {getCurrentLandingDisplay, LandingDisplayField} from '../landing/utils';
@@ -68,7 +69,9 @@ function TransactionHeader({
   const hasAnomalyDetection = organization.features?.includes(
     'performance-anomaly-detection-ui'
   );
-  const hasSessionReplay = organization.features?.includes('session-replay-ui');
+
+  const hasSessionReplay =
+    organization.features.includes('session-replay-ui') && projectSupportsReplay(project);
 
   const getWebVitals = useCallback(
     (hasMeasurements: boolean) => {
diff --git a/static/app/views/performance/transactionSummary/transactionEvents/content.spec.tsx b/static/app/views/performance/transactionSummary/transactionEvents/content.spec.tsx
index 45de14081485c5..325fe17ed80e4d 100644
--- a/static/app/views/performance/transactionSummary/transactionEvents/content.spec.tsx
+++ b/static/app/views/performance/transactionSummary/transactionEvents/content.spec.tsx
@@ -170,6 +170,8 @@ describe('Performance Transaction Events Content', function () {
             eventsDisplayFilterName={EventsDisplayFilterName.p100}
             onChangeEventsDisplayFilter={() => {}}
             setError={() => {}}
+            projectId="123"
+            projects={[]}
           />
         </OrganizationContext.Provider>
       </RouteContext.Provider>,
@@ -212,6 +214,8 @@ describe('Performance Transaction Events Content', function () {
             onChangeEventsDisplayFilter={() => {}}
             webVital={WebVital.LCP}
             setError={() => {}}
+            projectId="123"
+            projects={[]}
           />
         </OrganizationContext.Provider>
       </RouteContext.Provider>,
diff --git a/static/app/views/performance/transactionSummary/transactionEvents/content.tsx b/static/app/views/performance/transactionSummary/transactionEvents/content.tsx
index 45808d1e73efed..5631241c6cc6c3 100644
--- a/static/app/views/performance/transactionSummary/transactionEvents/content.tsx
+++ b/static/app/views/performance/transactionSummary/transactionEvents/content.tsx
@@ -13,11 +13,12 @@ import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
 import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {Organization} from 'sentry/types';
+import {Organization, Project} from 'sentry/types';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import EventView from 'sentry/utils/discover/eventView';
 import {WebVital} from 'sentry/utils/fields';
 import {decodeScalar} from 'sentry/utils/queryString';
+import projectSupportsReplay from 'sentry/utils/replays/projectSupportsReplay';
 import {useRoutes} from 'sentry/utils/useRoutes';
 
 import Filter, {filterToSearchConditions, SpanOperationBreakdownFilter} from '../filter';
@@ -33,6 +34,8 @@ type Props = {
   onChangeEventsDisplayFilter: (eventsDisplayFilterName: EventsDisplayFilterName) => void;
   onChangeSpanOperationBreakdownFilter: (newFilter: SpanOperationBreakdownFilter) => void;
   organization: Organization;
+  projectId: string;
+  projects: Project[];
   setError: SetStateAction<string | undefined>;
   spanOperationBreakdownFilter: SpanOperationBreakdownFilter;
   totalEventCount: string;
@@ -60,10 +63,13 @@ function EventsContent(props: Props) {
     webVital,
     setError,
     totalEventCount,
+    projectId,
+    projects,
   } = props;
   const routes = useRoutes();
   const eventView = originalEventView.clone();
   const transactionsListTitles = TRANSACTIONS_LIST_TITLES.slice();
+  const project = projects.find(p => p.id === projectId);
 
   if (webVital) {
     transactionsListTitles.splice(3, 0, webVital);
@@ -79,7 +85,10 @@ function EventsContent(props: Props) {
     transactionsListTitles.splice(2, 1, t('%s duration', spanOperationBreakdownFilter));
   }
 
-  if (organization.features.includes('session-replay-ui')) {
+  const showReplayCol =
+    organization.features.includes('session-replay-ui') && projectSupportsReplay(project);
+
+  if (showReplayCol) {
     transactionsListTitles.push(t('replay'));
   }
 
@@ -95,6 +104,7 @@ function EventsContent(props: Props) {
         setError={setError}
         columnTitles={transactionsListTitles}
         transactionName={transactionName}
+        showReplayCol={showReplayCol}
       />
     </Layout.Main>
   );
diff --git a/static/app/views/performance/transactionSummary/transactionEvents/eventsTable.tsx b/static/app/views/performance/transactionSummary/transactionEvents/eventsTable.tsx
index 438c6e72dbb054..b5e3125b9a6810 100644
--- a/static/app/views/performance/transactionSummary/transactionEvents/eventsTable.tsx
+++ b/static/app/views/performance/transactionSummary/transactionEvents/eventsTable.tsx
@@ -91,6 +91,7 @@ type Props = {
   issueId?: string;
   projectId?: string;
   referrer?: string;
+  showReplayCol?: boolean;
   totalEventCount?: string;
 };
 
@@ -296,7 +297,7 @@ class EventsTable extends Component<Props, State> {
     const canSort =
       field.field !== 'id' &&
       field.field !== 'trace' &&
-      field.field !== 'replayid' &&
+      field.field !== 'replayId' &&
       field.field !== SPAN_OP_RELATIVE_BREAKDOWN_FIELD &&
       isFieldSortable(field, tableMeta);
 
@@ -342,8 +343,15 @@ class EventsTable extends Component<Props, State> {
   };
 
   render() {
-    const {eventView, organization, location, setError, totalEventCount, referrer} =
-      this.props;
+    const {
+      eventView,
+      organization,
+      location,
+      setError,
+      totalEventCount,
+      referrer,
+      showReplayCol,
+    } = this.props;
 
     const totalTransactionsView = eventView.clone();
     totalTransactionsView.sorts = [];
@@ -356,11 +364,14 @@ class EventsTable extends Component<Props, State> {
         (col: TableColumn<React.ReactText>) =>
           col.name === SPAN_OP_RELATIVE_BREAKDOWN_FIELD
       );
+
     const columnOrder = eventView
       .getColumns()
       .filter(
         (col: TableColumn<React.ReactText>) =>
-          !containsSpanOpsBreakdown || !isSpanOperationBreakdownField(col.name)
+          ((!containsSpanOpsBreakdown || !isSpanOperationBreakdownField(col.name)) &&
+            col.name !== 'replayId') ||
+          showReplayCol
       )
       .map((col: TableColumn<React.ReactText>, i: number) => {
         if (typeof widths[i] === 'number') {
diff --git a/static/app/views/performance/transactionSummary/transactionEvents/index.tsx b/static/app/views/performance/transactionSummary/transactionEvents/index.tsx
index e85697c3f0bee9..6cc662566023b4 100644
--- a/static/app/views/performance/transactionSummary/transactionEvents/index.tsx
+++ b/static/app/views/performance/transactionSummary/transactionEvents/index.tsx
@@ -64,7 +64,15 @@ function TransactionEvents(props: Props) {
 }
 
 function EventsContentWrapper(props: ChildProps) {
-  const {location, organization, eventView, transactionName, setError} = props;
+  const {
+    location,
+    organization,
+    eventView,
+    transactionName,
+    setError,
+    projectId,
+    projects,
+  } = props;
   const eventsDisplayFilterName = decodeEventsDisplayFilterFromLocation(location);
   const spanOperationBreakdownFilter = decodeFilterFromLocation(location);
   const webVital = getWebVital(location);
@@ -199,6 +207,8 @@ function EventsContentWrapper(props: ChildProps) {
                   eventsDisplayFilterName={eventsDisplayFilterName}
                   onChangeEventsDisplayFilter={onChangeEventsDisplayFilter}
                   percentileValues={percentiles}
+                  projectId={projectId}
+                  projects={projects}
                   webVital={webVital}
                   setError={setError}
                 />
diff --git a/static/app/views/performance/transactionSummary/transactionOverview/content.tsx b/static/app/views/performance/transactionSummary/transactionOverview/content.tsx
index f404f3f1f7629e..988468fb3f38d0 100644
--- a/static/app/views/performance/transactionSummary/transactionOverview/content.tsx
+++ b/static/app/views/performance/transactionSummary/transactionOverview/content.tsx
@@ -31,6 +31,7 @@ import {
 import {QueryError} from 'sentry/utils/discover/genericDiscoverQuery';
 import {canUseMetricsData} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
 import {decodeScalar} from 'sentry/utils/queryString';
+import projectSupportsReplay from 'sentry/utils/replays/projectSupportsReplay';
 import {useRoutes} from 'sentry/utils/useRoutes';
 import withProjects from 'sentry/utils/withProjects';
 import {Actions, updateQuery} from 'sentry/views/eventsV2/table/cellAction';
@@ -217,7 +218,12 @@ function SummaryContent({
     t('timestamp'),
   ];
 
-  if (organization.features.includes('session-replay-ui')) {
+  const project = projects.find(p => p.id === projectId);
+
+  if (
+    organization.features.includes('session-replay-ui') &&
+    projectSupportsReplay(project)
+  ) {
     transactionsListTitles.push(t('replay'));
   }
 


Code review comments:
diff_hunk: @@ -0,0 +1,7 @@
+import type {MinimalProject} from 'sentry/types';
+
+function projectSupportsReplay(project?: MinimalProject) {
+  return Boolean(project?.platform?.includes('javascript'));
path: static/app/utils/replays/projectSupportsReplay.tsx
text: @user1:
I just discovered `static/app/data/platformCategories.tsx` and the export `frontend`  which might be a more maintainable thing to check against, or something to augment.

@author:
would dart or unity be supported though? those don't sound like web platforms

@user1:
I was thinking we could also augment the file and add our own list. Josh has worked on a prototype sdk for native iOS, so a list of things could be better long-term compared to using `.include`