From 5513d59159ab1995fd1a6f30a557b784411edaf5 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 14 Apr 2026 10:48:05 +0100 Subject: [PATCH 1/4] feat(preprod): Store metrics artifact type as string in EAP Write metrics_artifact_type as a human-readable string label instead of an integer enum value when producing PREPROD size metric EAP trace items. This enables string-based value autocomplete in Explore search without needing enum-to-name mapping at the display layer. - Add to_choice_label() helper on MetricsArtifactType enum - Update EAP write path to emit string labels - Repoint PREPROD search field as public artifact_type (string) - Add artifact_type to frontend string defaults with predefined values - Hide deprecated integer fields from Explore UI - Extract METRICS_ARTIFACT_TYPES as canonical shared constant Refs EME-874 Co-Authored-By: Claude Sonnet 4 --- src/sentry/preprod/eap/write.py | 8 +++++++- src/sentry/preprod/models.py | 8 ++++++++ src/sentry/search/eap/preprod_size/attributes.py | 4 ++-- static/app/utils/fields/index.ts | 7 +++++++ static/app/views/explore/constants.tsx | 4 ++++ static/app/views/settings/project/preprod/types.ts | 10 ++++++++-- tests/sentry/preprod/eap/test_write.py | 4 ++-- .../snuba/preprod/eap/test_preprod_eap_integration.py | 4 ++-- 8 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/sentry/preprod/eap/write.py b/src/sentry/preprod/eap/write.py index 37a149377a6bbe..b34a1509252c87 100644 --- a/src/sentry/preprod/eap/write.py +++ b/src/sentry/preprod/eap/write.py @@ -65,7 +65,13 @@ def produce_preprod_size_metric_to_eap( "preprod_artifact_id": size_metric.preprod_artifact_id, "size_metric_id": size_metric.id, "sub_item_type": "size_metric", - "metrics_artifact_type": size_metric.metrics_artifact_type, + "metrics_artifact_type": ( + PreprodArtifactSizeMetrics.MetricsArtifactType( + size_metric.metrics_artifact_type + ).to_choice_label() + if size_metric.metrics_artifact_type is not None + else None + ), "identifier": size_metric.identifier, "min_install_size": size_metric.min_install_size, "max_install_size": size_metric.max_install_size, diff --git a/src/sentry/preprod/models.py b/src/sentry/preprod/models.py index a110f45b4334b4..9f172db9d9dd3a 100644 --- a/src/sentry/preprod/models.py +++ b/src/sentry/preprod/models.py @@ -578,6 +578,14 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: (cls.APP_CLIP_ARTIFACT, "app_clip_artifact"), ) + def to_choice_label(self) -> str: + """Return the human-readable choice label for this enum value. + + Used by the EAP write path to store artifact type as a string attribute. + """ + choices = dict(self.as_choices()) + return choices[self.value] + class SizeAnalysisState(IntEnum): PENDING = 0 """Size analysis has not started yet.""" diff --git a/src/sentry/search/eap/preprod_size/attributes.py b/src/sentry/search/eap/preprod_size/attributes.py index 2e7085c2555e96..6dc23a05ef85e5 100644 --- a/src/sentry/search/eap/preprod_size/attributes.py +++ b/src/sentry/search/eap/preprod_size/attributes.py @@ -9,9 +9,9 @@ for column in COMMON_COLUMNS + [ ResolvedAttribute( - public_alias="metrics_artifact_type", + public_alias="artifact_type", internal_name="metrics_artifact_type", - search_type="integer", + search_type="string", ), ResolvedAttribute( public_alias="install_size", diff --git a/static/app/utils/fields/index.ts b/static/app/utils/fields/index.ts index 53a903fa53f7ac..c59191d2ce861f 100644 --- a/static/app/utils/fields/index.ts +++ b/static/app/utils/fields/index.ts @@ -5,6 +5,7 @@ import type {TagCollection} from 'sentry/types/group'; import {CONDITIONS_ARGUMENTS, WEB_VITALS_QUALITY} from 'sentry/utils/discover/types'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; import {SpanFields} from 'sentry/views/insights/types'; +import {METRICS_ARTIFACT_TYPES} from 'sentry/views/settings/project/preprod/types'; // Don't forget to update https://docs.sentry.io/product/sentry-basics/search/searchable-properties/ for any changes made here @@ -2518,6 +2519,12 @@ const PREPROD_FIELD_DEFINITIONS: Record = { kind: FieldKind.FIELD, valueType: FieldValueType.STRING, }, + artifact_type: { + desc: t('The type of artifact component (e.g., main app, watch app, app clip)'), + kind: FieldKind.FIELD, + valueType: FieldValueType.STRING, + values: [...METRICS_ARTIFACT_TYPES], + }, build_configuration_name: { desc: t('The name of the build configuration (e.g., Debug, Release)'), kind: FieldKind.FIELD, diff --git a/static/app/views/explore/constants.tsx b/static/app/views/explore/constants.tsx index db009892b8758c..3487a05d7631f5 100644 --- a/static/app/views/explore/constants.tsx +++ b/static/app/views/explore/constants.tsx @@ -96,6 +96,7 @@ export const SENTRY_LOG_NUMBER_TAGS: string[] = [OurLogKnownFieldKey.SEVERITY_NU export const SENTRY_PREPROD_STRING_TAGS: string[] = [ 'app_id', 'app_name', + 'artifact_type', 'build_configuration_name', 'build_number', 'build_version', @@ -126,6 +127,9 @@ export const HIDDEN_PREPROD_ATTRIBUTES = [ 'tags[artifact_state,number]', 'tags[artifact_date_built,number]', 'tags[build_number,number]', + 'metrics_artifact_type', + 'tags[metrics_artifact_type,number]', + 'tags[artifact_type,number]', ]; export const SENTRY_TRACEMETRIC_STRING_TAGS: string[] = [ diff --git a/static/app/views/settings/project/preprod/types.ts b/static/app/views/settings/project/preprod/types.ts index cad5394777d903..86577e269885ff 100644 --- a/static/app/views/settings/project/preprod/types.ts +++ b/static/app/views/settings/project/preprod/types.ts @@ -10,14 +10,20 @@ const ALL_MEASUREMENTS = ['absolute', 'absolute_diff', 'relative_diff'] as const export type MeasurementType = (typeof ALL_MEASUREMENTS)[number]; -export const ALL_ARTIFACT_TYPES = [ - 'all_artifacts', +/** + * Canonical list of metrics artifact types. + * Keep in sync with PreprodArtifactSizeMetrics.MetricsArtifactType.as_choices() in + * src/sentry/preprod/models.py + */ +export const METRICS_ARTIFACT_TYPES = [ 'main_artifact', 'watch_artifact', 'android_dynamic_feature_artifact', 'app_clip_artifact', ] as const; +export const ALL_ARTIFACT_TYPES = ['all_artifacts', ...METRICS_ARTIFACT_TYPES] as const; + export type ArtifactType = (typeof ALL_ARTIFACT_TYPES)[number]; export const DEFAULT_ARTIFACT_TYPE: ArtifactType = 'main_artifact'; diff --git a/tests/sentry/preprod/eap/test_write.py b/tests/sentry/preprod/eap/test_write.py index 78021a86763d37..de4c75db3dbcea 100644 --- a/tests/sentry/preprod/eap/test_write.py +++ b/tests/sentry/preprod/eap/test_write.py @@ -93,8 +93,8 @@ def test_write_preprod_size_metric_encodes_all_fields_correctly(self, mock_produ assert attrs["preprod_artifact_id"].int_value == artifact.id assert attrs["size_metric_id"].int_value == size_metric.id assert ( - attrs["metrics_artifact_type"].int_value - == PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT + attrs["metrics_artifact_type"].string_value + == PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT.to_choice_label() ) assert attrs["identifier"].string_value == "com.example.feature" assert attrs["min_install_size"].int_value == 1000 diff --git a/tests/snuba/preprod/eap/test_preprod_eap_integration.py b/tests/snuba/preprod/eap/test_preprod_eap_integration.py index e671188d866b47..9854acb52939c4 100644 --- a/tests/snuba/preprod/eap/test_preprod_eap_integration.py +++ b/tests/snuba/preprod/eap/test_preprod_eap_integration.py @@ -98,8 +98,8 @@ def test_write_and_read_size_metric_round_trip(self): response.column_values[columns["size_metric_id"]].results[0].val_int == size_metric.id ) assert ( - response.column_values[columns["metrics_artifact_type"]].results[0].val_int - == PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT + response.column_values[columns["metrics_artifact_type"]].results[0].val_str + == PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT.to_choice_label() ) assert response.column_values[columns["max_install_size"]].results[0].val_int == 5000 assert response.column_values[columns["max_download_size"]].results[0].val_int == 3000 From d692518b2ca7e966fcd1fb9f48649e6931b32601 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 14 Apr 2026 11:12:37 +0100 Subject: [PATCH 2/4] fix(preprod): Fix metrics_artifact_type read path type and add ValueError handling - Update read.py attribute definition to search_type="string" matching the write path change (fixes integration test round-trip failure) - Extract _metrics_artifact_type_label helper with ValueError handling for invalid database values (addresses Warden feedback) --- src/sentry/preprod/eap/read.py | 2 +- src/sentry/preprod/eap/write.py | 21 ++++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/sentry/preprod/eap/read.py b/src/sentry/preprod/eap/read.py index c90f118b7e4acb..2bd9cef6337ac1 100644 --- a/src/sentry/preprod/eap/read.py +++ b/src/sentry/preprod/eap/read.py @@ -33,7 +33,7 @@ ResolvedAttribute( public_alias="metrics_artifact_type", internal_name="metrics_artifact_type", - search_type="integer", + search_type="string", ), ResolvedAttribute( public_alias="max_install_size", diff --git a/src/sentry/preprod/eap/write.py b/src/sentry/preprod/eap/write.py index b34a1509252c87..a8939247fcd77e 100644 --- a/src/sentry/preprod/eap/write.py +++ b/src/sentry/preprod/eap/write.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import uuid from typing import Any @@ -27,6 +28,18 @@ from sentry.utils.eap import hex_to_item_id from sentry.utils.kafka_config import get_topic_definition +logger = logging.getLogger(__name__) + + +def _metrics_artifact_type_label(value: int | None) -> str | None: + if value is None: + return None + try: + return PreprodArtifactSizeMetrics.MetricsArtifactType(value).to_choice_label() + except ValueError: + logger.warning("preprod.eap.unknown_metrics_artifact_type", extra={"value": value}) + return None + def produce_preprod_size_metric_to_eap( size_metric: PreprodArtifactSizeMetrics, @@ -65,13 +78,7 @@ def produce_preprod_size_metric_to_eap( "preprod_artifact_id": size_metric.preprod_artifact_id, "size_metric_id": size_metric.id, "sub_item_type": "size_metric", - "metrics_artifact_type": ( - PreprodArtifactSizeMetrics.MetricsArtifactType( - size_metric.metrics_artifact_type - ).to_choice_label() - if size_metric.metrics_artifact_type is not None - else None - ), + "metrics_artifact_type": _metrics_artifact_type_label(size_metric.metrics_artifact_type), "identifier": size_metric.identifier, "min_install_size": size_metric.min_install_size, "max_install_size": size_metric.max_install_size, From ba030d88eb9b5682c588d243fa9c12ceba9c85dc Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 14 Apr 2026 11:23:54 +0100 Subject: [PATCH 3/4] fix(preprod): Also catch KeyError in metrics_artifact_type label conversion Handles the case where an enum member exists but is missing from as_choices(), preventing a crash in the EAP write path. --- src/sentry/preprod/eap/write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/preprod/eap/write.py b/src/sentry/preprod/eap/write.py index a8939247fcd77e..65fee767b2d5c0 100644 --- a/src/sentry/preprod/eap/write.py +++ b/src/sentry/preprod/eap/write.py @@ -36,7 +36,7 @@ def _metrics_artifact_type_label(value: int | None) -> str | None: return None try: return PreprodArtifactSizeMetrics.MetricsArtifactType(value).to_choice_label() - except ValueError: + except (ValueError, KeyError): logger.warning("preprod.eap.unknown_metrics_artifact_type", extra={"value": value}) return None From 068e16826e1fb6f9b2d99c642232574e4d2e6276 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 15 Apr 2026 11:31:35 +0100 Subject: [PATCH 4/4] ref(preprod): Remove frontend changes for separate PR Frontend and backend deploy independently, so splitting into separate PRs. Frontend changes will follow in a dedicated PR after this backend PR lands. --- static/app/utils/fields/index.ts | 40 +++++++++++++++---- static/app/views/explore/constants.tsx | 4 -- .../views/settings/project/preprod/types.ts | 10 +---- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/static/app/utils/fields/index.ts b/static/app/utils/fields/index.ts index c59191d2ce861f..9e47c9c1279433 100644 --- a/static/app/utils/fields/index.ts +++ b/static/app/utils/fields/index.ts @@ -5,7 +5,6 @@ import type {TagCollection} from 'sentry/types/group'; import {CONDITIONS_ARGUMENTS, WEB_VITALS_QUALITY} from 'sentry/utils/discover/types'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; import {SpanFields} from 'sentry/views/insights/types'; -import {METRICS_ARTIFACT_TYPES} from 'sentry/views/settings/project/preprod/types'; // Don't forget to update https://docs.sentry.io/product/sentry-basics/search/searchable-properties/ for any changes made here @@ -395,6 +394,7 @@ export enum AggregationKey { FAILURE_RATE = 'failure_rate', LAST_SEEN = 'last_seen', PERFORMANCE_SCORE = 'performance_score', + OPPORTUNITY_SCORE = 'opportunity_score', } export enum IsFieldValues { @@ -1024,7 +1024,35 @@ export const AGGREGATION_FIELDS: Record = { { name: 'value', kind: 'column', - columnTypes: [FieldValueType.NUMBER], + columnTypes: validateAllowedColumns([ + 'measurements.score.total', + 'measurements.score.lcp', + 'measurements.score.fcp', + 'measurements.score.inp', + 'measurements.score.cls', + 'measurements.score.ttfb', + ]), + defaultValue: 'measurements.score.total', + required: true, + }, + ], + }, + [AggregationKey.OPPORTUNITY_SCORE]: { + desc: t('Returns the opportunity score for a given web vital'), + kind: FieldKind.FUNCTION, + valueType: FieldValueType.SCORE, + parameters: [ + { + name: 'value', + kind: 'column', + columnTypes: validateAllowedColumns([ + 'measurements.score.total', + 'measurements.score.lcp', + 'measurements.score.fcp', + 'measurements.score.inp', + 'measurements.score.cls', + 'measurements.score.ttfb', + ]), defaultValue: 'measurements.score.total', required: true, }, @@ -1055,6 +1083,8 @@ export const ALLOWED_EXPLORE_VISUALIZE_AGGREGATES: AggregationKey[] = [ AggregationKey.EPS, AggregationKey.FAILURE_RATE, AggregationKey.FAILURE_COUNT, + AggregationKey.PERFORMANCE_SCORE, + AggregationKey.OPPORTUNITY_SCORE, ]; export const ALLOWED_EXPLORE_EQUATION_AGGREGATES: AggregationKey[] = [ @@ -2519,12 +2549,6 @@ const PREPROD_FIELD_DEFINITIONS: Record = { kind: FieldKind.FIELD, valueType: FieldValueType.STRING, }, - artifact_type: { - desc: t('The type of artifact component (e.g., main app, watch app, app clip)'), - kind: FieldKind.FIELD, - valueType: FieldValueType.STRING, - values: [...METRICS_ARTIFACT_TYPES], - }, build_configuration_name: { desc: t('The name of the build configuration (e.g., Debug, Release)'), kind: FieldKind.FIELD, diff --git a/static/app/views/explore/constants.tsx b/static/app/views/explore/constants.tsx index 3487a05d7631f5..db009892b8758c 100644 --- a/static/app/views/explore/constants.tsx +++ b/static/app/views/explore/constants.tsx @@ -96,7 +96,6 @@ export const SENTRY_LOG_NUMBER_TAGS: string[] = [OurLogKnownFieldKey.SEVERITY_NU export const SENTRY_PREPROD_STRING_TAGS: string[] = [ 'app_id', 'app_name', - 'artifact_type', 'build_configuration_name', 'build_number', 'build_version', @@ -127,9 +126,6 @@ export const HIDDEN_PREPROD_ATTRIBUTES = [ 'tags[artifact_state,number]', 'tags[artifact_date_built,number]', 'tags[build_number,number]', - 'metrics_artifact_type', - 'tags[metrics_artifact_type,number]', - 'tags[artifact_type,number]', ]; export const SENTRY_TRACEMETRIC_STRING_TAGS: string[] = [ diff --git a/static/app/views/settings/project/preprod/types.ts b/static/app/views/settings/project/preprod/types.ts index 86577e269885ff..cad5394777d903 100644 --- a/static/app/views/settings/project/preprod/types.ts +++ b/static/app/views/settings/project/preprod/types.ts @@ -10,20 +10,14 @@ const ALL_MEASUREMENTS = ['absolute', 'absolute_diff', 'relative_diff'] as const export type MeasurementType = (typeof ALL_MEASUREMENTS)[number]; -/** - * Canonical list of metrics artifact types. - * Keep in sync with PreprodArtifactSizeMetrics.MetricsArtifactType.as_choices() in - * src/sentry/preprod/models.py - */ -export const METRICS_ARTIFACT_TYPES = [ +export const ALL_ARTIFACT_TYPES = [ + 'all_artifacts', 'main_artifact', 'watch_artifact', 'android_dynamic_feature_artifact', 'app_clip_artifact', ] as const; -export const ALL_ARTIFACT_TYPES = ['all_artifacts', ...METRICS_ARTIFACT_TYPES] as const; - export type ArtifactType = (typeof ALL_ARTIFACT_TYPES)[number]; export const DEFAULT_ARTIFACT_TYPE: ArtifactType = 'main_artifact';