Browse Source

feat: Replays tab for Performance Summary view (#37134)

Hopefully I got the query right it seems to work as expected for me. Spent most of time getting familiar with how those views were setup and how the queries were working. 

### Changes
- Adds new route for replays under performance `/performance/summary/replays/`
- Adds Replays tab to performance summary layout
- Follows suit with other performance views code (index, content, etc)
- View has a query of events with replayIds that get passed to the ReplaysTable

Closes #36353
Dane Grant 2 years ago
parent
commit
a7c534395a

+ 7 - 0
static/app/components/replays/replaysFeatureBadge.tsx

@@ -0,0 +1,7 @@
+import FeatureBadge from 'sentry/components/featureBadge';
+
+function ReplaysFeatureBadge() {
+  return <FeatureBadge type="alpha" noTooltip />;
+}
+
+export default ReplaysFeatureBadge;

+ 6 - 0
static/app/routes.tsx

@@ -1166,6 +1166,12 @@ function buildRoutes() {
               import('sentry/views/performance/transactionSummary/transactionOverview')
               import('sentry/views/performance/transactionSummary/transactionOverview')
           )}
           )}
         />
         />
+        <Route
+          path="replays/"
+          component={make(
+            () => import('sentry/views/performance/transactionSummary/transactionReplays')
+          )}
+        />
         <Route
         <Route
           path="vitals/"
           path="vitals/"
           component={make(
           component={make(

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

@@ -11,6 +11,7 @@ import IdBadge from 'sentry/components/idBadge';
 import * as Layout from 'sentry/components/layouts/thirds';
 import * as Layout from 'sentry/components/layouts/thirds';
 import ListLink from 'sentry/components/links/listLink';
 import ListLink from 'sentry/components/links/listLink';
 import NavTabs from 'sentry/components/navTabs';
 import NavTabs from 'sentry/components/navTabs';
+import ReplaysFeatureBadge from 'sentry/components/replays/replaysFeatureBadge';
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import space from 'sentry/styles/space';
 import {Organization, Project} from 'sentry/types';
 import {Organization, Project} from 'sentry/types';
@@ -25,6 +26,7 @@ import {getSelectedProjectPlatforms} from '../utils';
 
 
 import {anomaliesRouteWithQuery} from './transactionAnomalies/utils';
 import {anomaliesRouteWithQuery} from './transactionAnomalies/utils';
 import {eventsRouteWithQuery} from './transactionEvents/utils';
 import {eventsRouteWithQuery} from './transactionEvents/utils';
+import {replaysRouteWithQuery} from './transactionReplays/utils';
 import {spansRouteWithQuery} from './transactionSpans/utils';
 import {spansRouteWithQuery} from './transactionSpans/utils';
 import {tagsRouteWithQuery} from './transactionTags/utils';
 import {tagsRouteWithQuery} from './transactionTags/utils';
 import {vitalsRouteWithQuery} from './transactionVitals/utils';
 import {vitalsRouteWithQuery} from './transactionVitals/utils';
@@ -222,6 +224,7 @@ class TransactionHeader extends Component<Props> {
     const eventsTarget = eventsRouteWithQuery(routeQuery);
     const eventsTarget = eventsRouteWithQuery(routeQuery);
     const spansTarget = spansRouteWithQuery(routeQuery);
     const spansTarget = spansRouteWithQuery(routeQuery);
     const anomaliesTarget = anomaliesRouteWithQuery(routeQuery);
     const anomaliesTarget = anomaliesRouteWithQuery(routeQuery);
+    const replaysTarget = replaysRouteWithQuery(routeQuery);
 
 
     const project = projects.find(p => p.id === projectId);
     const project = projects.find(p => p.id === projectId);
 
 
@@ -310,6 +313,17 @@ class TransactionHeader extends Component<Props> {
               </ListLink>
               </ListLink>
             </Feature>
             </Feature>
             {this.renderWebVitalsTab()}
             {this.renderWebVitalsTab()}
+            <Feature features={['session-replay']} organization={organization}>
+              <ListLink
+                data-test-id="replays-tab"
+                to={replaysTarget}
+                isActive={() => currentTab === Tab.Replays}
+                onClick={this.trackTabClick(Tab.Replays)}
+              >
+                {t('Replays')}
+                <ReplaysFeatureBadge />
+              </ListLink>
+            </Feature>
           </StyledNavTabs>
           </StyledNavTabs>
         </Fragment>
         </Fragment>
       </Layout.Header>
       </Layout.Header>

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

@@ -5,6 +5,7 @@ enum Tab {
   Events,
   Events,
   Spans,
   Spans,
   Anomalies,
   Anomalies,
+  Replays,
 }
 }
 
 
 export default Tab;
 export default Tab;

+ 150 - 0
static/app/views/performance/transactionSummary/transactionReplays/content.tsx

@@ -0,0 +1,150 @@
+import {browserHistory} from 'react-router';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+import omit from 'lodash/omit';
+
+import DatePageFilter from 'sentry/components/datePageFilter';
+import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
+import SearchBar from 'sentry/components/events/searchBar';
+import * as Layout from 'sentry/components/layouts/thirds';
+import Link from 'sentry/components/links/link';
+import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import Pagination from 'sentry/components/pagination';
+import {PanelTable} from 'sentry/components/panels';
+import {IconArrow} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Organization} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {TableData} from 'sentry/utils/discover/discoverQuery';
+import EventView from 'sentry/utils/discover/eventView';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {getQueryParamAsString} from 'sentry/utils/replays/getQueryParamAsString';
+import ReplayTable from 'sentry/views/replays/replayTable';
+import {Replay} from 'sentry/views/replays/types';
+
+import {SetStateAction} from '../types';
+
+type Props = {
+  eventView: EventView;
+  location: Location;
+  organization: Organization;
+  pageLinks: string | null;
+  setError: SetStateAction<string | undefined>;
+  tableData: TableData;
+  transactionName: string;
+};
+
+function ReplaysContent(props: Props) {
+  const {tableData, pageLinks, location, organization, eventView} = props;
+
+  const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
+  const query = decodeScalar(location.query.query, '');
+
+  const sort: {
+    field: string;
+  } = {
+    field: getQueryParamAsString(location.query.sort) || '-timestamp',
+  };
+  const arrowDirection = sort.field.startsWith('-') ? 'down' : 'up';
+  const sortArrow = <IconArrow color="gray300" size="xs" direction={arrowDirection} />;
+
+  function handleChange(key: string) {
+    return function (value: string | undefined) {
+      const queryParams = normalizeDateTimeParams({
+        ...(location.query || {}),
+        [key]: value,
+      });
+
+      // do not propagate pagination when making a new search
+      const toOmit = ['cursor'];
+      if (!defined(value)) {
+        toOmit.push(key);
+      }
+      const searchQueryParams = omit(queryParams, toOmit);
+
+      browserHistory.push({
+        ...location,
+        query: searchQueryParams,
+      });
+    };
+  }
+
+  return (
+    <Layout.Main fullWidth>
+      <FilterActions>
+        <PageFilterBar condensed>
+          <EnvironmentPageFilter />
+          <DatePageFilter alignDropdown="left" />
+        </PageFilterBar>
+        <SearchBar
+          organization={organization}
+          projectIds={eventView.project}
+          query={query}
+          fields={eventView.fields}
+          onSearch={handleChange('query')}
+        />
+      </FilterActions>
+      <StyledPanelTable
+        isEmpty={tableData.data.length === 0}
+        headers={[
+          t('Session'),
+          <SortLink
+            key="timestamp"
+            role="columnheader"
+            aria-sort={
+              !sort.field.endsWith('timestamp')
+                ? 'none'
+                : sort.field === '-timestamp'
+                ? 'descending'
+                : 'ascending'
+            }
+            to={{
+              pathname: location.pathname,
+              query: {
+                ...currentQuery,
+                sort: sort.field === '-timestamp' ? 'timestamp' : '-timestamp',
+              },
+            }}
+          >
+            {t('Timestamp')} {sort.field.endsWith('timestamp') && sortArrow}
+          </SortLink>,
+          t('Duration'),
+          t('Errors'),
+        ]}
+      >
+        <ReplayTable idKey="replayId" replayList={tableData.data as Replay[]} />
+      </StyledPanelTable>
+      <Pagination pageLinks={pageLinks} />
+    </Layout.Main>
+  );
+}
+
+const StyledPanelTable = styled(PanelTable)`
+  grid-template-columns: minmax(0, 1fr) max-content max-content max-content;
+`;
+
+const SortLink = styled(Link)`
+  color: inherit;
+
+  :hover {
+    color: inherit;
+  }
+
+  svg {
+    vertical-align: top;
+  }
+`;
+
+const FilterActions = styled('div')`
+  display: grid;
+  gap: ${space(2)};
+  margin-bottom: ${space(2)};
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: auto 1fr;
+  }
+`;
+
+export default ReplaysContent;

+ 138 - 0
static/app/views/performance/transactionSummary/transactionReplays/index.tsx

@@ -0,0 +1,138 @@
+import {Location} from 'history';
+
+import * as Layout from 'sentry/components/layouts/thirds';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {t} from 'sentry/locale';
+import {Organization, Project} from 'sentry/types';
+import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
+import EventView from 'sentry/utils/discover/eventView';
+import {isAggregateField} from 'sentry/utils/discover/fields';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import withOrganization from 'sentry/utils/withOrganization';
+import withProjects from 'sentry/utils/withProjects';
+
+import PageLayout, {ChildProps} from '../pageLayout';
+import Tab from '../tabs';
+
+import ReplaysContent from './content';
+
+type Props = {
+  location: Location;
+  organization: Organization;
+  projects: Project[];
+};
+
+function TransactionReplays(props: Props) {
+  const {location, organization, projects} = props;
+
+  return (
+    <PageLayout
+      location={location}
+      organization={organization}
+      projects={projects}
+      tab={Tab.Replays}
+      getDocumentTitle={getDocumentTitle}
+      generateEventView={generateEventView}
+      childComponent={ReplaysContentWrapper}
+    />
+  );
+}
+
+function ReplaysContentWrapper(props: ChildProps) {
+  const {location, organization, eventView, transactionName, setError} = props;
+
+  return (
+    <DiscoverQuery
+      eventView={eventView}
+      orgSlug={organization.slug}
+      location={location}
+      setError={error => setError(error?.message)}
+      referrer="api.performance.transaction-summary"
+      cursor="0:0:0"
+      useEvents
+    >
+      {({isLoading, tableData, pageLinks}) => {
+        if (isLoading) {
+          return (
+            <Layout.Main fullWidth>
+              <LoadingIndicator />
+            </Layout.Main>
+          );
+        }
+        return tableData ? (
+          <ReplaysContent
+            eventView={eventView}
+            location={location}
+            organization={organization}
+            setError={setError}
+            transactionName={transactionName}
+            tableData={tableData}
+            pageLinks={pageLinks}
+          />
+        ) : null;
+      }}
+    </DiscoverQuery>
+  );
+}
+
+function getDocumentTitle(transactionName: string): string {
+  const hasTransactionName =
+    typeof transactionName === 'string' && String(transactionName).trim().length > 0;
+
+  if (hasTransactionName) {
+    return [String(transactionName).trim(), t('Replays')].join(' \u2014 ');
+  }
+
+  return [t('Summary'), t('Replays')].join(' \u2014 ');
+}
+
+function generateEventView({
+  location,
+  transactionName,
+}: {
+  location: Location;
+  transactionName: string;
+}): EventView {
+  const query = decodeScalar(location.query.query, '');
+  const conditions = new MutableSearch(query);
+
+  conditions.setFilterValues('event.type', ['transaction']);
+  conditions.setFilterValues('transaction', [transactionName]);
+
+  Object.keys(conditions.filters).forEach(field => {
+    if (isAggregateField(field)) {
+      conditions.removeFilter(field);
+    }
+  });
+
+  // Default fields for relative span view
+  const fields = [
+    'replayId',
+    'eventID',
+    'project',
+    'timestamp',
+    'url',
+    'user.display',
+    'user.email',
+    'user.id',
+    'user.ip_address',
+    'user.name',
+    'user.username',
+  ];
+
+  return EventView.fromNewQueryWithLocation(
+    {
+      id: undefined,
+      version: 2,
+      name: transactionName,
+      fields,
+      query: `${conditions.formatString()} has:replayId`,
+      projects: [],
+      orderby: decodeScalar(location.query.sort, '-timestamp'),
+    },
+    location
+  );
+}
+
+export default withProjects(withOrganization(TransactionReplays));

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

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