Browse Source

feat(perf): Database view (#54848)

Adds a new Database view to Performance. Right now the view has no entry
points, and is hidden behind a feature flag. The view is powered by span
metrics that were created by Starfish, and all the view _contents_ are
in fact using Starfish components.

Using the same underlying components creates more-or-less a copy of the
corresponding Starfish, but this is important for us to _insulate_ this
LA release from active development. The alternative would be to make the
Starfish versions hyper-configurable (new routing contexts, passing in
the filter controls, more toggles based on module type, etc.) which
would have created a mess of UI conditionals. Plus, the Starfish version
of the Database module will be removed soon in favour of this one,
anyway.

This is LA preparation, so code cleanup, test coverage, and so on are
coming up next.
George Gritsouk 1 year ago
parent
commit
a7ee15d5b6

+ 2 - 2
static/app/components/sidebar/index.tsx

@@ -242,8 +242,8 @@ function Sidebar({location, organization}: Props) {
         <SidebarItem
           {...sidebarItemProps}
           label={<GuideAnchor target="starfish">{t('Database')}</GuideAnchor>}
-          to={`/organizations/${organization.slug}/starfish/database/`}
-          id="starfish"
+          to={`/organizations/${organization.slug}/performance/database/`}
+          id="performance-database"
           icon={<SubitemDot collapsed={collapsed} />}
         />
       </SidebarAccordion>

+ 13 - 0
static/app/routes.tsx

@@ -1569,6 +1569,19 @@ function buildRoutes() {
         path="trends/"
         component={make(() => import('sentry/views/performance/trends'))}
       />
+      <Route path="database/">
+        <IndexRoute
+          component={make(
+            () => import('sentry/views/performance/database/databaseLandingPage')
+          )}
+        />
+        <Route
+          path="spans/span/:groupId/"
+          component={make(
+            () => import('sentry/views/performance/database/databaseSpanSummaryPage')
+          )}
+        />
+      </Route>
       <Route path="summary/">
         <IndexRoute
           component={make(

+ 97 - 0
static/app/views/performance/database/databaseLandingPage.tsx

@@ -0,0 +1,97 @@
+import styled from '@emotion/styled';
+
+import Breadcrumbs from 'sentry/components/breadcrumbs';
+import DatePageFilter from 'sentry/components/datePageFilter';
+import FeatureBadge from 'sentry/components/featureBadge';
+import * as Layout from 'sentry/components/layouts/thirds';
+import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import ProjectPageFilter from 'sentry/components/projectPageFilter';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {ModulePageProviders} from 'sentry/views/performance/database/modulePageProviders';
+import {ModuleName, SpanMetricsFields} from 'sentry/views/starfish/types';
+import {ActionSelector} from 'sentry/views/starfish/views/spans/selectors/actionSelector';
+import {DomainSelector} from 'sentry/views/starfish/views/spans/selectors/domainSelector';
+import SpansTable from 'sentry/views/starfish/views/spans/spansTable';
+import {SpanTimeCharts} from 'sentry/views/starfish/views/spans/spanTimeCharts';
+import {useModuleFilters} from 'sentry/views/starfish/views/spans/useModuleFilters';
+import {useModuleSort} from 'sentry/views/starfish/views/spans/useModuleSort';
+
+function DatabaseLandingPage() {
+  const organization = useOrganization();
+  const moduleName = ModuleName.DB;
+
+  const moduleFilters = useModuleFilters();
+  const sort = useModuleSort();
+
+  return (
+    <ModulePageProviders title={[t('Performance'), t('Database')].join(' — ')}>
+      <Layout.Header>
+        <Layout.HeaderContent>
+          <Breadcrumbs
+            crumbs={[
+              {
+                label: 'Performance',
+                to: normalizeUrl(`/organizations/${organization.slug}/performance/`),
+                preservePageFilters: true,
+              },
+              {
+                label: 'Database',
+              },
+            ]}
+          />
+
+          <Layout.Title>
+            {t('Database')}
+            <FeatureBadge type="alpha" />
+          </Layout.Title>
+        </Layout.HeaderContent>
+      </Layout.Header>
+
+      <Layout.Body>
+        <Layout.Main fullWidth>
+          <PaddedContainer>
+            <PageFilterBar condensed>
+              <ProjectPageFilter />
+              <DatePageFilter alignDropdown="left" />
+            </PageFilterBar>
+          </PaddedContainer>
+
+          <SpanTimeCharts moduleName={moduleName} appliedFilters={moduleFilters} />
+
+          <FilterOptionsContainer>
+            <ActionSelector
+              moduleName={moduleName}
+              value={moduleFilters[SpanMetricsFields.SPAN_ACTION] || ''}
+            />
+
+            <DomainSelector
+              moduleName={moduleName}
+              value={moduleFilters[SpanMetricsFields.SPAN_DOMAIN] || ''}
+            />
+          </FilterOptionsContainer>
+
+          <SpansTable moduleName={moduleName} sort={sort} limit={LIMIT} />
+        </Layout.Main>
+      </Layout.Body>
+    </ModulePageProviders>
+  );
+}
+
+const PaddedContainer = styled('div')`
+  margin-bottom: ${space(2)};
+`;
+
+const FilterOptionsContainer = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: ${space(2)};
+  margin-bottom: ${space(2)};
+  max-width: 800px;
+`;
+
+const LIMIT: number = 25;
+
+export default DatabaseLandingPage;

+ 255 - 0
static/app/views/performance/database/databaseSpanSummaryPage.tsx

@@ -0,0 +1,255 @@
+import {RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+
+import Breadcrumbs from 'sentry/components/breadcrumbs';
+import DatePageFilter from 'sentry/components/datePageFilter';
+import FeatureBadge from 'sentry/components/featureBadge';
+import * as Layout from 'sentry/components/layouts/thirds';
+import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Sort} from 'sentry/utils/discover/fields';
+import {RateUnits} from 'sentry/utils/discover/fields';
+import {formatRate} from 'sentry/utils/formatters';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {ModulePageProviders} from 'sentry/views/performance/database/modulePageProviders';
+import {AVG_COLOR, THROUGHPUT_COLOR} from 'sentry/views/starfish/colours';
+import Chart, {useSynchronizeCharts} from 'sentry/views/starfish/components/chart';
+import ChartPanel from 'sentry/views/starfish/components/chartPanel';
+import {SpanDescription} from 'sentry/views/starfish/components/spanDescription';
+import {useFullSpanFromTrace} from 'sentry/views/starfish/queries/useFullSpanFromTrace';
+import {
+  SpanSummaryQueryFilters,
+  useSpanMetrics,
+} from 'sentry/views/starfish/queries/useSpanMetrics';
+import {useSpanMetricsSeries} from 'sentry/views/starfish/queries/useSpanMetricsSeries';
+import {SpanMetricsFields, StarfishFunctions} from 'sentry/views/starfish/types';
+import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
+import {
+  getDurationChartTitle,
+  getThroughputChartTitle,
+} from 'sentry/views/starfish/views/spans/types';
+import {useModuleSort} from 'sentry/views/starfish/views/spans/useModuleSort';
+import {Block, BlockContainer} from 'sentry/views/starfish/views/spanSummaryPage/block';
+import {SampleList} from 'sentry/views/starfish/views/spanSummaryPage/sampleList';
+import {SpanMetricsRibbon} from 'sentry/views/starfish/views/spanSummaryPage/spanMetricsRibbon';
+import {SpanTransactionsTable} from 'sentry/views/starfish/views/spanSummaryPage/spanTransactionsTable';
+
+type Query = {
+  endpoint: string;
+  endpointMethod: string;
+  [QueryParameterNames.SORT]: string;
+};
+
+type Props = {
+  location: Location<Query>;
+} & RouteComponentProps<Query, {groupId: string}>;
+
+function SpanSummaryPage({params}: Props) {
+  const organization = useOrganization();
+  const location = useLocation<Query>();
+
+  const {groupId} = params;
+  const {endpoint, endpointMethod} = location.query;
+
+  const queryFilter: SpanSummaryQueryFilters = endpoint
+    ? {transactionName: endpoint, 'transaction.method': endpointMethod}
+    : {};
+
+  const sort = useModuleSort(DEFAULT_SORT);
+
+  const {data: fullSpan} = useFullSpanFromTrace(groupId);
+
+  const {data: spanMetrics} = useSpanMetrics(
+    groupId,
+    queryFilter,
+    [
+      SpanMetricsFields.SPAN_OP,
+      SpanMetricsFields.SPAN_DESCRIPTION,
+      SpanMetricsFields.SPAN_ACTION,
+      SpanMetricsFields.SPAN_DOMAIN,
+      'count()',
+      `${StarfishFunctions.SPM}()`,
+      `sum(${SpanMetricsFields.SPAN_SELF_TIME})`,
+      `avg(${SpanMetricsFields.SPAN_SELF_TIME})`,
+      `${StarfishFunctions.TIME_SPENT_PERCENTAGE}()`,
+      `${StarfishFunctions.HTTP_ERROR_COUNT}()`,
+    ],
+    'api.starfish.span-summary-page-metrics'
+  );
+
+  const span = {
+    ...spanMetrics,
+    [SpanMetricsFields.SPAN_GROUP]: groupId,
+  } as {
+    [SpanMetricsFields.SPAN_OP]: string;
+    [SpanMetricsFields.SPAN_DESCRIPTION]: string;
+    [SpanMetricsFields.SPAN_ACTION]: string;
+    [SpanMetricsFields.SPAN_DOMAIN]: string;
+    [SpanMetricsFields.SPAN_GROUP]: string;
+  };
+
+  const {isLoading: areSpanMetricsSeriesLoading, data: spanMetricsSeriesData} =
+    useSpanMetricsSeries(
+      groupId,
+      queryFilter,
+      [`avg(${SpanMetricsFields.SPAN_SELF_TIME})`, 'spm()', 'http_error_count()'],
+      'api.starfish.span-summary-page-metrics-chart'
+    );
+
+  useSynchronizeCharts([!areSpanMetricsSeriesLoading]);
+
+  const spanMetricsThroughputSeries = {
+    seriesName: span?.[SpanMetricsFields.SPAN_OP]?.startsWith('db')
+      ? 'Queries'
+      : 'Requests',
+    data: spanMetricsSeriesData?.['spm()'].data,
+  };
+
+  return (
+    <ModulePageProviders
+      title={[t('Performance'), t('Database'), t('Query Summary')].join(' — ')}
+    >
+      <Layout.Header>
+        <Layout.HeaderContent>
+          <Breadcrumbs
+            crumbs={[
+              {
+                label: 'Performance',
+                to: normalizeUrl(`/organizations/${organization.slug}/performance/`),
+                preservePageFilters: true,
+              },
+              {
+                label: 'Database',
+                to: normalizeUrl(
+                  `/organizations/${organization.slug}/performance/database`
+                ),
+                preservePageFilters: true,
+              },
+              {
+                label: 'Query Summary',
+              },
+            ]}
+          />
+          <Layout.Title>
+            {t('Query Summary')}
+            <FeatureBadge type="alpha" />
+          </Layout.Title>
+        </Layout.HeaderContent>
+      </Layout.Header>
+
+      <Layout.Body>
+        <Layout.Main fullWidth>
+          <HeaderContainer>
+            <PaddedContainer>
+              <PageFilterBar condensed>
+                <DatePageFilter alignDropdown="left" />
+              </PageFilterBar>
+            </PaddedContainer>
+
+            <SpanMetricsRibbon spanMetrics={span} />
+          </HeaderContainer>
+
+          {span?.[SpanMetricsFields.SPAN_DESCRIPTION] && (
+            <DescriptionContainer>
+              <SpanDescription
+                span={{
+                  ...span,
+                  [SpanMetricsFields.SPAN_DESCRIPTION]:
+                    fullSpan?.description ??
+                    spanMetrics?.[SpanMetricsFields.SPAN_DESCRIPTION],
+                }}
+              />
+            </DescriptionContainer>
+          )}
+
+          <BlockContainer>
+            <Block>
+              <ChartPanel
+                title={getThroughputChartTitle(span?.[SpanMetricsFields.SPAN_OP])}
+              >
+                <Chart
+                  height={CHART_HEIGHT}
+                  data={[spanMetricsThroughputSeries]}
+                  loading={areSpanMetricsSeriesLoading}
+                  utc={false}
+                  chartColors={[THROUGHPUT_COLOR]}
+                  isLineChart
+                  definedAxisTicks={4}
+                  aggregateOutputFormat="rate"
+                  rateUnit={RateUnits.PER_MINUTE}
+                  tooltipFormatterOptions={{
+                    valueFormatter: value => formatRate(value, RateUnits.PER_MINUTE),
+                  }}
+                />
+              </ChartPanel>
+            </Block>
+
+            <Block>
+              <ChartPanel
+                title={getDurationChartTitle(span?.[SpanMetricsFields.SPAN_OP])}
+              >
+                <Chart
+                  height={CHART_HEIGHT}
+                  data={[
+                    spanMetricsSeriesData?.[`avg(${SpanMetricsFields.SPAN_SELF_TIME})`],
+                  ]}
+                  loading={areSpanMetricsSeriesLoading}
+                  utc={false}
+                  chartColors={[AVG_COLOR]}
+                  isLineChart
+                  definedAxisTicks={4}
+                />
+              </ChartPanel>
+            </Block>
+          </BlockContainer>
+
+          {span && (
+            <SpanTransactionsTable
+              span={span}
+              sort={sort}
+              endpoint={endpoint}
+              endpointMethod={endpointMethod}
+            />
+          )}
+
+          <SampleList
+            projectId={span[SpanMetricsFields.PROJECT_ID]}
+            groupId={span[SpanMetricsFields.SPAN_GROUP]}
+            transactionName={endpoint}
+            transactionMethod={endpointMethod}
+          />
+        </Layout.Main>
+      </Layout.Body>
+    </ModulePageProviders>
+  );
+}
+
+const CHART_HEIGHT = 160;
+
+const DEFAULT_SORT: Sort = {
+  kind: 'desc',
+  field: 'time_spent_percentage(local)',
+};
+
+const PaddedContainer = styled('div')`
+  margin-bottom: ${space(2)};
+`;
+
+const HeaderContainer = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  flex-wrap: wrap;
+`;
+
+const DescriptionContainer = styled('div')`
+  width: 100%;
+  margin-bottom: ${space(2)};
+  font-size: 1rem;
+  line-height: 1.2;
+`;
+
+export default SpanSummaryPage;

+ 34 - 0
static/app/views/performance/database/modulePageProviders.tsx

@@ -0,0 +1,34 @@
+import Feature from 'sentry/components/acl/feature';
+import * as Layout from 'sentry/components/layouts/thirds';
+import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import useOrganization from 'sentry/utils/useOrganization';
+import {NoAccess} from 'sentry/views/performance/database/noAccess';
+import {RoutingContextProvider} from 'sentry/views/starfish/utils/routingContext';
+
+interface Props {
+  children: React.ReactNode;
+  title: string;
+}
+
+export function ModulePageProviders({title, children}: Props) {
+  const organization = useOrganization();
+
+  return (
+    <RoutingContextProvider value={{baseURL: '/performance/database'}}>
+      <PageFiltersContainer>
+        <SentryDocumentTitle title={title} orgSlug={organization.slug}>
+          <Layout.Page>
+            <Feature
+              features={['performance-database-view']}
+              organization={organization}
+              renderDisabled={NoAccess}
+            >
+              {children}
+            </Feature>
+          </Layout.Page>
+        </SentryDocumentTitle>
+      </PageFiltersContainer>
+    </RoutingContextProvider>
+  );
+}

+ 11 - 0
static/app/views/performance/database/noAccess.tsx

@@ -0,0 +1,11 @@
+import {Alert} from 'sentry/components/alert';
+import * as Layout from 'sentry/components/layouts/thirds';
+import {t} from 'sentry/locale';
+
+export function NoAccess() {
+  return (
+    <Layout.Page withPadding>
+      <Alert type="warning">{t("You don't have access to this feature")}</Alert>
+    </Layout.Page>
+  );
+}

+ 1 - 4
static/app/views/starfish/views/spanSummaryPage/spanTransactionsTable.tsx

@@ -35,6 +35,7 @@ import {
 import {extractRoute} from 'sentry/views/starfish/utils/extractRoute';
 import {useRoutingContext} from 'sentry/views/starfish/utils/routingContext';
 import {DataTitles, getThroughputTitle} from 'sentry/views/starfish/views/spans/types';
+import type {ValidSort} from 'sentry/views/starfish/views/spans/useModuleSort';
 
 type Row = {
   'avg(span.self_time)': number;
@@ -54,10 +55,6 @@ type Props = {
   endpointMethod?: string;
 };
 
-type ValidSort = Sort & {
-  field: keyof Row;
-};
-
 export type TableColumnHeader = GridColumnHeader<keyof Row>;
 
 export function SpanTransactionsTable({span, endpoint, endpointMethod, sort}: Props) {

+ 2 - 2
static/app/views/starfish/views/spans/useModuleSort.ts

@@ -24,12 +24,12 @@ export type ValidSort = Sort & {
  * Parses a `Sort` object from the URL. In case of multiple specified sorts
  * picks the first one, since span module UIs only support one sort at a time.
  */
-export function useModuleSort() {
+export function useModuleSort(fallback: Sort = DEFAULT_SORT) {
   const location = useLocation<Query>();
 
   return (
     fromSorts(location.query[QueryParameterNames.SORT]).filter(isAValidSort)[0] ??
-    DEFAULT_SORT
+    fallback
   );
 }