Browse Source

feat(perf): Add HTTP code breakdown chart to sample panel (#68652)

Fancy new chart, fancy new selector. See a full breakdown of _all_ HTTP
codes within the selected range!
George Gritsouk 11 months ago
parent
commit
ce6edb99be

+ 1 - 1
static/app/utils/tokenizeSearch.tsx

@@ -1,6 +1,6 @@
 import {escapeDoubleQuotes} from 'sentry/utils';
 
-const ALLOWED_WILDCARD_FIELDS = ['span.description'];
+const ALLOWED_WILDCARD_FIELDS = ['span.description', 'span.status_code'];
 export const EMPTY_OPTION_VALUE = '(empty)' as const;
 
 export enum TokenType {

+ 12 - 9
static/app/views/performance/http/httpSamplesPanel.spec.tsx

@@ -111,6 +111,7 @@ describe('HTTPSamplesPanel', () => {
           transaction: '/api/0/users',
           transactionMethod: 'GET',
           panel: 'status',
+          responseCodeClass: '3',
         },
         hash: '',
         state: undefined,
@@ -127,12 +128,18 @@ describe('HTTPSamplesPanel', () => {
           }),
         ],
         body: {
-          'spm()': {
+          '301': {
             data: [
               [1699907700, [{count: 7810.2}]],
               [1699908000, [{count: 1216.8}]],
             ],
           },
+          '304': {
+            data: [
+              [1699907700, [{count: 2701.5}]],
+              [1699908000, [{count: 78.12}]],
+            ],
+          },
         },
       });
     });
@@ -177,22 +184,18 @@ describe('HTTPSamplesPanel', () => {
             dataset: 'spansMetrics',
             environment: [],
             excludeOther: 0,
-            field: [],
+            field: ['span.status_code', 'count()'],
             interval: '30m',
             orderby: undefined,
             partial: 1,
             per_page: 50,
             project: [],
             query:
-              'span.module:http span.domain:"\\*.sentry.dev" transaction:/api/0/users',
+              'span.module:http span.domain:"\\*.sentry.dev" transaction:/api/0/users span.status_code:[300,301,302,303,304,305,307,308]',
             referrer: 'api.starfish.http-module-samples-panel-response-code-chart',
             statsPeriod: '10d',
-            topEvents: undefined,
-            yAxis: [
-              'http_response_rate(3)',
-              'http_response_rate(4)',
-              'http_response_rate(5)',
-            ],
+            topEvents: '5',
+            yAxis: 'count()',
           },
         })
       );

+ 99 - 34
static/app/views/performance/http/httpSamplesPanel.tsx

@@ -5,6 +5,7 @@ import * as qs from 'query-string';
 
 import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
 import {Button} from 'sentry/components/button';
+import {CompactSelect} from 'sentry/components/compactSelect';
 import {SegmentedControl} from 'sentry/components/segmentedControl';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
@@ -19,9 +20,11 @@ import useProjects from 'sentry/utils/useProjects';
 import useRouter from 'sentry/utils/useRouter';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import {AverageValueMarkLine} from 'sentry/views/performance/charts/averageValueMarkLine';
+import {HTTP_RESPONSE_STATUS_CODES} from 'sentry/views/performance/http/definitions';
 import {DurationChart} from 'sentry/views/performance/http/durationChart';
 import decodePanel from 'sentry/views/performance/http/queryParameterDecoders/panel';
-import {ResponseRateChart} from 'sentry/views/performance/http/responseRateChart';
+import decodeResponseCodeClass from 'sentry/views/performance/http/queryParameterDecoders/responseCodeClass';
+import {ResponseCodeCountChart} from 'sentry/views/performance/http/responseCodeCountChart';
 import {SpanSamplesTable} from 'sentry/views/performance/http/spanSamplesTable';
 import {useDebouncedState} from 'sentry/views/performance/http/useDebouncedState';
 import {useSpanSamples} from 'sentry/views/performance/http/useSpanSamples';
@@ -32,6 +35,7 @@ import DetailPanel from 'sentry/views/starfish/components/detailPanel';
 import {getTimeSpentExplanation} from 'sentry/views/starfish/components/tableCells/timeSpentCell';
 import {useSpanMetrics} from 'sentry/views/starfish/queries/useSpanMetrics';
 import {useSpanMetricsSeries} from 'sentry/views/starfish/queries/useSpanMetricsSeries';
