From 212e4bbbb50ee1c0714f2a4aa604fb1f2d3ec768 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 28 May 2026 07:49:16 +0200 Subject: [PATCH 1/9] ref(forms): Migrate CreateTeamForm to TanStack form system (#115991) closes https://linear.app/getsentry/issue/DE-1256/migrate-componentsteamscreateteamformtsx-from-legacy-form-system --------- Co-authored-by: Claude Opus 4.7 --- static/app/actionCreators/teams.tsx | 29 ----- .../modals/createTeamModal.spec.tsx | 26 +++-- .../app/components/modals/createTeamModal.tsx | 106 ++++++++++++++---- .../app/components/teams/createTeamForm.tsx | 53 --------- tests/acceptance/test_create_team.py | 2 +- 5 files changed, 100 insertions(+), 116 deletions(-) delete mode 100644 static/app/components/teams/createTeamForm.tsx diff --git a/static/app/actionCreators/teams.tsx b/static/app/actionCreators/teams.tsx index 56bcaa910c92cb..915eda451d5470 100644 --- a/static/app/actionCreators/teams.tsx +++ b/static/app/actionCreators/teams.tsx @@ -91,35 +91,6 @@ export async function leaveTeamPromise( return data; } -export function createTeam(api: Client, team: Pick, params: OrgSlug) { - return api - .requestPromise(`/organizations/${params.orgId}/teams/`, { - method: 'POST', - data: team, - }) - .then( - data => { - TeamStore.onCreateSuccess(data); - addSuccessMessage( - tct('[team] has been added to the [organization] organization', { - team: `#${data.slug}`, - organization: params.orgId, - }) - ); - return data; - }, - err => { - addErrorMessage( - tct('Unable to create [team] in the [organization] organization', { - team: `#${team.slug}`, - organization: params.orgId, - }) - ); - throw err; - } - ); -} - export function removeTeam(api: Client, params: OrgAndTeamSlug) { return api .requestPromise(`/teams/${params.orgId}/${params.teamId}/`, { diff --git a/static/app/components/modals/createTeamModal.spec.tsx b/static/app/components/modals/createTeamModal.spec.tsx index c2937b506dbd50..3025a57284482e 100644 --- a/static/app/components/modals/createTeamModal.spec.tsx +++ b/static/app/components/modals/createTeamModal.spec.tsx @@ -1,19 +1,13 @@ import styled from '@emotion/styled'; import {OrganizationFixture} from 'sentry-fixture/organization'; +import {TeamFixture} from 'sentry-fixture/team'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import {makeCloseButton} from '@sentry/scraps/modal'; -import {createTeam} from 'sentry/actionCreators/teams'; import CreateTeamModal from 'sentry/components/modals/createTeamModal'; -jest.mock('sentry/actionCreators/teams', () => ({ - createTeam: jest.fn( - (...args: Parameters) => new Promise(resolve => resolve(args)) - ), -})); - describe('CreateTeamModal', () => { const org = OrganizationFixture(); const closeModal = jest.fn(); @@ -21,9 +15,17 @@ describe('CreateTeamModal', () => { beforeEach(() => { jest.clearAllMocks(); + MockApiClient.clearMockResponses(); }); - it('calls createTeam action creator on submit', async () => { + it('creates a team and closes the modal on submit', async () => { + const team = TeamFixture({slug: 'new-team'}); + const createRequest = MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/teams/`, + method: 'POST', + body: team, + }); + const styledWrapper = styled((c: {children: React.ReactNode}) => c.children); render( { await userEvent.type(screen.getByRole('textbox', {name: 'Team Slug'}), 'new-team'); await userEvent.click(screen.getByLabelText('Create Team')); - await waitFor(() => expect(createTeam).toHaveBeenCalledTimes(1)); - expect(onClose).toHaveBeenCalled(); + await waitFor(() => expect(createRequest).toHaveBeenCalledTimes(1)); + expect(createRequest).toHaveBeenCalledWith( + `/organizations/${org.slug}/teams/`, + expect.objectContaining({data: {slug: 'new-team'}}) + ); + expect(onClose).toHaveBeenCalledWith(team); expect(closeModal).toHaveBeenCalled(); }); }); diff --git a/static/app/components/modals/createTeamModal.tsx b/static/app/components/modals/createTeamModal.tsx index f225248a40cbde..1938d1499e0d89 100644 --- a/static/app/components/modals/createTeamModal.tsx +++ b/static/app/components/modals/createTeamModal.tsx @@ -1,45 +1,105 @@ -import {Fragment} from 'react'; +import {useMutation} from '@tanstack/react-query'; +import {z} from 'zod'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import {createTeam} from 'sentry/actionCreators/teams'; -import {CreateTeamForm} from 'sentry/components/teams/createTeamForm'; -import {t} from 'sentry/locale'; +import {t, tct} from 'sentry/locale'; +import {TeamStore} from 'sentry/stores/teamStore'; import type {Organization, Team} from 'sentry/types/organization'; -import {useApi} from 'sentry/utils/useApi'; +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {fetchMutation} from 'sentry/utils/queryClient'; +import {slugify} from 'sentry/utils/slugify'; interface Props extends ModalRenderProps { organization: Organization; onClose?: (team: Team) => void; } -function CreateTeamModal({Body, Header, organization, onClose, closeModal}: Props) { - const api = useApi(); - - const handleSubmit: React.ComponentProps['onSubmit'] = async ( - data, - onSuccess, - onError - ) => { - try { - const team: Team = await createTeam(api, data, {orgId: organization.slug}); +const schema = z.object({ + slug: z.string().min(1, t('Field is required')), +}); +function CreateTeamModal({ + Body, + Footer, + Header, + organization, + onClose, + closeModal, +}: Props) { + const {mutateAsync: submitCreateTeam} = useMutation({ + mutationFn: (data: {slug: string}) => + fetchMutation({ + method: 'POST', + url: getApiUrl('/organizations/$organizationIdOrSlug/teams/', { + path: {organizationIdOrSlug: organization.slug}, + }), + data, + }), + onSuccess: team => { + TeamStore.onCreateSuccess(team); + addSuccessMessage( + tct('[team] has been added to the [organization] organization', { + team: `#${team.slug}`, + organization: organization.slug, + }) + ); closeModal(); onClose?.(team); - onSuccess(team); - } catch (err) { - onError(err as Team); - } - }; + }, + onError: (_err, variables) => { + addErrorMessage( + tct('Unable to create [team] in the [organization] organization', { + team: `#${variables.slug}`, + organization: organization.slug, + }) + ); + }, + }); + + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: {slug: ''}, + validators: {onDynamic: schema}, + onSubmit: ({value}) => submitCreateTeam(value).catch(() => {}), + }); return ( - +
{t('Create Team')}
- + + + {t('Teams group members for issue assignment, ownership, and notifications.')} + + + {field => ( + + field.handleChange(slugify(value))} + placeholder={t('e.g. operations, web-frontend, mobile-ios')} + autoFocus + /> + + )} + + -
+
+ {t('Create Team')} +
+ ); } diff --git a/static/app/components/teams/createTeamForm.tsx b/static/app/components/teams/createTeamForm.tsx deleted file mode 100644 index c9c670137224bd..00000000000000 --- a/static/app/components/teams/createTeamForm.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import {Fragment} from 'react'; - -import {TextField} from 'sentry/components/forms/fields/textField'; -import {Form} from 'sentry/components/forms/form'; -import {t} from 'sentry/locale'; -import type {Organization, Team} from 'sentry/types/organization'; -import {slugify} from 'sentry/utils/slugify'; - -type Payload = { - slug: string; -}; - -type Props = { - onSubmit: ( - data: Payload, - onSuccess: (team: Team) => void, - onError: (team: Team) => void - ) => void; - organization: Organization; -}; - -export function CreateTeamForm({organization, onSubmit}: Props) { - return ( - -

- {t('Teams group members for issue assignment, ownership, and notifications.')} -

- -
- onSubmit(data as Payload, onSuccess, onError) - } - requireChanges - > - - -
- ); -} diff --git a/tests/acceptance/test_create_team.py b/tests/acceptance/test_create_team.py index b05338500d1738..7bf55e4b69e35c 100644 --- a/tests/acceptance/test_create_team.py +++ b/tests/acceptance/test_create_team.py @@ -24,7 +24,7 @@ def test_create(self) -> None: # Open the modal self.browser.click('button[aria-label="Create Team"]') self.browser.wait_until("[role='dialog']") - self.browser.element('input[id="slug"]').send_keys("new-team") + self.browser.element('input[name="slug"]').send_keys("new-team") self.browser.click("[role='dialog'] button[aria-label='Create Team']") # Wait for modal to go away. From 80e5eca0f521daec1f1606956656fc89232b465a Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 28 May 2026 07:49:37 +0200 Subject: [PATCH 2/9] ref(explore): Port schema hints list to scraps (#116159) ## Summary - Replace `SchemaHintsContainer` and `SchemaHintOption` styled components with `Flex` and `Container` Scraps layout primitives. - Replace `HintName`/`HintOperator`/`HintValue` styled spans with `Text` Scraps primitive (using `variant="muted"` and `variant="accent"`). - Update the hidden measurement code to clone the new ` + ))} - + ); } -const SchemaHintsContainer = styled('div')` - display: flex; - flex-direction: row; - gap: ${p => p.theme.space.md}; - flex-wrap: nowrap; - - > * { - flex-shrink: 0; - } -`; - -const SchemaHintOption = styled(Button)` - /* Ensures that filters do not grow outside of the container */ - min-width: fit-content; -`; - export const SchemaHintsSection = styled('div')` display: grid; /* This is to ensure the hints section spans all the columns */ @@ -498,18 +494,3 @@ export const SchemaHintsSection = styled('div')` margin-top: 0; } `; - -const HintName = styled('span')` - font-weight: ${p => p.theme.font.weight.sans.regular}; - color: ${p => p.theme.tokens.content.primary}; -`; - -const HintOperator = styled('span')` - font-weight: ${p => p.theme.font.weight.sans.regular}; - color: ${p => p.theme.tokens.content.secondary}; -`; - -const HintValue = styled('span')` - font-weight: ${p => p.theme.font.weight.sans.regular}; - color: ${p => p.theme.tokens.content.accent}; -`; From 2cab4fe65bc5675c050bd159747ccdc5376a569c Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Thu, 28 May 2026 10:09:21 +0200 Subject: [PATCH 3/9] feat(dynamic-sampling): Add per-org EAP transaction volume query (#115161) Add `get_eap_transaction_volumes()` to retrieve per-project transaction volumes from EAP spans, with optional ordering (`order_by_volume`) and `max_transactions` limit. Uses the existing `run_eap_spans_table_query_in_chunks()` for batched iteration. Replaces #115047 (which got corrupted during a rebase). Closes https://linear.app/getsentry/issue/TET-2306/create-transaction-volume-query-for-eap --------- Co-authored-by: Claude Sonnet 4 Co-authored-by: Simon Hellmayr Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- .../dynamic_sampling/per_org/tasks/queries.py | 108 ++++++- .../per_org/tasks/scheduler.py | 4 + .../per_org/tasks/telemetry.py | 1 + src/sentry/snuba/referrer.py | 3 + .../per_org/tasks/test_queries.py | 271 +++++++++++++++++- .../per_org/tasks/test_scheduler.py | 88 ++++++ 6 files changed, 463 insertions(+), 12 deletions(-) diff --git a/src/sentry/dynamic_sampling/per_org/tasks/queries.py b/src/sentry/dynamic_sampling/per_org/tasks/queries.py index d90bb0d35faea2..af77a229a97332 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/queries.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/queries.py @@ -1,15 +1,17 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Iterator, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from enum import StrEnum -from typing import Any +from typing import Any, Literal from sentry_protos.snuba.v1.trace_item_attribute_pb2 import ExtrapolationMode from sentry.dynamic_sampling.per_org.tasks.configuration import BaseDynamicSamplingConfiguration from sentry.dynamic_sampling.rules.utils import ProjectId +from sentry.dynamic_sampling.tasks.boost_low_volume_transactions import ProjectTransactions from sentry.dynamic_sampling.tasks.common import ( ACTIVE_ORGS_VOLUMES_DEFAULT_TIME_INTERVAL, OrganizationDataVolume, @@ -27,6 +29,7 @@ class DynamicSamplingQueryFilters(StrEnum): class DynamicSamplingQueryFields(StrEnum): DSC_PROJECT_ID = "sentry.dsc.project_id" + DSC_TRANSACTION = "sentry.dsc.transaction" COUNT = "count()" COUNT_SAMPLE = "count_sample()" @@ -39,32 +42,48 @@ class ProjectVolume: drop: int +@dataclass +class ProjectTransactionVolumesAccumulator: + transaction_counts: list[tuple[str, float]] = field(default_factory=list) + total_num_transactions: float = 0 + num_classes: int = 0 + + def _get_aggregate_int(row: Mapping[str, Any], column: str) -> int: return int(row.get(column, 0)) +def _get_aggregate_float(row: Mapping[str, Any], column: str) -> float: + return float(row.get(column, 0)) + + def run_eap_spans_table_query_in_chunks( query: dict[str, Any], + max_results: int | None = None, chunk_size: int = 1000, ) -> Iterator[dict[str, Any]]: offset = 0 + current_chunk_size = chunk_size while True: - result = Spans.run_table_query(**query, offset=offset, limit=chunk_size + 1) + if max_results is not None: + current_chunk_size = min(chunk_size, max_results - offset) + + result = Spans.run_table_query(**query, offset=offset, limit=current_chunk_size + 1) data = result.get("data", []) - more_results = len(data) > chunk_size + more_results = len(data) > current_chunk_size if more_results: - data = data[:chunk_size] + data = data[:current_chunk_size] if data: yield from data + offset += len(data) - if not more_results: + # either we run out of results or we hit the max results limit, in both cases we should stop + if not more_results or (max_results is not None and offset >= max_results): return - offset += chunk_size - def get_eap_organization_volume( config: BaseDynamicSamplingConfiguration, @@ -155,3 +174,76 @@ def get_eap_project_volumes( ) return project_volumes + + +def get_eap_transaction_volumes( + config: BaseDynamicSamplingConfiguration, + time_interval: timedelta = ACTIVE_ORGS_VOLUMES_DEFAULT_TIME_INTERVAL, + order_by_volume: Literal["asc", "desc"] = "asc", + max_transactions: int = 100, +) -> list[ProjectTransactions]: + end_time = datetime.now(UTC) + start_time = end_time - time_interval + volumes_by_project: defaultdict[int, ProjectTransactionVolumesAccumulator] = defaultdict( + ProjectTransactionVolumesAccumulator + ) + + count_order = ( + DynamicSamplingQueryFields.COUNT + if order_by_volume == "asc" + else f"-{DynamicSamplingQueryFields.COUNT}" + ) + orderby = [ + count_order, + DynamicSamplingQueryFields.DSC_PROJECT_ID, + DynamicSamplingQueryFields.DSC_TRANSACTION, + ] + + root_project_filter = ",".join(str(project.id) for project in config.projects) + result = Spans.run_table_query( + params=SnubaParams( + start=start_time, + end=end_time, + projects=config.projects, + organization=config.organization, + ), + query_string=f"{DynamicSamplingQueryFilters.IS_SEGMENT} {DynamicSamplingQueryFields.DSC_PROJECT_ID}:[{root_project_filter}] has:{DynamicSamplingQueryFields.DSC_TRANSACTION}", + selected_columns=[ + DynamicSamplingQueryFields.DSC_PROJECT_ID, + DynamicSamplingQueryFields.DSC_TRANSACTION, + DynamicSamplingQueryFields.COUNT, + ], + orderby=orderby, + offset=0, + limit=max_transactions, + referrer=Referrer.DYNAMIC_SAMPLING_PER_ORG_GET_EAP_TRANSACTION_VOLUMES.value, + config=SearchResolverConfig( + auto_fields=True, + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SERVER_ONLY, + ), + sampling_mode=SAMPLING_MODE_HIGHEST_ACCURACY, + ) + + for row in result.get("data", []): + transaction = row.get(DynamicSamplingQueryFields.DSC_TRANSACTION) + total = _get_aggregate_float(row, DynamicSamplingQueryFields.COUNT) + if total <= 0: + continue + + project_id = _get_aggregate_int(row, DynamicSamplingQueryFields.DSC_PROJECT_ID) + project_volumes = volumes_by_project[project_id] + + project_volumes.transaction_counts.append((str(transaction), total)) + project_volumes.total_num_transactions += total + project_volumes.num_classes += 1 + + return [ + { + "org_id": config.organization.id, + "project_id": project_id, + "transaction_counts": project_volumes.transaction_counts, + "total_num_transactions": project_volumes.total_num_transactions, + "total_num_classes": project_volumes.num_classes, + } + for project_id, project_volumes in sorted(volumes_by_project.items()) + ] diff --git a/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py b/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py index ca767af45fe793..86b92bcaa5de1c 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py @@ -13,6 +13,7 @@ from sentry.dynamic_sampling.per_org.tasks.queries import ( get_eap_organization_volume, get_eap_project_volumes, + get_eap_transaction_volumes, ) from sentry.dynamic_sampling.per_org.tasks.telemetry import ( SCHEDULER_BUCKET_ORG_STATUS_METRIC, @@ -116,4 +117,7 @@ def run_calculations_per_org_task(org_id: OrganizationId) -> DynamicSamplingStat if not project_volumes: return DynamicSamplingStatus.NO_PROJECT_VOLUMES + if not get_eap_transaction_volumes(config): + return DynamicSamplingStatus.NO_TRANSACTION_VOLUMES + return None diff --git a/src/sentry/dynamic_sampling/per_org/tasks/telemetry.py b/src/sentry/dynamic_sampling/per_org/tasks/telemetry.py index 9faf0c9d2c39b3..fa9d87e98d2459 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/telemetry.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/telemetry.py @@ -35,6 +35,7 @@ class DynamicSamplingStatus(StrEnum): NO_SUBSCRIPTION = "no_subscription" NO_ORG_VOLUME = "no_org_volume" NO_PROJECT_VOLUMES = "no_project_volumes" + NO_TRANSACTION_VOLUMES = "no_transaction_volumes" NOT_IN_ROLLOUT = "not_in_rollout" ORG_HAS_NO_DYNAMIC_SAMPLING = "org_has_no_dynamic_sampling" ORG_HAS_NO_PROJECTS = "org_has_no_projects" diff --git a/src/sentry/snuba/referrer.py b/src/sentry/snuba/referrer.py index 2517702f5df392..941d7e6f618edb 100644 --- a/src/sentry/snuba/referrer.py +++ b/src/sentry/snuba/referrer.py @@ -643,6 +643,9 @@ class Referrer(StrEnum): DYNAMIC_SAMPLING_PER_ORG_GET_EAP_PROJECT_VOLUMES = ( "dynamic_sampling.per_org.get_eap_project_volumes" ) + DYNAMIC_SAMPLING_PER_ORG_GET_EAP_TRANSACTION_VOLUMES = ( + "dynamic_sampling.per_org.get_eap_transaction_volumes" + ) DYNAMIC_SAMPLING_COUNTERS_FETCH_PROJECTS_WITH_COUNT_PER_TRANSACTION = ( "dynamic_sampling.counters.fetch_projects_with_count_per_transaction_volumes" ) 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 e0a53cde9c0d23..af2b7a4f30f575 100644 --- a/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py +++ b/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py @@ -15,6 +15,7 @@ ProjectVolume, get_eap_organization_volume, get_eap_project_volumes, + get_eap_transaction_volumes, run_eap_spans_table_query_in_chunks, ) from sentry.dynamic_sampling.tasks.common import OrganizationDataVolume @@ -75,10 +76,7 @@ def test_iterates_query_data_in_offset_chunks(self) -> None: ) assert len(rows) == 2 - assert {row["project.id"] for row in rows} == { - project.id, - other_project.id, - } + assert {row["project.id"] for row in rows} == {project.id, other_project.id} class EAPOrganizationVolumeTest(TestCase, SnubaTestCase, SpanTestCase): @@ -271,3 +269,268 @@ def test_get_eap_project_volumes_without_projects(self) -> None: assert project_volumes == [] run_table_query.assert_called_once() assert run_table_query.call_args.kwargs["params"].projects == [] + + +class EAPTransactionVolumesTest(TestCase, SnubaTestCase, SpanTestCase): + def get_config(self, organization: Organization) -> BaseDynamicSamplingConfiguration: + with patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", + return_value=1.0, + ): + return get_configuration(organization.id) + + def test_get_eap_transaction_volumes(self) -> None: + organization = self.create_organization() + project = self.create_project(organization=organization) + other_project = self.create_project(organization=organization) + other_organization = self.create_organization() + other_organization_project = self.create_project(organization=other_organization) + timestamp = before_now(minutes=15) + + self.store_spans( + [ + # owned by `project`, rooted at `project` + self.create_span( + { + "is_segment": True, + "sentry_tags": { + "transaction": "checkout", + "dsc.transaction": "checkout", + "dsc.project_id": str(project.id), + }, + }, + organization=organization, + project=project, + start_ts=timestamp, + ), + # owned by `other_project` but rooted at `project` — must count toward `project` + self.create_span( + { + "is_segment": True, + "sentry_tags": { + "transaction": "checkout", + "dsc.transaction": "checkout", + "dsc.project_id": str(project.id), + }, + "measurements": {"server_sample_rate": {"value": 0.5}}, + }, + organization=organization, + project=other_project, + start_ts=timestamp + timedelta(seconds=1), + ), + # owned by `project`, rooted at `project` + self.create_span( + { + "is_segment": True, + "sentry_tags": { + "transaction": "product", + "dsc.transaction": "product", + "dsc.project_id": str(project.id), + }, + }, + organization=organization, + project=project, + start_ts=timestamp + timedelta(seconds=2), + ), + # owned by `project` but rooted at `other_project` — must count toward `other_project` + self.create_span( + { + "is_segment": True, + "sentry_tags": { + "transaction": "checkout", + "dsc.transaction": "checkout", + "dsc.project_id": str(other_project.id), + }, + }, + organization=organization, + project=project, + start_ts=timestamp + timedelta(seconds=3), + ), + # non-segment span — excluded by is_transaction:true + self.create_span( + { + "is_segment": False, + "sentry_tags": { + "transaction": "ignored-span", + "dsc.transaction": "ignored-span", + "dsc.project_id": str(project.id), + }, + }, + organization=organization, + project=project, + start_ts=timestamp + timedelta(seconds=4), + ), + # missing dsc.project_id — excluded by the root_project filter + self.create_span( + { + "is_segment": True, + "sentry_tags": { + "transaction": "no-root", + "dsc.transaction": "no-root", + }, + }, + organization=organization, + project=project, + start_ts=timestamp + timedelta(seconds=5), + ), + # missing dsc.transaction — excluded by the has:sentry.dsc.transaction filter + self.create_span( + { + "is_segment": True, + "sentry_tags": { + "transaction": "no-dsc-transaction", + "dsc.project_id": str(project.id), + }, + }, + organization=organization, + project=project, + start_ts=timestamp + timedelta(seconds=6), + ), + # other org — excluded by org scope on SnubaParams + self.create_span( + { + "is_segment": True, + "sentry_tags": { + "transaction": "other-org", + "dsc.transaction": "other-org", + "dsc.project_id": str(other_organization_project.id), + }, + }, + organization=other_organization, + project=other_organization_project, + start_ts=timestamp, + ), + ] + ) + + volumes = get_eap_transaction_volumes( + self.get_config(organization), + time_interval=timedelta(hours=1), + order_by_volume="desc", + ) + + assert volumes == [ + { + "org_id": organization.id, + "project_id": project.id, + "transaction_counts": [("checkout", 3), ("product", 1)], + "total_num_transactions": 4, + "total_num_classes": 2, + }, + { + "org_id": organization.id, + "project_id": other_project.id, + "transaction_counts": [("checkout", 1)], + "total_num_transactions": 1, + "total_num_classes": 1, + }, + ] + + def test_get_eap_transaction_volumes_without_projects(self) -> None: + organization = self.create_organization() + + volumes = get_eap_transaction_volumes( + self.get_config(organization), time_interval=timedelta(hours=1) + ) + + assert volumes == [] + + def test_get_eap_transaction_volumes_attributes_to_originating_project(self) -> None: + organization = self.create_organization() + originating_project = self.create_project(organization=organization) + downstream_project = self.create_project(organization=organization) + timestamp = before_now(minutes=15) + + self.store_spans( + [ + # Owned by `downstream_project` but originated in `originating_project`. + self.create_span( + { + "is_segment": True, + "sentry_tags": { + "transaction": "checkout", + "dsc.transaction": "checkout", + "dsc.project_id": str(originating_project.id), + }, + }, + organization=organization, + project=downstream_project, + start_ts=timestamp, + ), + ] + ) + + volumes = get_eap_transaction_volumes( + self.get_config(organization), time_interval=timedelta(hours=1) + ) + + assert volumes == [ + { + "org_id": organization.id, + "project_id": originating_project.id, + "transaction_counts": [("checkout", 1)], + "total_num_transactions": 1, + "total_num_classes": 1, + } + ] + + def test_get_eap_transaction_volumes_with_max_transactions_caps_total_rows(self) -> None: + organization = self.create_organization() + project = self.create_project(organization=organization) + other_project = self.create_project(organization=organization) + timestamp = before_now(minutes=15) + + def segment(transaction, root_project_id, project, offset): + return self.create_span( + { + "is_segment": True, + "sentry_tags": { + "transaction": transaction, + "dsc.transaction": transaction, + "dsc.project_id": str(root_project_id), + }, + }, + organization=organization, + project=project, + start_ts=timestamp + timedelta(seconds=offset), + ) + + self.store_spans( + [ + # project/alpha → count = 3 + segment("alpha", project.id, project, 0), + segment("alpha", project.id, project, 1), + segment("alpha", project.id, project, 2), + # other_project/beta → count = 2 + segment("beta", other_project.id, other_project, 3), + segment("beta", other_project.id, other_project, 4), + # project/gamma → count = 1 (excluded by the global cap) + segment("gamma", project.id, project, 5), + ] + ) + + volumes = get_eap_transaction_volumes( + self.get_config(organization), + time_interval=timedelta(hours=1), + order_by_volume="desc", + max_transactions=2, + ) + + # Top 2 rows globally: project/alpha (3) and other_project/beta (2); + # project/gamma is excluded by the cap. + assert volumes == [ + { + "org_id": organization.id, + "project_id": project.id, + "transaction_counts": [("alpha", 3)], + "total_num_transactions": 3, + "total_num_classes": 1, + }, + { + "org_id": organization.id, + "project_id": other_project.id, + "transaction_counts": [("beta", 2)], + "total_num_transactions": 2, + "total_num_classes": 1, + }, + ] diff --git a/tests/sentry/dynamic_sampling/per_org/tasks/test_scheduler.py b/tests/sentry/dynamic_sampling/per_org/tasks/test_scheduler.py index 0e50657f90e33f..7386e37083ae04 100644 --- a/tests/sentry/dynamic_sampling/per_org/tasks/test_scheduler.py +++ b/tests/sentry/dynamic_sampling/per_org/tasks/test_scheduler.py @@ -172,6 +172,18 @@ def test_run_calculations_per_org_continues_with_traffic(self) -> None: "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", return_value=[(1, 100, 25, 75)], ) as get_project_volumes, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_transaction_volumes", + return_value=[ + { + "org_id": org.id, + "project_id": 1, + "transaction_counts": [("checkout", 1.0)], + "total_num_transactions": 1.0, + "total_num_classes": 1, + } + ], + ) as get_transaction_volumes, ): result = run_calculations_per_org_task(org.id) @@ -179,6 +191,7 @@ def test_run_calculations_per_org_continues_with_traffic(self) -> None: _assert_called_once_with_config(get_volume, org.id) get_blended_sample_rate.assert_called_once_with(organization_id=org.id) _assert_called_once_with_config(get_project_volumes, org.id) + _assert_called_once_with_config(get_transaction_volumes, org.id) @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) def test_run_calculations_per_org_returns_no_volume_without_project_volumes(self) -> None: @@ -199,6 +212,9 @@ def test_run_calculations_per_org_returns_no_volume_without_project_volumes(self "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", return_value=[], ) as get_project_volumes, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_transaction_volumes" + ) as get_transaction_volumes, ): result = run_calculations_per_org_task(org.id) @@ -206,6 +222,39 @@ def test_run_calculations_per_org_returns_no_volume_without_project_volumes(self get_blended_sample_rate.assert_called_once_with(organization_id=org.id) _assert_called_once_with_config(get_volume, org.id) _assert_called_once_with_config(get_project_volumes, org.id) + get_transaction_volumes.assert_not_called() + + @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) + def test_run_calculations_per_org_returns_no_volume_without_transaction_volumes(self) -> None: + org = self.create_organization() + self.create_project(organization=org) + org_volume = OrganizationDataVolume(org_id=org.id, total=100, indexed=25) + + with ( + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", + return_value=1.0, + ) as get_blended_sample_rate, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_organization_volume", + return_value=org_volume, + ) as get_volume, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", + return_value=[(1, 100, 25, 75)], + ) as get_project_volumes, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_transaction_volumes", + return_value=[], + ) as get_transaction_volumes, + ): + result = run_calculations_per_org_task(org.id) + + assert result == DynamicSamplingStatus.NO_TRANSACTION_VOLUMES + get_blended_sample_rate.assert_called_once_with(organization_id=org.id) + _assert_called_once_with_config(get_volume, org.id) + _assert_called_once_with_config(get_project_volumes, org.id) + _assert_called_once_with_config(get_transaction_volumes, org.id) @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) def test_run_calculations_per_org_skips_project_balancing_for_project_mode(self) -> None: @@ -227,6 +276,18 @@ def test_run_calculations_per_org_skips_project_balancing_for_project_mode(self) patch( "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", ) as get_project_volumes, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_transaction_volumes", + return_value=[ + { + "org_id": org.id, + "project_id": project.id, + "transaction_counts": [("checkout", 1.0)], + "total_num_transactions": 1.0, + "total_num_classes": 1, + } + ], + ) as get_transaction_volumes, ): result = run_calculations_per_org_task(org.id) @@ -234,6 +295,7 @@ def test_run_calculations_per_org_skips_project_balancing_for_project_mode(self) get_blended_sample_rate.assert_not_called() _assert_called_once_with_config(get_volume, org.id) get_project_volumes.assert_not_called() + _assert_called_once_with_config(get_transaction_volumes, org.id) @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) def test_run_calculations_per_org_queries_projects_for_am3_org_mode(self) -> None: @@ -255,6 +317,18 @@ def test_run_calculations_per_org_queries_projects_for_am3_org_mode(self) -> Non "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", return_value=[(1, 100, 25, 75)], ) as get_project_volumes, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_transaction_volumes", + return_value=[ + { + "org_id": org.id, + "project_id": 1, + "transaction_counts": [("checkout", 1.0)], + "total_num_transactions": 1.0, + "total_num_classes": 1, + } + ], + ) as get_transaction_volumes, ): result = run_calculations_per_org_task(org.id) @@ -262,6 +336,7 @@ def test_run_calculations_per_org_queries_projects_for_am3_org_mode(self) -> Non get_blended_sample_rate.assert_not_called() _assert_called_once_with_config(get_volume, org.id) _assert_called_once_with_config(get_project_volumes, org.id) + _assert_called_once_with_config(get_transaction_volumes, org.id) @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) def test_run_calculations_per_org_skips_project_mode_without_project_rates(self) -> None: @@ -307,6 +382,18 @@ def test_run_calculations_per_org_queries_projects_for_am2(self) -> None: "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", return_value=[(1, 100, 25, 75)], ) as get_project_volumes, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_transaction_volumes", + return_value=[ + { + "org_id": org.id, + "project_id": 1, + "transaction_counts": [("checkout", 1.0)], + "total_num_transactions": 1.0, + "total_num_classes": 1, + } + ], + ) as get_transaction_volumes, ): result = run_calculations_per_org_task(org.id) @@ -314,6 +401,7 @@ def test_run_calculations_per_org_queries_projects_for_am2(self) -> None: get_blended_sample_rate.assert_called_once_with(organization_id=org.id) _assert_called_once_with_config(get_volume, org.id) _assert_called_once_with_config(get_project_volumes, org.id) + _assert_called_once_with_config(get_transaction_volumes, org.id) @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) def test_run_calculations_per_org_skips_org_without_transaction_sample_rate(self) -> None: From 7b4d736c767a4e78d4a974d6cb4b75652017127b Mon Sep 17 00:00:00 2001 From: Ogi Date: Thu, 28 May 2026 10:37:04 +0200 Subject: [PATCH 4/9] ref(conversations): Simplify conversation details endpoint (#116087) --- .../organization_ai_conversation_details.py | 87 +++++++---- .../hooks/useConversation.spec.tsx | 8 +- .../conversations/hooks/useConversation.tsx | 2 +- ...st_organization_ai_conversation_details.py | 147 +++++++++--------- 4 files changed, 138 insertions(+), 106 deletions(-) diff --git a/src/sentry/api/endpoints/organization_ai_conversation_details.py b/src/sentry/api/endpoints/organization_ai_conversation_details.py index d3c0aafff71ffc..d31cc323249555 100644 --- a/src/sentry/api/endpoints/organization_ai_conversation_details.py +++ b/src/sentry/api/endpoints/organization_ai_conversation_details.py @@ -1,5 +1,7 @@ -from datetime import timedelta +from dataclasses import replace +from datetime import datetime, timedelta +import sentry_sdk from django.utils import timezone from rest_framework.request import Request from rest_framework.response import Response @@ -14,12 +16,15 @@ from sentry.models.organization import Organization from sentry.search.eap.occurrences.query_utils import build_escaped_term_filter from sentry.search.eap.types import SearchResolverConfig +from sentry.search.events.types import SnubaParams from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans +from sentry.utils.dates import parse_stats_period MAX_RETENTION_DAYS = 30 -# Base span fields always returned +_WIDENING_STEPS = [timedelta(days=7), timedelta(days=14), timedelta(days=MAX_RETENTION_DAYS)] + AI_CONVERSATION_ATTRIBUTES = [ "span_id", "trace", @@ -68,26 +73,16 @@ def get(self, request: Request, organization: Organization, conversation_id: str if not features.has("organizations:gen-ai-conversations", organization, actor=request.user): return Response(status=404) - # Check what date params were passed before calling get_snuba_params - stats_period = request.GET.get("statsPeriod") - has_explicit_range = request.GET.get("start") or request.GET.get("end") - try: snuba_params = self.get_snuba_params(request, organization) except NoProjects: return Response(status=404) - # Enforce 30-day retention limit - max_retention = timedelta(days=MAX_RETENTION_DAYS) now = timezone.now() - max_retention_cutoff = now - max_retention - - if stats_period or not has_explicit_range: - # Always use full 30d range when statsPeriod is passed or no date params - snuba_params.start = max_retention_cutoff - snuba_params.end = now - else: - # Validate explicit start/end aren't older than retention limit + max_retention_cutoff = now - timedelta(days=MAX_RETENTION_DAYS) + has_explicit_range = request.GET.get("start") or request.GET.get("end") + + if has_explicit_range: if snuba_params.start and snuba_params.start < max_retention_cutoff: return Response( {"detail": f"start time cannot be older than {MAX_RETENTION_DAYS} days"}, @@ -99,18 +94,17 @@ def get(self, request: Request, organization: Organization, conversation_id: str status=400, ) - selected_columns = AI_CONVERSATION_ATTRIBUTES + with handle_query_errors(): + if has_explicit_range: + resolved_params = snuba_params + else: + resolved_params = self._resolve_time_window( + snuba_params, request.GET.get("statsPeriod"), now, conversation_id + ) - def data_fn(offset: int, limit: int): - return self._fetch_conversation_spans( - snuba_params=snuba_params, - conversation_id=conversation_id, - selected_columns=selected_columns, - offset=offset, - limit=limit, - ) + def data_fn(offset: int, limit: int) -> list: + return self._fetch_spans(resolved_params, conversation_id, offset, limit) - with handle_query_errors(): return self.paginate( request=request, paginator=GenericOffsetPaginator(data_fn=data_fn), @@ -118,18 +112,49 @@ def data_fn(offset: int, limit: int): max_per_page=1000, ) - def _fetch_conversation_spans( + def _resolve_time_window( + self, + base_params: SnubaParams, + stats_period: str | None, + now: datetime, + conversation_id: str, + ) -> SnubaParams: + """Probe progressively wider windows to find which contains the conversation.""" + candidates = self._build_widening_params(base_params, stats_period, now) + for params in candidates: + if self._fetch_spans(params, conversation_id, offset=0, limit=1): + return params + return candidates[-1] + + def _build_widening_params( + self, base_params: SnubaParams, stats_period: str | None, now: datetime + ) -> list[SnubaParams]: + max_retention = timedelta(days=MAX_RETENTION_DAYS) + requested_delta: timedelta | None = ( + parse_stats_period(stats_period) if stats_period else None + ) + + steps: list[timedelta] = [] + if requested_delta and requested_delta < max_retention: + steps.append(requested_delta) + for step in _WIDENING_STEPS: + if not steps or step > steps[-1]: + steps.append(step) + + return [replace(base_params, start=now - delta, end=now) for delta in steps] + + @sentry_sdk.trace + def _fetch_spans( self, - snuba_params, + snuba_params: SnubaParams, conversation_id: str, - selected_columns: list[str], offset: int, limit: int, - ): + ) -> list: result = Spans.run_table_query( params=snuba_params, query_string=build_escaped_term_filter("gen_ai.conversation.id", [conversation_id]), - selected_columns=selected_columns, + selected_columns=AI_CONVERSATION_ATTRIBUTES, orderby=["precise.start_ts"], offset=offset, limit=limit, diff --git a/static/app/views/explore/conversations/hooks/useConversation.spec.tsx b/static/app/views/explore/conversations/hooks/useConversation.spec.tsx index 870c56b184aaf2..66a3b6ac512b84 100644 --- a/static/app/views/explore/conversations/hooks/useConversation.spec.tsx +++ b/static/app/views/explore/conversations/hooks/useConversation.spec.tsx @@ -403,7 +403,7 @@ describe('useConversation', () => { expect(queryArg).not.toHaveProperty('statsPeriod'); }); - it('falls back to ALL_ACCESS_PROJECTS and 30d when no filters are set', async () => { + it('falls back to ALL_ACCESS_PROJECTS with no time params when no filters are set', async () => { const mockRequest = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/ai-conversations/conv-123/`, body: [BASE_SPAN], @@ -421,10 +421,14 @@ describe('useConversation', () => { expect.objectContaining({ query: expect.objectContaining({ project: [-1], - statsPeriod: '30d', }), }) ); + // No statsPeriod sent — backend uses its 30d retention fallback + const queryArg = mockRequest.mock.calls[0]![1]!.query; + expect(queryArg).not.toHaveProperty('statsPeriod'); + expect(queryArg).not.toHaveProperty('start'); + expect(queryArg).not.toHaveProperty('end'); }); it('uses relative period from page filters when explicitly set', async () => { diff --git a/static/app/views/explore/conversations/hooks/useConversation.tsx b/static/app/views/explore/conversations/hooks/useConversation.tsx index c2997527b3d5e6..2ebb7ad1e2628c 100644 --- a/static/app/views/explore/conversations/hooks/useConversation.tsx +++ b/static/app/views/explore/conversations/hooks/useConversation.tsx @@ -202,7 +202,7 @@ export function useConversation( } : hasExplicitDatetime ? normalizeDateTimeParams(selection.datetime) - : {statsPeriod: '30d'}; + : {}; const project = selection.projects.length > 0 ? selection.projects : [ALL_ACCESS_PROJECTS]; diff --git a/tests/sentry/api/endpoints/test_organization_ai_conversation_details.py b/tests/sentry/api/endpoints/test_organization_ai_conversation_details.py index ddb54f8edf6e5c..62d220b84a4402 100644 --- a/tests/sentry/api/endpoints/test_organization_ai_conversation_details.py +++ b/tests/sentry/api/endpoints/test_organization_ai_conversation_details.py @@ -1,11 +1,14 @@ from datetime import timedelta from typing import Any +from unittest.mock import MagicMock, patch from uuid import uuid4 from django.urls import reverse +from urllib3.exceptions import ReadTimeoutError from sentry.testutils.helpers import parse_link_header from sentry.testutils.helpers.datetime import before_now +from sentry.utils.snuba_rpc import SnubaRPCTimeout from .test_organization_ai_conversations_base import BaseAIConversationsTestCase @@ -44,12 +47,10 @@ def test_no_project(self) -> None: assert response.status_code == 404 def test_conversation_not_found(self) -> None: - """Test endpoint returns empty list when no spans match conversation ID""" now = before_now(days=10).replace(microsecond=0) conversation_id = uuid4().hex other_conversation_id = uuid4().hex - # Store a span with a different conversation ID self.store_ai_span( conversation_id=other_conversation_id, timestamp=now, @@ -63,16 +64,14 @@ def test_conversation_not_found(self) -> None: } response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 0 def test_single_trace_conversation(self) -> None: - """Test returns all spans for a conversation in a single trace""" now = before_now(days=20).replace(microsecond=0) trace_id = uuid4().hex conversation_id = uuid4().hex - # Store multiple spans in the same conversation and trace self.store_ai_span( conversation_id=conversation_id, timestamp=now - timedelta(seconds=3), @@ -81,7 +80,6 @@ def test_single_trace_conversation(self) -> None: agent_name="Test Agent", trace_id=trace_id, ) - self.store_ai_span( conversation_id=conversation_id, timestamp=now - timedelta(seconds=2), @@ -90,7 +88,6 @@ def test_single_trace_conversation(self) -> None: tokens=100, trace_id=trace_id, ) - self.store_ai_span( conversation_id=conversation_id, timestamp=now - timedelta(seconds=1), @@ -106,26 +103,22 @@ def test_single_trace_conversation(self) -> None: } response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 3 - # Verify all spans belong to the same conversation for span in response.data: assert span["gen_ai.conversation.id"] == conversation_id - # Verify all spans have the same trace ID trace_ids = {span["trace"] for span in response.data} assert len(trace_ids) == 1 assert trace_id in trace_ids def test_multi_trace_conversation(self) -> None: - """Test returns all spans for a conversation across multiple traces""" now = before_now(days=10).replace(microsecond=0) conversation_id = uuid4().hex trace_id_1 = uuid4().hex trace_id_2 = uuid4().hex - # Spans in first trace self.store_ai_span( conversation_id=conversation_id, timestamp=now - timedelta(seconds=4), @@ -133,7 +126,6 @@ def test_multi_trace_conversation(self) -> None: operation_type="ai_client", trace_id=trace_id_1, ) - self.store_ai_span( conversation_id=conversation_id, timestamp=now - timedelta(seconds=3), @@ -141,8 +133,6 @@ def test_multi_trace_conversation(self) -> None: operation_type="tool", trace_id=trace_id_1, ) - - # Spans in second trace self.store_ai_span( conversation_id=conversation_id, timestamp=now - timedelta(seconds=2), @@ -150,7 +140,6 @@ def test_multi_trace_conversation(self) -> None: operation_type="ai_client", trace_id=trace_id_2, ) - self.store_ai_span( conversation_id=conversation_id, timestamp=now - timedelta(seconds=1), @@ -166,15 +155,13 @@ def test_multi_trace_conversation(self) -> None: } response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 4 - # Verify spans come from both traces trace_ids = {span["trace"] for span in response.data} assert trace_ids == {trace_id_1, trace_id_2} def test_returns_conversation_attributes(self) -> None: - """Test that the endpoint returns all AI conversation attributes""" now = before_now(days=5).replace(microsecond=0) trace_id = uuid4().hex conversation_id = uuid4().hex @@ -200,11 +187,10 @@ def test_returns_conversation_attributes(self) -> None: } response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 1 span = response.data[0] - # Verify core span fields assert "span_id" in span assert span["trace"] == trace_id assert "precise.start_ts" in span @@ -212,29 +198,23 @@ def test_returns_conversation_attributes(self) -> None: assert span["span.op"] == "gen_ai.chat" assert "span.duration" in span assert span["gen_ai.conversation.id"] == conversation_id - # Verify project fields assert "project" in span assert span["project.id"] == self.project.id - # Verify transaction fields assert "transaction" in span assert "is_transaction" in span - # Verify AI conversation attributes are included assert span["gen_ai.operation.type"] == "ai_client" assert span["gen_ai.request.messages"] is not None assert span["gen_ai.response.text"] == "Hi there!" assert span["gen_ai.usage.total_tokens"] == 150 assert span["gen_ai.cost.total_tokens"] == 0.0025 - # Verify user attributes are included assert span["user.id"] == "user-123" assert span["user.email"] == "test@example.com" def test_pagination(self) -> None: - """Test pagination works correctly""" now = before_now(days=5).replace(microsecond=0) conversation_id = uuid4().hex trace_id = uuid4().hex - # Create 5 spans for i in range(5): self.store_ai_span( conversation_id=conversation_id, @@ -250,9 +230,8 @@ def test_pagination(self) -> None: "end": (now + timedelta(hours=1)).isoformat(), } - # First page response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 2 links = parse_link_header(response.headers["Link"]) @@ -260,31 +239,27 @@ def test_pagination(self) -> None: assert next_link["results"] == "true" assert next_link["cursor"] - # Second page query["cursor"] = next_link["cursor"] response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 2 - # Third page (last) links = parse_link_header(response.headers["Link"]) next_link = next(link for link in links.values() if link["rel"] == "next") query["cursor"] = next_link["cursor"] response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 1 def test_span_ordering(self) -> None: - """Test spans are ordered by timestamp ascending""" now = before_now(days=5).replace(microsecond=0) conversation_id = uuid4().hex trace_id = uuid4().hex - # Store spans in reverse chronological order timestamps = [ - now - timedelta(seconds=1), # newest - now - timedelta(seconds=3), # middle - now - timedelta(seconds=5), # oldest + now - timedelta(seconds=1), + now - timedelta(seconds=3), + now - timedelta(seconds=5), ] for ts in timestamps: @@ -302,36 +277,30 @@ def test_span_ordering(self) -> None: } response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 3 - # Verify spans are ordered by timestamp ascending (oldest first) span_timestamps = [span["precise.start_ts"] for span in response.data] assert span_timestamps == sorted(span_timestamps) def test_only_returns_matching_conversation(self) -> None: - """Test that only spans from the requested conversation are returned""" now = before_now(days=5).replace(microsecond=0) conversation_id_1 = uuid4().hex conversation_id_2 = uuid4().hex trace_id = uuid4().hex - # Spans in conversation 1 self.store_ai_span( conversation_id=conversation_id_1, timestamp=now - timedelta(seconds=2), op="gen_ai.chat", trace_id=trace_id, ) - self.store_ai_span( conversation_id=conversation_id_1, timestamp=now - timedelta(seconds=1), op="gen_ai.chat", trace_id=trace_id, ) - - # Spans in conversation 2 self.store_ai_span( conversation_id=conversation_id_2, timestamp=now, @@ -345,22 +314,18 @@ def test_only_returns_matching_conversation(self) -> None: "end": (now + timedelta(hours=1)).isoformat(), } - # Request conversation 1 response = self.do_request(conversation_id_1, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 2 - for span in response.data: assert span["gen_ai.conversation.id"] == conversation_id_1 - # Request conversation 2 response = self.do_request(conversation_id_2, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]["gen_ai.conversation.id"] == conversation_id_2 def test_returns_tool_attributes(self) -> None: - """Test that tool spans include gen_ai.tool.name attribute""" now = before_now(days=5).replace(microsecond=0) trace_id = uuid4().hex conversation_id = uuid4().hex @@ -381,7 +346,7 @@ def test_returns_tool_attributes(self) -> None: } response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 1 span = response.data[0] @@ -389,43 +354,73 @@ def test_returns_tool_attributes(self) -> None: assert span["gen_ai.operation.type"] == "tool" assert span["gen_ai.tool.name"] == "search_database" - def test_stats_period_overrides_to_30d(self) -> None: - """Test that statsPeriod is overridden to 30d so all recent data is returned""" - timestamp = before_now(days=15).replace(microsecond=0) + def test_stats_period_is_tried_first_then_widened(self) -> None: + timestamp_15d = before_now(days=15).replace(microsecond=0) trace_id = uuid4().hex conversation_id = uuid4().hex self.store_ai_span( conversation_id=conversation_id, - timestamp=timestamp, + timestamp=timestamp_15d, op="gen_ai.chat", trace_id=trace_id, ) - # statsPeriod=1h would normally restrict to last hour and exclude our 15-day-old span, - # but endpoint overrides to 30d query = { "project": [self.project.id], "statsPeriod": "1h", } response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]["gen_ai.conversation.id"] == conversation_id - def test_tokens_on_multiple_span_types(self) -> None: - """Test that raw spans are returned with their individual token/cost values. + def test_stats_period_recent_conversation_returned_without_widening(self) -> None: + timestamp_1h = before_now(minutes=30).replace(microsecond=0) + trace_id = uuid4().hex + conversation_id = uuid4().hex + + self.store_ai_span( + conversation_id=conversation_id, + timestamp=timestamp_1h, + op="gen_ai.chat", + trace_id=trace_id, + ) + + query = { + "project": [self.project.id], + "statsPeriod": "1h", + } + + response = self.do_request(conversation_id, query) + assert response.status_code == 200 + assert len(response.data) == 1 + + def test_no_time_params_falls_back_to_30d(self) -> None: + timestamp = before_now(days=15).replace(microsecond=0) + trace_id = uuid4().hex + conversation_id = uuid4().hex + + self.store_ai_span( + conversation_id=conversation_id, + timestamp=timestamp, + op="gen_ai.chat", + trace_id=trace_id, + ) - This endpoint returns raw span data without aggregation. Consumers must - filter by gen_ai.operation.type:ai_client when summing tokens/costs - to avoid double counting from agent spans that may also have token data. - """ + query = {"project": [self.project.id]} + + response = self.do_request(conversation_id, query) + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]["gen_ai.conversation.id"] == conversation_id + + def test_tokens_on_multiple_span_types(self) -> None: now = before_now(days=5).replace(microsecond=0) trace_id = uuid4().hex conversation_id = uuid4().hex - # Agent span with tokens/cost self.store_ai_span( conversation_id=conversation_id, timestamp=now - timedelta(seconds=2), @@ -437,8 +432,6 @@ def test_tokens_on_multiple_span_types(self) -> None: tokens=500, cost=0.05, ) - - # ai_client span with tokens/cost self.store_ai_span( conversation_id=conversation_id, timestamp=now - timedelta(seconds=1), @@ -456,20 +449,30 @@ def test_tokens_on_multiple_span_types(self) -> None: } response = self.do_request(conversation_id, query) - assert response.status_code == 200, response.data + assert response.status_code == 200 assert len(response.data) == 2 - # Sort by timestamp to ensure consistent order (oldest first) spans = sorted(response.data, key=lambda s: s["precise.start_ts"]) - # First span is the agent span with its own token values agent_span = spans[0] assert agent_span["gen_ai.operation.type"] == "invoke_agent" assert agent_span["gen_ai.usage.total_tokens"] == 500 assert agent_span["gen_ai.cost.total_tokens"] == 0.05 - # Second span is the ai_client span with its own token values ai_client_span = spans[1] assert ai_client_span["gen_ai.operation.type"] == "ai_client" assert ai_client_span["gen_ai.usage.total_tokens"] == 100 assert ai_client_span["gen_ai.cost.total_tokens"] == 0.01 + + def test_timeout_returns_504(self) -> None: + conversation_id = uuid4().hex + + rpc_timeout = SnubaRPCTimeout(ReadTimeoutError(MagicMock(), "/", "timed out")) + + with patch( + "sentry.snuba.spans_rpc.Spans.run_table_query", + side_effect=rpc_timeout, + ): + response = self.do_request(conversation_id, {"project": [self.project.id]}) + + assert response.status_code == 504 From 5d2bc9c7205ec7438f6c500a183a6237fa3472c1 Mon Sep 17 00:00:00 2001 From: Giovanni M Guidini <99758426+giovanni-guidini@users.noreply.github.com> Date: Thu, 28 May 2026 10:41:19 +0200 Subject: [PATCH 5/9] chore(codeowners): reuse get_projects in associations endpoint (#116359) Replace manual Project queryset with self.get_projects() to reuse the existing helper already used across OrganizationEndpoint subclasses. --- .../endpoints/organization_codeowners_associations.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/sentry/issues/endpoints/organization_codeowners_associations.py b/src/sentry/issues/endpoints/organization_codeowners_associations.py index b2d39116240bfd..aa95f210131d99 100644 --- a/src/sentry/issues/endpoints/organization_codeowners_associations.py +++ b/src/sentry/issues/endpoints/organization_codeowners_associations.py @@ -10,10 +10,8 @@ OrganizationIntegrationsLoosePermission, ) from sentry.api.validators.project_codeowners import build_codeowners_associations -from sentry.constants import ObjectStatus from sentry.integrations.services.integration import integration_service from sentry.models.organization import Organization -from sentry.models.project import Project from sentry.models.projectcodeowners import ProjectCodeOwners @@ -30,10 +28,7 @@ def get(self, request: Request, organization: Organization) -> Response: Returns all ProjectCodeOwners associations for an organization as a dict with projects as keys e.g. {"projectSlug": {associations: {...}, errors: {...}}, ...] """ - projects = Project.objects.filter( - organization=organization, - status=ObjectStatus.ACTIVE, - ) + projects = self.get_projects(request, organization) project_code_owners = ProjectCodeOwners.objects.filter(project__in=projects) provider = request.GET.get("provider") if provider: From ae1b06f7a666b77cfc77d1e34a5891148b4cc2a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Thu, 28 May 2026 10:45:34 +0200 Subject: [PATCH 6/9] ref(settings): remove service hooks forms and routes (#116296) these routes were not accessible in the UI and not linked to from anywhere fixes DE-1001 --------- Co-authored-by: Claude Opus 4.6 --- static/app/router/routes.tsx | 23 -- static/app/types/integrations.tsx | 12 - .../project/projectCreateServiceHook.tsx | 27 --- .../project/projectServiceHookDetails.tsx | 225 ------------------ .../settings/project/projectServiceHooks.tsx | 192 --------------- .../project/serviceHookSettingsForm.tsx | 82 ------- tests/acceptance/test_project_servicehooks.py | 48 ---- 7 files changed, 609 deletions(-) delete mode 100644 static/app/views/settings/project/projectCreateServiceHook.tsx delete mode 100644 static/app/views/settings/project/projectServiceHookDetails.tsx delete mode 100644 static/app/views/settings/project/projectServiceHooks.tsx delete mode 100644 static/app/views/settings/project/serviceHookSettingsForm.tsx delete mode 100644 tests/acceptance/test_project_servicehooks.py diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index 763ed3b05da08e..283c1d98a01a0d 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -760,25 +760,6 @@ function buildRoutes(): RouteObject[] { path: 'issue-tracking/', redirectTo: '/settings/:orgId/:projectId/plugins/', }, - { - path: 'hooks/', - name: t('Service Hooks'), - component: make(() => import('sentry/views/settings/project/projectServiceHooks')), - }, - { - path: 'hooks/new/', - name: t('Create Service Hook'), - component: make( - () => import('sentry/views/settings/project/projectCreateServiceHook') - ), - }, - { - path: 'hooks/:hookId/', - name: t('Service Hook Details'), - component: make( - () => import('sentry/views/settings/project/projectServiceHookDetails') - ), - }, ]; const projectSettingsRoutes: SentryRouteObject = { path: 'projects/:projectId/', @@ -2885,10 +2866,6 @@ function buildRoutes(): RouteObject[] { path: 'filters/', redirectTo: '/settings/:orgId/projects/:projectId/filters/', }, - { - path: 'hooks/', - redirectTo: '/settings/:orgId/projects/:projectId/hooks/', - }, { path: 'keys/', redirectTo: '/settings/:orgId/projects/:projectId/keys/', diff --git a/static/app/types/integrations.tsx b/static/app/types/integrations.tsx index 375a41f1b67014..1d4ef5f7d62f88 100644 --- a/static/app/types/integrations.tsx +++ b/static/app/types/integrations.tsx @@ -553,20 +553,8 @@ export type AppOrProviderOrPlugin = | PluginWithProjectList | DocIntegration; -/** - * Webhooks and servicehooks - */ export type WebhookEvent = 'issue' | 'error' | 'comment' | 'seer' | 'preprod_artifact'; -export type ServiceHook = { - dateCreated: string; - events: string[]; - id: string; - secret: string; - status: string; - url: string; -}; - /** * Codeowners and repository path mappings. */ diff --git a/static/app/views/settings/project/projectCreateServiceHook.tsx b/static/app/views/settings/project/projectCreateServiceHook.tsx deleted file mode 100644 index 03c2b072a8b71d..00000000000000 --- a/static/app/views/settings/project/projectCreateServiceHook.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import {Fragment} from 'react'; - -import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; -import {t} from 'sentry/locale'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; -import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout'; -import {ServiceHookSettingsForm} from 'sentry/views/settings/project/serviceHookSettingsForm'; - -export default function ProjectCreateServiceHook() { - const organization = useOrganization(); - const {project} = useProjectSettingsOutlet(); - const title = t('Create Service Hook'); - - return ( - - - - - - - ); -} diff --git a/static/app/views/settings/project/projectServiceHookDetails.tsx b/static/app/views/settings/project/projectServiceHookDetails.tsx deleted file mode 100644 index e50af26d21baa1..00000000000000 --- a/static/app/views/settings/project/projectServiceHookDetails.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import {Fragment, useState} from 'react'; -import {useMutation} from '@tanstack/react-query'; - -import {Button} from '@sentry/scraps/button'; - -import { - addErrorMessage, - addLoadingMessage, - clearIndicators, -} from 'sentry/actionCreators/indicator'; -import {MiniBarChart} from 'sentry/components/charts/miniBarChart'; -import {EmptyMessage} from 'sentry/components/emptyMessage'; -import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {FieldGroup} from 'sentry/components/forms/fieldGroup'; -import {LoadingError} from 'sentry/components/loadingError'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {Panel} from 'sentry/components/panels/panel'; -import {PanelAlert} from 'sentry/components/panels/panelAlert'; -import {PanelBody} from 'sentry/components/panels/panelBody'; -import {PanelHeader} from 'sentry/components/panels/panelHeader'; -import {TextCopyInput} from 'sentry/components/textCopyInput'; -import {t} from 'sentry/locale'; -import type {ServiceHook} from 'sentry/types/integrations'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useApi} from 'sentry/utils/useApi'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useParams} from 'sentry/utils/useParams'; -import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; -import {ServiceHookSettingsForm} from 'sentry/views/settings/project/serviceHookSettingsForm'; - -function HookStats() { - const organization = useOrganization(); - const {hookId, projectId} = useParams<{hookId: string; projectId: string}>(); - - const [until] = useState(() => Math.floor(Date.now() / 1000)); - const since = until - 3600 * 24 * 30; - - const { - data: stats, - isPending, - isError, - refetch, - } = useApiQuery>( - [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/hooks/$hookId/stats/', { - path: { - organizationIdOrSlug: organization.slug, - projectIdOrSlug: projectId, - hookId, - }, - }), - { - query: { - since, - until, - resolution: '1d', - }, - }, - ], - {staleTime: 0} - ); - - if (isPending) { - return ; - } - - if (isError) { - return ; - } - - if (stats === null) { - return null; - } - let emptyStats = true; - - const series = { - seriesName: t('Events'), - data: stats.map(p => { - if (p.total) { - emptyStats = false; - } - return { - name: p.ts * 1000, - value: p.total, - }; - }), - }; - - return ( - - {t('Events in the last 30 days (by day)')} - - {emptyStats ? ( - - {t('Total webhooks fired for this configuration.')} - - ) : ( - - )} - - - ); -} - -export default function ProjectServiceHookDetails() { - const organization = useOrganization(); - const {hookId, projectId} = useParams<{hookId: string; projectId: string}>(); - const api = useApi({persistInFlight: true}); - const navigate = useNavigate(); - - const { - data: hook, - isPending, - isError, - refetch, - } = useApiQuery( - [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/hooks/$hookId/', { - path: { - organizationIdOrSlug: organization.slug, - projectIdOrSlug: projectId, - hookId, - }, - }), - ], - {staleTime: 0} - ); - - const deleteMutation = useMutation({ - mutationFn: () => { - return api.requestPromise( - `/projects/${organization.slug}/${projectId}/hooks/${hookId}/`, - { - method: 'DELETE', - } - ); - }, - onMutate: () => { - addLoadingMessage(t('Saving changes\u2026')); - }, - onSuccess: () => { - clearIndicators(); - navigate( - normalizeUrl(`/settings/${organization.slug}/projects/${projectId}/hooks/`) - ); - }, - onError: () => { - addErrorMessage(t('Unable to remove application. Please try again.')); - }, - }); - - if (isPending) { - return ; - } - - if (isError) { - return ; - } - - if (!hook) { - return null; - } - - return ( - - - - - - - - - - {t('Event Validation')} - - - Sentry will send the X-ServiceHook-Signature header built using{' '} - HMAC(SHA256, [secret], [payload]). You should always verify this - signature before trusting the information provided in the webhook. - - - {hook.secret} - - - - - {t('Delete Hook')} - - -
- -
-
-
-
-
- ); -} diff --git a/static/app/views/settings/project/projectServiceHooks.tsx b/static/app/views/settings/project/projectServiceHooks.tsx deleted file mode 100644 index c632999ba8b843..00000000000000 --- a/static/app/views/settings/project/projectServiceHooks.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import {Fragment} from 'react'; -import {useMutation, useQueryClient} from '@tanstack/react-query'; - -import {LinkButton} from '@sentry/scraps/button'; -import {Link} from '@sentry/scraps/link'; -import {Switch} from '@sentry/scraps/switch'; - -import { - addErrorMessage, - addLoadingMessage, - clearIndicators, -} from 'sentry/actionCreators/indicator'; -import {EmptyMessage} from 'sentry/components/emptyMessage'; -import {FieldGroup} from 'sentry/components/forms/fieldGroup'; -import {LoadingError} from 'sentry/components/loadingError'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {Panel} from 'sentry/components/panels/panel'; -import {PanelAlert} from 'sentry/components/panels/panelAlert'; -import {PanelBody} from 'sentry/components/panels/panelBody'; -import {PanelHeader} from 'sentry/components/panels/panelHeader'; -import {Truncate} from 'sentry/components/truncate'; -import {IconAdd} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import type {ServiceHook} from 'sentry/types/integrations'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {setApiQueryData, useApiQuery} from 'sentry/utils/queryClient'; -import {useApi} from 'sentry/utils/useApi'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useParams} from 'sentry/utils/useParams'; -import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; - -type RowProps = { - hook: ServiceHook; - onToggleActive: () => void; - orgId: string; - projectId: string; -}; - -function ServiceHookRow({orgId, projectId, hook, onToggleActive}: RowProps) { - return ( - - - - } - help={ - - {hook.events && hook.events.length !== 0 ? ( - hook.events.join(', ') - ) : ( - {t('no events configured')} - )} - - } - > - - - ); -} - -export default function ProjectServiceHooks() { - const organization = useOrganization(); - const {projectId} = useParams<{projectId: string}>(); - const api = useApi({persistInFlight: true}); - const queryClient = useQueryClient(); - - const { - data: hookList, - isPending, - isError, - refetch, - } = useApiQuery( - [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/hooks/', { - path: {organizationIdOrSlug: organization.slug, projectIdOrSlug: projectId}, - }), - ], - { - staleTime: 0, - } - ); - - const onToggleActiveMutation = useMutation({ - mutationFn: ({hook}: {hook: ServiceHook}) => { - return api.requestPromise( - `/projects/${organization.slug}/${projectId}/hooks/${hook.id}/`, - { - method: 'PUT', - data: { - isActive: hook.status !== 'active', - }, - } - ); - }, - onMutate: () => { - addLoadingMessage(t('Saving changes\u2026')); - }, - onSuccess: data => { - clearIndicators(); - setApiQueryData( - queryClient, - [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/hooks/', { - path: {organizationIdOrSlug: organization.slug, projectIdOrSlug: projectId}, - }), - ], - oldHookList => { - return oldHookList?.map(h => { - if (h.id === data.id) { - return { - ...h, - ...data, - }; - } - return h; - }); - } - ); - }, - onError: () => { - addErrorMessage(t('Unable to remove application. Please try again.')); - }, - }); - - if (isPending) { - return ; - } - - if (isError) { - return ; - } - - const renderEmpty = () => { - return ( - - {t('There are no service hooks associated with this project.')} - - ); - }; - - const renderResults = () => { - return ( - - {t('Service Hook')} - - - {t( - 'Service Hooks are an early adopter preview feature and will change in the future.' - )} - - {hookList?.map(hook => ( - onToggleActiveMutation.mutate({hook})} - /> - ))} - - - ); - }; - - const body = hookList && hookList.length > 0 ? renderResults() : renderEmpty(); - - return ( - - } - > - {t('Create New Hook')} - - ) : null - } - /> - {body} - - ); -} diff --git a/static/app/views/settings/project/serviceHookSettingsForm.tsx b/static/app/views/settings/project/serviceHookSettingsForm.tsx deleted file mode 100644 index c6ffb10c49586c..00000000000000 --- a/static/app/views/settings/project/serviceHookSettingsForm.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import {ApiForm} from 'sentry/components/forms/apiForm'; -import {MultipleCheckbox} from 'sentry/components/forms/controls/multipleCheckbox'; -import {BooleanField} from 'sentry/components/forms/fields/booleanField'; -import {TextField} from 'sentry/components/forms/fields/textField'; -import {FormField} from 'sentry/components/forms/formField'; -import {Panel} from 'sentry/components/panels/panel'; -import {PanelBody} from 'sentry/components/panels/panelBody'; -import {PanelHeader} from 'sentry/components/panels/panelHeader'; -import {t} from 'sentry/locale'; -import type {ServiceHook} from 'sentry/types/integrations'; -import type {Organization} from 'sentry/types/organization'; -import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useNavigate} from 'sentry/utils/useNavigate'; - -const EVENT_CHOICES = ['event.alert', 'event.created']; - -type Props = { - initialData: Partial & {isActive: boolean}; - organization: Organization; - projectId: string; - hookId?: string; -}; - -export function ServiceHookSettingsForm({ - initialData, - organization, - projectId, - hookId, -}: Props) { - const navigate = useNavigate(); - - const onSubmitSuccess = () => { - navigate(normalizeUrl(`/settings/${organization.slug}/projects/${projectId}/hooks/`)); - }; - - const endpoint = hookId - ? `/projects/${organization.slug}/${projectId}/hooks/${hookId}/` - : `/projects/${organization.slug}/${projectId}/hooks/`; - - return ( - - - {t('Hook Configuration')} - - - - - {({name, value, onChange}: any) => ( - - {EVENT_CHOICES.map(event => ( - - {event} - - ))} - - )} - - - - - ); -} diff --git a/tests/acceptance/test_project_servicehooks.py b/tests/acceptance/test_project_servicehooks.py deleted file mode 100644 index 24dc8b309db960..00000000000000 --- a/tests/acceptance/test_project_servicehooks.py +++ /dev/null @@ -1,48 +0,0 @@ -from sentry.sentry_apps.models.servicehook import ServiceHook -from sentry.testutils.cases import AcceptanceTestCase -from sentry.testutils.silo import no_silo_test - - -@no_silo_test -class ProjectServiceHooksTest(AcceptanceTestCase): - def setUp(self) -> None: - super().setUp() - self.user = self.create_user("foo@example.com") - self.org = self.create_organization(name="Rowdy Tiger", owner=None) - self.team = self.create_team(organization=self.org, name="Mariachi Band") - self.project = self.create_project(organization=self.org, teams=[self.team], name="Bengal") - self.create_member(user=self.user, organization=self.org, role="owner", teams=[self.team]) - - self.login_as(self.user) - self.list_hooks_path = f"/settings/{self.org.slug}/projects/{self.project.slug}/hooks/" - self.new_hook_path = f"/settings/{self.org.slug}/projects/{self.project.slug}/hooks/new/" - - def test_simple(self) -> None: - with self.feature("projects:servicehooks"): - self.browser.get(self.list_hooks_path) - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - # click "New" - self.browser.click('[data-test-id="new-service-hook"]') - - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - assert self.browser.current_url == f"{self.browser.live_server_url}{self.new_hook_path}" - self.browser.element('input[name="url"]').send_keys("https://example.com/hook") - # click "Save Changes" - self.browser.click('form [data-test-id="form-submit"]') - - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - assert ( - self.browser.current_url == f"{self.browser.live_server_url}{self.list_hooks_path}" - ) - - hook = ServiceHook.objects.get(project_id=self.project.id) - assert hook.url == "https://example.com/hook" - assert not hook.events - - # hopefully click the first service hook - self.browser.click('[data-test-id="project-service-hook"]') - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - assert self.browser.current_url == "{}{}".format( - self.browser.live_server_url, - f"/settings/{self.org.slug}/projects/{self.project.slug}/hooks/{hook.guid}/", - ) From b10549cb104656edde502c0825c297f97e75a241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Thu, 28 May 2026 11:03:54 +0200 Subject: [PATCH 7/9] fix(theme): Update config.theme when mutating user theme option (#116336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command palette's theme switching uses `useMutateUserOptions`, which optimistically updates `config.user.options.theme` but never updates the derived `config.theme` field that `ThemeAndStyleProvider` reads. On the first dark→light switch after page load, the theme object stays stale until `useColorscheme`'s `useEffect` catches up a frame later, causing some tokens to not update. Set `config.theme` directly in `onMutate` for non-system theme changes, matching the pattern already used by `updateUser` in `actionCreators/account.tsx`. fixes DE-1266 Co-authored-by: Claude Opus 4 --- static/app/utils/useMutateUserOptions.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/static/app/utils/useMutateUserOptions.tsx b/static/app/utils/useMutateUserOptions.tsx index cd66276b8a3567..4b0dd276dcdf46 100644 --- a/static/app/utils/useMutateUserOptions.tsx +++ b/static/app/utils/useMutateUserOptions.tsx @@ -25,6 +25,13 @@ export function useMutateUserOptions({onSuccess, onError}: Props = {}) { }); }, onMutate: (options: UpdateUserOptionsVariables) => { + if ( + options.theme && + user.options.theme !== options.theme && + options.theme !== 'system' + ) { + ConfigStore.set('theme', options.theme); + } ConfigStore.set('user', merge({}, user, {options})); return onSuccess?.(); }, From e9c319283b77b9b894a20f7804119344beaeb125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Thu, 28 May 2026 11:04:22 +0200 Subject: [PATCH 8/9] feat(eslint): add prefer-info-text lint rule and migrate existing usages (#116211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `@sentry/scraps/prefer-info-text` ESLint rule that detects `` wrapping text-like elements with `showUnderline` and suggests using `` instead. The rule ships with an autofix that rewrites simple cases automatically. Alongside the rule, this PR migrates existing violations across the codebase to use `` and enhances the `InfoText` component itself. **`InfoText` component enhancements** `InfoText` now accepts a `position` prop (forwarded to the underlying `Tooltip`) and a `variant` prop that controls the underline color to match the text style (e.g. muted text gets a muted underline). `variant="inherit"` falls back to the default tooltip underline color. **ESLint rule: `prefer-info-text`** Detects `` wrapping a single text-like child (intrinsic elements like ``, ``, ``, or i18n calls) and reports with an autofix that rewrites to ``. The rule is conservative — it skips Tooltips with unsupported props, multiple children, or non-text content. **Codebase migrations** Migrates ~15 files from the manual `` pattern to ``, including spend allocations, customer admin views, replay columns, and various settings pages. Also fixes a tooltip on the detectors assignee view that could never actually display, and updates test files to use `} isHoverable> - Hi - + Click me}>Hi )) ); diff --git a/static/app/components/core/tooltip/tooltip.spec.tsx b/static/app/components/core/tooltip/tooltip.spec.tsx index be762f0d3b0480..206738d8e21c8a 100644 --- a/static/app/components/core/tooltip/tooltip.spec.tsx +++ b/static/app/components/core/tooltip/tooltip.spec.tsx @@ -28,7 +28,7 @@ describe('Tooltip', () => { it('renders', async () => { render( - My Button + ); @@ -47,14 +47,14 @@ describe('Tooltip', () => { it('updates title', async () => { const {rerender} = render( - My Button + ); // Change title rerender( - My Button + ); @@ -70,7 +70,7 @@ describe('Tooltip', () => { it('disables and does not render', async () => { render( - My Button + ); @@ -84,7 +84,7 @@ describe('Tooltip', () => { it('resets visibility when becoming disabled', async () => { const {rerender} = render( - My Button + ); @@ -93,7 +93,7 @@ describe('Tooltip', () => { rerender( - My Button + ); expect(screen.queryByText('test')).not.toBeInTheDocument(); @@ -101,7 +101,7 @@ describe('Tooltip', () => { // Becomes enabled again rerender( - My Button + ); expect(screen.queryByText('test')).not.toBeInTheDocument(); @@ -110,7 +110,7 @@ describe('Tooltip', () => { it('does not render an empty tooltip', async () => { render( - My Button + ); await userEvent.hover(screen.getByText('My Button')); diff --git a/static/app/components/feedback/feedbackItem/messageTitle.tsx b/static/app/components/feedback/feedbackItem/messageTitle.tsx index f8ecbc8cd451c7..7ce8583c808db0 100644 --- a/static/app/components/feedback/feedbackItem/messageTitle.tsx +++ b/static/app/components/feedback/feedbackItem/messageTitle.tsx @@ -1,9 +1,9 @@ import styled from '@emotion/styled'; import {Tag} from '@sentry/scraps/badge'; +import {InfoText} from '@sentry/scraps/info'; import {Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {FeedbackItemUsername} from 'sentry/components/feedback/feedbackItem/feedbackItemUsername'; import {FeedbackTimestampsTooltip} from 'sentry/components/feedback/feedbackItem/feedbackTimestampsTooltip'; @@ -26,8 +26,8 @@ export function MessageTitle({feedbackItem, eventData}: Props) { {isSpam ? ( - {t('spam')} - + ) : null} { position="top" body={ - Inner trigger + } header="Hovercard Header" diff --git a/static/app/components/replays/table/replayTableColumns.tsx b/static/app/components/replays/table/replayTableColumns.tsx index e89403bcfd757f..f9d01d10c918b5 100644 --- a/static/app/components/replays/table/replayTableColumns.tsx +++ b/static/app/components/replays/table/replayTableColumns.tsx @@ -8,6 +8,7 @@ import {PlatformIcon} from 'platformicons'; import {LinkButton} from '@sentry/scraps/button'; import {Checkbox} from '@sentry/scraps/checkbox'; +import {InfoText} from '@sentry/scraps/info'; import {Flex} from '@sentry/scraps/layout'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -97,13 +98,14 @@ export interface ReplayTableColumn { export const ReplayActivityColumn: ReplayTableColumn = { Header: () => ( - {t('Activity')} - + ), interactive: false, sortKey: 'activity', @@ -179,8 +181,8 @@ export const ReplayBrowserColumn: ReplayTableColumn = { export const ReplayCountDeadClicksColumn: ReplayTableColumn = { Header: () => ( - = [minSDK]. [link:Learn more.]', { @@ -190,7 +192,7 @@ export const ReplayCountDeadClicksColumn: ReplayTableColumn = { )} > {t('Dead clicks')} - + ), interactive: false, sortKey: 'count_dead_clicks', @@ -223,8 +225,8 @@ export const ReplayCountDeadClicksColumn: ReplayTableColumn = { export const ReplayCountErrorsColumn: ReplayTableColumn = { Header: () => ( - {t('Errors')} - + ), interactive: false, sortKey: 'count_errors', @@ -274,8 +276,8 @@ export const ReplayCountErrorsColumn: ReplayTableColumn = { export const ReplayCountRageClicksColumn: ReplayTableColumn = { Header: () => ( - = [minSDK]. [link:Learn more.]', { @@ -285,7 +287,7 @@ export const ReplayCountRageClicksColumn: ReplayTableColumn = { )} > {t('Rage clicks')} - + ), interactive: false, sortKey: 'count_rage_clicks', @@ -497,9 +499,9 @@ export const ReplaySelectColumn: ReplayTableColumn = { export const ReplaySessionColumn: ReplayTableColumn = { Header: () => ( - + {t('Replay')} - + ), interactive: true, sortKey: 'started_at', diff --git a/static/app/utils/discover/fieldRenderers.tsx b/static/app/utils/discover/fieldRenderers.tsx index 4ba014cf7e6d37..e95d016ba0a06f 100644 --- a/static/app/utils/discover/fieldRenderers.tsx +++ b/static/app/utils/discover/fieldRenderers.tsx @@ -6,6 +6,7 @@ import partial from 'lodash/partial'; import {Tag} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; +import {InfoText} from '@sentry/scraps/info'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -332,9 +333,9 @@ export const FIELD_FORMATTERS: FieldFormatters = { if (data[field] > 0 && data[field] < NUMBER_MIN_VALUE) { return ( - + {`<${NUMBER_MIN_VALUE}`} - + ); } diff --git a/static/app/views/detectors/components/details/common/assignee.tsx b/static/app/views/detectors/components/details/common/assignee.tsx index 813694a5015625..a3be0a734fd825 100644 --- a/static/app/views/detectors/components/details/common/assignee.tsx +++ b/static/app/views/detectors/components/details/common/assignee.tsx @@ -1,6 +1,5 @@ import {Flex} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {Placeholder} from 'sentry/components/placeholder'; import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; @@ -48,11 +47,7 @@ function AssignToUser({userId}: {userId: string}) { } const title = user?.name ?? user?.email ?? t('Unknown user'); - return ( - - {t('Assign to %s', title)} - - ); + return t('Assign to %s', title); } function DetectorOwner({owner}: {owner: Detector['owner']}) { diff --git a/static/app/views/explore/conversations/components/conversationsTable.tsx b/static/app/views/explore/conversations/components/conversationsTable.tsx index 0467f60f471ee5..25fb64065a524c 100644 --- a/static/app/views/explore/conversations/components/conversationsTable.tsx +++ b/static/app/views/explore/conversations/components/conversationsTable.tsx @@ -1,6 +1,7 @@ import {Fragment, memo, useCallback, type ComponentPropsWithRef} from 'react'; import styled from '@emotion/styled'; +import {InfoText} from '@sentry/scraps/info'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {Pagination} from '@sentry/scraps/pagination'; @@ -269,9 +270,9 @@ const BodyCell = memo(function BodyCell({ case 'user': { if (!dataRow.user) { return ( - } isHoverable> - - + }> + — + ); } const displayName = getUserDisplayName(dataRow.user); diff --git a/static/app/views/explore/replays/detail/layout/focusTabs.tsx b/static/app/views/explore/replays/detail/layout/focusTabs.tsx index 2db2f0e61b51e7..2ecdabe47a29a3 100644 --- a/static/app/views/explore/replays/detail/layout/focusTabs.tsx +++ b/static/app/views/explore/replays/detail/layout/focusTabs.tsx @@ -1,10 +1,10 @@ import {useEffect, type ReactNode} from 'react'; import {FeatureBadge} from '@sentry/scraps/badge'; +import {InfoText} from '@sentry/scraps/info'; import {Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import {TabList, Tabs} from '@sentry/scraps/tabs'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {useOrganizationSeerSetup} from 'sentry/components/events/autofix/useOrganizationSeerSetup'; import {t, tct} from 'sentry/locale'; @@ -40,8 +40,8 @@ function getReplayTabs({ [TabKey.AI]: hasAiSummary && (!isVideoReplay || hasMobileSummary) ? ( - {t('AI Summary')} - + ) : null, diff --git a/static/app/views/insights/uptime/components/percent.tsx b/static/app/views/insights/uptime/components/percent.tsx index ff1a80b4962e0d..8895764692b46d 100644 --- a/static/app/views/insights/uptime/components/percent.tsx +++ b/static/app/views/insights/uptime/components/percent.tsx @@ -1,7 +1,7 @@ +import {InfoText} from '@sentry/scraps/info'; import {Flex, Grid} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import type {TextProps} from '@sentry/scraps/text'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {t} from 'sentry/locale'; import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; @@ -51,14 +51,13 @@ export function UptimePercent({summary, note, size}: UptimePercentProps) { ); return ( - - 99 ? 'success' : percent > 95 ? 'warning' : 'danger'} - > - {`${percent}%`} - - + 99 ? 'success' : percent > 95 ? 'warning' : 'danger'} + title={tooltip} + > + {`${percent}%`} + ); } diff --git a/static/app/views/settings/dynamicSampling/projectionPeriodControl.tsx b/static/app/views/settings/dynamicSampling/projectionPeriodControl.tsx index 27cc60b0e192c8..7a4d5940003666 100644 --- a/static/app/views/settings/dynamicSampling/projectionPeriodControl.tsx +++ b/static/app/views/settings/dynamicSampling/projectionPeriodControl.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; +import {InfoText} from '@sentry/scraps/info'; import {Flex} from '@sentry/scraps/layout'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {RadioGroup} from 'sentry/components/forms/controls/radioGroup'; import {t} from 'sentry/locale'; @@ -15,12 +15,12 @@ interface Props { export function ProjectionPeriodControl({period, onChange}: Props) { return ( - {t('Project the next')} - + - {t('Advanced Mode')} - + - + ) ) : ( diff --git a/static/app/views/settings/organizationTeams/otherTeamsTable.tsx b/static/app/views/settings/organizationTeams/otherTeamsTable.tsx index 36d00895beb755..ed7fade16fd9e2 100644 --- a/static/app/views/settings/organizationTeams/otherTeamsTable.tsx +++ b/static/app/views/settings/organizationTeams/otherTeamsTable.tsx @@ -3,11 +3,10 @@ import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; +import {InfoText} from '@sentry/scraps/info'; import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; import {Flex} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; -import {Text} from '@sentry/scraps/text'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {openCreateTeamModal} from 'sentry/actionCreators/modal'; import {IdBadge} from 'sentry/components/idBadge'; @@ -193,16 +192,15 @@ function TeamAction({ if (isPending) { return ( - - - {t('Request Pending')} - - + {t('Request Pending')} + ); } diff --git a/static/eslint/eslintPluginScraps/src/rules/index.ts b/static/eslint/eslintPluginScraps/src/rules/index.ts index 0cef2b34f85bb4..dda03efafd03a7 100644 --- a/static/eslint/eslintPluginScraps/src/rules/index.ts +++ b/static/eslint/eslintPluginScraps/src/rules/index.ts @@ -1,11 +1,13 @@ import {noCoreImport} from './no-core-import'; import {noTokenImport} from './no-token-import'; +import {preferInfoText} from './prefer-info-text'; import {restrictJsxSlotChildren} from './restrict-jsx-slot-children'; import {useSemanticToken} from './use-semantic-token'; export const rules = { 'no-core-import': noCoreImport, 'no-token-import': noTokenImport, + 'prefer-info-text': preferInfoText, 'restrict-jsx-slot-children': restrictJsxSlotChildren, 'use-semantic-token': useSemanticToken, }; diff --git a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts new file mode 100644 index 00000000000000..22252b90b9ee68 --- /dev/null +++ b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.spec.ts @@ -0,0 +1,393 @@ +import {RuleTester} from '@typescript-eslint/rule-tester'; + +import {preferInfoText} from './prefer-info-text'; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: {jsx: true}, + }, + }, +}); + +function errorWithSuggestion(output: string) { + return { + messageId: 'preferInfoText', + suggestions: [ + { + messageId: 'replaceWithInfoText', + output, + }, + ], + } as const; +} + +ruleTester.run('prefer-info-text', preferInfoText, { + valid: [ + { + name: 'Tooltip wrapping a non-text component', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = ; + `, + }, + { + name: 'Tooltip wrapping mixed text and non-text children', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = text; + `, + }, + { + name: 'Self-closing Tooltip', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = ; + `, + }, + { + name: 'Tooltip from a different package', + code: ` + import {Tooltip} from 'other-package'; + const x = text; + `, + }, + { + name: 'Tooltip wrapping a styled component (not detectable)', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = text; + `, + }, + { + name: 'Tooltip wrapping a div', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x =
text
; + `, + }, + { + name: 'Tooltip wrapping a variable reference', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {someVariable}; + `, + }, + { + name: 'Tooltip wrapping a complex component child', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = ; + `, + }, + { + name: 'No Tooltip import at all', + code: ` + const Tooltip = (props: any) => null; + const x = text; + `, + }, + { + name: 't() call from another binding', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const t = (value: string) => value; + const x = {t('label')}; + `, + }, + ], + + invalid: [ + { + name: 'Raw text child', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = Some text here; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = Some text here; + `), + ], + }, + { + name: 't() i18n call', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; + const x = {t('Click to expand')}; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {t('Click to expand')}; + `), + ], + }, + { + name: 'tct() i18n call', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {tct} from 'sentry/locale'; + const x = {tct('Hello [name]', {name})}; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {tct} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {tct('Hello [name]', {name})}; + `), + ], + }, + { + name: 'String literal in expression container', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {'some string'}; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {'some string'}; + `), + ], + }, + { + name: 'Template literal', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = {\`hello \${name}\`}; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {\`hello \${name}\`}; + `), + ], + }, + { + name: 'span wrapping text', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = label text; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = label text; + `), + ], + }, + { + name: 'span wrapping t() call', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; + const x = {t('label')}; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {t('label')}; + `), + ], + }, + { + name: 'inline semantic text wrapper', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = label text; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = label text; + `), + ], + }, + { + name: 'paragraph wrapping text', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x =

label text

; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x =

label text

; + `), + ], + }, + { + name: 'Text component wrapping text', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {Text} from '@sentry/scraps/text'; + const x = label; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {Text} from '@sentry/scraps/text'; +import {InfoText} from '@sentry/scraps/info'; + + const x = label; + `), + ], + }, + + { + name: 'showUnderline is stripped in suggestion', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = Some text here; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; + + const x = Some text here; + `), + ], + }, + { + name: 'Tooltip with extra props still flagged', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; + const x = {t('label')}; + `, + errors: [{messageId: 'preferInfoText'}], + }, + { + name: 'Multiple text-like children', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; + const x = Hello {t('world')}; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = Hello {t('world')}; + `), + ], + }, + { + name: 'Aliased Tooltip import', + code: ` + import {Tooltip as Tip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; + const x = {t('label')}; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip as Tip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {t('label')}; + `), + ], + }, + { + name: 'Fragment wrapping text', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; + const x = <>{t('label')}; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = <>{t('label')}; + `), + ], + }, + { + name: 'Conditional expression with text branches', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; + const x = {condition ? t('a') : t('b')}; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {condition ? t('a') : t('b')}; + `), + ], + }, + { + name: 'Logical AND with text', + code: ` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; + const x = {condition && t('label')}; + `, + errors: [ + errorWithSuggestion(` + import {Tooltip} from '@sentry/scraps/tooltip'; + import {t} from 'sentry/locale'; +import {InfoText} from '@sentry/scraps/info'; + + const x = {condition && t('label')}; + `), + ], + }, + { + name: 'Uses existing InfoText import in suggestion', + code: ` + import {InfoText as TextWithInfo} from '@sentry/scraps/info'; + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = Some text here; + `, + errors: [ + { + messageId: 'preferInfoText', + suggestions: [ + { + messageId: 'replaceWithInfoText', + output: ` + import {InfoText as TextWithInfo} from '@sentry/scraps/info'; + import {Tooltip} from '@sentry/scraps/tooltip'; + const x = Some text here; + `, + }, + ], + }, + ], + }, + ], +}); diff --git a/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts new file mode 100644 index 00000000000000..ff301e99bc967c --- /dev/null +++ b/static/eslint/eslintPluginScraps/src/rules/prefer-info-text.ts @@ -0,0 +1,317 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESLint, + type TSESTree, +} from '@typescript-eslint/utils'; + +import {createImportTracker} from '../ast/tracker/imports'; + +const TOOLTIP_SOURCE = '@sentry/scraps/tooltip'; +const TEXT_SOURCE = '@sentry/scraps/text'; +const INFO_SOURCE = '@sentry/scraps/info'; +const LOCALE_SOURCE = 'sentry/locale'; +const I18N_FUNCTIONS = new Set(['t', 'tct']); +const TEXT_LIKE_INTRINSICS = new Set([ + 'a', + 'abbr', + 'b', + 'code', + 'del', + 'em', + 'i', + 'kbd', + 'label', + 'mark', + 'p', + 's', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'time', + 'u', +]); +const TOOLTIP_PROPS_SUPPORTED_BY_INFO_TEXT = new Set(['title', 'showUnderline']); +const TOOLTIP_PROPS_TO_STRIP = new Set(['showUnderline']); + +function getElementName(nameNode: TSESTree.JSXTagNameExpression): string { + switch (nameNode.type) { + case AST_NODE_TYPES.JSXIdentifier: + return nameNode.name; + case AST_NODE_TYPES.JSXMemberExpression: + return `${getElementName(nameNode.object)}.${nameNode.property.name}`; + case AST_NODE_TYPES.JSXNamespacedName: + return `${nameNode.namespace.name}:${nameNode.name.name}`; + } +} + +function isI18nCall(node: TSESTree.Expression, i18nNames: string[]): boolean { + return ( + node.type === AST_NODE_TYPES.CallExpression && + node.callee.type === AST_NODE_TYPES.Identifier && + i18nNames.includes(node.callee.name) + ); +} + +export const preferInfoText = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'suggestion', + docs: { + description: 'Prefer over wrapping text content.', + }, + hasSuggestions: true, + schema: [], + messages: { + preferInfoText: + "Prefer over wrapping text content. Import InfoText from '@sentry/scraps/info'.", + replaceWithInfoText: 'Replace with .', + }, + }, + + create(context) { + const importTracker = createImportTracker(); + let resolved = false; + let tooltipNames: string[] = []; + let textNames: string[] = []; + let i18nNames: string[] = []; + + function resolveNames() { + if (resolved) { + return; + } + resolved = true; + tooltipNames = importTracker.findLocalNames(TOOLTIP_SOURCE, 'Tooltip'); + textNames = importTracker.findLocalNames(TEXT_SOURCE, 'Text'); + i18nNames = Array.from(I18N_FUNCTIONS).flatMap(name => + importTracker.findLocalNames(LOCALE_SOURCE, name) + ); + } + + function isTextLikeExpression(expr: TSESTree.Expression): boolean { + switch (expr.type) { + case AST_NODE_TYPES.Literal: + return typeof expr.value === 'string'; + case AST_NODE_TYPES.TemplateLiteral: + return true; + case AST_NODE_TYPES.CallExpression: + return isI18nCall(expr, i18nNames); + case AST_NODE_TYPES.ConditionalExpression: + return ( + isTextLikeExpression(expr.consequent) && isTextLikeExpression(expr.alternate) + ); + case AST_NODE_TYPES.LogicalExpression: + if (expr.operator === '&&') { + return isTextLikeExpression(expr.right); + } + return isTextLikeExpression(expr.left) && isTextLikeExpression(expr.right); + default: + return false; + } + } + + function isTextLikeChild(child: TSESTree.JSXChild): boolean { + switch (child.type) { + case AST_NODE_TYPES.JSXText: + return child.value.trim().length > 0; + case AST_NODE_TYPES.JSXExpressionContainer: + if (child.expression.type === AST_NODE_TYPES.JSXEmptyExpression) { + return false; + } + return isTextLikeExpression(child.expression); + case AST_NODE_TYPES.JSXElement: { + const name = getElementName(child.openingElement.name); + if (TEXT_LIKE_INTRINSICS.has(name) || textNames.includes(name)) { + return allChildrenAreTextLike(child.children); + } + return false; + } + case AST_NODE_TYPES.JSXFragment: + return allChildrenAreTextLike(child.children); + default: + return false; + } + } + + function allChildrenAreTextLike(children: TSESTree.JSXChild[]): boolean { + const meaningful = children.filter( + c => !(c.type === AST_NODE_TYPES.JSXText && c.value.trim() === '') + ); + return meaningful.length > 0 && meaningful.every(isTextLikeChild); + } + + function getMeaningfulChildren(children: TSESTree.JSXChild[]) { + return children.filter( + child => !(child.type === AST_NODE_TYPES.JSXText && child.value.trim() === '') + ); + } + + function getSingleTextElementChild(node: TSESTree.JSXElement) { + const meaningfulChildren = getMeaningfulChildren(node.children); + const child = meaningfulChildren[0]; + if (meaningfulChildren.length !== 1 || !child) { + return null; + } + + if (child.type !== AST_NODE_TYPES.JSXElement || child.closingElement === null) { + return null; + } + + const name = getElementName(child.openingElement.name); + if (!textNames.includes(name)) { + return null; + } + + return child; + } + + function canSuggestInfoText(node: TSESTree.JSXElement): boolean { + if (node.openingElement.selfClosing || node.closingElement === null) { + return false; + } + + if (!allChildrenAreTextLike(node.children)) { + return false; + } + + return node.openingElement.attributes.every(attr => { + if (attr.type !== AST_NODE_TYPES.JSXAttribute) { + return false; + } + return ( + attr.name.type === AST_NODE_TYPES.JSXIdentifier && + TOOLTIP_PROPS_SUPPORTED_BY_INFO_TEXT.has(attr.name.name) + ); + }); + } + + function getInfoTextName() { + return importTracker.findLocalNames(INFO_SOURCE, 'InfoText')[0] ?? 'InfoText'; + } + + function getInfoTextImportFix(fixer: TSESLint.RuleFixer) { + if (importTracker.findLocalNames(INFO_SOURCE, 'InfoText').length > 0) { + return null; + } + + const imports = context.sourceCode.ast.body.filter( + node => node.type === AST_NODE_TYPES.ImportDeclaration + ); + const infoImport = `import {InfoText} from '${INFO_SOURCE}';\n`; + const lastImport = imports.at(-1); + + if (lastImport) { + return fixer.insertTextAfter(lastImport, `\n${infoImport}`); + } + return fixer.insertTextBeforeRange([0, 0], infoImport); + } + + function getAttributeText( + attributes: TSESTree.JSXOpeningElement['attributes'], + stripNames?: Set + ) { + return attributes + .filter(attr => { + if (!stripNames) { + return true; + } + return !( + attr.type === AST_NODE_TYPES.JSXAttribute && + attr.name.type === AST_NODE_TYPES.JSXIdentifier && + stripNames.has(attr.name.name) + ); + }) + .map(attr => context.sourceCode.getText(attr)) + .join(' '); + } + + function buildOpeningTag(name: string, attributes: string[]) { + const attributeText = attributes.filter(Boolean).join(' '); + return attributeText ? `<${name} ${attributeText}>` : `<${name}>`; + } + + return { + ...importTracker.visitors, + + JSXElement(node) { + resolveNames(); + const name = getElementName(node.openingElement.name); + if (!tooltipNames.includes(name)) { + return; + } + if (allChildrenAreTextLike(node.children)) { + context.report({ + node, + messageId: 'preferInfoText', + suggest: canSuggestInfoText(node) + ? [ + { + messageId: 'replaceWithInfoText', + fix(fixer) { + if (!node.closingElement) { + return null; + } + const infoTextName = getInfoTextName(); + const textChild = getSingleTextElementChild(node); + if (textChild && textChild.closingElement !== null) { + const attributes = [ + getAttributeText( + node.openingElement.attributes, + TOOLTIP_PROPS_TO_STRIP + ), + getAttributeText(textChild.openingElement.attributes), + ]; + const childrenText = context.sourceCode.text.slice( + textChild.openingElement.range[1], + textChild.closingElement.range[0] + ); + const replacement = `${buildOpeningTag( + infoTextName, + attributes + )}${childrenText}`; + const fixes = [fixer.replaceText(node, replacement)]; + const importFix = getInfoTextImportFix(fixer); + if (importFix !== null) { + fixes.push(importFix); + } + return fixes; + } + + const fixes: TSESLint.RuleFix[] = [ + fixer.replaceText(node.openingElement.name, infoTextName), + fixer.replaceText(node.closingElement.name, infoTextName), + fixer.insertTextAfter( + node.openingElement.name, + ' variant="inherit"' + ), + ]; + for (const attr of node.openingElement.attributes) { + if ( + attr.type === AST_NODE_TYPES.JSXAttribute && + attr.name.type === AST_NODE_TYPES.JSXIdentifier && + TOOLTIP_PROPS_TO_STRIP.has(attr.name.name) + ) { + const src = context.sourceCode.getText(); + let start = attr.range[0]; + while (start > 0 && src[start - 1] === ' ') { + start--; + } + fixes.push(fixer.removeRange([start, attr.range[1]])); + } + } + const importFix = getInfoTextImportFix(fixer); + if (importFix !== null) { + fixes.push(importFix); + } + return fixes; + }, + }, + ] + : undefined, + }); + } + }, + }; + }, +}); diff --git a/static/gsAdmin/components/customerStatus.tsx b/static/gsAdmin/components/customerStatus.tsx index 6a55ba89476850..eae30317a9ce3c 100644 --- a/static/gsAdmin/components/customerStatus.tsx +++ b/static/gsAdmin/components/customerStatus.tsx @@ -1,7 +1,7 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; -import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; import type {Subscription} from 'getsentry/types'; import {formatCurrency} from 'getsentry/utils/formatCurrency'; @@ -69,9 +69,9 @@ export function CustomerStatus({customer}: Props) { {typeof label !== 'object' && label}
- + {`${customer.planDetails?.name} Plan (${customer.planTier})`} - +
); } diff --git a/static/gsAdmin/components/customers/customerOverview.tsx b/static/gsAdmin/components/customers/customerOverview.tsx index 5a4d4f44030678..69763cc7464bb3 100644 --- a/static/gsAdmin/components/customers/customerOverview.tsx +++ b/static/gsAdmin/components/customers/customerOverview.tsx @@ -5,9 +5,9 @@ import moment from 'moment-timezone'; import {Tag} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; +import {InfoText} from '@sentry/scraps/info'; import {Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {ConfigStore} from 'sentry/stores/configStore'; import {DataCategory} from 'sentry/types/core'; @@ -757,9 +757,12 @@ export function CustomerOverview({customer, onAction, organization}: Props) { + Partner -
+
} > {customer.partner ? ( @@ -874,14 +877,20 @@ export function CustomerOverview({customer, onAction, organization}: Props) { Standard Default - + Downsampled - +
- + Downsample Default - + diff --git a/static/gsApp/components/superuser/superuserWarning.tsx b/static/gsApp/components/superuser/superuserWarning.tsx index 9e1fa09cbb2565..a28f5d1f1a80e2 100644 --- a/static/gsApp/components/superuser/superuserWarning.tsx +++ b/static/gsApp/components/superuser/superuserWarning.tsx @@ -6,6 +6,7 @@ import {useResizeObserver} from '@react-aria/utils'; import {Badge} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; +import {InfoText} from '@sentry/scraps/info'; import {Flex, Container} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -167,8 +168,8 @@ export function SuperuserWarning({organization, className}: Props) { return ( - {WARNING_MESSAGE} @@ -177,7 +178,7 @@ export function SuperuserWarning({organization, className}: Props) { } > Superuser - + ); } diff --git a/static/gsApp/views/spendAllocations/components/allocationRow.tsx b/static/gsApp/views/spendAllocations/components/allocationRow.tsx index b2d3dcdcd2fd16..421ec10ddeaf89 100644 --- a/static/gsApp/views/spendAllocations/components/allocationRow.tsx +++ b/static/gsApp/views/spendAllocations/components/allocationRow.tsx @@ -2,7 +2,7 @@ import {useState} from 'react'; import {useTheme} from '@emotion/react'; import {Button} from '@sentry/scraps/button'; -import {Tooltip} from '@sentry/scraps/tooltip'; +import {InfoText} from '@sentry/scraps/info'; import {IconDelete, IconEdit} from 'sentry/icons'; import {t} from 'sentry/locale'; @@ -39,9 +39,12 @@ export function AllocationRow({ {allocation.costPerItem === 0 && ( - - -- - + + N/A + )} {allocation.costPerItem > 0 && ( @@ -55,9 +58,12 @@ export function AllocationRow({ - + {bigNumFormatter(allocation.reservedQuantity, undefined, metricUnit)} - + @@ -66,9 +72,12 @@ export function AllocationRow({ {allocation.costPerItem === 0 && ( - - -- - + + N/A + )} {allocation.costPerItem > 0 && ( @@ -82,7 +91,12 @@ export function AllocationRow({ - allocation.reservedQuantity + ? 'danger' + : 'inherit' + } title={(allocation.consumedQuantity > allocation.reservedQuantity ? `${allocation.consumedQuantity} (${ allocation.consumedQuantity - allocation.reservedQuantity @@ -90,16 +104,8 @@ export function AllocationRow({ : allocation.consumedQuantity ).toLocaleString()} > - allocation.reservedQuantity - ? {color: theme.colors.red500} - : {} - } - > - {bigNumFormatter(allocation.consumedQuantity, 2, metricUnit)} - - + {bigNumFormatter(allocation.consumedQuantity, 2, metricUnit)} + diff --git a/static/gsApp/views/spendAllocations/projectAllocationsTable.tsx b/static/gsApp/views/spendAllocations/projectAllocationsTable.tsx index ac4575b3bdb2b3..6a121a594fd563 100644 --- a/static/gsApp/views/spendAllocations/projectAllocationsTable.tsx +++ b/static/gsApp/views/spendAllocations/projectAllocationsTable.tsx @@ -1,8 +1,8 @@ import {useMemo} from 'react'; import styled from '@emotion/styled'; +import {InfoText} from '@sentry/scraps/info'; import {Container} from '@sentry/scraps/layout'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {t} from 'sentry/locale'; import type {DataCategory} from 'sentry/types/core'; @@ -60,13 +60,14 @@ export function ProjectAllocationsTable({ {t('Project')} - {t('Allocated')} - + @@ -78,9 +79,12 @@ export function ProjectAllocationsTable({ - + {t('Consumed')} - + diff --git a/static/gsApp/views/spendAllocations/rootAllocationCard.tsx b/static/gsApp/views/spendAllocations/rootAllocationCard.tsx index a49132dd7b572e..975ee34e6b83fe 100644 --- a/static/gsApp/views/spendAllocations/rootAllocationCard.tsx +++ b/static/gsApp/views/spendAllocations/rootAllocationCard.tsx @@ -3,10 +3,10 @@ import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; +import {InfoText} from '@sentry/scraps/info'; import {Container, Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {IconAdd} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; @@ -35,7 +35,6 @@ export function RootAllocationCard({ selectedMetric, subscription, }: Props) { - const theme = useTheme(); const availableEvents = useMemo(() => { return rootAllocation ? Math.max(rootAllocation.reservedQuantity - rootAllocation.consumedQuantity, 0) @@ -124,9 +123,12 @@ export function RootAllocationCard({ {t('Available')} {rootAllocation.costPerItem === 0 ? ( - - -- - + + N/A + ) : ( displayPrice({ cents: rootAllocation.costPerItem * availableEvents, @@ -134,9 +136,9 @@ export function RootAllocationCard({ )} - + {bigNumFormatter(availableEvents, 2, metricUnit)} - + @@ -144,9 +146,12 @@ export function RootAllocationCard({ {/* TODO: include OD costs if enabled */} {rootAllocation.costPerItem === 0 ? ( - - -- - + + N/A + ) : ( displayPrice({ cents: @@ -159,27 +164,28 @@ export function RootAllocationCard({ )} - - {bigNumFormatter( - Math.min( - rootAllocation.reservedQuantity, - rootAllocation.consumedQuantity - ), - 2, - metricUnit - )} - - {rootAllocation.consumedQuantity > - rootAllocation.reservedQuantity && ( - + -   - + {rootAllocation.consumedQuantity > + rootAllocation.reservedQuantity && ( + {tct('([overCount] over)', { overCount: bigNumFormatter( @@ -189,9 +195,9 @@ export function RootAllocationCard({ metricUnit ), })} - - - )} + + )} +
From 71b7eca3b5acf9aab2ca86ba8bfe77e185916eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Thu, 28 May 2026 11:35:27 +0200 Subject: [PATCH 9/9] fix(feedback): Make UserReport name and email nullable (#116362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend `UserReportSerializer` returns `name` and `email` as `str | None`, but the frontend `UserReport` type had both as non-nullable `string`. When either field is `null`, `isSameIdentity` crashes calling `.trim()` on `null`. Updates the frontend type to match the backend and adds null-handling in `userFeedback.tsx` — optional chaining in `isSameIdentity`, null-coalescing for avatar fallbacks, and conditionally rendering the email copy button. Fixes JAVASCRIPT-3A0C --- static/app/components/events/userFeedback.tsx | 46 ++++++++++--------- static/app/types/group.tsx | 4 +- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/static/app/components/events/userFeedback.tsx b/static/app/components/events/userFeedback.tsx index c3f2cd05da3557..808a8c128ed909 100644 --- a/static/app/components/events/userFeedback.tsx +++ b/static/app/components/events/userFeedback.tsx @@ -21,8 +21,6 @@ type Props = { export function EventUserFeedback({eventLink, report}: Props) { const {copy} = useCopyToClipboard(); const showEmailLabel = !isSameIdentity(report.name, report.email); - const copyEmail = () => - copy(report.email, {successMessage: t('Copied email to clipboard')}); return ( @@ -34,20 +32,26 @@ export function EventUserFeedback({eventLink, report}: Props) { {report.name} - + {report.email ? ( + + ) : null} {eventLink && ( @@ -71,8 +75,8 @@ export function EventUserFeedback({eventLink, report}: Props) { ); } -function isSameIdentity(name: string, email: string) { - return name.trim().toLowerCase() === email.trim().toLowerCase(); +function isSameIdentity(name: string | null, email: string | null) { + return name?.trim().toLowerCase() === email?.trim().toLowerCase(); } function getAvatarUser(report: UserReport): AvatarUser | undefined { @@ -81,8 +85,8 @@ function getAvatarUser(report: UserReport): AvatarUser | undefined { if (!user) { return { id: '', - email: report.email, - name: report.name, + email: report.email ?? '', + name: report.name ?? '', username: '', ip_address: '', }; @@ -91,7 +95,7 @@ function getAvatarUser(report: UserReport): AvatarUser | undefined { return { id: user.id, email: user.email ?? '', - name: user.name ?? report.name, + name: user.name ?? report.name ?? '', username: user.username ?? '', ip_address: user.ipAddress ?? '', avatarUrl: user.avatarUrl ?? undefined, diff --git a/static/app/types/group.tsx b/static/app/types/group.tsx index 9cf74b4a918dff..6092c6fa8abff6 100644 --- a/static/app/types/group.tsx +++ b/static/app/types/group.tsx @@ -1262,11 +1262,11 @@ export type ChunkType = { export type UserReport = { comments: string; dateCreated: string; - email: string; + email: string | null; event: {eventID: string; id: string}; eventID: string; id: string; - name: string; + name: string | null; user: { avatarUrl: string | null; email: string | null;