diff --git a/static/app/utils/discover/charts.tsx b/static/app/utils/discover/charts.tsx index c93bd389b039..04eb4a1fd597 100644 --- a/static/app/utils/discover/charts.tsx +++ b/static/app/utils/discover/charts.tsx @@ -4,6 +4,7 @@ import type {LegendComponentOption} from 'echarts'; import type {Series} from 'sentry/types/echarts'; import {defined} from 'sentry/utils'; import {formatBytesBase2} from 'sentry/utils/bytes/formatBytesBase2'; +import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10'; import type {AggregationOutputType, RateUnit} from 'sentry/utils/discover/fields'; import {axisDuration} from 'sentry/utils/duration/axisDuration'; import getDuration from 'sentry/utils/duration/getDuration'; @@ -46,6 +47,8 @@ export function tooltipFormatterUsingAggregateOutputType( return getDuration(value / 1000, 2, true); case 'size': return formatBytesBase2(value); + case 'size_base10': + return formatBytesBase10(value); default: return value.toString(); } @@ -94,6 +97,8 @@ export function axisLabelFormatterUsingAggregateOutputType( return axisDuration(value, durationUnit); case 'size': return formatBytesBase2(value, 0); + case 'size_base10': + return formatBytesBase10(value, 0); case 'rate': return formatRate(value, rateUnit); default: diff --git a/static/app/utils/discover/fields.tsx b/static/app/utils/discover/fields.tsx index 7f33477e9ecb..d253d6daaf08 100644 --- a/static/app/utils/discover/fields.tsx +++ b/static/app/utils/discover/fields.tsx @@ -630,6 +630,7 @@ export type AggregationOutputType = Extract< | 'percentage' | 'string' | 'size' + | 'size_base10' | 'rate' | 'score' >; diff --git a/static/app/utils/fields/index.ts b/static/app/utils/fields/index.ts index 307333c755c7..0646859fb335 100644 --- a/static/app/utils/fields/index.ts +++ b/static/app/utils/fields/index.ts @@ -290,6 +290,7 @@ export enum FieldValueType { STRING = 'string', NEVER = 'never', SIZE = 'size', + SIZE_BASE10 = 'size_base10', RATE = 'rate', PERCENT_CHANGE = 'percent_change', SCORE = 'score', diff --git a/static/app/views/dashboards/datasetConfig/base.tsx b/static/app/views/dashboards/datasetConfig/base.tsx index 7d2c11844be1..36b10ae08e3b 100644 --- a/static/app/views/dashboards/datasetConfig/base.tsx +++ b/static/app/views/dashboards/datasetConfig/base.tsx @@ -32,6 +32,7 @@ import {ErrorsConfig} from './errors'; import {ErrorsAndTransactionsConfig} from './errorsAndTransactions'; import {IssuesConfig} from './issues'; import {LogsConfig} from './logs'; +import {MobileAppSizeConfig} from './mobileAppSize'; import {ReleasesConfig} from './releases'; import {SpansConfig} from './spans'; import {TraceMetricsConfig} from './traceMetrics'; @@ -285,7 +286,9 @@ export function getDatasetConfig( ? typeof SpansConfig : T extends WidgetType.TRACEMETRICS ? typeof TraceMetricsConfig - : typeof ErrorsAndTransactionsConfig; + : T extends WidgetType.MOBILE_APP_SIZE + ? typeof MobileAppSizeConfig + : typeof ErrorsAndTransactionsConfig; export function getDatasetConfig( widgetType?: WidgetType @@ -297,7 +300,8 @@ export function getDatasetConfig( | typeof TransactionsConfig | typeof LogsConfig | typeof SpansConfig - | typeof TraceMetricsConfig { + | typeof TraceMetricsConfig + | typeof MobileAppSizeConfig { switch (widgetType) { case WidgetType.ISSUE: return IssuesConfig; @@ -313,6 +317,8 @@ export function getDatasetConfig( return SpansConfig; case WidgetType.TRACEMETRICS: return TraceMetricsConfig; + case WidgetType.MOBILE_APP_SIZE: + return MobileAppSizeConfig; case WidgetType.DISCOVER: default: return ErrorsAndTransactionsConfig; diff --git a/static/app/views/dashboards/datasetConfig/mobileAppSize.spec.tsx b/static/app/views/dashboards/datasetConfig/mobileAppSize.spec.tsx new file mode 100644 index 000000000000..ba8a45fceb2a --- /dev/null +++ b/static/app/views/dashboards/datasetConfig/mobileAppSize.spec.tsx @@ -0,0 +1,292 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {WidgetFixture} from 'sentry-fixture/widget'; + +import {WidgetType} from 'sentry/views/dashboards/types'; + +import type {AppSizeResponse} from './mobileAppSize'; +import {MobileAppSizeConfig} from './mobileAppSize'; + +describe('MobileAppSizeConfig', () => { + const organization = OrganizationFixture(); + + describe('transformSeries', () => { + it('transforms API response to series data', () => { + const data: AppSizeResponse[] = [ + { + data: [ + [1609459200, [{count: 1000000}]], + [1609545600, [{count: 1100000}]], + [1609632000, [{count: 1200000}]], + ], + start: 1609459200, + end: 1609632000, + meta: {fields: {}}, + }, + ]; + + const widgetQuery = { + conditions: 'app_id=com.example.app', + aggregates: [], + fields: [], + columns: [], + fieldAliases: [], + name: '', + orderby: '', + }; + + const result = MobileAppSizeConfig.transformSeries!( + data, + widgetQuery, + organization + ); + + expect(result).toHaveLength(1); + expect(result[0]!.seriesName).toBe('com.example.app : max(max_install_size)'); + expect(result[0]!.data).toHaveLength(3); + expect(result[0]!.data[0]).toEqual({ + name: 1609459200000, + value: 1000000, + }); + }); + + it('filters out null and undefined values', () => { + const data: AppSizeResponse[] = [ + { + data: [ + [1609459200, [{count: 1000000}]], + [1609545600, [{count: null}]], + [1609632000, []], + [1609718400, [{count: 1200000}]], + ], + start: 1609459200, + end: 1609718400, + meta: {fields: {}}, + }, + ]; + + const widgetQuery = { + conditions: '', + aggregates: [], + fields: [], + columns: [], + fieldAliases: [], + name: '', + orderby: '', + }; + + const result = MobileAppSizeConfig.transformSeries!( + data, + widgetQuery, + organization + ); + + expect(result[0]!.data).toHaveLength(2); + expect(result[0]!.data[0]!.value).toBe(1000000); + expect(result[0]!.data[1]!.value).toBe(1200000); + }); + + it('includes artifact type in series name', () => { + const data: AppSizeResponse[] = [ + { + data: [[1609459200, [{count: 1000000}]]], + start: 1609459200, + end: 1609459200, + meta: {fields: {}}, + }, + ]; + + const widgetQuery = { + conditions: 'app_id=com.example.app&artifact_type=2', + aggregates: [], + fields: [], + columns: [], + fieldAliases: [], + name: '', + orderby: '', + }; + + const result = MobileAppSizeConfig.transformSeries!( + data, + widgetQuery, + organization + ); + + expect(result[0]!.seriesName).toContain('com.example.app'); + expect(result[0]!.seriesName).toContain('apk'); + expect(result[0]!.seriesName).toContain('max(max_install_size)'); + }); + + it('includes branch and build config in series name', () => { + const data: AppSizeResponse[] = [ + { + data: [[1609459200, [{count: 1000000}]]], + start: 1609459200, + end: 1609459200, + meta: {fields: {}}, + }, + ]; + + const widgetQuery = { + conditions: + 'app_id=com.example.app&git_head_ref=main&build_configuration_name=Release', + aggregates: [], + fields: [], + columns: [], + fieldAliases: [], + name: '', + orderby: '', + }; + + const result = MobileAppSizeConfig.transformSeries!( + data, + widgetQuery, + organization + ); + + expect(result[0]!.seriesName).toContain('com.example.app'); + expect(result[0]!.seriesName).toContain('main'); + expect(result[0]!.seriesName).toContain('Release'); + expect(result[0]!.seriesName).toContain('max(max_install_size)'); + }); + + it('uses correct aggregate for download size', () => { + const data: AppSizeResponse[] = [ + { + data: [[1609459200, [{count: 500000}]]], + start: 1609459200, + end: 1609459200, + meta: {fields: {}}, + }, + ]; + + const widgetQuery = { + conditions: 'app_id=com.example.app&size_type=download', + aggregates: [], + fields: [], + columns: [], + fieldAliases: [], + name: '', + orderby: '', + }; + + const result = MobileAppSizeConfig.transformSeries!( + data, + widgetQuery, + organization + ); + + expect(result[0]!.seriesName).toBe('com.example.app : max(max_download_size)'); + }); + }); + + describe('getSeriesRequest', () => { + it('uses install size field by default', async () => { + const api = new MockApiClient(); + const widget = WidgetFixture({ + widgetType: WidgetType.MOBILE_APP_SIZE, + queries: [ + { + conditions: 'app_id=com.example.app', + aggregates: [], + fields: [], + columns: [], + fieldAliases: [], + name: '', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/preprod/app-size-stats/`, + body: { + data: [[1609459200, [{count: 1000000}]]], + start: 1609459200, + end: 1609459200, + meta: {fields: {}}, + }, + }); + + await MobileAppSizeConfig.getSeriesRequest!(api, widget, 0, organization, { + datetime: {start: null, end: null, period: '14d', utc: false}, + environments: [], + projects: [1], + }); + + expect(mockRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/preprod/app-size-stats/`, + expect.objectContaining({ + query: expect.objectContaining({ + field: 'max(max_install_size)', + }), + }) + ); + }); + + it('uses download size field when size_type=download', async () => { + const api = new MockApiClient(); + const widget = WidgetFixture({ + widgetType: WidgetType.MOBILE_APP_SIZE, + queries: [ + { + conditions: 'app_id=com.example.app&size_type=download', + aggregates: [], + fields: [], + columns: [], + fieldAliases: [], + name: '', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/preprod/app-size-stats/`, + body: { + data: [[1609459200, [{count: 500000}]]], + start: 1609459200, + end: 1609459200, + meta: {fields: {}}, + }, + }); + + await MobileAppSizeConfig.getSeriesRequest!(api, widget, 0, organization, { + datetime: {start: null, end: null, period: '14d', utc: false}, + environments: [], + projects: [1], + }); + + expect(mockRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/preprod/app-size-stats/`, + expect.objectContaining({ + query: expect.objectContaining({ + field: 'max(max_download_size)', + }), + }) + ); + }); + }); + + describe('getSeriesResultType', () => { + it('returns size_base10 output type for both install and download aggregates', () => { + const data: AppSizeResponse[] = []; + const widgetQuery = { + conditions: '', + aggregates: [], + fields: [], + columns: [], + fieldAliases: [], + name: '', + orderby: '', + }; + + const result = MobileAppSizeConfig.getSeriesResultType!(data, widgetQuery); + + // Both aggregates should be registered to handle multi-query widgets + expect(result).toEqual({ + 'max(max_install_size)': 'size_base10', + 'max(max_download_size)': 'size_base10', + }); + }); + }); +}); diff --git a/static/app/views/dashboards/datasetConfig/mobileAppSize.tsx b/static/app/views/dashboards/datasetConfig/mobileAppSize.tsx new file mode 100644 index 000000000000..17b6a78e8083 --- /dev/null +++ b/static/app/views/dashboards/datasetConfig/mobileAppSize.tsx @@ -0,0 +1,190 @@ +import type {Client, ResponseMeta} from 'sentry/api'; +import type {PageFilters} from 'sentry/types/core'; +import type {Series} from 'sentry/types/echarts'; +import type {Organization} from 'sentry/types/organization'; +import type {TableData} from 'sentry/utils/discover/discoverQuery'; +import type {AggregationOutputType, QueryFieldValue} from 'sentry/utils/discover/fields'; +import type {MEPState} from 'sentry/utils/performance/contexts/metricsEnhancedSetting'; +import type {OnDemandControlContext} from 'sentry/utils/performance/contexts/onDemandControl'; +import type {Widget, WidgetQuery} from 'sentry/views/dashboards/types'; +import {DisplayType} from 'sentry/views/dashboards/types'; +import {EventsSearchBar} from 'sentry/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/eventsSearchBar'; +import {FieldValueKind} from 'sentry/views/discover/table/types'; +import type {SamplingMode} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import {BuildDetailsArtifactType} from 'sentry/views/preprod/types/buildDetailsTypes'; + +import type {DatasetConfig} from './base'; +import {handleOrderByReset} from './base'; + +export interface AppSizeResponse { + data: Array<[number, Array<{count: number | null}>]>; + end: number; + meta: { + fields: Record; + }; + start: number; +} + +const ARTIFACT_TYPE_LABELS: Record = { + [BuildDetailsArtifactType.XCARCHIVE]: 'xcarchive', + [BuildDetailsArtifactType.AAB]: 'aab', + [BuildDetailsArtifactType.APK]: 'apk', +}; + +// Note: Query building is handled by custom logic in getSeriesRequest. +// This serves as the initial template for new widgets. +const DEFAULT_WIDGET_QUERY: WidgetQuery = { + name: '', + fields: [], + columns: [], + fieldAliases: [], + aggregates: [], + conditions: '', + orderby: '', +}; + +const DEFAULT_FIELD: QueryFieldValue = { + function: ['max', 'max_install_size', undefined, undefined], + kind: FieldValueKind.FUNCTION, +}; + +export const MobileAppSizeConfig: DatasetConfig = { + defaultField: DEFAULT_FIELD, + defaultWidgetQuery: DEFAULT_WIDGET_QUERY, + enableEquations: false, + SearchBar: EventsSearchBar, + supportedDisplayTypes: [DisplayType.LINE], + getTableFieldOptions: () => ({}), + getSeriesRequest: ( + api: Client, + widget: Widget, + queryIndex: number, + organization: Organization, + pageFilters: PageFilters, + _onDemandControlContext?: OnDemandControlContext, + _referrer?: string, + _mepSetting?: MEPState | null, + _samplingMode?: SamplingMode + ): Promise<[AppSizeResponse[], string | undefined, ResponseMeta | undefined]> => { + const widgetQuery = widget.queries[queryIndex]; + if (!widgetQuery) { + return Promise.reject(new Error('No widget query found')); + } + + let field = 'max(max_install_size)'; + if (widgetQuery.conditions) { + const condParams = new URLSearchParams(widgetQuery.conditions); + const sizeType = condParams.get('size_type'); + if (sizeType === 'download') { + field = 'max(max_download_size)'; + } + } + + const {start, end, period} = pageFilters.datetime; + const baseParams: Record = { + project: pageFilters.projects, + environment: pageFilters.environments, + start: start ? new Date(start).toISOString() : undefined, + end: end ? new Date(end).toISOString() : undefined, + statsPeriod: period || (!start && !end ? '14d' : undefined), + interval: widget.interval || '1d', + field, + }; + + if (widgetQuery.conditions) { + const params = new URLSearchParams(widgetQuery.conditions); + params.forEach((value, key) => { + // Skip size_type - it's client-side only to determine which field to use + if (value && key !== 'size_type') { + baseParams[key] = value; + } + }); + } + + return api + .requestPromise(`/organizations/${organization.slug}/preprod/app-size-stats/`, { + method: 'GET', + query: baseParams, + }) + .then(response => [[response], undefined, undefined]); + }, + transformTable: ( + data: TableData, + _widgetQuery: WidgetQuery, + _organization: Organization, + _pageFilters: PageFilters + ): TableData => { + return data; + }, + transformSeries: ( + data: AppSizeResponse[], + widgetQuery: WidgetQuery, + _organization: Organization + ): Series[] => { + let appId = ''; + let branch = ''; + let buildConfig = ''; + let artifactType = ''; + let sizeType = 'install'; + + if (widgetQuery.conditions) { + const params = new URLSearchParams(widgetQuery.conditions); + appId = params.get('app_id') ?? ''; + branch = params.get('git_head_ref') ?? ''; + buildConfig = params.get('build_configuration_name') ?? ''; + artifactType = params.get('artifact_type') ?? ''; + sizeType = params.get('size_type') ?? 'install'; + } + + return data.map(response => { + // Filter out time buckets with no data, creating a continuous line + const seriesData = response.data + .filter( + ([, values]) => values[0]?.count !== null && values[0]?.count !== undefined + ) + .map(([timestamp, values]) => ({ + name: timestamp * 1000, + value: values[0]!.count as number, + })); + + const parts: string[] = []; + if (appId) { + parts.push(appId); + } + if (artifactType) { + const artifactTypeNum = Number(artifactType) as BuildDetailsArtifactType; + if (artifactTypeNum in ARTIFACT_TYPE_LABELS) { + parts.push(ARTIFACT_TYPE_LABELS[artifactTypeNum]); + } + } + if (branch) { + parts.push(branch); + } + if (buildConfig) { + parts.push(buildConfig); + } + + const aggregate = + sizeType === 'download' ? 'max(max_download_size)' : 'max(max_install_size)'; + const baseLabel = parts.length > 0 ? parts.join(' : ') : 'App Size'; + const seriesName = `${baseLabel} : ${aggregate}`; + + return { + seriesName, + data: seriesData, + }; + }); + }, + getSeriesResultType: ( + _data: AppSizeResponse[], + _widgetQuery: WidgetQuery + ): Record => { + // Register both possible aggregates as size_base10 to handle multi-query widgets + // where different queries may use different size types (install vs download) + return { + 'max(max_install_size)': 'size_base10', + 'max(max_download_size)': 'size_base10', + }; + }, + handleOrderByReset, +}; diff --git a/static/app/views/dashboards/types.tsx b/static/app/views/dashboards/types.tsx index 7bddc1a837e8..54bb0a118c34 100644 --- a/static/app/views/dashboards/types.tsx +++ b/static/app/views/dashboards/types.tsx @@ -39,6 +39,7 @@ export enum WidgetType { SPANS = 'spans', LOGS = 'logs', TRACEMETRICS = 'tracemetrics', + MOBILE_APP_SIZE = 'mobile-app-size', } // These only pertain to on-demand warnings at this point in time diff --git a/static/app/views/dashboards/widgetBuilder/components/mobileAppSizeFilters.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/mobileAppSizeFilters.spec.tsx new file mode 100644 index 000000000000..3c97634e1d59 --- /dev/null +++ b/static/app/views/dashboards/widgetBuilder/components/mobileAppSizeFilters.spec.tsx @@ -0,0 +1,142 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import type {Organization} from 'sentry/types/organization'; +import {MobileAppSizeFilters} from 'sentry/views/dashboards/widgetBuilder/components/mobileAppSizeFilters'; +import {WidgetBuilderProvider} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext'; + +describe('MobileAppSizeFilters', () => { + let organization: Organization; + let mockApiRequest: jest.Mock; + + beforeEach(() => { + organization = OrganizationFixture(); + MockApiClient.clearMockResponses(); + + mockApiRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/preprod/app-size-stats/`, + body: { + filters: { + app_ids: ['com.example.app1', 'com.example.app2', 'com.example.app3'], + branches: ['main', 'develop', 'feature/test'], + build_configs: ['Release', 'Debug', 'Profile'], + }, + }, + }); + }); + + const renderComponent = (initialRouterConfig = {}) => { + return render( + + + , + { + organization, + initialRouterConfig, + } + ); + }; + + it('renders loading state initially', () => { + renderComponent(); + expect(screen.getByText('Loading filter options...')).toBeInTheDocument(); + }); + + it('fetches and displays filter options', async () => { + renderComponent(); + + await waitFor(() => { + expect(mockApiRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/preprod/app-size-stats/`, + expect.objectContaining({ + query: {includeFilters: 'true', statsPeriod: '90d'}, + }) + ); + }); + + expect(await screen.findByText('Size Analysis documentation')).toBeInTheDocument(); + expect(screen.getByText('Filter')).toBeInTheDocument(); + expect(screen.getByText('Query 1')).toBeInTheDocument(); + }); + + it('allows adding additional queries', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Query 1')).toBeInTheDocument(); + }); + + const addQueryButton = screen.getByRole('button', {name: 'Add Query'}); + await userEvent.click(addQueryButton); + + expect(screen.getByText('Query 2')).toBeInTheDocument(); + }); + + it('allows removing queries when multiple exist', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Query 1')).toBeInTheDocument(); + }); + + const addQueryButton = screen.getByRole('button', {name: 'Add Query'}); + await userEvent.click(addQueryButton); + + expect(screen.getByText('Query 2')).toBeInTheDocument(); + + const removeButtons = screen.getAllByRole('button', {name: 'Remove query'}); + expect(removeButtons).toHaveLength(2); + + await userEvent.click(removeButtons[0]!); + + // Should only have Query 1 left (renumbered from Query 2) + expect(screen.getByText('Query 1')).toBeInTheDocument(); + expect(screen.queryByText('Query 2')).not.toBeInTheDocument(); + }); + + it('handles empty filter response gracefully', async () => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/preprod/app-size-stats/`, + body: { + filters: { + app_ids: [], + branches: [], + build_configs: [], + }, + }, + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Filter')).toBeInTheDocument(); + }); + + expect(screen.getByText('Query 1')).toBeInTheDocument(); + }); + + it('updates query conditions when filters are selected', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Filter')).toBeInTheDocument(); + }); + + const appIdSelect = screen.getByText('Select an app'); + await userEvent.click(appIdSelect); + await userEvent.click(await screen.findByText('com.example.app1')); + + const artifactTypeSelect = screen.getByText('All artifact types'); + await userEvent.click(artifactTypeSelect); + await userEvent.click(await screen.findByText('apk (Android APK)')); + + await waitFor(() => { + expect(screen.getByText('com.example.app1')).toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.getByText('apk (Android APK)')).toBeInTheDocument(); + }); + }); +}); diff --git a/static/app/views/dashboards/widgetBuilder/components/mobileAppSizeFilters.tsx b/static/app/views/dashboards/widgetBuilder/components/mobileAppSizeFilters.tsx new file mode 100644 index 000000000000..cfbbfab2dece --- /dev/null +++ b/static/app/views/dashboards/widgetBuilder/components/mobileAppSizeFilters.tsx @@ -0,0 +1,317 @@ +import {Fragment, useCallback, useEffect, useRef, useState} from 'react'; +import styled from '@emotion/styled'; + +import {Button} from 'sentry/components/core/button'; +import {Container, Flex} from 'sentry/components/core/layout'; +import {ExternalLink} from 'sentry/components/core/link'; +import {Text} from 'sentry/components/core/text'; +import RadioGroup from 'sentry/components/forms/controls/radioGroup'; +import SelectField from 'sentry/components/forms/fields/selectField'; +import {IconAdd, IconClose} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {explodeField} from 'sentry/utils/discover/fields'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; +import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext'; +import {BuildDetailsArtifactType} from 'sentry/views/preprod/types/buildDetailsTypes'; + +import {SectionHeader} from './common/sectionHeader'; + +interface AppSizeFilterOptions { + appIds: Array<{label: string; value: string}>; + branches: Array<{label: string; value: string}>; + buildConfigs: Array<{label: string; value: string}>; +} + +interface FilterResponse { + filters: { + app_ids: string[]; + branches: string[]; + build_configs: string[]; + }; +} + +interface QueryConfig { + name: string; + sizeType: 'install' | 'download'; + appId?: string; + artifactType?: string; + branch?: string; + buildConfig?: string; +} + +function parseConditionsString(conditions: string): Omit { + const params = new URLSearchParams(conditions); + + return { + appId: params.get('app_id') ?? undefined, + branch: params.get('git_head_ref') ?? undefined, + buildConfig: params.get('build_configuration_name') ?? undefined, + artifactType: params.get('artifact_type') ?? undefined, + sizeType: (params.get('size_type') as 'install' | 'download') ?? 'install', + }; +} + +function buildConditionsString(config: QueryConfig): string { + const params = new URLSearchParams(); + + if (config.appId) { + params.set('app_id', config.appId); + } + if (config.branch) { + params.set('git_head_ref', config.branch); + } + if (config.buildConfig) { + params.set('build_configuration_name', config.buildConfig); + } + if (config.artifactType) { + params.set('artifact_type', config.artifactType); + } + // size_type is client-side only - used to determine which field to request + // (max_install_size vs max_download_size), but not sent to the API + if (config.sizeType) { + params.set('size_type', config.sizeType); + } + + return params.toString(); +} + +export function MobileAppSizeFilters() { + const organization = useOrganization(); + const {state, dispatch} = useWidgetBuilderContext(); + const hasInitialized = useRef(false); + const [queryConfigs, setQueryConfigs] = useState(() => { + const queries = state.query || []; + if (queries.length === 0) { + return [ + { + name: '', + sizeType: 'install', + }, + ]; + } + + return queries.map(conditions => ({ + name: '', + ...parseConditionsString(conditions), + })); + }); + + const {data: filterResponse, isPending: loading} = useApiQuery( + [ + `/organizations/${organization.slug}/preprod/app-size-stats/`, + {query: {includeFilters: 'true', statsPeriod: '90d'}}, + ], + { + // Cache for 5 minutes since filter options don't change frequently + staleTime: 5 * 60 * 1000, + } + ); + + const filterOptions: AppSizeFilterOptions = { + appIds: + filterResponse?.filters?.app_ids?.map((id: string) => ({ + label: id, + value: id, + })) ?? [], + branches: + filterResponse?.filters?.branches?.map((branch: string) => ({ + label: branch, + value: branch, + })) ?? [], + buildConfigs: + filterResponse?.filters?.build_configs?.map((config: string) => ({ + label: config, + value: config, + })) ?? [], + }; + + const updateQueries = useCallback( + (configs: QueryConfig[]) => { + const conditions = configs.map(buildConditionsString); + + dispatch({ + type: 'SET_QUERY', + payload: conditions, + }); + + // Update Y-axis with the aggregates for each query + const yAxisFields = configs.map(config => { + const fieldString = + config.sizeType === 'install' + ? 'max(max_install_size)' + : 'max(max_download_size)'; + return explodeField({field: fieldString}); + }); + + dispatch({ + type: 'SET_Y_AXIS', + payload: yAxisFields, + }); + }, + [dispatch] + ); + + // Sync initial query config to widget builder context + useEffect(() => { + if (!hasInitialized.current && (!state.query || state.query.every(q => !q))) { + updateQueries(queryConfigs); + hasInitialized.current = true; + } + }, [state.query, queryConfigs, updateQueries]); + + const handleQueryChange = useCallback( + (index: number, updates: Partial) => { + const newConfigs = [...queryConfigs]; + newConfigs[index] = {...newConfigs[index], ...updates} as QueryConfig; + setQueryConfigs(newConfigs); + updateQueries(newConfigs); + }, + [queryConfigs, updateQueries] + ); + + const handleAddQuery = useCallback(() => { + const newConfigs = [ + ...queryConfigs, + { + name: '', + sizeType: 'install' as const, + }, + ]; + setQueryConfigs(newConfigs); + updateQueries(newConfigs); + }, [queryConfigs, updateQueries]); + + const handleRemoveQuery = useCallback( + (index: number) => { + if (queryConfigs.length === 1) { + return; // Don't allow removing the last query + } + const newConfigs = queryConfigs.filter((_, i) => i !== index); + setQueryConfigs(newConfigs); + updateQueries(newConfigs); + }, + [queryConfigs, updateQueries] + ); + + if (loading) { + return ( + + + +
{t('Loading filter options...')}
+
+
+ ); + } + + return ( + + + {t('Size Analysis documentation')} + + + + {queryConfigs.map((config, index) => ( + + + + + {t('Query %s', index + 1)} + + {queryConfigs.length > 1 && ( + + + + ); +} + +const DocsLink = styled(ExternalLink)` + display: block; + width: fit-content; + margin-bottom: ${p => p.theme.space.md}; + font-size: ${p => p.theme.fontSize.sm}; +`; diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx index 16d647fa1d3d..b2f3d700f2bb 100644 --- a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx @@ -42,6 +42,7 @@ import {animationTransitionSettings} from 'sentry/views/dashboards/widgetBuilder import WidgetBuilderDatasetSelector from 'sentry/views/dashboards/widgetBuilder/components/datasetSelector'; import WidgetBuilderFilterBar from 'sentry/views/dashboards/widgetBuilder/components/filtersBar'; import WidgetBuilderGroupBySelector from 'sentry/views/dashboards/widgetBuilder/components/groupBySelector'; +import {MobileAppSizeFilters} from 'sentry/views/dashboards/widgetBuilder/components/mobileAppSizeFilters'; import WidgetBuilderNameAndDescription from 'sentry/views/dashboards/widgetBuilder/components/nameAndDescFields'; import { WidgetPreviewContainer, @@ -134,17 +135,23 @@ function WidgetBuilderSlideout({ : t('Custom Widget Builder'); const isChartWidget = isChartDisplayType(state.displayType); - const showVisualizeSection = state.displayType !== DisplayType.DETAILS; - const showQueryFilterBuilder = !( - state.dataset === WidgetType.ISSUE && isChartDisplayType(state.displayType) - ); - const showGroupBySelector = isChartWidget && !(state.dataset === WidgetType.ISSUE); + const showVisualizeSection = + state.displayType !== DisplayType.DETAILS && + state.dataset !== WidgetType.MOBILE_APP_SIZE; + const showQueryFilterBuilder = + !(state.dataset === WidgetType.ISSUE && isChartDisplayType(state.displayType)) && + state.dataset !== WidgetType.MOBILE_APP_SIZE; + const showGroupBySelector = + isChartWidget && + !(state.dataset === WidgetType.ISSUE) && + state.dataset !== WidgetType.MOBILE_APP_SIZE; const isSmallScreen = useMedia(`(max-width: ${theme.breakpoints.sm})`); const showSortByStep = - (isChartWidget && state.fields && state.fields.length > 0) || - state.displayType === DisplayType.TABLE; + state.dataset !== WidgetType.MOBILE_APP_SIZE && + ((isChartWidget && state.fields && state.fields.length > 0) || + state.displayType === DisplayType.TABLE); const observer = useMemo( () => @@ -361,13 +368,17 @@ function WidgetBuilderSlideout({ /> -
- -
- + {state.dataset !== WidgetType.MOBILE_APP_SIZE && (
- +
+ )} + + {state.dataset !== WidgetType.MOBILE_APP_SIZE && ( +
+ +
+ )}
{isSmallScreen && (
@@ -394,6 +405,11 @@ function WidgetBuilderSlideout({
)} + {state.dataset === WidgetType.MOBILE_APP_SIZE && ( +
+ +
+ )} {showQueryFilterBuilder && (
{ }, ]; + const appSizeWidgets: WidgetTemplate[] = [ + { + id: 'mobile-app-size', + title: t('Mobile App Size'), + description: t('Track mobile app install and download sizes over time.'), + displayType: DisplayType.LINE, + widgetType: WidgetType.MOBILE_APP_SIZE, + interval: '30d', + queries: [ + { + name: '', + conditions: '', + fields: [], + aggregates: ['max(max_install_size)'], + columns: [], + orderby: '', + }, + ], + }, + ]; + + const hasPreprodFeatures = organization.features.includes('preprod-frontend-routes'); + return isSelfHostedErrorsOnly ? errorsWidgets : organization.features.includes('visibility-explore-view') - ? [...spanWidgets, ...errorsWidgets] - : [...transactionsWidgets, ...errorsWidgets]; + ? [...spanWidgets, ...errorsWidgets, ...(hasPreprodFeatures ? appSizeWidgets : [])] + : [ + ...transactionsWidgets, + ...errorsWidgets, + ...(hasPreprodFeatures ? appSizeWidgets : []), + ]; }; export function getTopNConvertedDefaultWidgets( diff --git a/static/app/views/dashboards/widgets/common/types.tsx b/static/app/views/dashboards/widgets/common/types.tsx index 7fdf173442c8..df3dc2680e9c 100644 --- a/static/app/views/dashboards/widgets/common/types.tsx +++ b/static/app/views/dashboards/widgets/common/types.tsx @@ -12,6 +12,7 @@ type AttributeValueType = | 'percent_change' | 'string' | 'size' + | 'size_base10' | 'rate' | 'score' | 'currency'; diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/settings.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/settings.tsx index 20d37237e08e..423dbe5a89c5 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/settings.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/settings.tsx @@ -16,6 +16,7 @@ export const FALLBACK_UNIT_FOR_FIELD_TYPE = { percentage: null, string: null, size: SizeUnit.BYTE, + size_base10: SizeUnit.BYTE, rate: RateUnit.PER_SECOND, score: null, } satisfies Record; diff --git a/static/app/views/detectors/utils/metricDetectorSuffix.tsx b/static/app/views/detectors/utils/metricDetectorSuffix.tsx index b37c3a666e8b..80178a87e49e 100644 --- a/static/app/views/detectors/utils/metricDetectorSuffix.tsx +++ b/static/app/views/detectors/utils/metricDetectorSuffix.tsx @@ -15,6 +15,7 @@ export function getStaticDetectorThresholdSuffix(aggregate: string) { case 'duration': return 'ms'; case 'size': + case 'size_base10': return 'B'; case 'rate': return '1/s';