+import {useSpanMetricsTopNSeries} from 'sentry/views/starfish/queries/useSpanMetricsTopNSeries';
 import {
   ModuleName,
   SpanFunction,
@@ -53,6 +57,7 @@ export function HTTPSamplesPanel() {
       transaction: decodeScalar,
       transactionMethod: decodeScalar,
       panel: decodePanel,
+      responseCodeClass: decodeResponseCodeClass,
     },
   });
 
@@ -86,19 +91,50 @@ export function HTTPSamplesPanel() {
     });
   };
 
+  const handleResponseCodeClassChange = newResponseCodeClass => {
+    router.replace({
+      pathname: location.pathname,
+      query: {
+        ...location.query,
+        responseCodeClass: newResponseCodeClass.value,
+      },
+    });
+  };
+
   const isPanelOpen = Boolean(detailKey);
 
+  // The ribbon is above the data selectors, and not affected by them. So, it has its own filters.
+  const ribbonFilters: SpanMetricsQueryFilters = {
+    'span.module': ModuleName.HTTP,
+    'span.domain': query.domain,
+    transaction: query.transaction,
+  };
+
+  // These filters are for the charts and samples tables
   const filters: SpanMetricsQueryFilters = {
     'span.module': ModuleName.HTTP,
     'span.domain': query.domain,
     transaction: query.transaction,
   };
 
