From efdc1853460aeb09921b1b382e986803eebed9b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Wed, 27 May 2026 06:32:47 -0400 Subject: [PATCH 01/16] fix(profiling): render single-sample continuous profile chunks (#116234) Continuous profile chunks with a single sample give weight 0 to that sample (it diffs against itself). That causes `appendSample` to discard it and show "This flamegraph has no data." This adds a 10ms default instead to match matching the 100Hz sampling rate so the stack is still visible. This also fixes `getMaxConfigSpace` to use each profile's end position (`startedAt` + `duration`) instead of just `duration`. Without this, a profile that starts late in the observability window renders its frames mostly off-screen, appearing blank even after the weight fix. Put together, this fixes rendering for the two main views reported in PRO-43. Closes PRO-43. --- .../flamegraph/continuousFlamegraph.tsx | 14 ++++++++++---- .../profile/continuousProfile.spec.tsx | 17 +++++++++++++++++ .../profiling/profile/continuousProfile.tsx | 10 ++++++---- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/static/app/components/profiling/flamegraph/continuousFlamegraph.tsx b/static/app/components/profiling/flamegraph/continuousFlamegraph.tsx index df0a54ec51471d..7c7464986a22e1 100644 --- a/static/app/components/profiling/flamegraph/continuousFlamegraph.tsx +++ b/static/app/components/profiling/flamegraph/continuousFlamegraph.tsx @@ -88,7 +88,13 @@ function getMaxConfigSpace( unit: ProfilingFormatterUnit | string, [start, end]: readonly [number, number] | readonly [null, null] ): Rect { - const maxProfileDuration = Math.max(...profileGroup.profiles.map(p => p.duration)); + // Use the end position of each profile (startedAt offset + duration) rather than + // just duration, so profiles that start late in the window don't get clipped. + const maxProfileEnd = Math.max( + ...profileGroup.profiles.map(p => + start === null ? p.duration : p.startedAt - start + p.duration + ) + ); const spaceDuration = start !== null && end !== null ? end - start : 0; if (transactionSpan) { @@ -103,15 +109,15 @@ function getMaxConfigSpace( // and profile are fully visible to the user. const duration = Math.max( formatTo(transactionDuration, 'seconds', unit), - maxProfileDuration, + maxProfileEnd, spaceDuration ); return new Rect(0, 0, duration, 0); } // No transaction was found, so best we can do is align it to the starting - // position of the profiles - find the max of profile durations - return new Rect(0, 0, Math.max(maxProfileDuration, spaceDuration), 0); + // position of the profiles - find the max end position across all profiles + return new Rect(0, 0, Math.max(maxProfileEnd, spaceDuration), 0); } function getProfileOffset( diff --git a/static/app/utils/profiling/profile/continuousProfile.spec.tsx b/static/app/utils/profiling/profile/continuousProfile.spec.tsx index b4dd23ab484ee9..4f7b96dbd6147e 100644 --- a/static/app/utils/profiling/profile/continuousProfile.spec.tsx +++ b/static/app/utils/profiling/profile/continuousProfile.spec.tsx @@ -13,6 +13,23 @@ import { describe('ContinuousProfile', () => { describe('sampled profile', () => { + it('single sample renders using a default 10ms weight', () => { + const chunk: Profiling.ContinuousProfile = { + samples: [{timestamp: Date.now() / 1e3, stack_id: 0, thread_id: '0'}], + frames: [{function: 'foo', in_app: true}], + stacks: [[0]], + }; + + const profile = ContinuousProfile.FromProfile( + chunk, + createContinuousProfileFrameIndex(chunk.frames, 'node'), + {minTimestamp: 0, type: 'flamechart'} + ); + + expect(profile.samples).toHaveLength(1); + expect(profile.duration).toBe(10); + }); + it('imports the base properties', () => { const trace = makeSentryContinuousProfile({ profile: { diff --git a/static/app/utils/profiling/profile/continuousProfile.tsx b/static/app/utils/profiling/profile/continuousProfile.tsx index 1b76e1ade44d82..cc5da01e9083c1 100644 --- a/static/app/utils/profiling/profile/continuousProfile.tsx +++ b/static/app/utils/profiling/profile/continuousProfile.tsx @@ -46,13 +46,15 @@ export class ContinuousProfile extends Profile { } const weightedSamples: WeightedSample[] = chunk.samples.map((sample, i) => { - // falling back to the current sample timestamp has the effect - // of giving the last sample a weight of 0 const nextSample = chunk.samples[i + 1] ?? sample; + const weight = (nextSample.timestamp - sample.timestamp) * 1e3; return { ...sample, - // Chunk timestamps are in seconds, convert them to ms - weight: (nextSample.timestamp - sample.timestamp) * 1e3, + // Chunk timestamps are in seconds, convert them to ms. + // For a single-sample chunk every sample diffs against itself (weight=0), + // which would cause the sample to be discarded. Use a 10ms default (100Hz) + // so the stack is still visible. + weight: weight === 0 && chunk.samples.length === 1 ? 10 : weight, }; }); From c0c1db863a9fc2e031c8ef2755a8805182c43166 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Wed, 27 May 2026 13:30:36 +0200 Subject: [PATCH 02/16] fix(dynamic-sampling): use the correct field name for dynamic sampling project id (#116279) - Fix this, queries returned empty results --- .../dynamic_sampling/per_org/tasks/queries.py | 12 ++++++------ .../per_org/tasks/test_queries.py | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/sentry/dynamic_sampling/per_org/tasks/queries.py b/src/sentry/dynamic_sampling/per_org/tasks/queries.py index 5c234e7635a361..d90bb0d35faea2 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/queries.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/queries.py @@ -26,7 +26,7 @@ class DynamicSamplingQueryFilters(StrEnum): class DynamicSamplingQueryFields(StrEnum): - ROOT_PROJECT = "sentry.dsc.root_project" + DSC_PROJECT_ID = "sentry.dsc.project_id" COUNT = "count()" COUNT_SAMPLE = "count_sample()" @@ -126,11 +126,11 @@ def get_eap_project_volumes( ), "query_string": DynamicSamplingQueryFilters.IS_SEGMENT, "selected_columns": [ - DynamicSamplingQueryFields.ROOT_PROJECT, + DynamicSamplingQueryFields.DSC_PROJECT_ID, DynamicSamplingQueryFields.COUNT, DynamicSamplingQueryFields.COUNT_SAMPLE, ], - "orderby": [DynamicSamplingQueryFields.ROOT_PROJECT], + "orderby": [DynamicSamplingQueryFields.DSC_PROJECT_ID], "referrer": Referrer.DYNAMIC_SAMPLING_PER_ORG_GET_EAP_PROJECT_VOLUMES.value, "config": SearchResolverConfig( auto_fields=True, @@ -141,13 +141,13 @@ def get_eap_project_volumes( ): total = _get_aggregate_int(row, DynamicSamplingQueryFields.COUNT) keep = _get_aggregate_int(row, DynamicSamplingQueryFields.COUNT_SAMPLE) - root_project = row.get(DynamicSamplingQueryFields.ROOT_PROJECT) - if root_project is None: + dsc_project_id = row.get(DynamicSamplingQueryFields.DSC_PROJECT_ID) + if dsc_project_id is None: continue project_volumes.append( ProjectVolume( - project_id=ProjectId(int(root_project)), + project_id=ProjectId(int(dsc_project_id)), total=total, keep=keep, drop=max(total - keep, 0), diff --git a/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py b/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py index 4290e7516bfb01..e0a53cde9c0d23 100644 --- a/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py +++ b/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py @@ -170,12 +170,12 @@ def test_get_eap_project_volumes_existing_org(self) -> None: "sentry.dynamic_sampling.per_org.tasks.queries.run_eap_spans_table_query_in_chunks", return_value=[ { - "sentry.dsc.root_project": project.id, + "sentry.dsc.project_id": project.id, "count()": 2, "count_sample()": 2, }, { - "sentry.dsc.root_project": other_project.id, + "sentry.dsc.project_id": other_project.id, "count()": 1, "count_sample()": 1, }, @@ -197,11 +197,11 @@ def test_get_eap_project_volumes_existing_org(self) -> None: ] assert query["query_string"] == DynamicSamplingQueryFilters.IS_SEGMENT assert query["selected_columns"] == [ - DynamicSamplingQueryFields.ROOT_PROJECT, + DynamicSamplingQueryFields.DSC_PROJECT_ID, DynamicSamplingQueryFields.COUNT, DynamicSamplingQueryFields.COUNT_SAMPLE, ] - assert query["orderby"] == [DynamicSamplingQueryFields.ROOT_PROJECT] + assert query["orderby"] == [DynamicSamplingQueryFields.DSC_PROJECT_ID] assert query["referrer"] == Referrer.DYNAMIC_SAMPLING_PER_ORG_GET_EAP_PROJECT_VOLUMES.value def test_get_eap_project_volumes_without_traffic(self) -> None: @@ -226,7 +226,7 @@ def test_get_eap_project_volumes_handles_missing_aggregate_values(self) -> None: "sentry.dynamic_sampling.per_org.tasks.queries.run_eap_spans_table_query_in_chunks", return_value=[ { - "sentry.dsc.root_project": project.id, + "sentry.dsc.project_id": project.id, } ], ): @@ -234,7 +234,7 @@ def test_get_eap_project_volumes_handles_missing_aggregate_values(self) -> None: assert project_volumes == [ProjectVolume(project_id=project.id, total=0, keep=0, drop=0)] - def test_get_eap_project_volumes_skips_rows_without_root_project(self) -> None: + def test_get_eap_project_volumes_skips_rows_without_dsc_project_id(self) -> None: organization = self.create_organization() project = self.create_project(organization=organization) @@ -242,12 +242,12 @@ def test_get_eap_project_volumes_skips_rows_without_root_project(self) -> None: "sentry.dynamic_sampling.per_org.tasks.queries.run_eap_spans_table_query_in_chunks", return_value=[ { - "sentry.dsc.root_project": None, + "sentry.dsc.project_id": None, "count()": 3, "count_sample()": 1, }, { - "sentry.dsc.root_project": project.id, + "sentry.dsc.project_id": project.id, "count()": 2, "count_sample()": 1, }, From 31cb4e0a39c734dbd4f9c59e94c3438e54596f2d Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 27 May 2026 09:12:07 -0300 Subject: [PATCH 03/16] fix(metrics): Resolve flaky metrics tab tests (#116280) Replace synchronous DOM queries with async `findBy*` queries to properly wait for React state updates after user interactions. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Cursor Agent Co-authored-by: Nick --- .../views/explore/metrics/metricsTab.spec.tsx | 97 +++++++++++-------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/static/app/views/explore/metrics/metricsTab.spec.tsx b/static/app/views/explore/metrics/metricsTab.spec.tsx index 8421d654db09e3..6130245f77e002 100644 --- a/static/app/views/explore/metrics/metricsTab.spec.tsx +++ b/static/app/views/explore/metrics/metricsTab.spec.tsx @@ -186,7 +186,7 @@ describe('MetricsTabContent', () => { await userEvent.click(addButtons[0]!); - toolbars = screen.getAllByTestId('metric-toolbar'); + toolbars = await screen.findAllByTestId('metric-toolbar'); expect(toolbars).toHaveLength(2); // copies the last metric as a starting point expect(within(toolbars[1]!).getByRole('button', {name: 'bar'})).toBeInTheDocument(); @@ -195,21 +195,28 @@ describe('MetricsTabContent', () => { // change the second metric from bar to foo await userEvent.click(within(toolbars[1]!).getByRole('button', {name: 'bar'})); await userEvent.click(within(toolbars[1]!).getByRole('option', {name: 'foo'})); - expect(within(toolbars[1]!).getByRole('button', {name: 'foo'})).toBeInTheDocument(); + + const toolbar = await screen.findAllByTestId('metric-toolbar'); + + expect( + await within(toolbar[1]!).findByRole('button', { + name: 'foo', + }) + ).toBeInTheDocument(); addButtons = screen.getAllByRole('button', {name: 'Add Metric'}); expect(addButtons[0]).toBeEnabled(); await userEvent.click(addButtons[0]!); - toolbars = screen.getAllByTestId('metric-toolbar'); + toolbars = await screen.findAllByTestId('metric-toolbar'); expect(toolbars).toHaveLength(3); // copies the last metric as a starting point expect(within(toolbars[2]!).getByRole('button', {name: 'foo'})).toBeInTheDocument(); expect(screen.getAllByTestId('metric-panel')).toHaveLength(3); }); - it.isKnownFlake('should fire analytics for metadata', async () => { + it('should fire analytics for metadata', async () => { render( @@ -278,50 +285,56 @@ describe('MetricsTabContent', () => { expect(screen.getAllByTestId('metric-panel')).toHaveLength(2); }); - expect(trackAnalyticsMock).toHaveBeenNthCalledWith( - 1, - 'metrics.explorer.panel.metadata', - expect.objectContaining({ - panel_index: 1, - query_status: 'success', - sample_counts: [0], - table_result_length: 6, - table_result_mode: 'metric samples', - table_result_sort: ['-timestamp'], - user_queries: '', - user_queries_count: 0, - aggregate_function: 'sum', - confidences: ['null'], - dataScanned: 'full', - dataset: 'metrics', - empty_buckets_percentage: [], - group_bys: [], - interval: '1h', - metric_name: 'bar', - metric_type: 'distribution', - }) - ); + await waitFor(() => { + expect(trackAnalyticsMock).toHaveBeenNthCalledWith( + 1, + 'metrics.explorer.panel.metadata', + expect.objectContaining({ + panel_index: 1, + query_status: 'success', + sample_counts: [0], + table_result_length: 6, + table_result_mode: 'metric samples', + table_result_sort: ['-timestamp'], + user_queries: '', + user_queries_count: 0, + aggregate_function: 'sum', + confidences: ['null'], + dataScanned: 'full', + dataset: 'metrics', + empty_buckets_percentage: [], + group_bys: [], + interval: '1h', + metric_name: 'bar', + metric_type: 'distribution', + }) + ); + }); - expect(trackAnalyticsMock).toHaveBeenNthCalledWith( - 2, - 'metrics.explorer.metadata', - expect.objectContaining({ - metric_queries_count: 2, - metric_panels_with_filters_count: 0, - metric_panels_with_group_bys_count: 0, - project_count: 1, - environment_count: 0, - has_exceeded_performance_usage_limit: false, - interval: '1h', - title: 'Test Title', - }) - ); + await waitFor(() => { + expect(trackAnalyticsMock).toHaveBeenNthCalledWith( + 2, + 'metrics.explorer.metadata', + expect.objectContaining({ + metric_queries_count: 2, + metric_panels_with_filters_count: 0, + metric_panels_with_group_bys_count: 0, + project_count: 1, + environment_count: 0, + has_exceeded_performance_usage_limit: false, + interval: '1h', + title: 'Test Title', + }) + ); + }); expect(trackAnalyticsMock).toHaveBeenCalledTimes(2); trackAnalyticsMock.mockClear(); await userEvent.click(within(toolbars[0]!).getByRole('button', {name: 'bar'})); await userEvent.click(within(toolbars[0]!).getByRole('option', {name: 'foo'})); - expect(within(toolbars[0]!).getByRole('button', {name: 'foo'})).toBeInTheDocument(); + expect( + await within(toolbars[0]!).findByRole('button', {name: 'foo'}) + ).toBeInTheDocument(); await waitFor(() => { expect(trackAnalyticsMock).toHaveBeenNthCalledWith( From aff6a03179c8d5b97e9f4157d66c1ae92c6ab2d7 Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 27 May 2026 09:49:32 -0300 Subject: [PATCH 04/16] ref(search): Add EAP API attribute visibility checks (#116091) The goal of this PR is to lie down the foundational work of removing/hiding internal attributes from non-staff/non-super-user requests. To determine if an attribute should be hidden or not, we pull that information from the Sentry conventions package from the `visibility` field from attribute metadata. These changes are limited to the eap search resolver, as such this PR does not doing any enforcement of these attributes, that will come in follow up PRs. As well, this PR adds in some tests. --------- Co-authored-by: Codex Co-authored-by: GPT-5 Codex Co-authored-by: Claude Opus 4.6 --- src/sentry/search/eap/resolver.py | 75 ++++++- src/sentry/search/eap/types.py | 4 + src/sentry/search/eap/utils.py | 79 +++++++ src/sentry/snuba/rpc_dataset_common.py | 8 + tests/sentry/search/eap/test_spans.py | 195 +++++++++++++++++- tests/sentry/snuba/test_rpc_dataset_common.py | 130 +++++++++++- 6 files changed, 485 insertions(+), 6 deletions(-) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index a036cba06a0b80..c514f1e8e0f5f1 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -60,7 +60,7 @@ from sentry.search.eap.rpc_utils import and_trace_item_filters from sentry.search.eap.sampling import validate_sampling from sentry.search.eap.spans.attributes import SPANS_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS -from sentry.search.eap.types import EAPResponse, SearchResolverConfig +from sentry.search.eap.types import EAPResponse, SearchResolverConfig, SupportedTraceItemType from sentry.search.events import constants as qb_constants from sentry.search.events import fields from sentry.search.events import filter as event_filter @@ -83,6 +83,10 @@ def collect_issue_short_ids_from_parsed_terms(terms: Sequence[object]) -> set[st return out +class HiddenApiAttribute(InvalidSearchQuery): + pass + + @dataclass(frozen=True) class SearchResolver: """The only attributes are things we want to cache and params @@ -531,12 +535,14 @@ def _resolve_term( self, term: event_search.SearchFilter ) -> tuple[TraceItemFilter, VirtualColumnDefinition | None]: resolved_column, context_definition = self.resolve_column(term.key.name) + self._raise_if_hidden_api_attribute(term.key.name, resolved_column) value = term.value.value if self.params.is_timeseries_request and context_definition is not None: resolved_column, value = self.map_search_term_context_to_original_column( term, context_definition ) + self._raise_if_hidden_api_attribute(term.key.name, resolved_column) context_definition = None if not isinstance(resolved_column.proto_definition, AttributeKey): @@ -755,6 +761,8 @@ def map_context_to_original_column( if not isinstance(resolved_column.proto_definition, AttributeKey): raise ValueError(f"{resolved_column.public_alias} is not valid search term") + self._raise_if_hidden_api_attribute(context.to_column_name, resolved_column) + return resolved_column def map_search_term_context_to_original_column( @@ -812,6 +820,7 @@ def resolve_aggregate_term( self, term: event_search.AggregateFilter ) -> tuple[AggregationFilter, VirtualColumnDefinition | None]: resolved_column, context = self.resolve_column(term.key.name) + self._raise_if_hidden_api_attribute(term.key.name, resolved_column) proto_definition = resolved_column.proto_definition if not isinstance( @@ -967,7 +976,14 @@ def resolve_columns( for column in stripped_columns: match = fields.is_function(column) has_aggregates = has_aggregates or match is not None - resolved_column, context = self.resolve_column(column, match) + try: + resolved_column, context = self.resolve_column(column, match) + except HiddenApiAttribute: + continue + if isinstance(resolved_column, ResolvedAttribute) and self._should_hide_api_attribute( + column, resolved_column + ): + continue if ( self.config.disable_array_attributes and isinstance(resolved_column, ResolvedAttribute) @@ -1024,12 +1040,49 @@ def resolve_attributes( resolved_contexts = [] for column in columns: col, context = self.resolve_attribute(column) + self._raise_if_hidden_api_attribute(column, col) if self.config.disable_array_attributes and col.internal_type == constants.ARRAY: continue resolved_columns.append(col) resolved_contexts.append(context) return resolved_columns, resolved_contexts + def should_hide_api_column( + self, column: str, resolved_column: ResolvedAttribute | ResolvedFunction + ) -> bool: + if not isinstance(resolved_column, ResolvedAttribute): + return False + return self._should_hide_api_attribute(column, resolved_column) + + def _should_hide_api_attribute( + self, column: str, resolved_attribute: ResolvedAttribute + ) -> bool: + if self.config.api_attribute_visibility_item_type is None: + return False + + from sentry.search.eap.utils import can_expose_attribute_to_api + + item_type = SupportedTraceItemType(self.config.api_attribute_visibility_item_type) + if column in self.definitions.contexts and resolved_attribute.internal_name != column: + visibility_attribute = resolved_attribute.internal_name + elif column in self.definitions.contexts or column in self.definitions.columns: + visibility_attribute = column + else: + visibility_attribute = resolved_attribute.internal_name + return not can_expose_attribute_to_api( + visibility_attribute, + item_type, + include_internal=self.config.api_attribute_visibility_include_internal, + ) + + def _raise_if_hidden_api_attribute( + self, column: str, resolved_column: ResolvedAttribute | ResolvedFunction + ) -> None: + if isinstance(resolved_column, ResolvedAttribute) and self._should_hide_api_attribute( + column, resolved_column + ): + raise HiddenApiAttribute(f"Could not parse {column}") + def resolve_attribute( self, column: str, public_alias_override: str | None = None ) -> tuple[ResolvedAttribute, VirtualColumnDefinition | None]: @@ -1126,7 +1179,10 @@ def resolve_functions( """Helper function to resolve a list of functions instead of 1 attribute at a time""" resolved_functions, resolved_contexts = [], [] for column in columns: - function, context = self.resolve_function(column) + try: + function, context = self.resolve_function(column) + except HiddenApiAttribute: + continue resolved_functions.append(function) resolved_contexts.append(context) return resolved_functions, resolved_contexts @@ -1182,6 +1238,9 @@ def resolve_function( parsed_args.append(argument_definition.default_arg) else: parsed_argument, _ = self.resolve_attribute(argument_definition.default_arg) + self._raise_if_hidden_api_attribute( + argument_definition.default_arg, parsed_argument + ) parsed_args.append(parsed_argument) missing_args -= 1 continue @@ -1196,6 +1255,7 @@ def resolve_function( ) if isinstance(argument_definition, AttributeArgumentDefinition): parsed_argument, _ = self.resolve_attribute(argument) + self._raise_if_hidden_api_attribute(argument, parsed_argument) parsed_args.append(parsed_argument) else: if argument_definition.argument_types is None: @@ -1282,7 +1342,10 @@ def resolve_equations( formulas = [] contexts = [] for equation in equations: - formula, context = self.resolve_equation(equation) + try: + formula, context = self.resolve_equation(equation) + except HiddenApiAttribute: + continue formulas.append(formula) contexts.extend(context) return formulas, contexts @@ -1303,6 +1366,8 @@ def resolve_equation( col, context = self.resolve_column( operation, public_alias_override=f"equation|{equation}" ) + if isinstance(col, ResolvedAttribute): + self._raise_if_hidden_api_attribute(operation, col) return col, [context] if context else [] elif isinstance(operation, float): return ( @@ -1378,6 +1443,8 @@ def _resolve_operation( # Resolve the column, and turn it into a RPC Column so it can be used in a BinaryFormula # Columns in equations must pass default_value=0 otherwise they may become a null and ruin the entire formula col, context = self.resolve_column(operation, default_value=0) + if isinstance(col, ResolvedAttribute): + self._raise_if_hidden_api_attribute(operation, col) contexts = [context] if context is not None else [] proto_definition = col.proto_definition diff --git a/src/sentry/search/eap/types.py b/src/sentry/search/eap/types.py index 24e5e56476d3db..94d9901a60d8b2 100644 --- a/src/sentry/search/eap/types.py +++ b/src/sentry/search/eap/types.py @@ -39,6 +39,10 @@ class SearchResolverConfig: # When True, ResolvedAttributes whose internal_type is ARRAY are silently dropped based on # feature flag organizations:trace-item-details-array-fields disable_array_attributes: bool = True + # API-only visibility enforcement. Non-API callers should leave this as None + # so backend resolution semantics remain unchanged. + api_attribute_visibility_item_type: str | None = None + api_attribute_visibility_include_internal: bool = False def extra_conditions( self, diff --git a/src/sentry/search/eap/utils.py b/src/sentry/search/eap/utils.py index f6d61014606d7d..3d05577c8c580c 100644 --- a/src/sentry/search/eap/utils.py +++ b/src/sentry/search/eap/utils.py @@ -2,6 +2,7 @@ from typing import Literal from google.protobuf.timestamp_pb2 import Timestamp +from sentry_conventions.attributes import ATTRIBUTE_METADATA as ATTRIBUTE_METADATA from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeriesRequest from sentry.search.eap.columns import ColumnDefinitions, ResolvedAttribute @@ -237,6 +238,84 @@ def can_expose_attribute( return True +def _has_internal_convention_visibility(attribute: str) -> bool: + metadata = ATTRIBUTE_METADATA.get(attribute) + if metadata is None: + return False + + visibility = metadata.visibility + return getattr(visibility, "value", visibility) == "internal" + + +def _get_sentry_convention_visibility_candidates( + attribute: str, item_type: SupportedTraceItemType +) -> set[str]: + candidates = {attribute} + + if attribute.startswith(("dsc.", "_internal.")): + candidates.add(f"sentry.{attribute}") + + resolved_attribute = PUBLIC_ALIAS_TO_INTERNAL_MAPPING.get(item_type, {}).get(attribute) + if resolved_attribute is not None: + candidates.add(resolved_attribute.public_alias) + candidates.add(resolved_attribute.internal_name) + if resolved_attribute.replacement: + candidates.add(resolved_attribute.replacement) + + for mapping in INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS.get(item_type, {}).values(): + public_alias = mapping.get(attribute) + if public_alias is not None: + candidates.add(public_alias) + + replacement_map = SENTRY_CONVENTIONS_REPLACEMENT_MAPPINGS.get(item_type, {}) + pending = list(candidates) + while pending: + candidate = pending.pop() + replacement = replacement_map.get(candidate) + if replacement is not None and replacement not in candidates: + candidates.add(replacement) + pending.append(replacement) + + return candidates + + +def is_internal_sentry_convention_attribute( + attribute: str, item_type: SupportedTraceItemType +) -> bool: + return any( + _has_internal_convention_visibility(candidate) + for candidate in _get_sentry_convention_visibility_candidates(attribute, item_type) + ) + + +def can_expose_attribute_to_api( + attribute: str, item_type: SupportedTraceItemType, include_internal: bool = False +) -> bool: + """Return whether an attribute may be exposed by public API surfaces. + + The visibility check expands the requested attribute to its related public + aliases, internal names, and replacement attributes because any of those may + carry the metadata that marks the underlying convention as internal. + `include_internal` only allows those Sentry-owned internal convention + attributes. It does not bypass `can_expose_attribute`, which still filters + private attributes first. + """ + candidates = _get_sentry_convention_visibility_candidates(attribute, item_type) + + for candidate in candidates: + if not can_expose_attribute(candidate, item_type, include_internal=include_internal): + return False + + # Private attributes are rejected above before this internal-only override + # is applied. + if include_internal: + return True + + return not any( + is_internal_sentry_convention_attribute(candidate, item_type) for candidate in candidates + ) + + def is_sentry_convention_replacement_attribute( public_alias: str, item_type: SupportedTraceItemType ) -> bool: diff --git a/src/sentry/snuba/rpc_dataset_common.py b/src/sentry/snuba/rpc_dataset_common.py index 582f3f9bf0fe4f..eeca03f11edcda 100644 --- a/src/sentry/snuba/rpc_dataset_common.py +++ b/src/sentry/snuba/rpc_dataset_common.py @@ -311,6 +311,10 @@ def get_table_rpc_request(cls, query: TableQuery) -> TableRequest: raise InvalidSearchQuery("orderby must also be in the selected columns or groupby") else: resolved_column = resolver.resolve_column(stripped_orderby)[0] + if resolver.should_hide_api_column(stripped_orderby, resolved_column): + raise InvalidSearchQuery( + "orderby must also be in the selected columns or groupby" + ) # Virtual context columns transform values (e.g. "1" -> "low") which # can produce an undesirable alphabetical sort order. When a sort_column @@ -324,6 +328,10 @@ def get_table_rpc_request(cls, query: TableQuery) -> TableRequest: internal_name=context_def.sort_column, search_type="string", ) + if resolver.should_hide_api_column(stripped_orderby, sort_col): + raise InvalidSearchQuery( + "orderby must also be in the selected columns or groupby" + ) orderby_resolved = sort_col all_columns.append(sort_col) sort_column_aliases.add(sort_alias) diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index f9d50180525958..dce7f999618568 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -1,7 +1,9 @@ import os from datetime import datetime +from unittest import mock import pytest +from sentry_conventions.attributes import ATTRIBUTE_METADATA, ATTRIBUTE_NAMES from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import ( AggregationAndFilter, AggregationComparisonFilter, @@ -28,6 +30,7 @@ ) from sentry.exceptions import InvalidSearchQuery +from sentry.search.eap import utils as eap_utils from sentry.search.eap.columns import ResolvedAttribute from sentry.search.eap.occurrences.definitions import OCCURRENCE_DEFINITIONS from sentry.search.eap.resolver import SearchResolver @@ -40,13 +43,88 @@ ) from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS from sentry.search.eap.spans.sentry_conventions import SENTRY_CONVENTIONS_DIRECTORY -from sentry.search.eap.types import SearchResolverConfig +from sentry.search.eap.trace_metrics.definitions import TRACE_METRICS_DEFINITIONS +from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType +from sentry.search.eap.utils import can_expose_attribute_to_api from sentry.search.events.types import SnubaParams from sentry.testutils.cases import TestCase from sentry.testutils.helpers.datetime import freeze_time from sentry.utils import json +class AttributeVisibilityTest(TestCase): + def test_public_convention_attribute_visible_to_everyone(self) -> None: + assert can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_ENVIRONMENT, SupportedTraceItemType.SPANS + ) + + def test_internal_convention_attribute_hidden_unless_included(self) -> None: + assert not can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT, SupportedTraceItemType.SPANS + ) + assert can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT, + SupportedTraceItemType.SPANS, + include_internal=True, + ) + + def test_internal_convention_public_alias_is_hidden(self) -> None: + with mock.patch.dict( + eap_utils.PUBLIC_ALIAS_TO_INTERNAL_MAPPING[SupportedTraceItemType.SPANS], + { + "public.alias": ResolvedAttribute( + public_alias="public.alias", + internal_name=ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT, + search_type="string", + ) + }, + ): + assert not can_expose_attribute_to_api("public.alias", SupportedTraceItemType.SPANS) + + def test_internal_convention_translated_public_alias_is_hidden(self) -> None: + with mock.patch.dict( + eap_utils.INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS[SupportedTraceItemType.SPANS]["string"], + {ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT: "public.alias"}, + ): + assert not can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT, SupportedTraceItemType.SPANS + ) + + def test_internal_convention_replacement_is_hidden(self) -> None: + with mock.patch.dict( + eap_utils.SENTRY_CONVENTIONS_REPLACEMENT_MAPPINGS[SupportedTraceItemType.SPANS], + {"deprecated.attr": ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT}, + ): + assert not can_expose_attribute_to_api("deprecated.attr", SupportedTraceItemType.SPANS) + + def test_public_convention_deprecated_alias_visible_to_everyone(self) -> None: + aliases = ATTRIBUTE_METADATA[ATTRIBUTE_NAMES.SENTRY_ENVIRONMENT].aliases + + assert aliases is not None + assert can_expose_attribute_to_api(aliases[0], SupportedTraceItemType.SPANS) + + def test_stripped_internal_prefix_alias_is_hidden(self) -> None: + assert not can_expose_attribute_to_api( + "_internal.normalized_description", SupportedTraceItemType.SPANS + ) + assert can_expose_attribute_to_api( + "_internal.normalized_description", + SupportedTraceItemType.SPANS, + include_internal=True, + ) + + def test_stripped_dsc_convention_alias_is_hidden(self) -> None: + assert not can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix("sentry."), + SupportedTraceItemType.SPANS, + ) + assert can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix("sentry."), + SupportedTraceItemType.SPANS, + include_internal=True, + ) + + class SearchResolverQueryTest(TestCase): def setUp(self) -> None: self.resolver = SearchResolver( @@ -448,6 +526,45 @@ def test_aggregate_query_on_custom_attributes(self) -> None: ) ) + def test_query_hides_internal_api_attributes_in_where(self) -> None: + resolver = SearchResolver( + params=SnubaParams(), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=SPAN_DEFINITIONS, + ) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + with pytest.raises(InvalidSearchQuery, match="Could not parse"): + resolver.resolve_query(f"{hidden_attribute}:foo") + + def test_query_hides_internal_api_attributes_in_having(self) -> None: + resolver = SearchResolver( + params=SnubaParams(), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=SPAN_DEFINITIONS, + ) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + with pytest.raises(InvalidSearchQuery, match="Could not parse"): + resolver.resolve_query(f"count_unique({hidden_attribute}):>0") + + def test_query_hides_internal_api_attributes_in_if_subquery(self) -> None: + resolver = SearchResolver( + params=SnubaParams(), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=TRACE_METRICS_DEFINITIONS, + ) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + with pytest.raises(InvalidSearchQuery, match="Could not parse"): + resolver.resolve_query(f"count_if(`{hidden_attribute}:foo`, value):>0") + def test_aggregate_query_on_attributes_with_units(self) -> None: for value in ["1000", "1s", "1000ms"]: where, having, _ = self.resolver.resolve_query(f"avg(measurements.lcp):>{value}") @@ -745,6 +862,82 @@ def test_simple_number_tag(self) -> None: ) assert virtual_context is None + def test_resolve_columns_hides_internal_api_attributes(self) -> None: + resolver = SearchResolver( + params=SnubaParams(projects=[self.project]), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=SPAN_DEFINITIONS, + ) + + resolved_columns, resolved_contexts = resolver.resolve_columns( + [ + "span.op", + f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]", + ] + ) + + assert [column.public_alias for column in resolved_columns] == ["span.op"] + assert resolved_contexts == [None] + + def test_resolve_columns_hides_functions_with_internal_api_attributes(self) -> None: + resolver = SearchResolver( + params=SnubaParams(projects=[self.project]), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=SPAN_DEFINITIONS, + ) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + resolved_columns, resolved_contexts = resolver.resolve_columns( + ["span.op", f"count_unique({hidden_attribute})"] + ) + + assert [column.public_alias for column in resolved_columns] == ["span.op"] + assert resolved_contexts == [None] + + def test_resolve_equations_hides_internal_api_attributes(self) -> None: + resolver = SearchResolver( + params=SnubaParams(projects=[self.project]), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=SPAN_DEFINITIONS, + ) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + resolved_equations, resolved_contexts = resolver.resolve_equations( + [f"count_unique({hidden_attribute}) / count()", "count() / 1"] + ) + + assert [column.public_alias for column in resolved_equations] == ["equation|count() / 1"] + assert resolved_contexts == [] + + def test_resolve_columns_includes_internal_api_attributes_when_configured(self) -> None: + resolver = SearchResolver( + params=SnubaParams(projects=[self.project]), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS, + api_attribute_visibility_include_internal=True, + ), + definitions=SPAN_DEFINITIONS, + ) + + resolved_columns, resolved_contexts = resolver.resolve_columns( + [ + "span.op", + f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]", + ] + ) + + assert [column.public_alias for column in resolved_columns] == [ + "span.op", + f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]", + ] + assert resolved_contexts == [None, None] + def test_sum_function(self) -> None: resolved_column, virtual_context = self.resolver.resolve_column("sum(span.self_time)") assert resolved_column.proto_definition == AttributeAggregation( diff --git a/tests/sentry/snuba/test_rpc_dataset_common.py b/tests/sentry/snuba/test_rpc_dataset_common.py index b9586755bc2680..390927a1365aeb 100644 --- a/tests/sentry/snuba/test_rpc_dataset_common.py +++ b/tests/sentry/snuba/test_rpc_dataset_common.py @@ -1,9 +1,13 @@ from datetime import datetime, timedelta, timezone +from typing import Any +from unittest import mock import pytest +from sentry_conventions.attributes import ATTRIBUTE_NAMES from sentry_protos.snuba.v1.downsampled_storage_pb2 import DownsampledStorageConfig -from sentry.search.eap.types import SearchResolverConfig +from sentry.exceptions import InvalidSearchQuery +from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType from sentry.search.events.types import SnubaParams from sentry.snuba.occurrences_rpc import Occurrences from sentry.snuba.ourlogs import OurLogs @@ -131,6 +135,130 @@ def test_force_sampling_mode_in_table(dataset, query, mode): assert rpc_request.rpc_request.meta.downsampled_storage_config.mode == mode +@django_db_all +def test_table_orderby_rejects_hidden_api_attribute() -> None: + owner = Factories.create_user() + organization = Factories.create_organization(owner=owner) + project = Factories.create_project(organization=organization) + end = datetime.now(timezone.utc) + start = end - timedelta(days=1) + snuba_params = SnubaParams(start=start, end=end, organization=organization, projects=[project]) + config = SearchResolverConfig(api_attribute_visibility_item_type=SupportedTraceItemType.SPANS) + resolver = Spans.get_resolver(snuba_params, config) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + table_query = TableQuery( + "", + [hidden_attribute], + [hidden_attribute], + 0, + 1, + "TestReferrer", + None, + resolver, + ) + + with pytest.raises( + InvalidSearchQuery, match="orderby must also be in the selected columns or groupby" + ): + RPCBase.get_table_rpc_request(table_query) + + +def test_table_orderby_rejects_hidden_remapped_virtual_context_sort_attribute() -> None: + organization = mock.Mock(id=1) + project = mock.Mock(id=1, slug="project-slug", organization=organization) + end = datetime.now(timezone.utc) + start = end - timedelta(days=1) + snuba_params = SnubaParams(start=start, end=end, organization=organization, projects=[project]) + config = SearchResolverConfig(api_attribute_visibility_item_type=SupportedTraceItemType.SPANS) + resolver = Spans.get_resolver(snuba_params, config) + + table_query = TableQuery( + "", + ["device.class", "count()"], + ["device.class"], + 0, + 1, + "TestReferrer", + None, + resolver, + ) + + def can_expose(attribute: str, *_args: Any, **_kwargs: Any) -> bool: + return attribute != "sentry.device.class" + + with ( + mock.patch("sentry.search.eap.utils.can_expose_attribute_to_api", can_expose), + pytest.raises( + InvalidSearchQuery, match="orderby must also be in the selected columns or groupby" + ), + ): + RPCBase.get_table_rpc_request(table_query) + + +@django_db_all +def test_timeseries_groupby_rejects_hidden_api_attribute() -> None: + owner = Factories.create_user() + organization = Factories.create_organization(owner=owner) + project = Factories.create_project(organization=organization) + end = datetime.now(timezone.utc) + start = end - timedelta(days=1) + snuba_params = SnubaParams( + start=start, + end=end, + granularity_secs=60, + organization=organization, + projects=[project], + ) + config = SearchResolverConfig(api_attribute_visibility_item_type=SupportedTraceItemType.SPANS) + resolver = Spans.get_resolver(snuba_params, config) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + with pytest.raises(InvalidSearchQuery, match="Could not parse"): + RPCBase.get_timeseries_query( + search_resolver=resolver, + params=snuba_params, + query_string="", + y_axes=["count()"], + groupby=[hidden_attribute], + referrer="TestReferrer", + sampling_mode=None, + ) + + +def test_timeseries_groupby_rejects_hidden_remapped_virtual_context_attribute() -> None: + organization = mock.Mock(id=1) + project = mock.Mock(id=1, slug="project-slug", organization=organization) + end = datetime.now(timezone.utc) + start = end - timedelta(days=1) + snuba_params = SnubaParams( + start=start, + end=end, + granularity_secs=60, + organization=organization, + projects=[project], + ) + config = SearchResolverConfig(api_attribute_visibility_item_type=SupportedTraceItemType.SPANS) + resolver = Spans.get_resolver(snuba_params, config) + + def can_expose(attribute: str, *_args: Any, **_kwargs: Any) -> bool: + return attribute != "sentry.category" + + with ( + mock.patch("sentry.search.eap.utils.can_expose_attribute_to_api", can_expose), + pytest.raises(InvalidSearchQuery, match="Could not parse"), + ): + RPCBase.get_timeseries_query( + search_resolver=resolver, + params=snuba_params, + query_string="", + y_axes=["count()"], + groupby=["span.module"], + referrer="TestReferrer", + sampling_mode=None, + ) + + @pytest.mark.parametrize( ["dataset"], [ From 18e55f3411bcf948880818eb882be652faa44317 Mon Sep 17 00:00:00 2001 From: Ogi Date: Wed, 27 May 2026 15:14:51 +0200 Subject: [PATCH 05/16] style: add right padding to seer header copy button (#116286) --- .../views/explore/conversations/components/conversationView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/views/explore/conversations/components/conversationView.tsx b/static/app/views/explore/conversations/components/conversationView.tsx index 2f3def8a57dec0..376d556fb7ac65 100644 --- a/static/app/views/explore/conversations/components/conversationView.tsx +++ b/static/app/views/explore/conversations/components/conversationView.tsx @@ -133,6 +133,7 @@ function ConversationView({ flexShrink={0} align="center" gap="sm" + paddingRight="sm" borderBottom="primary" background="primary" > From 1b67236b111d4d34cde17bce0b8884a4035e04c7 Mon Sep 17 00:00:00 2001 From: ArthurKnaus Date: Wed, 27 May 2026 15:25:27 +0200 Subject: [PATCH 06/16] feat(low-value-spans): Add configuration issue UI (#116271) Adds a first version of the low-value span configuration issue detail UI so these issues use a focused Problem and Troubleshooting layout. Screenshot 2026-05-27 at 08 35 02 Closes TET-2198 --------- Co-authored-by: OpenAI Codex --- .../lowValueSpanIssueDetails.spec.tsx | 68 ++++++++ .../lowValueSpanIssueDetails.tsx | 9 +- .../problemSection.spec.tsx | 59 +++++++ .../lowValueSpanIssues/problemSection.tsx | 74 ++++++++- .../lowValueSpanIssues/spanCode.tsx | 10 ++ .../troubleshootingSection.spec.tsx | 69 ++++++++ .../troubleshootingSection.tsx | 150 +++++++++++++----- .../lowValueSpanIssues/types.ts | 31 ++++ .../lowValueSpanIssues/utils.ts | 126 +++++++++++++++ 9 files changed, 551 insertions(+), 45 deletions(-) create mode 100644 static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/lowValueSpanIssueDetails.spec.tsx create mode 100644 static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/problemSection.spec.tsx create mode 100644 static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/spanCode.tsx create mode 100644 static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/troubleshootingSection.spec.tsx create mode 100644 static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/types.ts create mode 100644 static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/utils.ts diff --git a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/lowValueSpanIssueDetails.spec.tsx b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/lowValueSpanIssueDetails.spec.tsx new file mode 100644 index 00000000000000..137107f27629d2 --- /dev/null +++ b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/lowValueSpanIssueDetails.spec.tsx @@ -0,0 +1,68 @@ +import {EventFixture} from 'sentry-fixture/event'; +import {GroupFixture} from 'sentry-fixture/group'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {LowValueSpanIssueDetails} from './lowValueSpanIssueDetails'; + +describe('LowValueSpanIssueDetails', () => { + it('renders only problem and troubleshooting sections from backend evidence', () => { + render( + + ); + + expect(screen.getByText('Problem')).toBeInTheDocument(); + expect(screen.getByText('Troubleshooting')).toBeInTheDocument(); + expect(screen.getAllByText('function - compute_checksum').length).toBeGreaterThan(0); + expect(screen.getByText('$12.34')).toBeInTheDocument(); + expect(screen.queryByText('Value score')).not.toBeInTheDocument(); + expect(screen.queryByText('15%')).not.toBeInTheDocument(); + expect(screen.queryByText('Diagnosis')).not.toBeInTheDocument(); + expect(screen.queryByText('Impact')).not.toBeInTheDocument(); + }); + + it('only reads the backend evidence field names', () => { + render( + + ); + + expect(screen.getAllByText('Unknown span').length).toBeGreaterThan(0); + expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0); + expect(screen.queryByText('function - compute_checksum')).not.toBeInTheDocument(); + expect(screen.queryByText('$12.34')).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/lowValueSpanIssueDetails.tsx b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/lowValueSpanIssueDetails.tsx index a3017c91bf3dc2..5ec8a4fd0b37a9 100644 --- a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/lowValueSpanIssueDetails.tsx +++ b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/lowValueSpanIssueDetails.tsx @@ -5,6 +5,7 @@ import {SectionDivider} from 'sentry/views/issueDetails/streamline/foldSection'; import {ProblemSection} from './problemSection'; import {TroubleshootingSection} from './troubleshootingSection'; +import {getLowValueSpanEvidenceData} from './types'; interface LowValueSpanIssueDetailsProps { event: Event; @@ -12,12 +13,14 @@ interface LowValueSpanIssueDetailsProps { project: Project; } -export function LowValueSpanIssueDetails(_props: LowValueSpanIssueDetailsProps) { +export function LowValueSpanIssueDetails({event}: LowValueSpanIssueDetailsProps) { + const evidenceData = getLowValueSpanEvidenceData(event.occurrence?.evidenceData); + return (
- + - +
); } diff --git a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/problemSection.spec.tsx b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/problemSection.spec.tsx new file mode 100644 index 00000000000000..84660cd6aae94e --- /dev/null +++ b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/problemSection.spec.tsx @@ -0,0 +1,59 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {ProblemSection} from './problemSection'; +import type {LowValueSpanEvidenceData} from './types'; + +const evidenceData: LowValueSpanEvidenceData = { + op: 'function', + description: 'compute_checksum', + count: 1234, + avgDurationMs: 0.4, + estimatedCostUsd: 12.34, + sdkName: 'sentry.javascript.nextjs', +}; + +describe('LowValueSpanIssues ProblemSection', () => { + it('renders low-value span evidence from the occurrence', () => { + render(); + + expect(screen.getByText('Problem')).toBeInTheDocument(); + expect(screen.getByText('function')).toBeInTheDocument(); + expect(screen.getByText('function - compute_checksum')).toBeInTheDocument(); + expect(screen.getByText('1,234')).toBeInTheDocument(); + expect(screen.getByText('Estimated cost')).toBeInTheDocument(); + expect(screen.getByText('$12.34')).toBeInTheDocument(); + expect( + screen.getByLabelText('More information', { + selector: '[aria-label="More information"]', + }) + ).toBeInTheDocument(); + expect(screen.getByText('<1ms')).toBeInTheDocument(); + expect(screen.getByText('sentry.javascript.nextjs')).toBeInTheDocument(); + }); + + it('does not render estimated cost when unavailable', () => { + render( + + ); + + expect(screen.queryByText('Estimated cost')).not.toBeInTheDocument(); + }); + + it('does not render estimated cost when zero', () => { + render( + + ); + + expect(screen.queryByText('Estimated cost')).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/problemSection.tsx b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/problemSection.tsx index 7f29e7f17fb3bf..0ebe2f70566604 100644 --- a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/problemSection.tsx +++ b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/problemSection.tsx @@ -1,17 +1,81 @@ -import {Stack} from '@sentry/scraps/layout'; +import type {ReactNode} from 'react'; + +import {InlineCode} from '@sentry/scraps/code'; +import {InfoTip} from '@sentry/scraps/info'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; -import {t} from 'sentry/locale'; +import {t, tct} from 'sentry/locale'; + +import {SpanCode} from './spanCode'; +import type {LowValueSpanEvidenceData} from './types'; +import { + formatCount, + formatDurationMs, + formatEstimatedCostUsd, + getSpanLabel, +} from './utils'; + +interface ProblemSectionProps { + evidenceData: LowValueSpanEvidenceData; +} + +function DetailRow({label, value}: {label: string; value: ReactNode}) { + return ( + + {label} + {value} + + ); +} + +export function ProblemSection({evidenceData}: ProblemSectionProps) { + const hasEstimatedCost = + evidenceData.estimatedCostUsd !== null && evidenceData.estimatedCostUsd > 0; -export function ProblemSection() { return ( - {t('Problem')} + + {t('Problem')} + {evidenceData.op && {evidenceData.op}} + {t( - 'This span adds low-value detail to traces, which can make useful telemetry harder to scan. Review the instrumentation and remove or rename the span if it does not describe work you need to debug.' + 'Sentry detected a span that appears frequently but adds low-value telemetry. It can make traces noisier and increase stored span volume without adding useful debugging context.' )} + + {tct('The affected span is [span].', { + span: {getSpanLabel(evidenceData)}, + })} + + + + {hasEstimatedCost && ( + + {t('Estimated cost')} + + {formatEstimatedCostUsd(evidenceData.estimatedCostUsd)} + + + + )} + + {evidenceData.sdkName && ( + {evidenceData.sdkName}} + /> + )} + ); } diff --git a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/spanCode.tsx b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/spanCode.tsx new file mode 100644 index 00000000000000..d35a5a1dc8dd8d --- /dev/null +++ b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/spanCode.tsx @@ -0,0 +1,10 @@ +import styled from '@emotion/styled'; + +import {InlineCode} from '@sentry/scraps/code'; + +export const SpanCode = styled(InlineCode)` + max-width: 100%; + overflow-wrap: anywhere; + white-space: normal; + word-break: break-word; +`; diff --git a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/troubleshootingSection.spec.tsx b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/troubleshootingSection.spec.tsx new file mode 100644 index 00000000000000..472ac6cf5a6b6c --- /dev/null +++ b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/troubleshootingSection.spec.tsx @@ -0,0 +1,69 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {TroubleshootingSection} from './troubleshootingSection'; +import type {LowValueSpanEvidenceData} from './types'; + +const baseEvidenceData: LowValueSpanEvidenceData = { + op: 'function', + description: 'compute_checksum', + count: 1234, + avgDurationMs: 0.4, + estimatedCostUsd: 12.34, + sdkName: 'sentry.javascript.nextjs', +}; + +describe('LowValueSpanIssues TroubleshootingSection', () => { + it('renders the two troubleshooting paths', () => { + render(); + + expect(screen.getByText('Troubleshooting')).toBeInTheDocument(); + expect(screen.getByText('1. Find where the span is created')).toBeInTheDocument(); + expect( + screen.getByText('2. Remove custom instrumentation when possible') + ).toBeInTheDocument(); + expect( + screen.getByText('3. Filter automatic instrumentation exactly') + ).toBeInTheDocument(); + expect(screen.getByText('function - compute_checksum')).toBeInTheDocument(); + }); + + it('recommends JavaScript span filtering and mentions beforeSendSpan', () => { + render(); + + expect(screen.getByText('ignoreSpans')).toBeInTheDocument(); + expect(screen.getByText('beforeSendSpan')).toBeInTheDocument(); + expect(screen.getByText(/op: "function"/)).toBeInTheDocument(); + expect(screen.getByText(/name: "compute_checksum"/)).toBeInTheDocument(); + }); + + it('recommends before_send_transaction for Python SDKs', () => { + render( + + ); + + expect(screen.getByText('before_send_transaction')).toBeInTheDocument(); + expect(screen.getAllByText(/event\["spans"\]/).length).toBeGreaterThan(0); + expect(screen.getByText(/span.get\("op"\) == "function"/)).toBeInTheDocument(); + }); + + it('renders generic guidance when the SDK is unavailable', () => { + render( + + ); + + expect( + screen.getByText(/Check your SDK tracing options and add an exact-match filter/) + ).toBeInTheDocument(); + expect(screen.queryByText(/beforeSendTransaction/)).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/troubleshootingSection.tsx b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/troubleshootingSection.tsx index e90ecc724b4e6e..5045c21eea6b82 100644 --- a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/troubleshootingSection.tsx +++ b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/troubleshootingSection.tsx @@ -1,47 +1,123 @@ -import {Disclosure} from '@sentry/scraps/disclosure'; -import {Stack} from '@sentry/scraps/layout'; +import {CodeBlock, InlineCode} from '@sentry/scraps/code'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; -import {t} from 'sentry/locale'; +import {ExternalLink} from 'sentry/components/links/externalLink'; +import {IconDocs} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; + +import {SpanCode} from './spanCode'; +import type {LowValueSpanEvidenceData} from './types'; +import { + getJavaScriptSpanFilterSnippet, + getPythonSpanFilterSnippet, + getSpanFilteringDocsUrl, + getSpanLabel, + isJavaScriptSdk, + isPythonSdk, +} from './utils'; + +interface TroubleshootingSectionProps { + evidenceData: LowValueSpanEvidenceData; +} + +function AutomaticInstrumentationFix({ + evidenceData, +}: { + evidenceData: LowValueSpanEvidenceData; +}) { + if (isPythonSdk(evidenceData)) { + return ( + + + {tct( + 'For Python automatic instrumentation, use [hook] to remove matching spans from [spans].', + { + hook: before_send_transaction, + spans: event["spans"], + } + )} + + + {getPythonSpanFilterSnippet(evidenceData)} + + + ); + } + + if (isJavaScriptSdk(evidenceData)) { + return ( + + + {tct( + 'For JavaScript automatic instrumentation, use [ignoreSpans] to drop this exact span before it is sent. Use [beforeSendSpan] only if you need to transform span data instead of dropping it.', + { + beforeSendSpan: beforeSendSpan, + ignoreSpans: ignoreSpans, + } + )} + + + {getJavaScriptSpanFilterSnippet(evidenceData)} + + + ); + } -export function TroubleshootingSection() { return ( - - {t('Troubleshooting suggestions')} - - - {t('Confirm the Span Is Useful')} - - - {t( - 'Keep spans that describe meaningful work, such as database calls, RPCs, jobs, or expensive operations. Remove spans that duplicate parent spans or only wrap framework internals.' - )} - - - - - {t('Adjust Instrumentation')} - - - {t( - 'Update custom instrumentation to skip this operation, or change its name and attributes so it groups with spans that share the same purpose.' - )} - - - - - - {t('Review SDK and OpenTelemetry Configuration')} - - + + {tct( + 'Check your SDK tracing options and add an exact-match filter for [span]. Filter only this operation and description so useful spans with similar names keep flowing.', + {span: {getSpanLabel(evidenceData)}} + )} + + ); +} + +export function TroubleshootingSection({evidenceData}: TroubleshootingSectionProps) { + return ( + + {t('Troubleshooting')} + + {t( + 'Start by confirming whether this span is created by custom code or by SDK automatic instrumentation. Then remove it at the source or filter the exact span before it is sent.' + )} + + + + {t('1. Find where the span is created')} + + {tct('Search for code or SDK configuration that creates [span].', { + span: {getSpanLabel(evidenceData)}, + })} + + {evidenceData.sdkName && ( - {t( - 'Check automatic instrumentation settings before changing application code. Some SDK or OpenTelemetry integrations can be tuned to avoid noisy spans at the source.' - )} + {tct('The latest evidence points to the [sdk] SDK.', { + sdk: {evidenceData.sdkName}, + })} - - + )} + + + {t('2. Remove custom instrumentation when possible')} + + {t( + 'If this span is manually instrumented and does not describe work you need for debugging, delete the custom span creation line or replace it with a more meaningful span.' + )} + + + + {t('3. Filter automatic instrumentation exactly')} + + + + + + {t('Read the SDK filtering docs')} + + ); } diff --git a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/types.ts b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/types.ts new file mode 100644 index 00000000000000..39e4f0a30616f5 --- /dev/null +++ b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/types.ts @@ -0,0 +1,31 @@ +import type {EventOccurrence} from 'sentry/types/event'; + +export interface LowValueSpanEvidenceData { + avgDurationMs: number | null; + count: number | null; + description: string | null; + estimatedCostUsd: number | null; + op: string | null; + sdkName: string | null; +} + +function getStringValue(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function getNumberValue(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +export function getLowValueSpanEvidenceData( + evidenceData: EventOccurrence['evidenceData'] | null | undefined +): LowValueSpanEvidenceData { + return { + op: getStringValue(evidenceData?.op), + description: getStringValue(evidenceData?.description), + count: getNumberValue(evidenceData?.count), + avgDurationMs: getNumberValue(evidenceData?.avg_duration_ms), + estimatedCostUsd: getNumberValue(evidenceData?.estimated_cost_usd), + sdkName: getStringValue(evidenceData?.sdk_name), + }; +} diff --git a/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/utils.ts b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/utils.ts new file mode 100644 index 00000000000000..8247f58ba4be9c --- /dev/null +++ b/static/app/views/issueDetails/configurationIssues/lowValueSpanIssues/utils.ts @@ -0,0 +1,126 @@ +import {t} from 'sentry/locale'; + +import type {LowValueSpanEvidenceData} from './types'; + +const JAVASCRIPT_SPAN_FILTERING_DOCS_URL = + 'https://docs.sentry.io/platforms/javascript/configuration/options/#ignorespans'; +const PYTHON_SPAN_FILTERING_DOCS_URL = + 'https://docs.sentry.io/platforms/python/configuration/filtering/#filtering-transaction-events'; +const GENERIC_SPAN_FILTERING_DOCS_URL = 'https://docs.sentry.io/product/explore/traces/'; + +export function getSpanLabel(evidenceData: LowValueSpanEvidenceData): string { + const {op, description} = evidenceData; + + if (op && description) { + return `${op} - ${description}`; + } + if (op) { + return op; + } + if (description) { + return description; + } + return t('Unknown span'); +} + +export function formatCount(count: number | null): string { + return count === null ? t('Unknown') : count.toLocaleString(); +} + +export function formatDurationMs(duration: number | null): string { + if (duration === null) { + return t('Unknown'); + } + if (duration < 1) { + return t('<1ms'); + } + return t('%sms', duration.toFixed(1)); +} + +export function formatEstimatedCostUsd(estimatedCostUsd: number | null): string { + if (estimatedCostUsd === null) { + return t('Unknown'); + } + if (estimatedCostUsd > 0 && estimatedCostUsd < 0.01) { + return t('<$0.01'); + } + return estimatedCostUsd.toLocaleString(undefined, { + currency: 'USD', + maximumFractionDigits: 2, + minimumFractionDigits: 2, + style: 'currency', + }); +} + +export function isPythonSdk(evidenceData: LowValueSpanEvidenceData): boolean { + const sdkName = evidenceData.sdkName?.toLowerCase() ?? ''; + + return sdkName.includes('python'); +} + +export function isJavaScriptSdk(evidenceData: LowValueSpanEvidenceData): boolean { + const sdkName = evidenceData.sdkName?.toLowerCase() ?? ''; + + return ( + sdkName.includes('javascript') || + sdkName.includes('node') || + sdkName.includes('browser') || + sdkName.includes('nextjs') || + sdkName.includes('react') + ); +} + +export function getSpanFilteringDocsUrl(evidenceData: LowValueSpanEvidenceData): string { + if (isPythonSdk(evidenceData)) { + return PYTHON_SPAN_FILTERING_DOCS_URL; + } + if (isJavaScriptSdk(evidenceData)) { + return JAVASCRIPT_SPAN_FILTERING_DOCS_URL; + } + return GENERIC_SPAN_FILTERING_DOCS_URL; +} + +function toCodeString(value: string | null, fallback: string): string { + return JSON.stringify(value ?? fallback); +} + +export function getJavaScriptSpanFilterSnippet( + evidenceData: LowValueSpanEvidenceData +): string { + const spanOp = toCodeString(evidenceData.op, ''); + const spanDescription = toCodeString(evidenceData.description, ''); + + return `Sentry.init({ + ignoreSpans: [ + { + op: ${spanOp}, + name: ${spanDescription}, + }, + ], +});`; +} + +export function getPythonSpanFilterSnippet( + evidenceData: LowValueSpanEvidenceData +): string { + const spanOp = toCodeString(evidenceData.op, ''); + const spanDescription = toCodeString(evidenceData.description, ''); + + return `import sentry_sdk + + +def before_send_transaction(event, hint): + event["spans"] = [ + span for span in event.get("spans", []) + if not ( + span.get("op") == ${spanOp} + and span.get("description") == ${spanDescription} + ) + ] + return event + + +sentry_sdk.init( + before_send_transaction=before_send_transaction, +)`; +} From 3df62b0b15189bfedde0483e2c0718e80ccdeb37 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Wed, 27 May 2026 15:37:39 +0200 Subject: [PATCH 07/16] ref(snuba): Port query subscriptions consumer to taskbroker raw mode (#116288) Adds taskbroker raw mode tasks for query subscription consumers (events, transactions, metrics, generic_metrics, eap). ref STREAM-1021 --- src/sentry/conf/server.py | 1 + src/sentry/snuba/query_subscriptions/run.py | 75 +++++++++++++++++++++ src/sentry/taskworker/namespaces.py | 25 +++++++ 3 files changed, 101 insertions(+) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 30750f3d0d7d81..8d7b5f2b21f759 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -941,6 +941,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.seer.entrypoints.operator", "sentry.seer.entrypoints.slack.messaging", "sentry.seer.entrypoints.slack.tasks", + "sentry.snuba.query_subscriptions.run", "sentry.snuba.tasks", "sentry.tasks.activity", "sentry.tasks.assemble", diff --git a/src/sentry/snuba/query_subscriptions/run.py b/src/sentry/snuba/query_subscriptions/run.py index a2e157a26fbdca..a6389047597d69 100644 --- a/src/sentry/snuba/query_subscriptions/run.py +++ b/src/sentry/snuba/query_subscriptions/run.py @@ -12,11 +12,21 @@ ) from arroyo.types import BrokerValue, Commit, Message, Partition from sentry_kafka_schemas import get_codec +from taskbroker_client.registry import TaskNamespace from sentry import options from sentry.conf.types.kafka_definition import Topic +from sentry.silo.base import SiloMode from sentry.snuba.dataset import Dataset from sentry.snuba.query_subscriptions.constants import dataset_to_logical_topic +from sentry.tasks.base import instrumented_task +from sentry.taskworker.namespaces import ( + snuba_eap_subscriptions_raw_tasks, + snuba_events_subscriptions_raw_tasks, + snuba_generic_metrics_subscriptions_raw_tasks, + snuba_metrics_subscriptions_raw_tasks, + snuba_transactions_subscriptions_raw_tasks, +) from sentry.utils.arroyo import MultiprocessingPool, run_task_with_multiprocessing from sentry.utils.kafka_config import get_topic_definition @@ -111,3 +121,68 @@ def process_message( "value": message_value, }, ) + + +def _process_subscription_message(message_bytes: bytes, dataset: Dataset) -> None: + """Process a subscription message from raw Kafka message bytes.""" + from sentry.snuba.query_subscriptions.consumer import handle_message + from sentry.utils import metrics + + logical_topic = dataset_to_logical_topic[dataset] + topic = get_topic_definition(Topic(logical_topic))["real_topic_name"] + + with ( + sentry_sdk.start_transaction( + op="handle_message", + name="query_subscription_consumer_process_message", + custom_sampling_context={"sample_rate": options.get("subscriptions-query.sample-rate")}, + ), + metrics.timer("snuba_query_subscriber.handle_message", tags={"dataset": dataset.value}), + ): + try: + handle_message( + message_bytes, + -1, # offset not available in raw mode + -1, # partition not available in raw mode + topic, + dataset.value, + get_codec(logical_topic), + ) + except Exception: + logger.exception( + "Unexpected error while handling message in QuerySubscriptionStrategy. Skipping message.", + extra={"value": message_bytes}, + ) + + +def _register_subscription_tasks() -> None: + tasks: dict[str, tuple[Dataset, TaskNamespace]] = { + "events": (Dataset.Events, snuba_events_subscriptions_raw_tasks), + "transactions": (Dataset.Transactions, snuba_transactions_subscriptions_raw_tasks), + "metrics": (Dataset.Metrics, snuba_metrics_subscriptions_raw_tasks), + "generic_metrics": ( + Dataset.PerformanceMetrics, + snuba_generic_metrics_subscriptions_raw_tasks, + ), + "eap": (Dataset.EventsAnalyticsPlatform, snuba_eap_subscriptions_raw_tasks), + } + + registered_datasets = {dataset for dataset, _ in tasks.values()} + expected_datasets = set(dataset_to_logical_topic.keys()) + assert registered_datasets == expected_datasets, ( + f"Missing tasks for datasets: {expected_datasets - registered_datasets}" + ) + + for name, (dataset, namespace) in tasks.items(): + + @instrumented_task( + name=f"sentry.snuba.query_subscriptions.run.process_{name}_subscription_from_kafka", + namespace=namespace, + processing_deadline_duration=60, + silo_mode=SiloMode.CELL, + ) + def task_fn(message_bytes: bytes, _d: Dataset = dataset) -> None: + _process_subscription_message(message_bytes, _d) + + +_register_subscription_tasks() diff --git a/src/sentry/taskworker/namespaces.py b/src/sentry/taskworker/namespaces.py index bfadb09ea4886e..ec1e668771bd17 100644 --- a/src/sentry/taskworker/namespaces.py +++ b/src/sentry/taskworker/namespaces.py @@ -112,6 +112,31 @@ app_feature="errors", ) +snuba_events_subscriptions_raw_tasks = app.taskregistry.create_namespace( + "snuba.subscriptions.events.raw", + app_feature="errors", +) + +snuba_transactions_subscriptions_raw_tasks = app.taskregistry.create_namespace( + "snuba.subscriptions.transactions.raw", + app_feature="transactions", +) + +snuba_metrics_subscriptions_raw_tasks = app.taskregistry.create_namespace( + "snuba.subscriptions.metrics.raw", + app_feature="sessions", +) + +snuba_generic_metrics_subscriptions_raw_tasks = app.taskregistry.create_namespace( + "snuba.subscriptions.generic_metrics.raw", + app_feature="transactions", +) + +snuba_eap_subscriptions_raw_tasks = app.taskregistry.create_namespace( + "snuba.subscriptions.eap.raw", + app_feature="transactions", +) + issues_tasks = app.taskregistry.create_namespace( "issues", app_feature="issueplatform", From a1a1c16cfa17b4dd70be0aa2d617ec700c6f0f42 Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Wed, 27 May 2026 09:50:09 -0400 Subject: [PATCH 08/16] chore(dashboards): remove text widget flag defintion (#116212) should be merged AFTER https://github.com/getsentry/sentry-options-automator/pull/7935 --- src/sentry/features/temporary.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index ede59bbc69cf84..3c1652652315c8 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -73,8 +73,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:dashboards-prebuilt-insights-dashboards", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the details widget for dashboards manager.add("organizations:dashboards-details-widget", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable text widgets for dashboards - manager.add("organizations:dashboards-text-widgets", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable Insights to Dashboards UI migration manager.add("organizations:insights-to-dashboards-ui-rollout", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable AI-powered dashboard generation via Seer From 7787bdaa52cac1b017aeb46ddc8ddafff151e68a Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> Date: Wed, 27 May 2026 09:50:15 -0400 Subject: [PATCH 09/16] ref(replays): Remove unused data export notifications endpoint (#116232) --- src/sentry/api/urls.py | 6 ---- .../endpoints/data_export_notifications.py | 25 ------------- .../utils/api/knownSentryApiUrls.generated.ts | 1 - .../test_data_export_notifications.py | 35 ------------------- 4 files changed, 67 deletions(-) delete mode 100644 src/sentry/replays/endpoints/data_export_notifications.py delete mode 100644 tests/sentry/replays/endpoints/test_data_export_notifications.py diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index f25e1b58d741db..3a2f581a9ac433 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -489,7 +489,6 @@ from sentry.relocation.api.endpoints.recover import RelocationRecoverEndpoint from sentry.relocation.api.endpoints.retry import RelocationRetryEndpoint from sentry.relocation.api.endpoints.unpause import RelocationUnpauseEndpoint -from sentry.replays.endpoints.data_export_notifications import DataExportNotificationsEndpoint from sentry.replays.endpoints.organization_replay_count import OrganizationReplayCountEndpoint from sentry.replays.endpoints.organization_replay_details import OrganizationReplayDetailsEndpoint from sentry.replays.endpoints.organization_replay_events_meta import ( @@ -3795,11 +3794,6 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: AcceptOrganizationInvite.as_view(), name="sentry-api-0-organization-accept-organization-invite", ), - re_path( - r"^data-export/notifications/google-cloud/$", - DataExportNotificationsEndpoint.as_view(), - name="sentry-api-0-data-export-notifications", - ), re_path( r"^notification-defaults/$", NotificationDefaultsEndpoints.as_view(), diff --git a/src/sentry/replays/endpoints/data_export_notifications.py b/src/sentry/replays/endpoints/data_export_notifications.py deleted file mode 100644 index 89d1bb36402a2d..00000000000000 --- a/src/sentry/replays/endpoints/data_export_notifications.py +++ /dev/null @@ -1,25 +0,0 @@ -import logging - -from rest_framework.request import Request -from rest_framework.response import Response - -from sentry.api.api_owners import ApiOwner -from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import Endpoint, control_silo_endpoint -from sentry.api.permissions import SentryIsAuthenticated -from sentry.replays.data_export import request_run_transfer_job, retry_transfer_job_run - -logger = logging.getLogger() - - -@control_silo_endpoint -class DataExportNotificationsEndpoint(Endpoint): - """PubSub notifications endpoint.""" - - owner = ApiOwner.DATA_BROWSING - publish_status = {"POST": ApiPublishStatus.PRIVATE} - permission_classes = (SentryIsAuthenticated,) - - def post(self, request: Request) -> Response: - retry_transfer_job_run(request.data, request_run_transfer_job) - return Response("", status=200) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index df57f57867638a..f80f30e39d2d37 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -30,7 +30,6 @@ export type KnownSentryApiUrls = | '/authenticators/' | '/broadcasts/' | '/broadcasts/$broadcastId/' - | '/data-export/notifications/google-cloud/' | '/doc-integrations/' | '/doc-integrations/$docIntegrationIdOrSlug/' | '/doc-integrations/$docIntegrationIdOrSlug/avatar/' diff --git a/tests/sentry/replays/endpoints/test_data_export_notifications.py b/tests/sentry/replays/endpoints/test_data_export_notifications.py deleted file mode 100644 index d7b304855894a1..00000000000000 --- a/tests/sentry/replays/endpoints/test_data_export_notifications.py +++ /dev/null @@ -1,35 +0,0 @@ -import base64 -from unittest.mock import patch - -from sentry.testutils.cases import APITestCase -from sentry.testutils.silo import control_silo_test -from sentry.utils import json - - -@control_silo_test -class DataExportNotificationsTestCase(APITestCase): - endpoint = "sentry-api-0-data-export-notifications" - - def setUp(self) -> None: - super().setUp() - self.login_as(user=self.user) - - @patch("sentry.replays.endpoints.data_export_notifications.retry_transfer_job_run") - def test_simple(self, retry_transfer_job_run) -> None: # type: ignore[no-untyped-def] - retry_transfer_job_run.return_value = None - - data = { - "data": base64.b64encode( - json.dumps( - { - "transferOperation": { - "status": "FAILED", - "transferJobName": "test", - "projectId": "test-project", - } - } - ).encode() - ).decode("utf-8") - } - self.get_success_response(method="post", **data, status_code=200) - assert retry_transfer_job_run.called From df7db7afdb54dcb33e6a229fa109bdf464bf7593 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Wed, 27 May 2026 09:59:04 -0400 Subject: [PATCH 10/16] chore(relocation) Exclude Email model from relocations v2 (#116256) Redo of https://github.com/getsentry/sentry/pull/116226 which I reverted because it caused test failures on master that weren't caught by selective testing. ------ This model frequently blocks relocations for import/export differences, and I'd like that to not happen anymore. The Email model is populated by signals on create/delete operations to UserEmail it does not synchronize state on updates, and there are several older user accounts that are missing records in sentry_email that are present in sentry_useremail which cause relocations to fail. By excluding this model from exports, these records won't participate in the validation checks and can't cause diffs. When the relocation is processed records will still be stored in sentry_email because of the aformentioned signals. --- .../backup/app-user-with-empty-email.json | 8 -- src/sentry/users/models/email.py | 2 +- tests/sentry/backup/test_exports.py | 35 +----- tests/sentry/backup/test_imports.py | 64 +++-------- tests/sentry/backup/test_validate.py | 102 ++---------------- 5 files changed, 20 insertions(+), 191 deletions(-) diff --git a/fixtures/backup/app-user-with-empty-email.json b/fixtures/backup/app-user-with-empty-email.json index 424aa229b19534..984f08e8c3e838 100644 --- a/fixtures/backup/app-user-with-empty-email.json +++ b/fixtures/backup/app-user-with-empty-email.json @@ -1,12 +1,4 @@ [ - { - "model": "sentry.email", - "pk": 9, - "fields": { - "email": "", - "date_added": "2023-07-26T19:19:37.201Z" - } - }, { "model": "sentry.user", "pk": 34, diff --git a/src/sentry/users/models/email.py b/src/sentry/users/models/email.py index 51ab802fe57167..68957bd78b0a8b 100644 --- a/src/sentry/users/models/email.py +++ b/src/sentry/users/models/email.py @@ -18,7 +18,7 @@ class Email(Model): UserEmail represents whether a given user account has access to that email. """ - __relocation_scope__ = RelocationScope.User + __relocation_scope__ = RelocationScope.Excluded __relocation_dependencies__ = {"sentry.User"} __relocation_custom_ordinal__ = ["email"] diff --git a/tests/sentry/backup/test_exports.py b/tests/sentry/backup/test_exports.py index e9c2c647f246c8..89e85d2c1fffeb 100644 --- a/tests/sentry/backup/test_exports.py +++ b/tests/sentry/backup/test_exports.py @@ -26,7 +26,6 @@ from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.models.orgauthtoken import OrgAuthToken -from sentry.silo.base import SiloMode from sentry.testutils.helpers.backups import ( NOOP_PRINTER, BackupTransactionTestCase, @@ -35,8 +34,6 @@ generate_rsa_key_pair, ) from sentry.testutils.helpers.datetime import freeze_time -from sentry.testutils.silo import assume_test_silo_mode -from sentry.users.models.email import Email from sentry.users.models.user import User from sentry.users.models.useremail import UserEmail from sentry.users.models.userpermission import UserPermission @@ -463,12 +460,9 @@ def test_export_filter_users(self) -> None: data = self.export(tmp_dir, scope=ExportScope.User, filter_by={"user_2"}) # Count users, but also count a random model naively derived from just `User` alone, - # like `UserEmail`. Because `Email` and `UserEmail` have some automagic going on that - # causes them to be created when a `User` is, we explicitly check to ensure that they - # are behaving correctly as well. + # like `UserEmail`. assert self.count(data, User) == 1 assert self.count(data, UserEmail) == 1 - assert self.count(data, Email) == 1 assert not self.exists(data, User, "username", "user_1") assert self.exists(data, User, "username", "user_2") @@ -488,7 +482,6 @@ def test_export_filter_users_shared_email(self) -> None: assert self.count(data, User) == 3 assert self.count(data, UserEmail) == 3 - assert self.count(data, Email) == 2 assert self.exists(data, User, "username", "user_1") assert self.exists(data, User, "username", "user_2") @@ -504,30 +497,6 @@ def test_export_filter_users_empty(self) -> None: assert len(data) == 0 - def test_export_user_mismatched_email(self) -> None: - self.create_user( - email="Testing.Upper@example.com", - username="user_1", - is_staff=False, - is_superuser=False, - ) - with assume_test_silo_mode(SiloMode.CONTROL): - # Modify the generated email record to have different casing. - # This can happen if a useremail record is updated as we don't sync - # data to sentry_email on update. - email = Email.objects.get(email="Testing.Upper@example.com") - email.email = "testing.upper@example.com" - email.save() - - with TemporaryDirectory() as tmp_dir: - data = self.export(tmp_dir, scope=ExportScope.User) - - # Ensure email record is exported despite email casing being different. - assert self.count(data, User) == 1 - assert self.count(data, UserEmail) == 1 - assert self.count(data, Email) == 1 - assert self.exists(data, User, "username", "user_1") - def test_export_filter_orgs_single(self) -> None: # Create a superadmin not in any orgs, so that we can test that `OrganizationMember`s # invited by users outside of their org are still properly exported. @@ -587,7 +556,6 @@ def test_export_filter_orgs_single(self) -> None: assert self.count(data, User) == 7 assert self.count(data, UserEmail) == 7 - assert self.count(data, Email) == 6 # Lower due to `shared@example.com` assert not self.exists(data, User, "username", "user_a_only") assert self.exists(data, User, "username", "user_b_only") @@ -673,7 +641,6 @@ def test_export_filter_orgs_multiple(self) -> None: assert self.count(data, User) == 8 assert self.count(data, UserEmail) == 8 - assert self.count(data, Email) == 6 # Lower due to `shared@example.com` assert self.exists(data, User, "username", "user_a_only") assert not self.exists(data, User, "username", "user_b_only") diff --git a/tests/sentry/backup/test_imports.py b/tests/sentry/backup/test_imports.py index 0bd0a6cc7e8d2c..a5a5053f66c6ab 100644 --- a/tests/sentry/backup/test_imports.py +++ b/tests/sentry/backup/test_imports.py @@ -75,7 +75,6 @@ from sentry.testutils.hybrid_cloud import use_split_dbs from sentry.testutils.silo import assume_test_silo_mode from sentry.users.models.authenticator import Authenticator -from sentry.users.models.email import Email from sentry.users.models.lostpasswordhash import LostPasswordHash from sentry.users.models.user import User from sentry.users.models.user_option import UserOption @@ -118,9 +117,6 @@ def test_users_sanitized_in_user_scope(self) -> None: == 4 ) - # Every user except `max_user` shares an email. - assert Email.objects.count() == 2 - # All `UserEmail`s must have their verification status reset in this scope. assert UserEmail.objects.count() == 4 assert UserEmail.objects.filter(is_verified=True).count() == 0 @@ -159,9 +155,6 @@ def test_users_sanitized_in_organization_scope(self) -> None: == 4 ) - # Every user except `max_user` shares an email. - assert Email.objects.count() == 2 - # All `UserEmail`s must have their verification status reset in this scope. assert UserEmail.objects.count() == 4 assert UserEmail.objects.filter(is_verified=True).count() == 0 @@ -210,9 +203,6 @@ def test_users_unsanitized_in_config_scope(self) -> None: # scope. assert Authenticator.objects.count() == 0 - # Every user except `max_user` shares an email. - assert Email.objects.count() == 2 - # All `UserEmail`s must have their verification status reset in this scope. assert UserEmail.objects.count() == 4 assert UserEmail.objects.filter(is_verified=True).count() == 0 @@ -258,9 +248,6 @@ def test_users_unsanitized_in_global_scope(self) -> None: # Unlike the "config" scope, we keep authentication information for the "global" scope. assert Authenticator.objects.count() == 4 - # Every user except `max_user` shares an email. - assert Email.objects.count() == 2 - # All `UserEmail`s must have their imported verification status reset in this scope. assert UserEmail.objects.count() == 4 assert UserEmail.objects.filter(is_verified=True).count() == 4 @@ -530,7 +517,7 @@ def test_good_multiple_useremails_per_user_in_user_scope(self) -> None: models = self.json_of_exhaustive_user_with_minimum_privileges() # Add two copies (1 verified, 1 not) of the same `UserEmail` - so the user now has 3 - # `UserEmail` models, the latter of which have no corresponding `Email` entry. + # `UserEmail` models. models.append( { "model": "sentry.useremail", @@ -582,7 +569,7 @@ def test_good_multiple_useremails_per_user_in_global_scope(self) -> None: models = self.json_of_exhaustive_user_with_minimum_privileges() # Add two copies (1 verified, 1 not) of the same `UserEmail` - so the user now has 3 - # `UserEmail` models, the latter of which have no corresponding `Email` entry. + # `UserEmail` models. models.append( { "model": "sentry.useremail", @@ -699,7 +686,7 @@ class SignalingTests(ImportTestCase): """ Some models are automatically created via signals and similar automagic from related models. We test that behavior here. Specifically, we test the following: - - That `Email` and `UserEmail` are automatically created when `User` is. + - That `UserEmail` is automatically created when `User` is. - That `OrganizationMapping` and `OrganizationMemberMapping` are automatically created when `Organization is. - That `ProjectKey` and `ProjectOption` instances are automatically created when `Project` @@ -721,9 +708,6 @@ def test_import_signaling_user(self) -> None: assert UserEmail.objects.count() == 1 assert UserEmail.objects.filter(email="me@example.com").exists() - assert Email.objects.count() == 1 - assert Email.objects.filter(email="me@example.com").exists() - def test_import_signaling_organization(self) -> None: owner = self.create_exhaustive_user("owner") invited = self.create_exhaustive_user("invited") @@ -1131,12 +1115,11 @@ def test_import_filter_users(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): # Count users, but also count a random model naively derived from just `User` alone, - # like `UserEmail`. Because `Email` and `UserEmail` have some automagic going on that - # causes them to be created when a `User` is, we explicitly check to ensure that they - # are behaving correctly as well. + # like `UserEmail`. Because `UserEmail` have some automagic going on that + # causes them to be created when a `User` is, we explicitly check to ensure that it + # is behaving correctly as well. assert User.objects.count() == 1 assert UserEmail.objects.count() == 1 - assert Email.objects.count() == 1 assert ( ControlImportChunk.objects.filter( @@ -1150,12 +1133,6 @@ def test_import_filter_users(self) -> None: ).count() == 1 ) - assert ( - ControlImportChunk.objects.filter( - model="sentry.email", min_ordinal=1, max_ordinal=1 - ).count() - == 1 - ) assert not User.objects.filter(username="user_1").exists() assert User.objects.filter(username="user_2").exists() @@ -1176,7 +1153,6 @@ def test_import_filter_users_shared_email(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): assert User.objects.count() == 3 assert UserEmail.objects.count() == 3 - assert Email.objects.count() == 2 # Lower due to shared emails assert ( ControlImportChunk.objects.filter( @@ -1190,12 +1166,6 @@ def test_import_filter_users_shared_email(self) -> None: ).count() == 1 ) - assert ( - ControlImportChunk.objects.filter( - model="sentry.email", min_ordinal=1, max_ordinal=2 - ).count() - == 1 - ) assert User.objects.filter(username="user_1").exists() assert User.objects.filter(username="user_2").exists() @@ -1214,7 +1184,6 @@ def test_import_filter_users_empty(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): assert User.objects.count() == 0 assert UserEmail.objects.count() == 0 - assert Email.objects.count() == 0 def test_import_filter_orgs_single(self) -> None: a = self.create_exhaustive_user("user_a_only", email="shared@example.com") @@ -1249,7 +1218,6 @@ def test_import_filter_orgs_single(self) -> None: assert User.objects.count() == 4 assert UserEmail.objects.count() == 4 - assert Email.objects.count() == 3 # Lower due to `shared@example.com` assert not User.objects.filter(username="user_a_only").exists() assert User.objects.filter(username="user_b_only").exists() @@ -1299,7 +1267,6 @@ def test_import_filter_orgs_multiple(self) -> None: assert User.objects.count() == 5 assert UserEmail.objects.count() == 5 - assert Email.objects.count() == 3 # Lower due to `shared@example.com` assert User.objects.filter(username="user_a_only").exists() assert not User.objects.filter(username="user_b_only").exists() @@ -1331,7 +1298,6 @@ def test_import_filter_orgs_empty(self) -> None: assert User.objects.count() == 0 assert UserEmail.objects.count() == 0 - assert Email.objects.count() == 0 COLLISION_TESTED: set[NormalizedModelName] = set() @@ -1907,7 +1873,7 @@ def test_colliding_configs_overwrite_configs_disabled_in_config_scope( with open(tmp_path, "rb") as tmp_file: verify_models_in_output(expected_models, orjson.loads(tmp_file.read())) - @expect_models(COLLISION_TESTED, Email, User, UserEmail) + @expect_models(COLLISION_TESTED, User, UserEmail) def test_colliding_user_with_merging_enabled_in_user_scope( self, expected_models: list[type[Model]] ): @@ -1927,7 +1893,6 @@ def test_colliding_user_with_merging_enabled_in_user_scope( assert User.objects.count() == 1 assert UserEmail.objects.count() == 1 # Keep only original when merging. assert Authenticator.objects.count() == 1 - assert Email.objects.count() == 2 user_chunk = ControlImportChunk.objects.get( model="sentry.user", min_ordinal=1, max_ordinal=1 @@ -1951,7 +1916,7 @@ def test_colliding_user_with_merging_enabled_in_user_scope( with open(tmp_path, "rb") as tmp_file: verify_models_in_output(expected_models, orjson.loads(tmp_file.read())) - @expect_models(COLLISION_TESTED, Email, User, UserEmail) + @expect_models(COLLISION_TESTED, User, UserEmail) def test_colliding_user_with_merging_disabled_in_user_scope( self, expected_models: list[type[Model]] ): @@ -1971,7 +1936,6 @@ def test_colliding_user_with_merging_disabled_in_user_scope( assert User.objects.count() == 2 assert UserEmail.objects.count() == 2 assert Authenticator.objects.count() == 1 # Only imported in global scope - assert Email.objects.count() == 2 user_chunk = ControlImportChunk.objects.get( model="sentry.user", min_ordinal=1, max_ordinal=1 @@ -1996,7 +1960,7 @@ def test_colliding_user_with_merging_disabled_in_user_scope( with open(tmp_path, "rb") as tmp_file: verify_models_in_output(expected_models, orjson.loads(tmp_file.read())) - @expect_models(COLLISION_TESTED, Email, Organization, OrganizationMember, User, UserEmail) + @expect_models(COLLISION_TESTED, Organization, OrganizationMember, User, UserEmail) def test_colliding_user_with_merging_enabled_in_organization_scope( self, expected_models: list[type[Model]] ): @@ -2030,7 +1994,6 @@ def test_colliding_user_with_merging_enabled_in_organization_scope( assert User.objects.count() == 1 assert UserEmail.objects.count() == 1 # Keep only original when merging. assert Authenticator.objects.count() == 1 # Only imported in global scope - assert Email.objects.count() == 2 user_chunk = ControlImportChunk.objects.get( model="sentry.user", min_ordinal=1, max_ordinal=1 @@ -2083,7 +2046,7 @@ def test_colliding_user_with_merging_enabled_in_organization_scope( with open(tmp_path, "rb") as tmp_file: verify_models_in_output(expected_models, orjson.loads(tmp_file.read())) - @expect_models(COLLISION_TESTED, Email, Organization, OrganizationMember, User, UserEmail) + @expect_models(COLLISION_TESTED, Organization, OrganizationMember, User, UserEmail) def test_colliding_user_with_merging_disabled_in_organization_scope( self, expected_models: list[type[Model]] ): @@ -2118,7 +2081,6 @@ def test_colliding_user_with_merging_disabled_in_organization_scope( assert User.objects.count() == 2 assert UserEmail.objects.count() == 2 assert Authenticator.objects.count() == 1 # Only imported in global scope - assert Email.objects.count() == 2 user_chunk = ControlImportChunk.objects.get( model="sentry.user", min_ordinal=1, max_ordinal=1 @@ -2175,7 +2137,7 @@ def test_colliding_user_with_merging_disabled_in_organization_scope( with open(tmp_path, "rb") as tmp_file: verify_models_in_output(expected_models, orjson.loads(tmp_file.read())) - @expect_models(COLLISION_TESTED, Email, User, UserEmail, UserPermission) + @expect_models(COLLISION_TESTED, User, UserEmail, UserPermission) def test_colliding_user_with_merging_enabled_in_config_scope( self, expected_models: list[type[Model]] ): @@ -2198,7 +2160,6 @@ def test_colliding_user_with_merging_enabled_in_config_scope( assert UserEmail.objects.count() == 1 # Keep only original when merging. assert UserPermission.objects.count() == 1 # Keep only original when merging. assert Authenticator.objects.count() == 1 - assert Email.objects.count() == 2 user_chunk = ControlImportChunk.objects.get( model="sentry.user", min_ordinal=1, max_ordinal=1 @@ -2223,7 +2184,7 @@ def test_colliding_user_with_merging_enabled_in_config_scope( with open(tmp_path, "rb") as tmp_file: verify_models_in_output(expected_models, orjson.loads(tmp_file.read())) - @expect_models(COLLISION_TESTED, Email, User, UserEmail, UserPermission) + @expect_models(COLLISION_TESTED, User, UserEmail, UserPermission) def test_colliding_user_with_merging_disabled_in_config_scope( self, expected_models: list[type[Model]] ): @@ -2246,7 +2207,6 @@ def test_colliding_user_with_merging_disabled_in_config_scope( assert UserEmail.objects.count() == 2 assert UserPermission.objects.count() == 2 assert Authenticator.objects.count() == 1 # Only imported in global scope - assert Email.objects.count() == 2 user_chunk = ControlImportChunk.objects.get( model="sentry.user", min_ordinal=1, max_ordinal=1 diff --git a/tests/sentry/backup/test_validate.py b/tests/sentry/backup/test_validate.py index f01646ff76dbb4..ba3e59c84d827c 100644 --- a/tests/sentry/backup/test_validate.py +++ b/tests/sentry/backup/test_validate.py @@ -261,10 +261,10 @@ def test_auto_assign_email_obfuscating_comparator() -> None: email_left = orjson.loads( """ { - "model": "sentry.email", + "model": "sentry.sentryapp", "pk": 1, "fields": { - "email": "testing@example.com", + "creator_label": "testing@example.com", "date_added": "2023-06-22T00:00:00.000Z" } } @@ -273,10 +273,10 @@ def test_auto_assign_email_obfuscating_comparator() -> None: email_right = orjson.loads( """ { - "model": "sentry.email", + "model": "sentry.sentryapp", "pk": 1, "fields": { - "email": "foo@example.fake", + "creator_label": "foo@example.fake", "date_added": "2023-06-22T00:00:00.000Z" } } @@ -290,10 +290,10 @@ def test_auto_assign_email_obfuscating_comparator() -> None: assert len(findings) == 1 assert findings[0].kind == ComparatorFindingKind.EmailObfuscatingComparator - assert findings[0].on == InstanceID("sentry.email", 1) + assert findings[0].on == InstanceID("sentry.sentryapp", 1) assert findings[0].left_pk == 1 assert findings[0].right_pk == 1 - assert """`email`""" in findings[0].reason + assert """`creator_label`""" in findings[0].reason assert """left value ("t...@...le.com")""" in findings[0].reason assert """right value ("f...@...e.fake")""" in findings[0].reason @@ -476,64 +476,6 @@ def test_auto_assign_ignored_comparator() -> None: assert not findings -def test_bad_missing_custom_ordinal() -> None: - left = orjson.loads( - """ - [ - { - "model": "sentry.email", - "pk": 1, - "fields": { - "email": "a@example.com", - "date_added": "2023-06-22T00:00:00.000Z" - } - }, - { - "model": "sentry.email", - "pk": 2, - "fields": { - "email": "b@example.com", - "date_added": "2023-06-22T00:00:00.000Z" - } - } - ] - """ - ) - right = orjson.loads( - """ - [ - { - "model": "sentry.email", - "pk": 1, - "fields": { - "email": "c@example.com", - "date_added": "2023-06-22T00:00:00.000Z" - } - }, - { - "model": "sentry.email", - "pk": 2, - "fields": { - "email": "a@example.com", - "date_added": "2023-06-22T00:00:00.000Z" - } - } - ] - """ - ) - out = validate(left, right) - findings = out.findings - - assert len(findings) == 1 - - assert findings[0].kind == ComparatorFindingKind.EmailObfuscatingComparator - assert findings[0].on == InstanceID("sentry.email", 2) - assert findings[0].left_pk == 2 - assert findings[0].right_pk == 1 - assert "b...@...le.com" in findings[0].reason - assert "c...@...le.com" in findings[0].reason - - def test_bad_unequal_custom_ordinal() -> None: left = orjson.loads( """ @@ -681,22 +623,6 @@ def test_good_user_custom_ordinal() -> None: "email": "b@example.com" } }, - { - "model": "sentry.email", - "pk": 1, - "fields": { - "email": "a@example.com", - "date_added": "2023-06-22T00:00:00.000Z" - } - }, - { - "model": "sentry.email", - "pk": 2, - "fields": { - "email": "b@example.com", - "date_added": "2023-06-22T00:00:00.000Z" - } - }, { "model": "sentry.useremail", "pk": 56, @@ -749,22 +675,6 @@ def test_good_user_custom_ordinal() -> None: "email": "a@example.com" } }, - { - "model": "sentry.email", - "pk": 1, - "fields": { - "email": "b@example.com", - "date_added": "2023-06-22T00:00:00.000Z" - } - }, - { - "model": "sentry.email", - "pk": 2, - "fields": { - "email": "a@example.com", - "date_added": "2023-06-22T00:00:00.000Z" - } - }, { "model": "sentry.useremail", "pk": 56, From b8b171b41ffc3382dc83c99198c0e572bccfda30 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Wed, 27 May 2026 10:28:23 -0400 Subject: [PATCH 11/16] fix(relocation) Remove invalid token scopes during export (#116214) As of #113394 we don't allow `project:distribution` to be a scope for user tokens. Currently, there are several members in the sentry org with tokens that have the `project:distribution` scope. This is causing all relocations to fail because the 'baseline-config' validation is detecting changes. By normalizing the ApiToken scopes during export we can avoid differences after importing & re-exporting the baseline configuration. --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- .../backup/services/import_export/impl.py | 1 + src/sentry/db/models/base.py | 6 ++++ src/sentry/models/apitoken.py | 11 +++++++ tests/sentry/backup/test_exports.py | 32 +++++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/src/sentry/backup/services/import_export/impl.py b/src/sentry/backup/services/import_export/impl.py index a5ef382194ebca..e154c7174941c1 100644 --- a/src/sentry/backup/services/import_export/impl.py +++ b/src/sentry/backup/services/import_export/impl.py @@ -535,6 +535,7 @@ def filter_objects(queryset_iterator): # keep track of the new pk in the input map as well. in_pk_map.insert(model_name, item.pk, item.pk, ImportKind.Inserted) out_pk_map.insert(model_name, item.pk, item.pk, ImportKind.Inserted) + item.normalize_before_relocation_export() yield item def yield_objects(): diff --git a/src/sentry/db/models/base.py b/src/sentry/db/models/base.py index d78f6a14a7cc4c..bcbdf9711c4e25 100644 --- a/src/sentry/db/models/base.py +++ b/src/sentry/db/models/base.py @@ -294,6 +294,12 @@ def normalize_before_relocation_import( return old_pk + def normalize_before_relocation_export(self) -> None: + """ + Called during export and enables records to clean/prep their data for export. + """ + pass + def write_relocation_import( self, _s: ImportScope, _f: ImportFlags ) -> tuple[int, ImportKind] | None: diff --git a/src/sentry/models/apitoken.py b/src/sentry/models/apitoken.py index 64808d05e628c8..30c5ca88648fb3 100644 --- a/src/sentry/models/apitoken.py +++ b/src/sentry/models/apitoken.py @@ -572,6 +572,17 @@ def sanitize_relocation_json( lambda _: hashed_refresh_token, ) + def normalize_before_relocation_export(self) -> None: + """ + Trim ApiToken scopes to eliminate scopes that are no longer valid. + + We have historical ApiToken records with the `project:distribution` scope + which is no longer valid for ApiTokens. + """ + from sentry.receivers.tokens import enforce_scope_hierarchy + + enforce_scope_hierarchy(self) + @property def organization_id(self) -> int | None: from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation diff --git a/tests/sentry/backup/test_exports.py b/tests/sentry/backup/test_exports.py index 89e85d2c1fffeb..4ab504fddb8e73 100644 --- a/tests/sentry/backup/test_exports.py +++ b/tests/sentry/backup/test_exports.py @@ -6,6 +6,7 @@ from tempfile import TemporaryDirectory from typing import Any +from django.db import router from orjson import JSONDecodeError, dumps from sentry.backup.crypto import ( @@ -22,10 +23,13 @@ from sentry.backup.scopes import ExportScope from sentry.backup.services.import_export.model import RpcExportOk from sentry.db import models +from sentry.models.apitoken import ApiToken from sentry.models.options.option import Option from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.models.orgauthtoken import OrgAuthToken +from sentry.silo.base import SiloMode +from sentry.silo.safety import unguarded_write from sentry.testutils.helpers.backups import ( NOOP_PRINTER, BackupTransactionTestCase, @@ -34,6 +38,8 @@ generate_rsa_key_pair, ) from sentry.testutils.helpers.datetime import freeze_time +from sentry.testutils.silo import assume_test_silo_mode +from sentry.types.token import AuthTokenType from sentry.users.models.user import User from sentry.users.models.useremail import UserEmail from sentry.users.models.userpermission import UserPermission @@ -737,6 +743,32 @@ class QueryTests(ExportTestCase): Some models have custom export logic that requires bespoke testing. """ + def test_export_for_api_token(self) -> None: + with assume_test_silo_mode(SiloMode.CONTROL): + user = self.create_user() + scopes = ["org:read", "org:write", "project:read", "project:distribution"] + token = ApiToken.objects.create( + user=user, token_type=AuthTokenType.USER, scope_list=scopes + ) + # Perform a queryset.update to dodge signals that remove project:distribution + # Disable outbox write checks as well. + with unguarded_write(using=router.db_for_write(ApiToken)): + ApiToken.objects.filter(id=token.id).update(scope_list=scopes) + token.refresh_from_db() + assert token.scope_list == scopes, "Need to have invalid scopes to continue" + + with TemporaryDirectory() as tmp_dir: + data = self.export( + tmp_dir, + scope=ExportScope.Global, + ) + # no project:distribution as that is not a valid scope for user tokens as of #113394 + # and scopes are export as a str + expected_scopes = '["org:read", "org:write", "project:read"]' + assert self.count(data, User) == 1 + assert self.count(data, ApiToken) == 1 + assert self.exists(data, ApiToken, "scope_list", expected_scopes) + def test_export_query_for_option_model(self) -> None: # There are a number of options we specifically exclude by name, for various reasons # enumerated in that model's definition file. From aa346d88b8721feb898091b7768b61def0861abf Mon Sep 17 00:00:00 2001 From: Max Topolsky <30879163+mtopo27@users.noreply.github.com> Date: Wed, 27 May 2026 10:36:35 -0400 Subject: [PATCH 12/16] feat(preprod): Display snapshot image tags in card headers (#115723) Add a `tags` field to `SnapshotImage` and render tags as muted badges in snapshot card headers. Each tag appears as a `key=value` pill below the image name, giving reviewers quick visibility into the variant dimensions (language, direction, theme) of each snapshot. **Card header layout rework** The header switches from a single row with a subtitle file name to a two-row layout: the top row holds the display name, action icons, and status text; the bottom row holds the tag badges. The file name moves from inline text to a small icon button that copies the name on click. Depends on #115659 CleanShot 2026-05-26 at 18 06 40@2x CleanShot 2026-05-26 at 18 05 54@2x --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Claude Opus 4.6 --- .../snapshots/main/collapsibleBadgeRow.tsx | 97 +++++++++++++++++++ .../diffImageDisplay.snapshots.tsx | 1 + .../main/snapshotCards.snapshots.tsx | 7 ++ .../preprod/snapshots/main/snapshotCards.tsx | 83 ++++++++-------- .../main/snapshotMainContent.spec.tsx | 1 + .../snapshots/main/snapshotMainContent.tsx | 3 + .../app/views/preprod/types/snapshotTypes.ts | 1 + 7 files changed, 155 insertions(+), 38 deletions(-) create mode 100644 static/app/views/preprod/snapshots/main/collapsibleBadgeRow.tsx diff --git a/static/app/views/preprod/snapshots/main/collapsibleBadgeRow.tsx b/static/app/views/preprod/snapshots/main/collapsibleBadgeRow.tsx new file mode 100644 index 00000000000000..05569fd652c49f --- /dev/null +++ b/static/app/views/preprod/snapshots/main/collapsibleBadgeRow.tsx @@ -0,0 +1,97 @@ +import {useEffect, useRef, useState} from 'react'; + +import {Badge} from '@sentry/scraps/badge'; +import {Button} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; + +import {t} from 'sentry/locale'; + +const ONE_ROW_HEIGHT = 20; + +export function CollapsibleBadgeRow({tags}: {tags: Record}) { + const [expanded, setExpanded] = useState(false); + const [overflowCount, setOverflowCount] = useState(0); + const containerRef = useRef(null); + const toggleRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (expanded || !container) { + return; + } + + const calculate = () => { + const buttonEl = toggleRef.current; + const buttonWidth = buttonEl?.offsetWidth ?? 0; + const containerWidth = container.offsetWidth; + + let count = 0; + for (const child of container.children) { + if (child === buttonEl) { + continue; + } + const el = child as HTMLElement; + if ( + el.offsetTop >= ONE_ROW_HEIGHT || + (buttonWidth > 0 && + el.offsetLeft + el.offsetWidth > containerWidth - buttonWidth) + ) { + count++; + } + } + setOverflowCount(prev => (prev === count ? prev : count)); + }; + + const rafId = requestAnimationFrame(calculate); + const observer = new ResizeObserver(() => requestAnimationFrame(calculate)); + observer.observe(container); + + return () => { + cancelAnimationFrame(rafId); + observer.disconnect(); + }; + }, [tags, expanded, overflowCount]); + + const entries = Object.entries(tags); + + return ( + + {entries.map(([key, value]) => ( + + {key}={value} + + ))} + {overflowCount > 0 && !expanded && ( + + + + )} + + ); +} diff --git a/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx b/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx index 0d88d83094803c..e3bb3e7519f0aa 100644 --- a/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx +++ b/static/app/views/preprod/snapshots/main/imageDisplay/diffImageDisplay.snapshots.tsx @@ -74,6 +74,7 @@ function image(overrides: Partial = {}): SnapshotImage { height: 180, image_file_name: 'button.light.png', key: 'head-button-light', + tags: null, width: 320, ...overrides, }; diff --git a/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx b/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx index d18f3a790fc1d5..4d074f99aae81a 100644 --- a/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotCards.snapshots.tsx @@ -10,6 +10,11 @@ import {DiffStatus} from 'sentry/views/preprod/types/snapshotTypes'; import {CardHeader, ImageCard, PairCard} from './snapshotCards'; +jest.mock('@sentry/scraps/badge', () => ({ + ...jest.requireActual('sentry/components/core/badge/badge'), + ...jest.requireActual('sentry/components/core/badge/tag'), +})); + jest.mock('@sentry/scraps/layout', () => { const actual = jest.requireActual('@sentry/scraps/layout'); return { @@ -120,6 +125,7 @@ function image(overrides: Partial = {}): SnapshotImage { height: 180, image_file_name: 'button.light.png', key: 'head-button-light', + tags: {lang: 'en', dir: 'ltr', theme: 'light'}, width: 320, ...overrides, }; @@ -172,6 +178,7 @@ describe('SnapshotCards', () => { fileName: 'button.light.png', isDark: false, onToggleDark: noop, + tags: {lang: 'en', dir: 'ltr', theme: 'light'} as Record, }; it.snapshot( diff --git a/static/app/views/preprod/snapshots/main/snapshotCards.tsx b/static/app/views/preprod/snapshots/main/snapshotCards.tsx index 0a0e40d14ef06b..67f8463e61b53b 100644 --- a/static/app/views/preprod/snapshots/main/snapshotCards.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotCards.tsx @@ -8,7 +8,7 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; -import {IconInfo, IconLightning, IconLink, IconMoon} from 'sentry/icons'; +import {IconFile, IconInfo, IconLightning, IconLink, IconMoon} from 'sentry/icons'; import {t} from 'sentry/locale'; import {ConfigStore} from 'sentry/stores/configStore'; import {formatPercentage} from 'sentry/utils/number/formatPercentage'; @@ -23,6 +23,7 @@ import type { import {DiffStatus, getImageName} from 'sentry/views/preprod/types/snapshotTypes'; import type {DiffMode} from './imageDisplay/diffImageDisplay'; +import {CollapsibleBadgeRow} from './collapsibleBadgeRow'; import { ImageColumn, OnionCardBody, @@ -136,6 +137,7 @@ export const PairCard = memo(function PairCard({ setIsDark(v => !v)} @@ -241,6 +244,7 @@ export const ImageCard = memo(function ImageCard({ export const CardHeader = memo(function CardHeader({ displayName, fileName, + tags, status, diffPercent, isDark, @@ -264,45 +268,49 @@ export const CardHeader = memo(function CardHeader({ onDoubleClick?: () => void; showBottomBorder?: boolean; status?: DiffStatus | null; + tags?: Record | null; }) { const {copy} = useCopyToClipboard(); return ( - - {displayName ? ( - - - {displayName} - - - {fileName} - - - ) : ( - - {fileName} + + + + {displayName ?? fileName} - )} - - e.stopPropagation()}> - {status && } - : } - onClick={onToggleDark} - /> - } - onClick={() => { - copy(copyUrl, {successMessage: t('Copied link to this snapshot')}); - onCopyLink?.(); - }} - /> - + {displayName && ( + } + onClick={e => { + e.stopPropagation(); + copy(fileName, {successMessage: t('Copied file name')}); + }} + /> + )} + + e.stopPropagation()}> + {status && } + : } + onClick={onToggleDark} + /> + } + onClick={() => { + copy(copyUrl, {successMessage: t('Copied link to this snapshot')}); + onCopyLink?.(); + }} + /> + + + {tags && Object.keys(tags).length > 0 && } ); }); @@ -405,7 +413,7 @@ function IconButton({ }: { 'aria-label': string; icon: React.ReactNode; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; tooltip?: string; }) { const button = ( @@ -431,9 +439,8 @@ const CardHeaderRow = styled('div')<{ $showBottomBorder?: boolean; }>` display: flex; - align-items: flex-start; - justify-content: space-between; - gap: ${p => p.theme.space.md}; + flex-direction: column; + gap: ${p => p.theme.space['2xs']}; padding: ${p => p.theme.space.lg} ${p => p.theme.space.xl}; border-bottom: ${p => p.$showBottomBorder ? `1px solid ${p.theme.tokens.border.secondary}` : 0}; diff --git a/static/app/views/preprod/snapshots/main/snapshotMainContent.spec.tsx b/static/app/views/preprod/snapshots/main/snapshotMainContent.spec.tsx index 2d6ccd958bfa2a..ff2d05a685bd29 100644 --- a/static/app/views/preprod/snapshots/main/snapshotMainContent.spec.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotMainContent.spec.tsx @@ -65,6 +65,7 @@ function image(overrides: Partial = {}): SnapshotImage { height: 180, image_file_name: 'button.light.png', key: 'head-button-light', + tags: null, width: 320, ...overrides, }; diff --git a/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx b/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx index b3702ad93cd05a..ddd1f4d387c690 100644 --- a/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotMainContent.tsx @@ -263,6 +263,7 @@ export function SnapshotMainContent({ headerProps={{ displayName: image.display_name, fileName: image.image_file_name, + tags: image.tags, status: DiffStatus.CHANGED, diffPercent: currentPair.diff, copyData: currentPair, @@ -316,6 +317,7 @@ export function SnapshotMainContent({ headerProps={{ displayName: image.display_name, fileName: image.image_file_name, + tags: image.tags, status: DiffStatus.RENAMED, copyData: currentPair, copyUrl: buildSnapshotLink(image.image_file_name), @@ -375,6 +377,7 @@ export function SnapshotMainContent({ headerProps={{ displayName: currentImage.display_name, fileName: currentImage.image_file_name, + tags: currentImage.tags, status, copyData: currentImage, copyUrl: buildSnapshotLink(currentImage.image_file_name), diff --git a/static/app/views/preprod/types/snapshotTypes.ts b/static/app/views/preprod/types/snapshotTypes.ts index 788841780be568..6735ee5fe7751a 100644 --- a/static/app/views/preprod/types/snapshotTypes.ts +++ b/static/app/views/preprod/types/snapshotTypes.ts @@ -11,6 +11,7 @@ export interface SnapshotImage { group?: string | null; height: number; key: string; + tags: Record | null; width: number; } From b93e3afadcc4339c6042817c3e0c56aa60db3b91 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 27 May 2026 08:08:32 -0700 Subject: [PATCH 13/16] fix(replays): Shrink timeline hover timestamp (#116268) The replay scrubber hover timestamp was tall enough to trigger page overflow in the details layout. before image after image --- .../components/replays/breadcrumbs/replayTimelineTooltip.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/components/replays/breadcrumbs/replayTimelineTooltip.tsx b/static/app/components/replays/breadcrumbs/replayTimelineTooltip.tsx index bb74f637168b29..84f10277d81121 100644 --- a/static/app/components/replays/breadcrumbs/replayTimelineTooltip.tsx +++ b/static/app/components/replays/breadcrumbs/replayTimelineTooltip.tsx @@ -56,7 +56,7 @@ export function TimelineTooltip({container}: Props) { p.theme.space.sm} ${p => p.theme.space.md}; + padding: ${p => p.theme.space['2xs']} ${p => p.theme.space.sm}; pointer-events: none; white-space: nowrap; z-index: 1000; From dec0ff2704210315321ebb5c151c5e6cafa92c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Wed, 27 May 2026 17:31:22 +0200 Subject: [PATCH 14/16] ref: instruct agents to prefer type inference over call-side generics (#116290) --- .../skills/generate-frontend-forms/SKILL.md | 16 +++++- .../skills/migrate-frontend-forms/SKILL.md | 27 ++++++++- static/AGENTS.md | 55 +++++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/.agents/skills/generate-frontend-forms/SKILL.md b/.agents/skills/generate-frontend-forms/SKILL.md index b6305955e9cc3b..ce2dc0ba65f54a 100644 --- a/.agents/skills/generate-frontend-forms/SKILL.md +++ b/.agents/skills/generate-frontend-forms/SKILL.md @@ -914,7 +914,13 @@ mutationOptions={{ Type the `mutationFn` with the API's data type, **not** the zod schema type. The schema is for client-side field validation — the mutation should accept whatever the API endpoint accepts. Don't use generic types like `Record` either, as that breaks TanStack Form's ability to narrow field types. +**NEVER pass call-site generics to `mutationOptions`, `useMutation`, or any TanStack Query function.** Types must be inferred, not asserted. See the full rules in `static/AGENTS.md` under "TanStack Query Type Inference." + ```tsx +// ❌ NEVER pass generics to mutationOptions/useMutation +mutationOptions({...}) +useMutation({...}) + // ❌ Don't use generic types - breaks field type narrowing const opts = mutationOptions({ mutationFn: (data: Record) => fetchMutation({...}), @@ -925,9 +931,15 @@ const opts = mutationOptions({ mutationFn: (data: Partial>) => fetchMutation({...}), }); -// ✅ Use the API's data type +// ❌ Don't explicitly type context — it's inferred from onMutate return +type MyContext = {previousData: UserDetails}; + +// ❌ Don't use RequestError as the error generic — use runtime narrowing instead + +// ✅ Use the API's data type on mutationFn, let everything else be inferred const opts = mutationOptions({ - mutationFn: (data: Partial) => fetchMutation({...}), + mutationFn: (data: Partial) => + fetchMutation({...}), }); ``` diff --git a/.agents/skills/migrate-frontend-forms/SKILL.md b/.agents/skills/migrate-frontend-forms/SKILL.md index 04af03de3ed617..f3b1a00b9ececd 100644 --- a/.agents/skills/migrate-frontend-forms/SKILL.md +++ b/.agents/skills/migrate-frontend-forms/SKILL.md @@ -216,15 +216,24 @@ mutationOptions={{ Make sure the zod schema's types are compatible with (i.e., assignable to) the API type. For example, if the API expects a string union like `'off' | 'low' | 'high'`, use `z.enum(['off', 'low', 'high'])` instead of `z.string()`. -**Don't pass generics to `useMutation` either.** Type the `mutationFn` payload, and let `fetchMutation` carry the return type. `useMutation` is not the codebase style. +**NEVER pass call-site generics to `useMutation`, `mutationOptions`, or any TanStack Query function.** This applies to ALL generics — data, error, variables, AND context. Types must be inferred, not asserted. See the full rules in `static/AGENTS.md` under "TanStack Query Type Inference." ```tsx -// ❌ Generics on useMutation +// ❌ Generics on useMutation — NEVER do this const mutation = useMutation({ mutationFn: ([payload]) => fetchMutation({url, method: 'POST', data: payload}), }); -// ✅ Type the payload; fetchMutation carries the return type +// ❌ Generics on mutationOptions — NEVER do this either +mutationOptions({...}) + +// ❌ Explicit context type — inferred from onMutate return +type MyContext = {changeId: string}; + +// ❌ RequestError as error generic — it's a type assertion in disguise +// Other things can go wrong that would NOT yield a RequestError + +// ✅ Type the mutationFn payload; fetchMutation carries the return type const mutation = useMutation({ mutationFn: (payload: {codeMappingId: string; raw: string}) => fetchMutation({ @@ -233,6 +242,18 @@ const mutation = useMutation({ data: payload, }), }); + +// ✅ Context is inferred from onMutate, error is Error by default +mutationOptions({ + mutationFn: (variables: MyVars) => fetchMutation({...}), + onMutate: async () => { + return {changeId: uniqueId()}; // context type inferred from this + }, + onError: (_error, _vars, context) => { + // context?.changeId is typed automatically + // _error is Error — use runtime narrowing for RequestError + }, +}) ``` ### mapFormErrors → `setFieldErrors` diff --git a/static/AGENTS.md b/static/AGENTS.md index 5088924c0e7693..a9462cf6aaf235 100644 --- a/static/AGENTS.md +++ b/static/AGENTS.md @@ -65,6 +65,61 @@ Key rules: - **Cache stores `{json, headers}`**, not just the body. `apiOptions` uses `select` to extract `.json` by default, but `getQueryData`, `setQueryData`, `retry` functions, and `predicate` callbacks all receive the raw `ApiResponse` shape. - **never** use `api.requestPromise` for a Query - it returns the wrong structure. If you must make a manual `queryFn`, use `apiFetch`. +### TanStack Query Type Inference — NEVER Pass Call-Site Generics + +**CRITICAL**: Never pass type parameters to `useQuery`, `useMutation`, `mutationOptions`, `queryOptions`, or any TanStack Query function at the call site. Let TypeScript infer types from your `queryFn`/`mutationFn` and callbacks. Passing call-site generics defeats inference, hides bugs, and creates maintenance burden. + +```typescript +// ❌ NEVER pass generics to useQuery, useMutation, mutationOptions, etc. +useMutation({...}) +mutationOptions({...}) +useQuery({...}) + +// ✅ Let types be inferred — annotate the mutationFn/queryFn instead +useMutation({ + mutationFn: (variables: MyVariables) => + fetchMutation({...}), +}) +``` + +Specific rules: + +1. **Type the `mutationFn` parameters**, not the hook/function generics. The variables type flows from the `mutationFn` signature. +2. **Use `fetchMutation`** to type the return value — the generic on `fetchMutation` is correct because it types the API response. +3. **Never type the error generic as `RequestError`** — that's a type assertion in disguise. The error is `Error` by default. Use runtime narrowing (`if (error instanceof RequestError)`) when you need `RequestError`-specific properties. +4. **Never explicitly type the context** — it is inferred from what `onMutate` returns. Creating a separate `type FooContext = {...}` and passing it as a generic is unnecessary. +5. **Same rule applies to queries** — `useQuery`, `queryOptions`, `useInfiniteQuery`, etc. Types flow from `queryFn` and `select`. + +```typescript +// ❌ Explicit context type + error assertion +type MyContext = {previousData: Item[]}; + +mutationOptions({ + mutationFn: variables => fetchMutation({...}), + onMutate: async () => { + const previousData = queryClient.getQueryData(itemQueryOptions); + return {previousData}; + }, + onError: (_error, _variables, context) => { + queryClient.setQueryData(key, context?.previousData); + }, +}) + +// ✅ Everything is inferred +mutationOptions({ + mutationFn: (variables: UpdateItemVars) => + fetchMutation({...}), + onMutate: async () => { + const previousData = queryClient.getQueryData(itemQueryOptions); + return {previousData}; + }, + onError: (_error, _variables, context) => { + // context type is inferred from onMutate return + queryClient.setQueryData(key, context?.previousData); + }, +}) +``` + #### Accessing response headers (pagination, hit counts) By default, `apiOptions` selects only the JSON body from the response. If you need response headers (e.g., `Link` for pagination or `X-Hits` / `X-Max-Hits` for total counts), override `select` with `selectJsonWithHeaders`: From 005f194e38e436165a58788b2c4afa455797124b Mon Sep 17 00:00:00 2001 From: Pierre Massat Date: Wed, 27 May 2026 08:32:11 -0700 Subject: [PATCH 15/16] test(eap): Query typed-colon attribute as boolean instead of number (#116299) ## Summary - `test_typed_attributes_with_colons` stored a boolean and queried it as a `,number]`, which only worked because the eap-items consumer double-wrote booleans into the float columns - Snuba is removing that double-write in getsentry/snuba#7689, breaking the assertion in Snuba CI - Query the same attribute with its real `,boolean]` type so the colon-in-key check is exercised against the column the value actually lives in ## Test plan - [x] CI on this branch Refs getsentry/snuba#7689 --- .../api/endpoints/test_organization_events_span_indexed.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/snuba/api/endpoints/test_organization_events_span_indexed.py b/tests/snuba/api/endpoints/test_organization_events_span_indexed.py index e05edfe0b19843..caa9bee2834485 100644 --- a/tests/snuba/api/endpoints/test_organization_events_span_indexed.py +++ b/tests/snuba/api/endpoints/test_organization_events_span_indexed.py @@ -5016,8 +5016,8 @@ def test_typed_attributes_with_colons(self) -> None: response = self.do_request( { - "field": ["tags[flag.evaluation.feature.organizations:foo,number]"], - "query": "has:tags[flag.evaluation.feature.organizations:foo,number] tags[flag.evaluation.feature.organizations:foo,number]:1", + "field": ["tags[flag.evaluation.feature.organizations:foo,boolean]"], + "query": "has:tags[flag.evaluation.feature.organizations:foo,boolean] tags[flag.evaluation.feature.organizations:foo,boolean]:true", "project": self.project.id, "dataset": "spans", } @@ -5027,7 +5027,7 @@ def test_typed_attributes_with_colons(self) -> None: { "id": span["span_id"], "project.name": self.project.slug, - "tags[flag.evaluation.feature.organizations:foo,number]": 1, + "tags[flag.evaluation.feature.organizations:foo,boolean]": True, }, ] From 63656dd859dc8717033b297ad7f05f47b945cd93 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 27 May 2026 11:38:11 -0400 Subject: [PATCH 16/16] ref(github-enterprise): Use monospace font for private key field (#116303) --- .../components/pipeline/pipelineIntegrationGitHubEnterprise.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/components/pipeline/pipelineIntegrationGitHubEnterprise.tsx b/static/app/components/pipeline/pipelineIntegrationGitHubEnterprise.tsx index d9a87e138b67aa..6cf6a188e07cdf 100644 --- a/static/app/components/pipeline/pipelineIntegrationGitHubEnterprise.tsx +++ b/static/app/components/pipeline/pipelineIntegrationGitHubEnterprise.tsx @@ -178,6 +178,7 @@ function InstallationConfigStep({ value={field.state.value} onChange={field.handleChange} rows={3} + monospace placeholder={ '-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----' }