Browse Source

feat(stats): Display subseries in tooltip (#74547)

Priscila Oliveira 7 months ago
parent
commit
8c984419db

+ 4 - 0
static/app/components/charts/baseChart.tsx

@@ -111,6 +111,10 @@ interface TooltipOption
     name: string,
     seriesParams?: TooltipComponentFormatterCallback<any>
   ) => string;
+  /**
+   * If true does not display sublabels with a value of 0.
+   */
+  skipZeroValuedSubLabels?: boolean;
   /**
    * Array containing data that is used to display indented sublabels.
    */

+ 13 - 2
static/app/components/charts/components/tooltip.tsx

@@ -118,6 +118,10 @@ export type FormatterOptions = Pick<
      * Limit the number of series rendered in the tooltip and display "+X more".
      */
     limit?: number;
+    /**
+     * If true does not display sublabels with a value of 0.
+     */
+    skipZeroValuedSubLabels?: boolean;
     /**
      * Array containing data that is used to display indented sublabels.
      */
@@ -138,6 +142,7 @@ export function getFormatter({
   subLabels = [],
   addSecondsToTimeFormat = false,
   limit,
+  skipZeroValuedSubLabels,
 }: FormatterOptions): TooltipComponentFormatterCallback<any> {
   const getFilter = (seriesParam: any) => {
     // Series do not necessarily have `data` defined, e.g. releases don't have `data`, but rather
@@ -253,7 +258,11 @@ export function getFormatter({
           ];
 
           for (const subLabel of filteredSubLabels) {
-            const serieValue = subLabel.data[serie.dataIndex].value;
+            const serieValue = subLabel.data[serie.dataIndex]?.value ?? 0;
+
+            if (skipZeroValuedSubLabels && serieValue === 0) {
+              continue;
+            }
 
             labelWithSubLabels.push(
               `<div><span class="tooltip-label tooltip-label-indent"><strong>${
@@ -261,7 +270,7 @@ export function getFormatter({
               }</strong></span> ${valueFormatter(serieValue)}</div>`
             );
 
-            acc.total = acc.total + subLabel.data[serie.dataIndex].value;
+            acc.total = acc.total + serieValue;
           }
 
           acc.series.push(labelWithSubLabels.join(''));
@@ -336,6 +345,7 @@ export function computeChartTooltip(
     hideDelay,
     subLabels,
     chartId,
+    skipZeroValuedSubLabels,
     ...props
   }: Props,
   theme: Theme
@@ -355,6 +365,7 @@ export function computeChartTooltip(
       nameFormatter,
       markerFormatter,
       subLabels,
+      skipZeroValuedSubLabels,
     });
 
   return {

+ 2 - 1
static/app/types/core.tsx

@@ -118,10 +118,11 @@ export enum Outcome {
   ACCEPTED = 'accepted',
   FILTERED = 'filtered',
   INVALID = 'invalid',
-  DROPPED = 'dropped', // this is not a real outcome coming from the server
+  ABUSE = 'abuse',
   RATE_LIMITED = 'rate_limited',
   CLIENT_DISCARD = 'client_discard',
   CARDINALITY_LIMITED = 'cardinality_limited',
+  DROPPED = 'dropped', // this is not a real outcome coming from the server
 }
 
 export type IntervalPeriod = ReturnType<typeof getInterval>;

+ 6 - 3
static/app/utils/theme.tsx

@@ -424,9 +424,12 @@ const dataCategory = {
  * Default colors for data usage outcomes
  */
 const outcome = {
-  [Outcome.ACCEPTED]: CHART_PALETTE[0][0],
-  [Outcome.FILTERED]: CHART_PALETTE[1][1],
-  [Outcome.DROPPED]: CHART_PALETTE[5][3],
+  [Outcome.ACCEPTED]: CHART_PALETTE[5][0], // #444674 - chart 100
+  [Outcome.FILTERED]: CHART_PALETTE[5][2], // #B85586 - chart 300
+  [Outcome.RATE_LIMITED]: CHART_PALETTE[5][3], // #E9626E - chart 400
+  [Outcome.INVALID]: CHART_PALETTE[5][4], // #F58C46 - chart 500
+  [Outcome.CLIENT_DISCARD]: CHART_PALETTE[5][5], // #F2B712 - chart 600
+  [Outcome.DROPPED]: CHART_PALETTE[5][3], // #F58C46 - chart 500
 };
 
 const generateAlertTheme = (colors: BaseColors, alias: Aliases) => ({

+ 194 - 0
static/app/views/organizationStats/getReasonGroupName.ts

@@ -0,0 +1,194 @@
+import {Outcome} from 'sentry/types';
+
+// List of Relay's current invalid reasons - https://github.com/getsentry/relay/blob/89a8dd7caaad1f126e1cacced0d73bb50fcd4f5a/relay-server/src/services/outcome.rs#L333
+enum DiscardReason {
+  DUPLICATE = 'duplicate',
+  PROJECT_ID = 'project_id',
+  AUTH_VERSION = 'auth_version',
+  AUTH_CLIENT = 'auth_client',
+  NO_DATA = 'no_data',
+  DISALLOWED_METHOD = 'disallowed_method',
+  CONTENT_TYPE = 'content_type',
+  INVALID_MULTIPART = 'invalid_multipart',
+  INVALID_MSGPACK = 'invalid_msgpack',
+  INVALID_JSON = 'invalid_json',
+  INVALID_ENVELOPE = 'invalid_envelope',
+  TIMESTAMP = 'timestamp',
+  DUPLICATE_ITEM = 'duplicate_item',
+  INVALID_TRANSACTION = 'invalid_transaction',
+  INVALID_SPAN = 'invalid_span',
+  INVALID_REPLAY = 'invalid_replay',
+  INVALID_REPLAY_RECORDING = 'invalid_replay_recording',
+  INVALID_REPLAY_VIDEO = 'invalid_replay_video',
+  PAYLOAD = 'payload',
+  INVALID_COMPRESSION = 'invalid_compression',
+  TOO_LARGE = 'too_large',
+  MISSING_MINIDUMP_UPLOAD = 'missing_minidump_upload',
+  INVALID_MINIDUMP = 'invalid_minidump',
+  SECURITY_REPORT = 'security_report',
+  SECURITY_REPORT_TYPE = 'security_report_type',
+  PROCESS_UNREAL = 'process_unreal',
+  CORS = 'cors',
+  NO_EVENT_PAYLOAD = 'no_event_payload',
+  EMPTY_ENVELOPE = 'empty_envelope',
+  INVALID_REPLAY_NO_PAYLOAD = 'invalid_replay_no_payload',
+  TRANSACTION_SAMPLED = 'transaction_sampled',
+  INTERNAL = 'internal',
+  MULTI_PROJECT_ID = 'multi_project_id',
+  PROJECT_STATE = 'project_state',
+  PROJECT_STATE_PII = 'project_state_pii',
+  INVALID_REPLAY_PII_SCRUBBER_FAILED = 'invalid_replay_pii_scrubber_failed',
+  FEATURE_DISABLED = 'feature_disabled',
+}
+
+// List of Relay's current filtered reasons - https://github.com/getsentry/relay/blob/ce5520b4a3bea022808982a52a66bfddacc70ac0/relay-filter/src/common.rs#L11
+enum FilteredReason {
+  BROWSER_EXTENSION = 'browser-extensions',
+  DENIED_NAME = 'denied-name',
+  DISABLED_NAMESPACE = 'disabled-namespace',
+  ERROR_MESSAGE = 'error-message',
+  FILTERED_TRANSACTION = 'filtered-transaction',
+  INVALID_CSP = 'invalid-csp',
+  IP_ADDRESS = 'ip-address',
+  LEGACY_BROWSER = 'legacy-browsers',
+  LOCALHOST = 'localhost',
+  RELEASE_VERSION = 'release-version',
+  WEB_CRAWLER = 'web-crawlers',
+}
+
+// List of Client Discard Reason according to the Client Report's doc - https://develop.sentry.dev/sdk/client-reports/#envelope-item-payload
+enum ClientDiscardReason {
+  QUEUE_OVERFLOW = 'queue_overflow',
+  CACHE_OVERFLOW = 'cache_overflow',
+  RATELIMIT_BACKOFF = 'ratelimit_backoff',
+  NETWORK_ERROR = 'network_error',
+  SAMPLE_RATE = 'sample_rate',
+  BEFORE_SEND = 'before_send',
+  EVENT_PROCESSSOR = 'event_processor',
+  SEND_ERROR = 'send_error',
+  INTERNAL_SDK_ERROR = 'internal_sdk_error',
+  INSUFFICIENT_DATA = 'insufficient_data',
+  BACKPRESSURE = 'backpressure',
+}
+
+enum RateLimitedReason {
+  KEY_QUOTA = 'key_quota',
+  SPIKE_PROTECTION = 'spike_protection',
+  SMART_RATE_LIMIT = 'smart_rate_limit',
+}
+
+// Invalid reasons should not be exposed directly, but instead in the following groups:
+const invalidReasonsGroup: Record<string, DiscardReason[]> = {
+  duplicate: [DiscardReason.DUPLICATE],
+  project_missing: [DiscardReason.PROJECT_ID],
+  invalid_request: [
+    DiscardReason.AUTH_VERSION,
+    DiscardReason.AUTH_CLIENT,
+    DiscardReason.NO_DATA,
+    DiscardReason.DISALLOWED_METHOD,
+    DiscardReason.CONTENT_TYPE,
+    DiscardReason.INVALID_MULTIPART,
+    DiscardReason.INVALID_MSGPACK,
+    DiscardReason.INVALID_JSON,
+    DiscardReason.INVALID_ENVELOPE,
+    DiscardReason.TIMESTAMP,
+    DiscardReason.DUPLICATE_ITEM,
+  ],
+  invalid_data: [
+    DiscardReason.INVALID_TRANSACTION,
+    DiscardReason.INVALID_SPAN,
+    DiscardReason.INVALID_REPLAY,
+    DiscardReason.INVALID_REPLAY_RECORDING,
+    DiscardReason.INVALID_REPLAY_VIDEO,
+  ],
+  payload: [DiscardReason.PAYLOAD, DiscardReason.INVALID_COMPRESSION],
+  too_large: [DiscardReason.TOO_LARGE],
+  minidump: [DiscardReason.MISSING_MINIDUMP_UPLOAD, DiscardReason.INVALID_MINIDUMP],
+  security_report: [DiscardReason.SECURITY_REPORT, DiscardReason.SECURITY_REPORT_TYPE],
+  unreal: [DiscardReason.PROCESS_UNREAL],
+  cors: [DiscardReason.CORS],
+  empty: [
+    DiscardReason.NO_EVENT_PAYLOAD,
+    DiscardReason.EMPTY_ENVELOPE,
+    DiscardReason.INVALID_REPLAY_NO_PAYLOAD,
+  ],
+  sampling: [DiscardReason.TRANSACTION_SAMPLED],
+};
+
+function getInvalidReasonGroupName(reason: DiscardReason): string {
+  for (const [group, reasons] of Object.entries(invalidReasonsGroup)) {
+    if (reasons.includes(reason)) {
+      return group;
+    }
+  }
+  return 'internal';
+}
+
+function getRateLimitedReasonGroupName(reason: RateLimitedReason | string): string {
+  if (reason.endsWith('_usage_exceeded')) {
+    return 'quota';
+  }
+
+  switch (reason) {
+    case RateLimitedReason.KEY_QUOTA:
+      return 'key limit';
+    case RateLimitedReason.SPIKE_PROTECTION:
+    case RateLimitedReason.SMART_RATE_LIMIT:
+      return 'spike protection';
+    default:
+      return 'internal';
+  }
+}
+
+function getFilteredReasonGroupName(reason: FilteredReason): string {
+  switch (reason) {
+    case FilteredReason.BROWSER_EXTENSION:
+    case FilteredReason.ERROR_MESSAGE:
+    case FilteredReason.FILTERED_TRANSACTION:
+    case FilteredReason.INVALID_CSP:
+    case FilteredReason.IP_ADDRESS:
+    case FilteredReason.LEGACY_BROWSER:
+    case FilteredReason.LOCALHOST:
+    case FilteredReason.RELEASE_VERSION:
+    case FilteredReason.WEB_CRAWLER:
+      return reason;
+    default:
+      return 'other';
+  }
+}
+
+function getClientDiscardReasonGroupName(reason: ClientDiscardReason): string {
+  switch (reason) {
+    case ClientDiscardReason.QUEUE_OVERFLOW:
+    case ClientDiscardReason.CACHE_OVERFLOW:
+    case ClientDiscardReason.RATELIMIT_BACKOFF:
+    case ClientDiscardReason.NETWORK_ERROR:
+    case ClientDiscardReason.SAMPLE_RATE:
+    case ClientDiscardReason.BEFORE_SEND:
+    case ClientDiscardReason.EVENT_PROCESSSOR:
+    case ClientDiscardReason.SEND_ERROR:
+    case ClientDiscardReason.INTERNAL_SDK_ERROR:
+    case ClientDiscardReason.INSUFFICIENT_DATA:
+    case ClientDiscardReason.BACKPRESSURE:
+      return reason;
+    default:
+      return 'other';
+  }
+}
+
+export function getReasonGroupName(outcome: string | number, reason: string): string {
+  switch (outcome) {
+    case Outcome.INVALID:
+      return getInvalidReasonGroupName(reason as DiscardReason);
+    case Outcome.CARDINALITY_LIMITED:
+    case Outcome.RATE_LIMITED:
+    case Outcome.ABUSE:
+      return getRateLimitedReasonGroupName(reason as RateLimitedReason);
+    case Outcome.FILTERED:
+      return getFilteredReasonGroupName(reason as FilteredReason);
+    case Outcome.CLIENT_DISCARD:
+      return getClientDiscardReasonGroupName(reason as ClientDiscardReason);
+    default:
+      return String(reason);
+  }
+}

+ 33 - 23
static/app/views/organizationStats/index.spec.tsx

@@ -89,6 +89,8 @@ describe('OrganizationStats', function () {
   it('renders the base view', async () => {
     render(<OrganizationStats {...defaultProps} />, {router});
 
+    expect(await screen.findByTestId('usage-stats-chart')).toBeInTheDocument();
+
     // Default to Errors category
     expect(screen.getAllByText('Errors')[0]).toBeInTheDocument();
 
@@ -98,26 +100,32 @@ describe('OrganizationStats', function () {
 
     // Render the cards
     expect(screen.getAllByText('Total')[0]).toBeInTheDocument();
-    expect(screen.getByText('64')).toBeInTheDocument();
+    // Total from cards and project table should match
+    expect(screen.getAllByText('67')).toHaveLength(2);
 
     expect(screen.getAllByText('Accepted')[0]).toBeInTheDocument();
-    expect(screen.getByText('28')).toBeInTheDocument();
+    // Total from cards and project table should match
+    expect(screen.getAllByText('28')).toHaveLength(2);
     expect(await screen.findByText('6 in last min')).toBeInTheDocument();
 
     expect(screen.getAllByText('Filtered')[0]).toBeInTheDocument();
     expect(screen.getAllByText('7')[0]).toBeInTheDocument();
 
-    expect(screen.getAllByText('Dropped')[0]).toBeInTheDocument();
-    expect(screen.getAllByText('29')[0]).toBeInTheDocument();
+    expect(screen.getAllByText('Rate Limited')[0]).toBeInTheDocument();
+    expect(screen.getAllByText('17')[0]).toBeInTheDocument();
+
+    expect(screen.getAllByText('Invalid')[0]).toBeInTheDocument();
+    expect(screen.getAllByText('15')[0]).toBeInTheDocument();
 
     // Correct API Calls
     const mockExpectations = {
       UsageStatsOrg: {
         statsPeriod: DEFAULT_STATS_PERIOD,
         interval: '1h',
-        groupBy: ['category', 'outcome'],
+        groupBy: ['outcome', 'reason'],
         project: [-1],
         field: ['sum(quantity)'],
+        category: 'error',
       },
       UsageStatsPerMin: {
         statsPeriod: '5m',
@@ -314,9 +322,10 @@ describe('OrganizationStats', function () {
         query: {
           statsPeriod: DEFAULT_STATS_PERIOD,
           interval: '1h',
-          groupBy: ['category', 'outcome'],
+          groupBy: ['outcome', 'reason'],
           project: selectedProjects,
           field: ['sum(quantity)'],
+          category: 'error',
         },
       })
     );
@@ -355,9 +364,10 @@ describe('OrganizationStats', function () {
         query: {
           statsPeriod: DEFAULT_STATS_PERIOD,
           interval: '1h',
-          groupBy: ['category', 'outcome'],
+          groupBy: ['outcome', 'reason'],
           project: selectedProject,
           field: ['sum(quantity)'],
+          category: 'error',
         },
       })
     );
@@ -422,66 +432,66 @@ const mockStatsResponse = {
     {
       by: {
         project: 1,
-        category: 'attachment',
+        category: 'error',
         outcome: 'accepted',
       },
       totals: {
-        'sum(quantity)': 28000,
+        'sum(quantity)': 28,
       },
       series: {
-        'sum(quantity)': [1000, 2000, 3000, 4000, 5000, 6000, 7000],
+        'sum(quantity)': [1, 2, 3, 4, 5, 6, 7],
       },
     },
     {
       by: {
         project: 1,
-        outcome: 'accepted',
-        category: 'transaction',
+        category: 'error',
+        outcome: 'filtered',
       },
       totals: {
-        'sum(quantity)': 28,
+        'sum(quantity)': 7,
       },
       series: {
-        'sum(quantity)': [1, 2, 3, 4, 5, 6, 7],
+        'sum(quantity)': [1, 1, 1, 1, 1, 1, 1],
       },
     },
     {
       by: {
         project: 1,
         category: 'error',
-        outcome: 'accepted',
+        outcome: 'rate_limited',
       },
       totals: {
-        'sum(quantity)': 28,
+        'sum(quantity)': 14,
       },
       series: {
-        'sum(quantity)': [1, 2, 3, 4, 5, 6, 7],
+        'sum(quantity)': [2, 2, 2, 2, 2, 2, 2],
       },
     },
     {
       by: {
         project: 1,
         category: 'error',
-        outcome: 'filtered',
+        outcome: 'abuse',
       },
       totals: {
-        'sum(quantity)': 7,
+        'sum(quantity)': 2,
       },
       series: {
-        'sum(quantity)': [1, 1, 1, 1, 1, 1, 1],
+        'sum(quantity)': [2, 0, 0, 0, 0, 0, 0],
       },
     },
     {
       by: {
         project: 1,
         category: 'error',
-        outcome: 'rate_limited',
+        outcome: 'cardinality_limited',
       },
       totals: {
-        'sum(quantity)': 14,
+        'sum(quantity)': 1,
       },
       series: {
-        'sum(quantity)': [2, 2, 2, 2, 2, 2, 2],
+        'sum(quantity)': [1, 0, 0, 0, 0, 0, 0],
       },
     },
     {

+ 1 - 0
static/app/views/organizationStats/index.tsx

@@ -270,6 +270,7 @@ export class OrganizationStats extends Component<OrganizationStatsProps> {
         organization={organization}
         dataCategory={this.dataCategory}
         dataCategoryName={this.dataCategoryInfo.titleName}
+        dataCategoryApiName={this.dataCategoryInfo.apiName}
         dataDatetime={this.dataDatetime}
         chartTransform={this.chartTransform}
         handleChangeState={this.setStateOnUrl}

+ 111 - 0
static/app/views/organizationStats/mapSeriesToChart.spec.ts

@@ -0,0 +1,111 @@
+import {mapSeriesToChart} from './mapSeriesToChart';
+import type {UsageSeries} from './types';
+
+const mockSeries: UsageSeries = {
+  start: '2021-01-01T00:00:00Z',
+  end: '2021-01-07T00:00:00Z',
+  intervals: ['2021-01-01T00:00:00Z', '2021-01-02T00:00:00Z', '2021-01-03T00:00:00Z'],
+  groups: [
+    {
+      by: {
+        outcome: 'accepted',
+      },
+      totals: {
+        'sum(quantity)': 6,
+      },
+      series: {
+        'sum(quantity)': [1, 2, 3],
+      },
+    },
+    {
+      by: {
+        outcome: 'filtered',
+        reason: 'other',
+      },
+      totals: {
+        'sum(quantity)': 4,
+      },
+      series: {
+        'sum(quantity)': [0, 1, 3],
+      },
+    },
+    {
+      by: {
+        outcome: 'invalid',
+        reason: 'invalid_transaction',
+      },
+      totals: {
+        'sum(quantity)': 6,
+      },
+      series: {
+        'sum(quantity)': [2, 2, 2],
+      },
+    },
+    {
+      by: {
+        outcome: 'invalid',
+        reason: 'other_reason_a',
+      },
+      totals: {
+        'sum(quantity)': 6,
+      },
+      series: {
+        'sum(quantity)': [1, 2, 3],
+      },
+    },
+    {
+      by: {
+        outcome: 'invalid',
+        reason: 'other_reason_b',
+      },
+      totals: {
+        'sum(quantity)': 3,
+      },
+      series: {
+        'sum(quantity)': [1, 1, 1],
+      },
+    },
+  ],
+};
+
+describe('mapSeriesToChart func', function () {
+  it("should return correct chart tooltip's reasons", function () {
+    const mappedSeries = mapSeriesToChart({
+      orgStats: mockSeries,
+      chartDateInterval: '1h',
+      chartDateUtc: true,
+      dataCategory: 'transactions',
+      endpointQuery: {},
+    });
+
+    expect(mappedSeries.chartSubLabels).toEqual([
+      {
+        parentLabel: 'Filtered',
+        label: 'Other',
+        data: [
+          {name: '2021-01-01T00:00:00Z', value: 0},
+          {name: '2021-01-02T00:00:00Z', value: 1},
+          {name: '2021-01-03T00:00:00Z', value: 3},
+        ],
+      },
+      {
+        parentLabel: 'Invalid',
+        label: 'Invalid Data',
+        data: [
+          {name: '2021-01-01T00:00:00Z', value: 2},
+          {name: '2021-01-02T00:00:00Z', value: 2},
+          {name: '2021-01-03T00:00:00Z', value: 2},
+        ],
+      },
+      {
+        parentLabel: 'Invalid',
+        label: 'Internal',
+        data: [
+          {name: '2021-01-01T00:00:00Z', value: 2},
+          {name: '2021-01-02T00:00:00Z', value: 3},
+          {name: '2021-01-03T00:00:00Z', value: 4},
+        ],
+      },
+    ]);
+  });
+});

+ 229 - 0
static/app/views/organizationStats/mapSeriesToChart.ts

@@ -0,0 +1,229 @@
+import * as Sentry from '@sentry/react';
+import startCase from 'lodash/startCase';
+import moment from 'moment';
+
+import type {TooltipSubLabel} from 'sentry/components/charts/components/tooltip';
+import type {DataCategoryInfo, IntervalPeriod} from 'sentry/types';
+import {Outcome} from 'sentry/types';
+
+import {getDateFromMoment} from './usageChart/utils';
+import {getReasonGroupName} from './getReasonGroupName';
+import type {UsageSeries, UsageStat} from './types';
+import type {ChartStats} from './usageChart';
+import {SeriesTypes} from './usageChart';
+import {formatUsageWithUnits, getFormatUsageOptions} from './utils';
+
+export function mapSeriesToChart({
+  orgStats,
+  dataCategory,
+  chartDateUtc,
+  endpointQuery,
+  chartDateInterval,
+}: {
+  chartDateInterval: IntervalPeriod;
+  chartDateUtc: boolean;
+  dataCategory: DataCategoryInfo['plural'];
+  endpointQuery: Record<string, unknown>;
+  orgStats?: UsageSeries;
+}): {
+  cardStats: {
+    accepted?: string;
+    filtered?: string;
+    invalid?: string;
+    rateLimited?: string;
+    total?: string;
+  };
+  chartStats: ChartStats;
+  chartSubLabels: TooltipSubLabel[];
+  dataError?: Error;
+} {
+  const cardStats = {
+    total: undefined,
+    accepted: undefined,
+    filtered: undefined,
+    invalid: undefined,
+    rateLimited: undefined,
+  };
+  const chartStats: ChartStats = {
+    accepted: [],
+    filtered: [],
+    rateLimited: [],
+    invalid: [],
+    clientDiscard: [],
+    projected: [],
+  };
+  const chartSubLabels: TooltipSubLabel[] = [];
+
+  if (!orgStats) {
+    return {cardStats, chartStats, chartSubLabels};
+  }
+
+  try {
+    const usageStats: UsageStat[] = orgStats.intervals.map(interval => {
+      const dateTime = moment(interval);
+
+      return {
+        date: getDateFromMoment(dateTime, chartDateInterval, chartDateUtc),
+        total: 0,
+        accepted: 0,
+        filtered: 0,
+        rateLimited: 0,
+        invalid: 0,
+        clientDiscard: 0,
+      };
+    });
+
+    // Tally totals for card data
+    const count = {
+      total: 0,
+      [Outcome.ACCEPTED]: 0,
+      [Outcome.FILTERED]: 0,
+      [Outcome.INVALID]: 0,
+      [Outcome.RATE_LIMITED]: 0, // Combined with dropped later
+      [Outcome.CLIENT_DISCARD]: 0,
+      [Outcome.CARDINALITY_LIMITED]: 0, // Combined with dropped later
+      [Outcome.ABUSE]: 0, // Combined with dropped later
+    };
+
+    orgStats.groups.forEach(group => {
+      const {outcome} = group.by;
+
+      if (outcome !== Outcome.CLIENT_DISCARD) {
+        count.total += group.totals['sum(quantity)'];
+      }
+
+      count[outcome] += group.totals['sum(quantity)'];
+
+      group.series['sum(quantity)'].forEach((stat, i) => {
+        const dataObject = {name: orgStats.intervals[i], value: stat};
+
+        const strigfiedReason = String(group.by.reason ?? '');
+        const reason = getReasonGroupName(outcome, strigfiedReason);
+
+        const label = startCase(reason.replace(/-|_/g, ' '));
+
+        // Combine rate limited counts
+        count[Outcome.RATE_LIMITED] +=
+          count[Outcome.ABUSE] + count[Outcome.CARDINALITY_LIMITED];
+
+        // Function to handle chart sub-label updates
+        const updateChartSubLabels = (parentLabel: SeriesTypes) => {
+          const existingSubLabel = chartSubLabels.find(
+            subLabel => subLabel.label === label && subLabel.parentLabel === parentLabel
+          );
+
+          if (existingSubLabel) {
+            // Check if the existing sub-label's data length matches the intervals length
+            if (existingSubLabel.data.length === group.series['sum(quantity)'].length) {
+              // Update the value of the current interval
+              existingSubLabel.data[i].value += stat;
+            } else {
+              // Add a new data object if the length does not match
+              existingSubLabel.data.push(dataObject);
+            }
+          } else {
+            chartSubLabels.push({
+              parentLabel,
+              label,
+              data: [dataObject],
+            });
+          }
+        };
+
+        switch (outcome) {
+          case Outcome.FILTERED:
+            usageStats[i].filtered += stat;
+            updateChartSubLabels(SeriesTypes.FILTERED);
+            break;
+          case Outcome.ACCEPTED:
+            usageStats[i].accepted += stat;
+            break;
+          case Outcome.CARDINALITY_LIMITED:
+          case Outcome.RATE_LIMITED:
+          case Outcome.ABUSE:
+            usageStats[i].rateLimited += stat;
+            updateChartSubLabels(SeriesTypes.RATE_LIMITED);
+            break;
+          case Outcome.CLIENT_DISCARD:
+            usageStats[i].clientDiscard += stat;
+            updateChartSubLabels(SeriesTypes.CLIENT_DISCARD);
+            break;
+          case Outcome.INVALID:
+            usageStats[i].invalid += stat;
+            updateChartSubLabels(SeriesTypes.INVALID);
+            break;
+          default:
+            break;
+        }
+      });
+    });
+
+    usageStats.forEach(stat => {
+      stat.total = [
+        stat.accepted,
+        stat.filtered,
+        stat.rateLimited,
+        stat.invalid,
+        stat.clientDiscard,
+      ].reduce((acc, val) => acc + val, 0);
+
+      // Chart Data
+      const chartData = [
+        {key: 'accepted', value: stat.accepted},
+        {key: 'filtered', value: stat.filtered},
+        {key: 'rateLimited', value: stat.rateLimited},
+        {key: 'invalid', value: stat.invalid},
+        {key: 'clientDiscard', value: stat.clientDiscard},
+      ];
+
+      chartData.forEach(data => {
+        (chartStats[data.key] as any[]).push({value: [stat.date, data.value]});
+      });
+    });
+
+    return {
+      cardStats: {
+        total: formatUsageWithUnits(
+          count.total,
+          dataCategory,
+          getFormatUsageOptions(dataCategory)
+        ),
+        accepted: formatUsageWithUnits(
+          count[Outcome.ACCEPTED],
+          dataCategory,
+          getFormatUsageOptions(dataCategory)
+        ),
+        filtered: formatUsageWithUnits(
+          count[Outcome.FILTERED],
+          dataCategory,
+          getFormatUsageOptions(dataCategory)
+        ),
+        invalid: formatUsageWithUnits(
+          count[Outcome.INVALID],
+          dataCategory,
+          getFormatUsageOptions(dataCategory)
+        ),
+        rateLimited: formatUsageWithUnits(
+          count[Outcome.RATE_LIMITED],
+          dataCategory,
+          getFormatUsageOptions(dataCategory)
+        ),
+      },
+      chartStats,
+      chartSubLabels,
+    };
+  } catch (err) {
+    Sentry.withScope(scope => {
+      scope.setContext('query', endpointQuery);
+      scope.setContext('body', {...orgStats});
+      Sentry.captureException(err);
+    });
+
+    return {
+      cardStats,
+      chartStats,
+      chartSubLabels,
+      dataError: new Error('Failed to parse stats data'),
+    };
+  }
+}

+ 3 - 4
static/app/views/organizationStats/types.tsx

@@ -12,11 +12,10 @@ export interface UsageSeries extends SeriesApi {
 
 export type UsageStat = {
   accepted: number;
+  clientDiscard: number;
   date: string;
-  dropped: {
-    total: number;
-    other?: number;
-  };
   filtered: number;
+  invalid: number;
+  rateLimited: number;
   total: number;
 };

Some files were not shown because too many files changed in this diff