+  const responseCodeInRange = query.responseCodeClass
+    ? Object.keys(HTTP_RESPONSE_STATUS_CODES).filter(code =>
+        code.startsWith(query.responseCodeClass)
+      )
+    : [];
+
+  if (responseCodeInRange.length > 0) {
+    // TODO: Allow automatic array parameter concatenation
+    filters['span.status_code'] = `[${responseCodeInRange.join(',')}]`;
+  }
+
+  const search = MutableSearch.fromQueryObject(filters);
+
   const {
     data: domainTransactionMetrics,
     isFetching: areDomainTransactionMetricsFetching,
   } = useSpanMetrics({
-    search: MutableSearch.fromQueryObject(filters),
+    search: MutableSearch.fromQueryObject(ribbonFilters),
     fields: [
       `${SpanFunction.SPM}()`,
       `avg(${SpanMetricsField.SPAN_SELF_TIME})`,
@@ -117,7 +153,7 @@ export function HTTPSamplesPanel() {
     data: durationData,
     error: durationError,
   } = useSpanMetricsSeries({
-    search: MutableSearch.fromQueryObject(filters),
+    search,
     yAxis: [`avg(span.self_time)`],
     enabled: isPanelOpen && query.panel === 'duration',
     referrer: 'api.starfish.http-module-samples-panel-duration-chart',
@@ -127,9 +163,11 @@ export function HTTPSamplesPanel() {
     isFetching: isResponseCodeDataLoading,
     data: responseCodeData,
     error: responseCodeError,
-  } = useSpanMetricsSeries({
-    search: MutableSearch.fromQueryObject(filters),
-    yAxis: ['http_response_rate(3)', 'http_response_rate(4)', 'http_response_rate(5)'],
+  } = useSpanMetricsTopNSeries({
+    search,
+    fields: ['span.status_code', 'count()'],
+    yAxis: ['count()'],
+    topEvents: 5,
     enabled: isPanelOpen && query.panel === 'status',
     referrer: 'api.starfish.http-module-samples-panel-response-code-chart',
   });
@@ -142,7 +180,7 @@ export function HTTPSamplesPanel() {
     error: samplesDataError,
     refetch: refetchSpanSamples,
   } = useSpanSamples({
-    search: MutableSearch.fromQueryObject(filters),
+    search,
     fields: [
       SpanIndexedField.TRANSACTION_ID,
       SpanIndexedField.SPAN_DESCRIPTION,
@@ -276,18 +314,29 @@ export function HTTPSamplesPanel() {
           </ModuleLayout.Full>
 
           <ModuleLayout.Full>
-            <SegmentedControl
-              value={query.panel}
-              onChange={handlePanelChange}
-              aria-label={t('Choose breakdown type')}
-            >
-              <SegmentedControl.Item key="duration">
-                {t('By Duration')}
-              </SegmentedControl.Item>
-              <SegmentedControl.Item key="status">
-                {t('By Response Code')}
-              </SegmentedControl.Item>
-            </SegmentedControl>
+            <PanelControls>
+              <SegmentedControl
+                value={query.panel}
+                onChange={handlePanelChange}
+                aria-label={t('Choose breakdown type')}
+              >
+                <SegmentedControl.Item key="duration">
+                  {t('By Duration')}
+                </SegmentedControl.Item>
+                <SegmentedControl.Item key="status">
+                  {t('By Response Code')}
+                </SegmentedControl.Item>
+              </SegmentedControl>
+
+              <CompactSelect
+                value={query.responseCodeClass}
+                options={HTTP_RESPONSE_CODE_CLASS_OPTIONS}
+                onChange={handleResponseCodeClassChange}
+                triggerProps={{
+                  prefix: t('Response Code'),
+                }}
+              />
+            </PanelControls>
           </ModuleLayout.Full>
 
           {query.panel === 'duration' && (
@@ -339,21 +388,8 @@ export function HTTPSamplesPanel() {
 
           {query.panel === 'status' && (
             <ModuleLayout.Full>
-              <ResponseRateChart
-                series={[
-                  {
-                    ...responseCodeData[`http_response_rate(3)`],
-                    seriesName: t('3XX'),
-                  },
-                  {
-                    ...responseCodeData[`http_response_rate(4)`],
-                    seriesName: t('4XX'),
-                  },
-                  {
-                    ...responseCodeData[`http_response_rate(5)`],
-                    seriesName: t('5XX'),
-                  },
-                ]}
+              <ResponseCodeCountChart
+                series={Object.values(responseCodeData).filter(Boolean)}
                 isLoading={isResponseCodeDataLoading}
                 error={responseCodeError}
               />
@@ -377,6 +413,29 @@ const SpanSummaryProjectAvatar = styled(ProjectAvatar)`
   padding-right: ${space(1)};
 `;
 
+const HTTP_RESPONSE_CODE_CLASS_OPTIONS = [
+  {
+    value: '',
+    label: t('All'),
+  },
+  {
+    value: '2',
+    label: t('2XXs'),
+  },
+  {
+    value: '3',
+    label: t('3XXs'),
+  },
+  {
+    value: '4',
+    label: t('4XXs'),
+  },
+  {
+    value: '5',
+    label: t('5XXs'),
+  },
+];
+
 const HeaderContainer = styled('div')`
   display: grid;
   grid-template-rows: auto auto auto;
@@ -404,3 +463,9 @@ const MetricsRibbon = styled('div')`
   flex-wrap: wrap;
   gap: ${space(4)};
 `;
+
+const PanelControls = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  gap: ${space(2)};
+`;

+ 23 - 0
static/app/views/performance/http/queryParameterDecoders/responseCodeClass.tsx

@@ -0,0 +1,23 @@
+import {decodeScalar} from 'sentry/utils/queryString';
+
+const OPTIONS = ['' as const, '2' as const, '3' as const, '4' as const, '5' as const];
+const DEFAULT = '';
+
+type ResponseCodeClass = (typeof OPTIONS)[number];
+
+export default function decode(
+  value: string | string[] | undefined | null
+): ResponseCodeClass {
+  const decodedValue = decodeScalar(value, DEFAULT);
+
+  if (isAValidOption(decodedValue)) {
+    return decodedValue;
+  }
+
+  return DEFAULT;
+}
+
+function isAValidOption(maybeOption: string): maybeOption is ResponseCodeClass {
+  // Manually widen  to allow the comparison to string
+  return (OPTIONS as unknown as string[]).includes(maybeOption as ResponseCodeClass);
+}

+ 33 - 0
static/app/views/performance/http/responseCodeCountChart.tsx

@@ -0,0 +1,33 @@
+import {t} from 'sentry/locale';
+import type {Series} from 'sentry/types/echarts';
+import {CHART_HEIGHT} from 'sentry/views/performance/database/settings';
+import Chart, {ChartType} from 'sentry/views/starfish/components/chart';
+import ChartPanel from 'sentry/views/starfish/components/chartPanel';
+
+interface Props {
+  isLoading: boolean;
+  series: Series[];
+  error?: Error | null;
+}
+
+export function ResponseCodeCountChart({series, isLoading, error}: Props) {
+  return (
+    <ChartPanel title={t('Response Codes')}>
+      <Chart
+        showLegend
+        height={CHART_HEIGHT}
+        grid={{
+          left: '4px',
+          right: '0',
+          top: '8px',
+          bottom: '0',
+        }}
+        data={series}
+        loading={isLoading}
+        error={error}
+        type={ChartType.LINE}
+        aggregateOutputFormat="number"
+      />
+    </ChartPanel>
+  );
+}