Browse Source

feat(perf): Add barebones HTTP module page (#65188)

Don't sweat the names of things yet, this is just to get started. Mostly
just copying the Database landing page for now, but they'll diverge over
time.

---------

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
George Gritsouk 1 year ago
parent
commit
a2a9dddb16

+ 11 - 1
static/app/components/sidebar/index.tsx

@@ -229,7 +229,8 @@ function Sidebar() {
         if (
           organization.features.includes('performance-database-view') ||
           organization.features.includes('starfish-browser-webvitals') ||
-          organization.features.includes('performance-screens-view')
+          organization.features.includes('performance-screens-view') ||
+          organization.features.includes('performance-http-view')
         ) {
           return (
             <SidebarAccordion
@@ -254,6 +255,15 @@ function Sidebar() {
                   icon={<SubitemDot collapsed />}
                 />
               </Feature>
+              <Feature features="performance-http-view" organization={organization}>
+                <SidebarItem
+                  {...sidebarItemProps}
+                  label={<GuideAnchor target="performance-http">{t('HTTP')}</GuideAnchor>}
+                  to={`/organizations/${organization.slug}/performance/http/`}
+                  id="performance-http"
+                  icon={<SubitemDot collapsed />}
+                />
+              </Feature>
               <Feature features="starfish-browser-webvitals" organization={organization}>
                 <SidebarItem
                   {...sidebarItemProps}

+ 5 - 0
static/app/routes.tsx

@@ -1683,6 +1683,11 @@ function buildRoutes() {
           )}
         />
       </Route>
+      <Route path="http/">
+        <IndexRoute
+          component={make(() => import('sentry/views/performance/http/httpLandingPage'))}
+        />
+      </Route>
       <Route path="browser/">
         <Route path="interactions/">
           <IndexRoute

+ 147 - 0
static/app/views/performance/http/domainsTable.tsx

@@ -0,0 +1,147 @@
+import {browserHistory} from 'react-router';
+import type {Location} from 'history';
+
+import type {GridColumnHeader} from 'sentry/components/gridEditable';
+import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
+import type {CursorHandler} from 'sentry/components/pagination';
+import Pagination from 'sentry/components/pagination';
+import {t} from 'sentry/locale';
+import type {Organization} from 'sentry/types';
+import type {EventsMetaType} from 'sentry/utils/discover/eventView';
+import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
+import type {Sort} from 'sentry/utils/discover/fields';
+import {RATE_UNIT_TITLE, RateUnit} from 'sentry/utils/discover/fields';
+import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {renderHeadCell} from 'sentry/views/starfish/components/tableCells/renderHeadCell';
+import type {MetricsResponse} from 'sentry/views/starfish/types';
+import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
+import {DataTitles} from 'sentry/views/starfish/views/spans/types';
+
+type Row = Pick<
+  MetricsResponse,
+  | 'project.id'
+  | 'span.domain'
+  | 'spm()'
+  | 'avg(span.self_time)'
+  | 'sum(span.self_time)'
+  | 'time_spent_percentage()'
+>;
+
+type Column = GridColumnHeader<
+  'span.domain' | 'spm()' | 'avg(span.self_time)' | 'time_spent_percentage()'
+>;
+
+const COLUMN_ORDER: Column[] = [
+  {
+    key: 'span.domain',
+    name: t('Domain'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'spm()',
+    name: `${t('Requests')} ${RATE_UNIT_TITLE[RateUnit.PER_MINUTE]}`,
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: `avg(span.self_time)`,
+    name: DataTitles.avg,
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'time_spent_percentage()',
+    name: DataTitles.timeSpent,
+    width: COL_WIDTH_UNDEFINED,
+  },
+];
+
+const SORTABLE_FIELDS = [
+  'avg(span.self_time)',
+  'spm()',
+  'time_spent_percentage()',
+] as const;
+
+type ValidSort = Sort & {
+  field: (typeof SORTABLE_FIELDS)[number];
+};
+
+export function isAValidSort(sort: Sort): sort is ValidSort {
+  return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field);
+}
+
+interface Props {
+  response: {
+    data: Row[];
+    isLoading: boolean;
+    meta?: EventsMetaType;
+    pageLinks?: string;
+  };
+  sort: ValidSort;
+}
+
+export function DomainsTable({response, sort}: Props) {
+  const {data, isLoading, meta, pageLinks} = response;
+  const location = useLocation();
+  const organization = useOrganization();
+
+  const handleCursor: CursorHandler = (newCursor, pathname, query) => {
+    browserHistory.push({
+      pathname,
+      query: {...query, [QueryParameterNames.DOMAINS_CURSOR]: newCursor},
+    });
+  };
+
+  return (
+    <VisuallyCompleteWithData
+      id="DomainsTable"
+      hasData={data.length > 0}
+      isLoading={isLoading}
+    >
+      <GridEditable
+        isLoading={isLoading}
+        data={data}
+        columnOrder={COLUMN_ORDER}
+        columnSortBy={[
+          {
+            key: sort.field,
+            order: sort.kind,
+          },
+        ]}
+        grid={{
+          renderHeadCell: column =>
+            renderHeadCell({
+              column,
+              sort,
+              location,
+              sortParameterName: QueryParameterNames.DOMAINS_SORT,
+            }),
+          renderBodyCell: (column, row) =>
+            renderBodyCell(column, row, meta, location, organization),
+        }}
+        location={location}
+      />
+      <Pagination pageLinks={pageLinks} onCursor={handleCursor} />
+    </VisuallyCompleteWithData>
+  );
+}
+
+function renderBodyCell(
+  column: Column,
+  row: Row,
+  meta: EventsMetaType | undefined,
+  location: Location,
+  organization: Organization
+) {
+  if (!meta?.fields) {
+    return row[column.key];
+  }
+
+  const renderer = getFieldRenderer(column.key, meta.fields, false);
+
+  return renderer(row, {
+    location,
+    organization,
+    unit: meta.units?.[column.key],
+  });
+}

+ 174 - 0
static/app/views/performance/http/httpLandingPage.spec.tsx

@@ -0,0 +1,174 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary';
+
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {HTTPLandingPage} from 'sentry/views/performance/http/httpLandingPage';
+
+jest.mock('sentry/utils/useLocation');
+jest.mock('sentry/utils/usePageFilters');
+jest.mock('sentry/utils/useOrganization');
+
+describe('HTTPLandingPage', function () {
+  const organization = OrganizationFixture();
+
+  let spanListRequestMock, spanChartsRequestMock;
+
+  jest.mocked(usePageFilters).mockReturnValue({
+    isReady: true,
+    desyncedFilters: new Set(),
+    pinnedFilters: new Set(),
+    shouldPersist: true,
+    selection: {
+      datetime: {
+        period: '10d',
+        start: null,
+        end: null,
+        utc: false,
+      },
+      environments: [],
+      projects: [],
+    },
+  });
+
+  jest.mocked(useLocation).mockReturnValue({
+    pathname: '',
+    search: '',
+    query: {statsPeriod: '10d'},
+    hash: '',
+    state: undefined,
+    action: 'PUSH',
+    key: '',
+  });
+
+  jest.mocked(useOrganization).mockReturnValue(organization);
+
+  beforeEach(function () {
+    spanListRequestMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      method: 'GET',
+      match: [
+        MockApiClient.matchQuery({referrer: 'api.starfish.http-module-domains-list'}),
+      ],
+      body: {
+        data: [
+          {
+            'span.domain': '*.sentry.io',
+          },
+          {
+            'span.domain': '*.github.com',
+          },
+        ],
+      },
+    });
+
+    spanChartsRequestMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events-stats/`,
+      method: 'GET',
+      body: {
+        'spm()': {
+          data: [
+            [1699907700, [{count: 7810.2}]],
+            [1699908000, [{count: 1216.8}]],
+          ],
+        },
+      },
+    });
+  });
+
+  afterAll(function () {
+    jest.resetAllMocks();
+  });
+
+  it('fetches module data', async function () {
+    render(<HTTPLandingPage />);
+
+    expect(spanChartsRequestMock).toHaveBeenNthCalledWith(
+      1,
+      `/organizations/${organization.slug}/events-stats/`,
+      expect.objectContaining({
+        method: 'GET',
+        query: {
+          cursor: undefined,
+          dataset: 'spansMetrics',
+          environment: [],
+          excludeOther: 0,
+          field: [],
+          interval: '30m',
+          orderby: undefined,
+          partial: 1,
+          per_page: 50,
+          project: [],
+          query: 'span.module:http has:span.domain',
+          referrer: 'api.starfish.http-module-landing-throughput-chart',
+          statsPeriod: '10d',
+          topEvents: undefined,
+          yAxis: 'spm()',
+        },
+      })
+    );
+
+    expect(spanChartsRequestMock).toHaveBeenNthCalledWith(
+      2,
+      `/organizations/${organization.slug}/events-stats/`,
+      expect.objectContaining({
+        method: 'GET',
+        query: {
+          cursor: undefined,
+          dataset: 'spansMetrics',
+          environment: [],
+          excludeOther: 0,
+          field: [],
+          interval: '30m',
+          orderby: undefined,
+          partial: 1,
+          per_page: 50,
+          project: [],
+          query: 'span.module:http has:span.domain',
+          referrer: 'api.starfish.http-module-landing-duration-chart',
+          statsPeriod: '10d',
+          topEvents: undefined,
+          yAxis: 'avg(span.self_time)',
+        },
+      })
+    );
+
+    expect(spanListRequestMock).toHaveBeenCalledWith(
+      `/organizations/${organization.slug}/events/`,
+      expect.objectContaining({
+        method: 'GET',
+        query: {
+          dataset: 'spansMetrics',
+          environment: [],
+          field: [
+            'project.id',
+            'span.domain',
+            'spm()',
+            'avg(span.self_time)',
+            'sum(span.self_time)',
+            'time_spent_percentage()',
+          ],
+          per_page: 10,
+          project: [],
+          query: 'span.module:http has:span.domain',
+          referrer: 'api.starfish.http-module-domains-list',
+          sort: '-time_spent_percentage()',
+          statsPeriod: '10d',
+        },
+      })
+    );
+
+    await waitForElementToBeRemoved(() => screen.queryAllByTestId('loading-indicator'));
+  });
+
+  it('renders a list of domains', async function () {
+    render(<HTTPLandingPage />);
+
+    await waitForElementToBeRemoved(() => screen.queryAllByTestId('loading-indicator'));
+
+    expect(screen.getByRole('cell', {name: '*.sentry.io'})).toBeInTheDocument();
+    expect(screen.getByRole('cell', {name: '*.github.com'})).toBeInTheDocument();
+  });
+});

+ 172 - 0
static/app/views/performance/http/httpLandingPage.tsx

@@ -0,0 +1,172 @@
+import React, {Fragment} from 'react';
+import styled from '@emotion/styled';
+import pickBy from 'lodash/pickBy';
+
+import {Breadcrumbs} from 'sentry/components/breadcrumbs';
+import FloatingFeedbackWidget from 'sentry/components/feedback/widget/floatingFeedbackWidget';
+import * as Layout from 'sentry/components/layouts/thirds';
+import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
+import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
+import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {fromSorts} from 'sentry/utils/discover/eventView';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {useOnboardingProject} from 'sentry/views/performance/browser/webVitals/utils/useOnboardingProject';
+import {DurationChart} from 'sentry/views/performance/database/durationChart';
+import {ModulePageProviders} from 'sentry/views/performance/database/modulePageProviders';
+import {DomainsTable, isAValidSort} from 'sentry/views/performance/http/domainsTable';
+import {ThroughputChart} from 'sentry/views/performance/http/throughputChart';
+import Onboarding from 'sentry/views/performance/onboarding';
+import {useSynchronizeCharts} from 'sentry/views/starfish/components/chart';
+import {useSpanMetrics} from 'sentry/views/starfish/queries/useSpanMetrics';
+import {useSpanMetricsSeries} from 'sentry/views/starfish/queries/useSpanMetricsSeries';
+import {ModuleName} from 'sentry/views/starfish/types';
+import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
+
+export function HTTPLandingPage() {
+  const organization = useOrganization();
+  const location = useLocation();
+  const onboardingProject = useOnboardingProject();
+
+  const sortField = decodeScalar(location.query?.[QueryParameterNames.DOMAINS_SORT]);
+
+  const sort = fromSorts(sortField).filter(isAValidSort).at(0) ?? DEFAULT_SORT;
+
+  const chartFilters = {
+    'span.module': ModuleName.HTTP,
+    has: 'span.domain',
+  };
+
+  const tableFilters = {
+    'span.module': ModuleName.HTTP,
+    has: 'span.domain',
+  };
+
+  const cursor = decodeScalar(location.query?.[QueryParameterNames.DOMAINS_CURSOR]);
+
+  const {isLoading: isThroughputDataLoading, data: throughputData} = useSpanMetricsSeries(
+    {
+      filters: chartFilters,
+      yAxis: ['spm()'],
+      referrer: 'api.starfish.http-module-landing-throughput-chart',
+    }
+  );
+
+  const {isLoading: isDurationDataLoading, data: durationData} = useSpanMetricsSeries({
+    filters: chartFilters,
+    yAxis: [`avg(span.self_time)`],
+    referrer: 'api.starfish.http-module-landing-duration-chart',
+  });
+
+  const domainsListResponse = useSpanMetrics({
+    filters: pickBy(tableFilters, value => value !== undefined),
+    fields: [
+      'project.id',
+      'span.domain',
+      'spm()',
+      'avg(span.self_time)',
+      'sum(span.self_time)',
+      'time_spent_percentage()',
+    ],
+    sorts: [sort],
+    limit: DOMAIN_TABLE_ROW_COUNT,
+    cursor,
+    referrer: 'api.starfish.http-module-domains-list',
+  });
+
+  useSynchronizeCharts([!isThroughputDataLoading && !isDurationDataLoading]);
+
+  return (
+    <React.Fragment>
+      <Layout.Header>
+        <Layout.HeaderContent>
+          <Breadcrumbs
+            crumbs={[
+              {
+                label: t('Performance'),
+                to: normalizeUrl(`/organizations/${organization.slug}/performance/`),
+                preservePageFilters: true,
+              },
+              {
+                label: t('HTTP'),
+              },
+            ]}
+          />
+
+          <Layout.Title>{t('HTTP')}</Layout.Title>
+        </Layout.HeaderContent>
+      </Layout.Header>
+
+      <Layout.Body>
+        <Layout.Main fullWidth>
+          <FloatingFeedbackWidget />
+
+          <PaddedContainer>
+            <PageFilterBar condensed>
+              <ProjectPageFilter />
+              <EnvironmentPageFilter />
+              <DatePageFilter />
+            </PageFilterBar>
+          </PaddedContainer>
+
+          {onboardingProject && (
+            <Onboarding organization={organization} project={onboardingProject} />
+          )}
+          {!onboardingProject && (
+            <Fragment>
+              <ChartContainer>
+                <ThroughputChart
+                  series={throughputData['spm()']}
+                  isLoading={isThroughputDataLoading}
+                />
+
+                <DurationChart
+                  series={durationData[`avg(span.self_time)`]}
+                  isLoading={isDurationDataLoading}
+                />
+              </ChartContainer>
+
+              <DomainsTable response={domainsListResponse} sort={sort} />
+            </Fragment>
+          )}
+        </Layout.Main>
+      </Layout.Body>
+    </React.Fragment>
+  );
+}
+
+const DEFAULT_SORT = {
+  field: 'time_spent_percentage()' as const,
+  kind: 'desc' as const,
+};
+
+const PaddedContainer = styled('div')`
+  margin-bottom: ${space(2)};
+`;
+
+const ChartContainer = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr;
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: 1fr 1fr;
+    gap: ${space(2)};
+  }
+`;
+
+const DOMAIN_TABLE_ROW_COUNT = 10;
+
+function LandingPageWithProviders() {
+  return (
+    <ModulePageProviders title={[t('Performance'), t('HTTP')].join(' — ')}>
+      <HTTPLandingPage />
+    </ModulePageProviders>
+  );
+}
+
+export default LandingPageWithProviders;

+ 38 - 0
static/app/views/performance/http/throughputChart.tsx

@@ -0,0 +1,38 @@
+import type {Series} from 'sentry/types/echarts';
+import {RateUnit} from 'sentry/utils/discover/fields';
+import {formatRate} from 'sentry/utils/formatters';
+import {CHART_HEIGHT} from 'sentry/views/performance/database/settings';
+import {THROUGHPUT_COLOR} from 'sentry/views/starfish/colours';
+import Chart from 'sentry/views/starfish/components/chart';
+import ChartPanel from 'sentry/views/starfish/components/chartPanel';
+import {getThroughputChartTitle} from 'sentry/views/starfish/views/spans/types';
+
+interface Props {
+  isLoading: boolean;
+  series: Series;
+}
+
+export function ThroughputChart({series, isLoading}: Props) {
+  return (
+    <ChartPanel title={getThroughputChartTitle('http')}>
+      <Chart
+        height={CHART_HEIGHT}
+        grid={{
+          left: '0',
+          right: '0',
+          top: '8px',
+          bottom: '0',
+        }}
+        data={[series]}
+        loading={isLoading}
+        chartColors={[THROUGHPUT_COLOR]}
+        isLineChart
+        aggregateOutputFormat="rate"
+        rateUnit={RateUnit.PER_MINUTE}
+        tooltipFormatterOptions={{
+          valueFormatter: value => formatRate(value, RateUnit.PER_MINUTE),
+        }}
+      />
+    </ChartPanel>
+  );
+}

+ 1 - 0
static/app/views/starfish/components/tableCells/renderHeadCell.tsx

@@ -19,6 +19,7 @@ type Options = {
   sortParameterName?:
     | QueryParameterNames.ENDPOINTS_SORT
     | QueryParameterNames.SPANS_SORT
+    | QueryParameterNames.DOMAINS_SORT
     | typeof DEFAULT_SORT_PARAMETER_NAME;
 };
 

+ 2 - 0
static/app/views/starfish/views/queryParameters.tsx

@@ -1,6 +1,8 @@
 export enum QueryParameterNames {
   SPANS_CURSOR = 'spansCursor',
   SPANS_SORT = 'spansSort',
+  DOMAINS_CURSOR = 'domainsCursor',
+  DOMAINS_SORT = 'domainsSort',
   ENDPOINTS_CURSOR = 'endpointsCursor',
   ENDPOINTS_SORT = 'endpointsSort',
   PAGES_CURSOR = 'pagesCursor',