Browse Source

feat(profiling): Add profiles table and scatter plot (#31741)

This adds a scatter plot for a list of profiles and lists them in a table.
Tony Xiao 3 years ago
parent
commit
1b39c8cf5e

+ 1 - 1
static/app/components/charts/scatterChart.tsx

@@ -28,7 +28,7 @@ function ScatterChart({series, ...props}: Props) {
       series={series.map(({seriesName, data, ...options}) =>
         ScatterSeries({
           name: seriesName,
-          data: data.map(({name, value}) => [name, value, 'stuff']),
+          data: data.map(({name, value}) => [name, value]),
           ...options,
           animation: false,
         })

+ 40 - 0
static/app/types/profiling/trace.ts

@@ -0,0 +1,40 @@
+type Annotation = {
+  key: string;
+  values: string[];
+};
+
+export type Trace = {
+  app_version: string;
+  device_class: string;
+  device_locale: string;
+  device_manufacturer: string;
+  device_model: string;
+  failed: boolean;
+  id: string;
+  interaction_name: string;
+  start_time_unix: number;
+  trace_duration_ms: number;
+  backtrace_available?: boolean;
+  error_code?: number;
+  error_code_name?: string;
+  error_description?: string;
+  span_annotations?: Readonly<Annotation[]>;
+  spans?: Readonly<Span[]>;
+  trace_annotations?: Readonly<Annotation[]>;
+};
+
+export type Span = {
+  duration_ms: number;
+  id: string | number;
+  name: string;
+  relative_start_ms: number;
+  thread_name: string;
+  annotations?: Readonly<Annotation[]>;
+  children?: Readonly<Span[]>;
+  network_request?: Readonly<{
+    method: string;
+    status_code: number;
+    success: boolean;
+  }>;
+  queue_label?: string;
+};

+ 57 - 3
static/app/views/profiling/content.tsx

@@ -1,7 +1,61 @@
-type Props = {};
+import styled from '@emotion/styled';
+import {Location} from 'history';
 
-function ProfilingContent(_props: Props) {
-  return <h1>Profiling</h1>;
+import * as Layout from 'sentry/components/layouts/thirds';
+import NoProjectMessage from 'sentry/components/noProjectMessage';
+import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import PageHeading from 'sentry/components/pageHeading';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t} from 'sentry/locale';
+import {PageContent} from 'sentry/styles/organization';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {ProfilingScatterChart} from './landing/profilingScatterChart';
+import {ProfilingTable} from './landing/profilingTable';
+
+interface ProfilingContentProps {
+  location: Location;
 }
 
+function ProfilingContent({location}: ProfilingContentProps) {
+  const organization = useOrganization();
+  const dateSelection = normalizeDateTimeParams(location.query);
+
+  return (
+    <SentryDocumentTitle title={t('Profiling')} orgSlug={organization.slug}>
+      <PageFiltersContainer>
+        <NoProjectMessage organization={organization}>
+          <StyledPageContent>
+            <Layout.Header>
+              <Layout.HeaderContent>
+                <StyledHeading>{t('Profiling')}</StyledHeading>
+              </Layout.HeaderContent>
+            </Layout.Header>
+            <Layout.Body>
+              <Layout.Main fullWidth>
+                <ProfilingScatterChart
+                  {...dateSelection}
+                  traces={[]}
+                  loading={false}
+                  reloading={false}
+                />
+                <ProfilingTable location={location} traces={[]} />
+              </Layout.Main>
+            </Layout.Body>
+          </StyledPageContent>
+        </NoProjectMessage>
+      </PageFiltersContainer>
+    </SentryDocumentTitle>
+  );
+}
+
+const StyledPageContent = styled(PageContent)`
+  padding: 0;
+`;
+
+const StyledHeading = styled(PageHeading)`
+  line-height: 40px;
+`;
+
 export default ProfilingContent;

+ 153 - 0
static/app/views/profiling/landing/profilingScatterChart.tsx

@@ -0,0 +1,153 @@
+import React from 'react';
+import {browserHistory, withRouter, WithRouterProps} from 'react-router';
+import {useTheme} from '@emotion/react';
+import {Location} from 'history';
+
+import ChartZoom from 'sentry/components/charts/chartZoom';
+import OptionSelector from 'sentry/components/charts/optionSelector';
+import ScatterChart from 'sentry/components/charts/scatterChart';
+import {
+  ChartContainer,
+  ChartControls,
+  InlineContainer,
+} from 'sentry/components/charts/styles';
+import TransitionChart from 'sentry/components/charts/transitionChart';
+import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
+import {getSeriesSelection} from 'sentry/components/charts/utils';
+import {Panel} from 'sentry/components/panels';
+import {t} from 'sentry/locale';
+import {Series, SeriesDataUnit} from 'sentry/types/echarts';
+import {Trace} from 'sentry/types/profiling/trace';
+import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
+import {Theme} from 'sentry/utils/theme';
+
+import {COLOR_ENCODINGS, getColorEncodingFromLocation} from '../utils';
+
+interface ProfilingScatterChartProps extends WithRouterProps {
+  loading: boolean;
+  location: Location;
+  reloading: boolean;
+  traces: Trace[];
+  end?: string;
+  start?: string;
+  statsPeriod?: string | null;
+  utc?: string;
+}
+
+function ProfilingScatterChart({
+  router,
+  location,
+  traces,
+  loading,
+  reloading,
+  start,
+  end,
+  statsPeriod,
+  utc,
+}: ProfilingScatterChartProps) {
+  const theme = useTheme();
+
+  const colorEncoding = React.useMemo(
+    () => getColorEncodingFromLocation(location),
+    [location]
+  );
+
+  const series: Series[] = React.useMemo(() => {
+    const seriesMap: Record<string, SeriesDataUnit[]> = {};
+
+    for (const row of traces) {
+      const seriesName = row[colorEncoding];
+      if (!seriesMap[seriesName]) {
+        seriesMap[seriesName] = [];
+      }
+      seriesMap[seriesName].push({
+        name: row.start_time_unix * 1000,
+        value: row.trace_duration_ms,
+      });
+    }
+
+    return Object.entries(seriesMap).map(([seriesName, data]) => ({seriesName, data}));
+  }, [colorEncoding]);
+
+  const chartOptions = React.useMemo(
+    () => makeScatterChartOptions({location, theme}),
+    [location, theme]
+  );
+
+  const handleColorEncodingChange = React.useCallback(
+    value => {
+      browserHistory.push({
+        ...location,
+        query: {
+          ...location.query,
+          colorEncoding: value,
+        },
+      });
+    },
+    [location]
+  );
+
+  return (
+    <Panel>
+      <ChartContainer>
+        <ChartZoom
+          router={router}
+          period={statsPeriod}
+          start={start}
+          end={end}
+          utc={utc === 'true'}
+        >
+          {zoomRenderProps => {
+            return (
+              <TransitionChart loading={loading} reloading={reloading}>
+                <TransparentLoadingMask visible={reloading} />
+                <ScatterChart series={series} {...chartOptions} {...zoomRenderProps} />
+              </TransitionChart>
+            );
+          }}
+        </ChartZoom>
+      </ChartContainer>
+      <ChartControls>
+        <InlineContainer>
+          <OptionSelector
+            title={t('Group By')}
+            selected={colorEncoding}
+            options={COLOR_ENCODINGS}
+            onChange={handleColorEncodingChange}
+          />
+        </InlineContainer>
+      </ChartControls>
+    </Panel>
+  );
+}
+
+function makeScatterChartOptions({location, theme}: {location: Location; theme: Theme}) {
+  return {
+    grid: {
+      left: '10px',
+      right: '10px',
+      top: '40px',
+      bottom: '0px',
+    },
+    tooltip: {
+      trigger: 'item' as const,
+      valueFormatter: (value: number) => tooltipFormatter(value, 'p50()'),
+    },
+    yAxis: {
+      axisLabel: {
+        color: theme.chartLabel,
+        formatter: (value: number) => axisLabelFormatter(value, 'p50()'),
+      },
+    },
+    legend: {
+      right: 10,
+      top: 5,
+      selected: getSeriesSelection(location),
+    },
+    onClick: _params => {}, // TODO
+  };
+}
+
+const ProfilingScatterChartWithRouter = withRouter(ProfilingScatterChart);
+
+export {ProfilingScatterChartWithRouter as ProfilingScatterChart};

+ 82 - 0
static/app/views/profiling/landing/profilingTable.tsx

@@ -0,0 +1,82 @@
+import {Location} from 'history';
+
+import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
+import {t} from 'sentry/locale';
+import {Trace} from 'sentry/types/profiling/trace';
+
+import {ProfilingTableCell} from './profilingTableCell';
+import {TableColumnKey, TableColumnOrders} from './types';
+
+interface ProfilingTableProps {
+  location: Location;
+  traces: Trace[];
+}
+
+function ProfilingTable({location, traces}: ProfilingTableProps) {
+  return (
+    <GridEditable
+      isLoading={false}
+      data={traces}
+      columnOrder={COLUMN_ORDER.map(key => COLUMNS[key])}
+      columnSortBy={[]}
+      grid={{renderBodyCell: ProfilingTableCell}}
+      location={location}
+    />
+  );
+}
+
+const COLUMN_ORDER: TableColumnKey[] = [
+  'id',
+  'failed',
+  'app_version',
+  'interaction_name',
+  'start_time_unix',
+  'trace_duration_ms',
+  'device_model',
+  'device_class',
+];
+
+const COLUMNS: TableColumnOrders = {
+  id: {
+    key: 'id',
+    name: t('Flamegraph'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  failed: {
+    key: 'failed',
+    name: t('Status'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  app_version: {
+    key: 'app_version',
+    name: t('Version'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  interaction_name: {
+    key: 'interaction_name',
+    name: t('Interaction Name'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  start_time_unix: {
+    key: 'start_time_unix',
+    name: t('Timestamp'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  trace_duration_ms: {
+    key: 'trace_duration_ms',
+    name: t('Duration'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  device_model: {
+    key: 'device_model',
+    name: t('Device Model'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  device_class: {
+    key: 'device_class',
+    name: t('Device Class'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+};
+
+export {ProfilingTable};

+ 48 - 0
static/app/views/profiling/landing/profilingTableCell.tsx

@@ -0,0 +1,48 @@
+import DateTime from 'sentry/components/dateTime';
+import Duration from 'sentry/components/duration';
+import {IconCheckmark, IconClose} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {Container, NumberContainer} from 'sentry/utils/discover/styles';
+
+import {TableColumn, TableDataRow} from './types';
+
+function ProfilingTableCell(
+  column: TableColumn,
+  dataRow: TableDataRow,
+  _rowIndex: number,
+  _columnIndex: number
+) {
+  const value = dataRow[column.key];
+
+  switch (column.key) {
+    case 'id':
+      // TODO: this needs to be a link
+      return <Container>{t('View Flamegraph')}</Container>;
+    case 'failed':
+      return (
+        <Container>
+          {status ? (
+            <IconCheckmark size="sm" color="green300" isCircled />
+          ) : (
+            <IconClose size="sm" color="red300" isCircled />
+          )}
+        </Container>
+      );
+    case 'start_time_unix':
+      return (
+        <Container>
+          <DateTime date={value} />
+        </Container>
+      );
+    case 'trace_duration_ms':
+      return (
+        <NumberContainer>
+          <Duration seconds={value / 1000} abbreviation />
+        </NumberContainer>
+      );
+    default:
+      return <Container>{value}</Container>;
+  }
+}
+
+export {ProfilingTableCell};

+ 24 - 0
static/app/views/profiling/landing/types.tsx

@@ -0,0 +1,24 @@
+import {GridColumnOrder} from 'sentry/components/gridEditable';
+import {Trace} from 'sentry/types/profiling/trace';
+
+export type TableColumnKey = keyof Trace;
+
+type NonTableColumnKey =
+  | 'device_locale'
+  | 'device_manufacturer'
+  | 'backtrace_available'
+  | 'error_code'
+  | 'error_code_name'
+  | 'error_description'
+  | 'span_annotations'
+  | 'spans'
+  | 'trace_annotations';
+
+export type TableColumnOrders = Omit<
+  Record<TableColumnKey, TableColumn>,
+  NonTableColumnKey
+>;
+
+export type TableColumn = GridColumnOrder<TableColumnKey>;
+
+export type TableDataRow = Omit<Record<TableColumnKey, any>, NonTableColumnKey>;

+ 37 - 0
static/app/views/profiling/utils.tsx

@@ -0,0 +1,37 @@
+import {Location} from 'history';
+
+import {t} from 'sentry/locale';
+import {SelectValue} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {decodeScalar} from 'sentry/utils/queryString';
+
+type ColorEncoding =
+  | 'app_version'
+  | 'device_manufacturer'
+  | 'device_model'
+  | 'device_os_version'
+  | 'interaction_name'
+  | 'android_api_level';
+
+const COLOR_ENCODING_LABELS: Record<ColorEncoding, string> = {
+  app_version: t('App Version'),
+  device_manufacturer: t('Device Manufacturer'),
+  device_model: t('Device Model'),
+  device_os_version: t('Device Os Version'),
+  interaction_name: t('Interaction Name'),
+  android_api_level: t('Android Api Level'),
+};
+
+export const COLOR_ENCODINGS: SelectValue<ColorEncoding>[] = Object.entries(
+  COLOR_ENCODING_LABELS
+).map(([value, label]) => ({label, value: value as ColorEncoding}));
+
+export function getColorEncodingFromLocation(location: Location): ColorEncoding {
+  const colorCoding = decodeScalar(location.query.colorEncoding);
+
+  if (defined(colorCoding) && COLOR_ENCODING_LABELS.hasOwnProperty(colorCoding)) {
+    return colorCoding as ColorEncoding;
+  }
+
+  return 'interaction_name';
+}