diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9ccb63af15f43b..2c63852ce151ea 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -798,15 +798,21 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/views/settings/ @getsentry/design-engineering /static/app/views/nav/ @getsentry/design-engineering /static/app/bootstrap/ @getsentry/design-engineering - -# LLM Agent guidelines +# Config files +/figma.config.json @getsentry/design-engineering +/knip.config.ts @getsentry/design-engineering +# Agents + Skills /static/.cursor/BUGBOT.md @getsentry/design-engineering /static/CLAUDE.md @getsentry/design-engineering /static/AGENTS.md @getsentry/design-engineering -/.claude/skills/design-system/ @getsentry/design-engineering - /static/eslint/ @getsentry/design-engineering /scripts/analyze-styled.ts @getsentry/design-engineering +/.agents/skills/design-system/ @getsentry/design-engineering +/.agents/skills/migrate-frontend-forms/ @getsentry/design-engineering +/.agents/skills/generate-frontend-forms/ @getsentry/design-engineering +/.agents/skills/lint-fix/ @getsentry/design-engineering +/.agents/skills/lint-new/ @getsentry/design-engineering +/.agents/skills/react-component-documentation/ @getsentry/design-engineering ## End of Frontend Platform # Coding Workflows diff --git a/.github/codeowners-coverage-baseline.txt b/.github/codeowners-coverage-baseline.txt index e7e04e0b076873..289adc57b63de7 100644 --- a/.github/codeowners-coverage-baseline.txt +++ b/.github/codeowners-coverage-baseline.txt @@ -11,22 +11,11 @@ src/sentry/data/error-** src/sentry/locale/** -.agents/skills/design-system/SKILL.md -.agents/skills/generate-frontend-forms/SKILL.md -.agents/skills/lint-fix/SKILL.md -.agents/skills/lint-fix/references/fix-patterns.md -.agents/skills/lint-fix/references/token-taxonomy.md -.agents/skills/lint-new/SKILL.md -.agents/skills/lint-new/references/rule-archetypes.md -.agents/skills/lint-new/references/schema-patterns.md -.agents/skills/lint-new/references/style-collector-guide.md -.agents/skills/migrate-frontend-forms/SKILL.md .agents/skills/notification-platform/SKILL.md .agents/skills/notification-platform/references/custom-renderers.md .agents/skills/notification-platform/references/data-and-templates.md .agents/skills/notification-platform/references/provider-template.md .agents/skills/notification-platform/references/targets-and-sending.md -.agents/skills/react-component-documentation/SKILL.md .agents/skills/setup-dev/SKILL.md .agents/skills/setup-dev/references/orbstack-fix.md .codeowners-config.yml @@ -68,9 +57,7 @@ bin/split-silo-database bin/update-migration config/build-chartcuterie.ts config/commit-template -figma.config.json jest.config.snapshots.ts -knip.config.ts migrations_lockfile.txt rspack.config.ts src/AGENTS.md diff --git a/pyproject.toml b/pyproject.toml index 23ee17f75a183b..b87f14a46bfe49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,6 +143,7 @@ default = true [dependency-groups] dev = [ + "codeowners-coverage>=0.2.1", "covdefaults>=2.3.0", "devservices>=1.2.4", "docker>=7.1.0", diff --git a/src/sentry/api/endpoints/organization_events.py b/src/sentry/api/endpoints/organization_events.py index fe4ebb707d0fc8..0b7d2400a6a27b 100644 --- a/src/sentry/api/endpoints/organization_events.py +++ b/src/sentry/api/endpoints/organization_events.py @@ -1,6 +1,6 @@ import logging from collections.abc import Mapping -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import as_completed from typing import Any, NotRequired, TypedDict import sentry_sdk @@ -54,6 +54,7 @@ from sentry.snuba.types import DatasetQuery from sentry.snuba.utils import RPC_DATASETS, dataset_split_decision_inferred_from_query, get_dataset from sentry.types.ratelimit import RateLimit, RateLimitCategory +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.cursors import Cursor, EAPPageTokenCursor from sentry.utils.snuba import SnubaError @@ -409,7 +410,7 @@ def _discover_data_fn( # Unable to infer based on selected fields and query string, so run both queries. else: map = {} - with ThreadPoolExecutor(max_workers=3) as exe: + with ContextPropagatingThreadPoolExecutor(max_workers=3) as exe: futures = { exe.submit( _data_fn, dataset_query, offset, limit, scoped_query diff --git a/src/sentry/api/endpoints/organization_events_trace.py b/src/sentry/api/endpoints/organization_events_trace.py index e13ff81b50b804..a46ec41d5c3237 100644 --- a/src/sentry/api/endpoints/organization_events_trace.py +++ b/src/sentry/api/endpoints/organization_events_trace.py @@ -4,7 +4,7 @@ import logging from collections import defaultdict, deque from collections.abc import Callable, Iterable, Mapping, Sequence -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import as_completed from datetime import datetime, timedelta from typing import Any, Optional, TypedDict, TypeVar, cast @@ -38,6 +38,7 @@ from sentry.snuba.occurrences_rpc import OccurrenceCategory, Occurrences from sentry.snuba.query_sources import QuerySource from sentry.snuba.referrer import Referrer +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.numbers import base32_encode, format_grouped_length from sentry.utils.sdk import set_span_attribute from sentry.utils.snuba import bulk_snuba_queries @@ -1286,7 +1287,7 @@ def update_children(event: TraceEvent, limit: int) -> None: @staticmethod def nodestore_event_map(events: Sequence[SnubaTransaction]) -> dict[str, Event | GroupEvent]: event_map = {} - with ThreadPoolExecutor(max_workers=20) as executor: + with ContextPropagatingThreadPoolExecutor(max_workers=20) as executor: future_to_event = { executor.submit( eventstore.backend.get_event_by_id, event["project.id"], event["id"] diff --git a/src/sentry/api/endpoints/organization_events_trends_v2.py b/src/sentry/api/endpoints/organization_events_trends_v2.py index a4222c663c25e5..8407e94ad54b54 100644 --- a/src/sentry/api/endpoints/organization_events_trends_v2.py +++ b/src/sentry/api/endpoints/organization_events_trends_v2.py @@ -1,5 +1,4 @@ import logging -from concurrent.futures import ThreadPoolExecutor from functools import partial import sentry_sdk @@ -24,6 +23,7 @@ from sentry.snuba.metrics_performance import query as metrics_query from sentry.snuba.referrer import Referrer from sentry.types.ratelimit import RateLimit, RateLimitCategory +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.iterators import chunked from sentry.utils.snuba import SnubaTSResult @@ -275,7 +275,9 @@ def get_trends_data(stats_data, request): ] # send the data to microservice - with ThreadPoolExecutor(thread_name_prefix=__name__) as query_thread_pool: + with ContextPropagatingThreadPoolExecutor( + thread_name_prefix=__name__ + ) as query_thread_pool: results = list( query_thread_pool.map( partial(detect_breakpoints, viewer_context=viewer_context), diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 4735b153fa5d67..29ac157b439e83 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -1,5 +1,4 @@ from collections.abc import Callable, Sequence -from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta from typing import Any, Literal, NotRequired, TypedDict @@ -71,6 +70,7 @@ from sentry.snuba.referrer import Referrer from sentry.tagstore.types import TagValue from sentry.utils import snuba_rpc +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.cursors import Cursor, CursorResult POSSIBLE_ATTRIBUTE_TYPES = ["string", "number", "boolean"] @@ -321,7 +321,7 @@ def get(self, request: Request, organization: Organization) -> Response: def data_fn(offset: int, limit: int) -> list[TraceItemAttributeKey]: futures = [] - with ThreadPoolExecutor( + with ContextPropagatingThreadPoolExecutor( thread_name_prefix=__name__, max_workers=len(POSSIBLE_ATTRIBUTE_TYPES), ) as pool: diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py index b0013e71a0c5ea..19ce04218773b3 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py @@ -1,6 +1,5 @@ import logging from collections import defaultdict -from concurrent.futures import ThreadPoolExecutor from typing import Any, TypedDict, cast from rest_framework.request import Request @@ -34,6 +33,7 @@ from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans from sentry.utils import snuba_rpc +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.snuba_rpc import trace_item_stats_rpc logger = logging.getLogger(__name__) @@ -326,7 +326,7 @@ def run_table_query_with_error_handling(query_string): referrer=Referrer.API_SPAN_SAMPLE_GET_SPAN_DATA.value, ) - with ThreadPoolExecutor( + with ContextPropagatingThreadPoolExecutor( thread_name_prefix=__name__, max_workers=PARALLELIZATION_FACTOR * 2 + 2, # 2 cohorts * threads + 2 totals queries ) as query_thread_pool: diff --git a/src/sentry/api/endpoints/organization_trace_item_stats.py b/src/sentry/api/endpoints/organization_trace_item_stats.py index cd11ba13580cdd..76cce4f8bdd649 100644 --- a/src/sentry/api/endpoints/organization_trace_item_stats.py +++ b/src/sentry/api/endpoints/organization_trace_item_stats.py @@ -1,6 +1,6 @@ import logging from collections import defaultdict -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import as_completed from rest_framework import serializers from rest_framework.request import Request @@ -28,6 +28,7 @@ from sentry.search.eap.types import SearchResolverConfig from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.cursors import Cursor, CursorResult logger = logging.getLogger(__name__) @@ -210,7 +211,7 @@ def data_fn(offset: int, limit: int): ) stats_results: dict[str, dict[str, dict]] = defaultdict(lambda: {"data": {}}) - with ThreadPoolExecutor( + with ContextPropagatingThreadPoolExecutor( thread_name_prefix=__name__, max_workers=MAX_THREADS, ) as query_thread_pool: diff --git a/src/sentry/api/endpoints/organization_trace_meta.py b/src/sentry/api/endpoints/organization_trace_meta.py index e23b111691876a..a097df205cf3e3 100644 --- a/src/sentry/api/endpoints/organization_trace_meta.py +++ b/src/sentry/api/endpoints/organization_trace_meta.py @@ -1,5 +1,4 @@ import logging -from concurrent.futures import ThreadPoolExecutor from typing import TypedDict import sentry_sdk @@ -28,6 +27,7 @@ from sentry.snuba.rpc_dataset_common import RPCBase, TableQuery from sentry.snuba.spans_rpc import Spans from sentry.snuba.trace import _run_uptime_results_query, _uptime_results_query +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor logger = logging.getLogger(__name__) @@ -208,7 +208,7 @@ def get(self, request: Request, organization: Organization, trace_id: str) -> Ht # parallelizing the queries here, but ideally this parallelization happens by calling run_bulk_table_queries include_uptime = request.GET.get("include_uptime", "0") == "1" max_workers = 3 + (1 if include_uptime else 0) - with ThreadPoolExecutor( + with ContextPropagatingThreadPoolExecutor( thread_name_prefix=__name__, max_workers=max_workers, ) as query_thread_pool: diff --git a/src/sentry/api/endpoints/organization_traces.py b/src/sentry/api/endpoints/organization_traces.py index 7789cd953d0805..6f534e1474dccc 100644 --- a/src/sentry/api/endpoints/organization_traces.py +++ b/src/sentry/api/endpoints/organization_traces.py @@ -1,7 +1,6 @@ import dataclasses from collections import defaultdict from collections.abc import Callable, Generator, Mapping, MutableMapping -from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from datetime import timedelta from typing import Any, Literal, NotRequired, TypedDict @@ -56,6 +55,7 @@ from sentry.snuba.occurrences_rpc import OccurrenceCategory from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.numbers import clip from sentry.utils.sdk import set_span_attribute from sentry.utils.snuba import bulk_snuba_queries_with_referrers @@ -253,7 +253,7 @@ def _execute_rpc(self): # issue if len(self.snuba_params.projects) < len(all_projects) and self.offset == 0: selected_project_request = self.get_traces_rpc(list(self.snuba_params.projects)) - with ThreadPoolExecutor( + with ContextPropagatingThreadPoolExecutor( thread_name_prefix=__name__, max_workers=2 ) as query_thread_pool: all_project_future = query_thread_pool.submit(get_traces_rpc, all_project_request) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index c4d90b51361673..19a3e13758c6cd 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -216,11 +216,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:performance-transaction-name-only-search", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the EAP-powered transactions summary view manager.add("organizations:performance-transaction-summary-eap", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enables the new UI for span summary and the spans tab - manager.add("organizations:performance-spans-new-ui", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:performance-use-metrics", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=True) - # Enable standalone cls and lcp in the web vitals module - manager.add("organizations:performance-vitals-standalone-cls-lcp", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable Seer Suggestions for Web Vitals Module manager.add("organizations:performance-web-vitals-seer-suggestions", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the warning banner to inform users of pending deprecation of the transactions dataset @@ -361,14 +357,10 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:sentry-app-webhook-requests", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable standalone span ingestion manager.add("organizations:standalone-span-ingestion", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) - # Enable mobile starfish app start module view - manager.add("organizations:starfish-mobile-appstart", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable mobile starfish ui module view manager.add("organizations:starfish-mobile-ui-module", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the new experimental starfish view manager.add("organizations:starfish-view", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable UI for regression issues RCA using spans data - manager.add("organizations:statistical-detectors-rca-spans-only", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Allow organizations to configure all symbol sources. manager.add("organizations:symbol-sources", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=True) # Enable static ClickHouse sampling for `OrganizationTagsEndpoint` diff --git a/src/sentry/hybridcloud/tasks/deliver_webhooks.py b/src/sentry/hybridcloud/tasks/deliver_webhooks.py index f80eeb41502887..e8d9d3411e47d9 100644 --- a/src/sentry/hybridcloud/tasks/deliver_webhooks.py +++ b/src/sentry/hybridcloud/tasks/deliver_webhooks.py @@ -1,6 +1,6 @@ import datetime import logging -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import as_completed import orjson import requests @@ -35,6 +35,7 @@ from sentry.taskworker.namespaces import hybridcloud_control_tasks from sentry.types.cell import Cell, get_cell_by_name from sentry.utils import metrics +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor logger = logging.getLogger(__name__) @@ -494,7 +495,7 @@ def _run_parallel_delivery_batch( id__gte=payload.id, mailbox_name=payload.mailbox_name ).order_by("id") - with ThreadPoolExecutor(max_workers=worker_threads) as threadpool: + with ContextPropagatingThreadPoolExecutor(max_workers=worker_threads) as threadpool: futures = { threadpool.submit(deliver_message_parallel, record) for record in query[:worker_threads] } diff --git a/src/sentry/integrations/aws_lambda/integration.py b/src/sentry/integrations/aws_lambda/integration.py index f23cb4fac9aa94..6530a7f13b495f 100644 --- a/src/sentry/integrations/aws_lambda/integration.py +++ b/src/sentry/integrations/aws_lambda/integration.py @@ -2,7 +2,6 @@ import logging from collections.abc import Mapping -from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Any from botocore.exceptions import ClientError @@ -31,6 +30,7 @@ from sentry.silo.base import control_silo_function from sentry.users.models.user import User from sentry.users.services.user.serial import serialize_rpc_user +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.sdk import capture_exception from .client import ConfigurationError, gen_aws_client @@ -421,7 +421,7 @@ def _enable_lambda(function): failures = [] success_count = 0 - with ThreadPoolExecutor( + with ContextPropagatingThreadPoolExecutor( max_workers=options.get("aws-lambda.thread-count") ) as _lambda_setup_thread_pool: # use threading here to parallelize requests diff --git a/src/sentry/integrations/middleware/hybrid_cloud/parser.py b/src/sentry/integrations/middleware/hybrid_cloud/parser.py index 29d4b6998eb88e..581c52a2e34dc9 100644 --- a/src/sentry/integrations/middleware/hybrid_cloud/parser.py +++ b/src/sentry/integrations/middleware/hybrid_cloud/parser.py @@ -2,7 +2,7 @@ import logging from abc import ABC -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import as_completed from typing import TYPE_CHECKING, Any, ClassVar from django.conf import settings @@ -33,6 +33,7 @@ from sentry.silo.client import CellSiloClient, SiloClientError from sentry.types.cell import Cell, find_cells_for_orgs, get_cell_by_name from sentry.utils import metrics +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.options import sample_modulo logger = logging.getLogger(__name__) @@ -147,7 +148,7 @@ def get_responses_from_cell_silos(self, cells: list[Cell]) -> dict[str, CellResu cell_to_response_map = {} - with ThreadPoolExecutor(max_workers=len(cells)) as executor: + with ContextPropagatingThreadPoolExecutor(max_workers=len(cells)) as executor: future_to_cell = { executor.submit(self.get_response_from_cell_silo, cell): cell for cell in cells } diff --git a/src/sentry/integrations/utils/issue_summary_for_alerts.py b/src/sentry/integrations/utils/issue_summary_for_alerts.py index 652a5368bfcc74..e7ef10174177cc 100644 --- a/src/sentry/integrations/utils/issue_summary_for_alerts.py +++ b/src/sentry/integrations/utils/issue_summary_for_alerts.py @@ -10,6 +10,7 @@ from sentry.seer.autofix.constants import SeerAutomationSource from sentry.seer.autofix.issue_summary import get_issue_summary from sentry.seer.autofix.utils import is_seer_scanner_rate_limited +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor logger = logging.getLogger(__name__) @@ -47,7 +48,7 @@ def fetch_issue_summary(group: Group) -> dict[str, Any] | None: try: with sentry_sdk.start_span(op="ai_summary.fetch_issue_summary_for_alert"): - with concurrent.futures.ThreadPoolExecutor() as executor: + with ContextPropagatingThreadPoolExecutor() as executor: future = executor.submit( get_issue_summary, group, source=SeerAutomationSource.ALERT ) diff --git a/src/sentry/models/files/abstractfile.py b/src/sentry/models/files/abstractfile.py index aa4d373a86f86c..40e3c312631608 100644 --- a/src/sentry/models/files/abstractfile.py +++ b/src/sentry/models/files/abstractfile.py @@ -7,7 +7,6 @@ import os import tempfile from collections.abc import Sequence -from concurrent.futures import ThreadPoolExecutor from hashlib import sha1 from typing import TYPE_CHECKING, Any, Generic, TypeVar @@ -25,6 +24,7 @@ from sentry.models.files.abstractfileblobindex import AbstractFileBlobIndex from sentry.models.files.utils import DEFAULT_BLOB_SIZE, AssembleChecksumMismatch, nooplogger from sentry.utils import metrics +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor logger = logging.getLogger(__name__) @@ -104,7 +104,7 @@ def fetch_file(offset, getfile) -> None: mem[offset : offset + len(chunk)] = chunk offset += len(chunk) - with ThreadPoolExecutor(max_workers=4) as exe: + with ContextPropagatingThreadPoolExecutor(max_workers=4) as exe: for idx in self._indexes: exe.submit(fetch_file, idx.offset, idx.blob.getfile) diff --git a/src/sentry/replays/tasks.py b/src/sentry/replays/tasks.py index 38920f60b256d6..cbf0683441b9b4 100644 --- a/src/sentry/replays/tasks.py +++ b/src/sentry/replays/tasks.py @@ -1,6 +1,5 @@ from __future__ import annotations -import concurrent.futures as cf import logging from typing import Any @@ -27,6 +26,7 @@ from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import replays_tasks from sentry.utils import metrics +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.pubsub import KafkaPublisher logger = logging.getLogger() @@ -86,7 +86,7 @@ def delete_replays_script_async( for segment in segments: rrweb_filenames.append(make_recording_filename(segment)) - with cf.ThreadPoolExecutor(max_workers=100) as pool: + with ContextPropagatingThreadPoolExecutor(max_workers=100) as pool: pool.map(_delete_if_exists, rrweb_filenames) # Backwards compatibility. Should be deleted one day. @@ -128,7 +128,7 @@ def delete_replay_recording(project_id: int, replay_id: str) -> None: direct_storage_segments.append(segment) # Issue concurrent delete requests when interacting with a remote service provider. - with cf.ThreadPoolExecutor(max_workers=100) as pool: + with ContextPropagatingThreadPoolExecutor(max_workers=100) as pool: if direct_storage_segments: pool.map(storage.delete, direct_storage_segments) diff --git a/src/sentry/replays/usecases/delete.py b/src/sentry/replays/usecases/delete.py index 17a3a68cfb484d..1367c0714ca127 100644 --- a/src/sentry/replays/usecases/delete.py +++ b/src/sentry/replays/usecases/delete.py @@ -1,6 +1,5 @@ from __future__ import annotations -import concurrent.futures as cf import functools import logging from datetime import datetime @@ -36,6 +35,7 @@ from sentry.replays.usecases.query import execute_query, handle_search_filters from sentry.replays.usecases.query.configs.aggregate import search_config as agg_search_config from sentry.seer.signed_seer_api import SeerViewerContext +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.retries import ConditionalRetryPolicy, exponential_delay from sentry.utils.snuba import ( QueryExecutionError, @@ -76,7 +76,7 @@ def delete_replays(project_id: int, replay_ids: list[str]) -> None: def delete_replay_recordings(project_id: int, row: MatchedRow) -> None: - with cf.ThreadPoolExecutor(max_workers=100) as pool: + with ContextPropagatingThreadPoolExecutor(max_workers=100) as pool: pool.map(_delete_if_exists, _make_recording_filenames(project_id, row)) diff --git a/src/sentry/replays/usecases/reader.py b/src/sentry/replays/usecases/reader.py index 5c7b8de714c154..a0f79337cf6b01 100644 --- a/src/sentry/replays/usecases/reader.py +++ b/src/sentry/replays/usecases/reader.py @@ -3,7 +3,6 @@ import uuid import zlib from collections.abc import Generator, Iterator -from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta from typing import Any @@ -29,6 +28,7 @@ from sentry.replays.lib.storage import RecordingSegmentStorageMeta, filestore, storage from sentry.replays.models import ReplayRecordingSegment from sentry.replays.usecases.pack import unpack +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.snuba import raw_snql_query # METADATA QUERY BEHAVIOR. @@ -265,7 +265,7 @@ def download_segments(segments: list[RecordingSegmentStorageMeta]) -> Iterator[b def iter_segment_data( segments: list[RecordingSegmentStorageMeta], ) -> Generator[tuple[int, memoryview]]: - with ThreadPoolExecutor(max_workers=10) as pool: + with ContextPropagatingThreadPoolExecutor(max_workers=10) as pool: segment_data = pool.map(_download_segment, segments) for i, result in enumerate(segment_data): diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 302dedfad2d8ed..3dbc0005d5abee 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -50,6 +50,7 @@ from sentry.tasks.seer.autofix import check_autofix_status from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.event_frames import EventFrame logger = logging.getLogger(__name__) @@ -341,7 +342,7 @@ def _fetch_trace(): try: with sentry_sdk.start_span(op="seer.autofix.get_trace_tree_for_event"): - with concurrent.futures.ThreadPoolExecutor() as executor: + with ContextPropagatingThreadPoolExecutor() as executor: future = executor.submit(_fetch_trace) return future.result(timeout=timeout) except concurrent.futures.TimeoutError: diff --git a/src/sentry/seer/explorer/index_data.py b/src/sentry/seer/explorer/index_data.py index 9d7feef5763deb..24946149ecfac0 100644 --- a/src/sentry/seer/explorer/index_data.py +++ b/src/sentry/seer/explorer/index_data.py @@ -1,6 +1,6 @@ import logging import re -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import as_completed from datetime import UTC, datetime, timedelta from typing import Any @@ -32,6 +32,7 @@ from sentry.services.eventstore.models import Event, GroupEvent from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor logger = logging.getLogger(__name__) @@ -396,7 +397,9 @@ def get_profiles_for_trace(trace_id: str, project_id: int) -> TraceProfiles | No for p in profile_data ] - with ThreadPoolExecutor(max_workers=min(len(profiles_to_fetch), 5)) as executor: + with ContextPropagatingThreadPoolExecutor( + max_workers=min(len(profiles_to_fetch), 5) + ) as executor: future_to_profile = { executor.submit( _fetch_and_process_profile, diff --git a/src/sentry/snuba/spans_rpc.py b/src/sentry/snuba/spans_rpc.py index 0c9bb441c82b49..7e289ef8728a40 100644 --- a/src/sentry/snuba/spans_rpc.py +++ b/src/sentry/snuba/spans_rpc.py @@ -1,7 +1,6 @@ import logging import time from collections import defaultdict -from concurrent.futures import ThreadPoolExecutor from typing import Any import sentry_sdk @@ -30,6 +29,7 @@ from sentry.search.events.types import SAMPLING_MODES, SnubaParams from sentry.snuba import rpc_dataset_common from sentry.utils import json, snuba_rpc +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor logger = logging.getLogger("sentry.snuba.spans_rpc") @@ -200,7 +200,9 @@ def process_item_groups(item_groups): break request.page_token.CopyFrom(response.page_token) # We want to process the spans while querying the next page - with ThreadPoolExecutor(thread_name_prefix=__name__, max_workers=2) as thread_pool: + with ContextPropagatingThreadPoolExecutor( + thread_name_prefix=__name__, max_workers=2 + ) as thread_pool: _ = thread_pool.submit(process_item_groups, response.item_groups) response_future = thread_pool.submit(snuba_rpc.get_trace_rpc, request) response = response_future.result() diff --git a/src/sentry/snuba/trace.py b/src/sentry/snuba/trace.py index 54991b0ca23d30..a346b1f055cf94 100644 --- a/src/sentry/snuba/trace.py +++ b/src/sentry/snuba/trace.py @@ -1,11 +1,11 @@ import logging from collections import defaultdict from collections.abc import Mapping -from concurrent.futures import ThreadPoolExecutor from datetime import datetime from typing import Any, Literal, NotRequired, TypedDict from sentry.uptime.subscriptions.regions import get_region_config +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor logger = logging.getLogger(__name__) @@ -640,7 +640,9 @@ def query_trace_data( # 1 worker each for spans, errors, performance issues, and optionally uptime max_workers = 4 if include_uptime else 3 - query_thread_pool = ThreadPoolExecutor(thread_name_prefix=__name__, max_workers=max_workers) + query_thread_pool = ContextPropagatingThreadPoolExecutor( + thread_name_prefix=__name__, max_workers=max_workers + ) with query_thread_pool: spans_future = query_thread_pool.submit( Spans.run_trace_query, diff --git a/src/sentry/utils/snuba.py b/src/sentry/utils/snuba.py index e88baf3c044e18..219b7eff37e292 100644 --- a/src/sentry/utils/snuba.py +++ b/src/sentry/utils/snuba.py @@ -9,7 +9,6 @@ import time from collections import namedtuple from collections.abc import Callable, Collection, Mapping, MutableMapping, Sequence -from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from copy import deepcopy from datetime import datetime, timedelta, timezone @@ -46,6 +45,7 @@ from sentry.snuba.query_sources import QuerySource from sentry.snuba.referrer import validate_referrer from sentry.utils import json, metrics +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.dates import outside_retention_with_modified_start logger = logging.getLogger(__name__) @@ -1237,7 +1237,7 @@ def _bulk_snuba_query(snuba_requests: Sequence[SnubaRequest]) -> ResultSet: span.set_tag("snuba.num_queries", len(snuba_requests_list)) if len(snuba_requests_list) > 1: - with ThreadPoolExecutor( + with ContextPropagatingThreadPoolExecutor( thread_name_prefix=__name__, max_workers=10, ) as query_thread_pool: diff --git a/src/sentry/utils/snuba_rpc.py b/src/sentry/utils/snuba_rpc.py index 12437cae9e7a61..88545c5578354b 100644 --- a/src/sentry/utils/snuba_rpc.py +++ b/src/sentry/utils/snuba_rpc.py @@ -2,7 +2,6 @@ import logging import os -from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from functools import partial from typing import Protocol, TypeVar @@ -48,6 +47,7 @@ from urllib3.response import BaseHTTPResponse from sentry.utils import json, metrics +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor from sentry.utils.snuba import SnubaError, _snuba_pool logger = logging.getLogger(__name__) @@ -159,7 +159,9 @@ def _make_rpc_requests( thread_isolation_scope=sentry_sdk.get_isolation_scope(), thread_current_scope=sentry_sdk.get_current_scope(), ) - with ThreadPoolExecutor(thread_name_prefix=__name__, max_workers=10) as query_thread_pool: + with ContextPropagatingThreadPoolExecutor( + thread_name_prefix=__name__, max_workers=10 + ) as query_thread_pool: response = [ result for result in query_thread_pool.map( diff --git a/static/app/components/commandPalette/ui/content.spec.tsx b/static/app/components/commandPalette/ui/content.spec.tsx index 8a027cccde7bfd..5417283a57f79c 100644 --- a/static/app/components/commandPalette/ui/content.spec.tsx +++ b/static/app/components/commandPalette/ui/content.spec.tsx @@ -124,4 +124,167 @@ describe('CommandPaletteContent', () => { expect(onChild).toHaveBeenCalled(); expect(closeSpy).toHaveBeenCalledTimes(1); }); + + describe('search', () => { + it('typing a query filters results to matching items only', async () => { + render(); + const input = await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.type(input, 'route'); + + expect( + await screen.findByRole('option', {name: 'Go to route'}) + ).toBeInTheDocument(); + expect(screen.queryByRole('option', {name: 'Other'})).not.toBeInTheDocument(); + expect( + screen.queryByRole('option', {name: 'Parent action'}) + ).not.toBeInTheDocument(); + }); + + it('non-matching items are not shown', async () => { + render(); + const input = await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.type(input, 'xyzzy'); + + expect(screen.queryAllByRole('option')).toHaveLength(0); + }); + + it('clearing the query restores all top-level items', async () => { + render(); + const input = await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.type(input, 'route'); + expect( + await screen.findByRole('option', {name: 'Go to route'}) + ).toBeInTheDocument(); + + await userEvent.clear(input); + + expect( + await screen.findByRole('option', {name: 'Go to route'}) + ).toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'Other'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'Parent action'})).toBeInTheDocument(); + }); + + it('child actions are hidden when query is empty', async () => { + render(); + await screen.findByRole('option', {name: 'Parent action'}); + + expect( + screen.queryByRole('option', {name: 'Child action'}) + ).not.toBeInTheDocument(); + }); + + it('child actions are directly searchable without drilling into the group', async () => { + render(); + const input = await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.type(input, 'child'); + + expect( + await screen.findByRole('option', {name: 'Parent action → Child action'}) + ).toBeInTheDocument(); + }); + + it('search is case-insensitive', async () => { + render(); + const input = await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.type(input, 'ROUTE'); + + expect( + await screen.findByRole('option', {name: 'Go to route'}) + ).toBeInTheDocument(); + }); + + it('actions are ranked by match quality — better matches appear first', async () => { + const actions: CommandPaletteAction[] = [ + { + type: 'navigate', + to: '/a/', + display: {label: 'Something with issues buried'}, + groupingKey: 'navigate', + }, + { + type: 'navigate', + to: '/b/', + display: {label: 'Issues'}, + groupingKey: 'navigate', + }, + ]; + render(); + const input = await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.type(input, 'issues'); + + const options = await screen.findAllByRole('option'); + expect(options[0]).toHaveAccessibleName('Issues'); + expect(options[1]).toHaveAccessibleName('Something with issues buried'); + }); + + it('top-level actions rank before child actions when both match the query', async () => { + const actions: CommandPaletteAction[] = [ + { + type: 'group', + display: {label: 'Group'}, + groupingKey: 'navigate', + actions: [{type: 'navigate', to: '/child/', display: {label: 'Issues'}}], + }, + { + type: 'navigate', + to: '/top/', + display: {label: 'Issues'}, + groupingKey: 'navigate', + }, + ]; + render(); + const input = await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.type(input, 'issues'); + + const options = await screen.findAllByRole('option'); + expect(options[0]).toHaveAccessibleName('Issues'); + expect(options[1]).toHaveAccessibleName('Group → Issues'); + }); + + it('actions with matching keywords are included in results', async () => { + const actions: CommandPaletteAction[] = [ + { + type: 'navigate', + to: '/shortcuts/', + display: {label: 'Keyboard shortcuts'}, + keywords: ['hotkeys', 'keybindings'], + groupingKey: 'help', + }, + ]; + render(); + const input = await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.type(input, 'hotkeys'); + + expect( + await screen.findByRole('option', {name: 'Keyboard shortcuts'}) + ).toBeInTheDocument(); + }); + + it("searching within a drilled-in group filters that group's children", async () => { + const actions: CommandPaletteAction[] = [ + { + type: 'group', + display: {label: 'Theme'}, + groupingKey: 'navigate', + actions: [ + {type: 'callback', onAction: jest.fn(), display: {label: 'Light'}}, + {type: 'callback', onAction: jest.fn(), display: {label: 'Dark'}}, + ], + }, + ]; + render(); + + // Drill into the group + await userEvent.click(await screen.findByRole('option', {name: 'Theme'})); + await screen.findByRole('option', {name: 'Light'}); + + // Now type a query that only matches one child + const input = screen.getByRole('textbox', {name: 'Search commands'}); + await userEvent.type(input, 'dark'); + + expect(await screen.findByRole('option', {name: 'Dark'})).toBeInTheDocument(); + expect(screen.queryByRole('option', {name: 'Light'})).not.toBeInTheDocument(); + }); + }); }); diff --git a/static/app/components/commandPalette/ui/useCommandPaletteState.tsx b/static/app/components/commandPalette/ui/useCommandPaletteState.tsx index 54510ed40e9316..789d8e912ecc5f 100644 --- a/static/app/components/commandPalette/ui/useCommandPaletteState.tsx +++ b/static/app/components/commandPalette/ui/useCommandPaletteState.tsx @@ -1,25 +1,13 @@ import {useMemo, useState} from 'react'; -import type Fuse from 'fuse.js'; import {useCommandPaletteActions} from 'sentry/components/commandPalette/context'; import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; -import {strGetFn} from 'sentry/components/search/sources/utils'; -import {useFuzzySearch} from 'sentry/utils/fuzzySearch'; +import {fzf} from 'sentry/utils/search/fzf'; type CommandPaletteActionWithPriority = CommandPaletteActionWithKey & { priority: number; }; -const FUZZY_SEARCH_CONFIG: Fuse.IFuseOptions = { - keys: ['display.label', 'display.details'], - getFn: strGetFn, - shouldSort: true, - minMatchCharLength: 1, - includeScore: true, - threshold: 0.2, - ignoreLocation: true, -}; - /** * Brings up child actions to make them directly searchable. * @@ -84,21 +72,40 @@ export function useCommandPaletteState() { return flattenActions(actions); }, [actions, selectedAction]); - const fuseSearch = useFuzzySearch(displayedActions, FUZZY_SEARCH_CONFIG); const filteredActions = useMemo(() => { - if (!fuseSearch || query.length === 0) { + if (query.length === 0) { // Do not display child actions before search return displayedActions.filter(a => a.priority === 0); } - const fuzzyResults = fuseSearch.search(query).map(a => a.item); - const fuzzyKeys = new Set(fuzzyResults.map(a => a.key)); - const searchResults = displayedActions.filter( - a => a.groupingKey === 'search-result' && !fuzzyKeys.has(a.key) - ); - return [...fuzzyResults, ...searchResults].toSorted( - (a, b) => a.priority - b.priority + + const normalizedQuery = query.toLowerCase(); + + const scored = displayedActions.map(action => { + const label = typeof action.display.label === 'string' ? action.display.label : ''; + const details = + typeof action.display.details === 'string' ? action.display.details : ''; + const keywords = action.keywords?.join(' ') ?? ''; + const searchText = [label, details, keywords].filter(Boolean).join(' '); + const result = fzf(searchText, normalizedQuery, false); + return {action, score: result.score, matched: result.end !== -1}; + }); + + const matched = scored.filter(r => r.matched); + const unmatchedSearchResults = scored.filter( + r => !r.matched && r.action.groupingKey === 'search-result' ); - }, [fuseSearch, query, displayedActions]); + + const sortedMatches = matched.toSorted((a, b) => { + const priorityDiff = a.action.priority - b.action.priority; + if (priorityDiff !== 0) return priorityDiff; + return b.score - a.score; + }); + + return [ + ...sortedMatches.map(r => r.action), + ...unmatchedSearchResults.map(r => r.action), + ]; + }, [query, displayedActions]); return { actions: filteredActions, diff --git a/static/app/components/core/statusIndicator/statusIndicator.mdx b/static/app/components/core/statusIndicator/statusIndicator.mdx index c1d884a61179a2..bf59b891b5a48b 100644 --- a/static/app/components/core/statusIndicator/statusIndicator.mdx +++ b/static/app/components/core/statusIndicator/statusIndicator.mdx @@ -7,7 +7,7 @@ resources: js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/statusIndicator/statusIndicator.tsx --- -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Grid} from '@sentry/scraps/layout'; import {StatusIndicator} from '@sentry/scraps/statusIndicator'; import {Text} from '@sentry/scraps/text'; @@ -15,33 +15,42 @@ import * as Storybook from 'sentry/stories'; export const documentation = import('!!type-loader!@sentry/scraps/statusIndicator'); -The `StatusIndicator` is a small circular indicator that communicates status or severity. It supports three semantic variants mapped to design tokens. +The `StatusIndicator` is a small dot indicator that communicates status or severity. It supports six semantic variants mapped to design tokens. Each dot renders with a subtle muted background pulse behind it. Placement is the caller's responsibility — the component renders as an inline element with no positioning of its own. ## Variants - - - - Info - - - - Warning - - - - Danger - - + + + Accent + + + Success + + + Warning + + + Danger + + + Promotion + + + Muted + + ```jsx - + + + + ``` ## Accessibility @@ -64,7 +73,7 @@ Omit `aria-label` when the indicator accompanies visible text that already conve // Meaningful — the indicator is the only signal - + // Live region — updates are announced to screen readers diff --git a/static/app/components/core/statusIndicator/statusIndicator.spec.tsx b/static/app/components/core/statusIndicator/statusIndicator.spec.tsx index 9c5fe52f320cb1..f7c626146e2162 100644 --- a/static/app/components/core/statusIndicator/statusIndicator.spec.tsx +++ b/static/app/components/core/statusIndicator/statusIndicator.spec.tsx @@ -4,12 +4,12 @@ import {StatusIndicator} from '@sentry/scraps/statusIndicator'; describe('StatusIndicator', () => { it('is hidden from the accessibility tree when no aria-label is provided', () => { - render(); + render(); expect(screen.getByTestId('dot')).toHaveAttribute('aria-hidden', 'true'); }); it('has role="img" and aria-label when aria-label is provided', () => { - render(); + render(); const dot = screen.getByRole('img', {name: 'Online'}); expect(dot).toBeInTheDocument(); expect(dot).not.toHaveAttribute('aria-hidden'); @@ -18,7 +18,7 @@ describe('StatusIndicator', () => { it('respects an explicit role override alongside aria-label', () => { render( { variant: StatusIndicatorVariant; } /** - * A small circular dot indicator that communicates status or state via color. + * A small dot indicator that communicates status or state via color. * * Accessibility: * - Provide `aria-label` when the dot conveys meaningful information not @@ -46,22 +52,37 @@ export function StatusIndicator(props: StatusIndicatorProps) { function getDotTokens( variant: StatusIndicatorVariant, theme: Theme -): {background: string; border: string} { +): {dot: string; pulse: string} { switch (variant) { + case 'accent': + return { + dot: theme.tokens.background.accent.vibrant, + pulse: theme.tokens.background.transparent.accent.muted, + }; case 'danger': return { - background: theme.tokens.background.danger.vibrant, - border: theme.tokens.border.danger.muted, + dot: theme.tokens.background.danger.vibrant, + pulse: theme.tokens.background.transparent.danger.muted, }; case 'warning': return { - background: theme.tokens.background.warning.vibrant, - border: theme.tokens.border.warning.muted, + dot: theme.tokens.background.warning.vibrant, + pulse: theme.tokens.background.transparent.warning.muted, + }; + case 'success': + return { + dot: theme.tokens.background.success.vibrant, + pulse: theme.tokens.background.transparent.success.muted, }; - case 'info': + case 'promotion': return { - background: theme.tokens.background.accent.vibrant, - border: theme.tokens.border.accent.muted, + dot: theme.tokens.background.promotion.vibrant, + pulse: theme.tokens.background.transparent.promotion.muted, + }; + case 'muted': + return { + dot: theme.tokens.graphics.neutral.moderate, + pulse: theme.tokens.background.transparent.neutral.muted, }; default: unreachable(variant); @@ -69,10 +90,42 @@ function getDotTokens( } } +const gentlePulse = keyframes` + 0% { + opacity: 0; + border-radius: 6px; + transform: scale(0.9) rotate(0); + } + 75% { + opacity: 1; + border-radius: 3px; + transform: scale(1) rotate(1turn); + } + 100% { + opacity: 0; + border-radius: 3px; + transform: scale(1.25) rotate(1turn); + } +`; + const Dot = styled('span')<{variant: StatusIndicatorVariant}>` - border-radius: 50%; - width: 10px; - height: 10px; - background-color: ${p => getDotTokens(p.variant, p.theme).background}; - border: 2px solid ${p => getDotTokens(p.variant, p.theme).border}; + position: relative; + isolation: isolate; + border-radius: ${p => p.theme.radius.xs}; + width: 8px; + height: 8px; + background-color: ${p => getDotTokens(p.variant, p.theme).dot}; + + &::before { + content: ''; + position: absolute; + z-index: -1; + top: -2px; + left: -2px; + width: 12px; + height: 12px; + border-radius: ${p => p.theme.radius['2xs']}; + background-color: ${p => getDotTokens(p.variant, p.theme).pulse}; + animation: ${gentlePulse} 2.2s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; + } `; diff --git a/static/app/components/events/eventStatisticalDetector/aggregateSpanDiff.spec.tsx b/static/app/components/events/eventStatisticalDetector/aggregateSpanDiff.spec.tsx index 5060356aad8c0c..c4c5694d9a2661 100644 --- a/static/app/components/events/eventStatisticalDetector/aggregateSpanDiff.spec.tsx +++ b/static/app/components/events/eventStatisticalDetector/aggregateSpanDiff.spec.tsx @@ -30,20 +30,23 @@ const defaultProject = ProjectFixture(); describe('AggregateSpanDiff', () => { beforeEach(() => { MockApiClient.clearMockResponses(); + // Spans endpoint is always queried first (primary data source) MockApiClient.addMockResponse({ - url: '/organizations/org-slug/events-root-cause-analysis/', - body: [ - { - span_op: 'db', - span_group: 'abc123', - span_description: 'SELECT * FROM users', - score: 0.9, - p95_before: 10.0, - p95_after: 20.0, - spm_before: 100.0, - spm_after: 90.0, - }, - ], + url: '/organizations/org-slug/events/', + body: { + data: [ + { + 'span.op': 'db', + 'span.group': 'abc123', + 'span.description': 'SELECT * FROM users', + [`regression_score(span.self_time,${BREAKPOINT_TIMESTAMP})`]: 0.9, + [`avg_by_timestamp(span.self_time,less,${BREAKPOINT_TIMESTAMP})`]: 10000, + [`avg_by_timestamp(span.self_time,greater,${BREAKPOINT_TIMESTAMP})`]: 20000, + [`epm_by_timestamp(less,${BREAKPOINT_TIMESTAMP})`]: 100.0, + [`epm_by_timestamp(greater,${BREAKPOINT_TIMESTAMP})`]: 90.0, + }, + ], + }, }); }); @@ -57,8 +60,8 @@ describe('AggregateSpanDiff', () => { it('renders empty state when no spans are returned', async () => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ - url: '/organizations/org-slug/events-root-cause-analysis/', - body: [], + url: '/organizations/org-slug/events/', + body: {data: []}, }); render(); @@ -68,16 +71,32 @@ describe('AggregateSpanDiff', () => { ).toBeInTheDocument(); }); - it('renders error state when request fails', async () => { + it('falls back to RCA endpoint when spans query fails', async () => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ - url: '/organizations/org-slug/events-root-cause-analysis/', + url: '/organizations/org-slug/events/', statusCode: 500, body: {detail: 'Internal Server Error'}, }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-root-cause-analysis/', + body: [ + { + span_op: 'db', + span_group: 'abc123', + span_description: 'SELECT * FROM users', + score: 0.9, + p95_before: 10.0, + p95_after: 20.0, + spm_before: 100.0, + spm_after: 90.0, + }, + ], + }); render(); - expect(await screen.findByText(/events-root-cause-analysis/)).toBeInTheDocument(); + expect(await screen.findByText('SELECT * FROM users')).toBeInTheDocument(); + expect(screen.getByText('db')).toBeInTheDocument(); }); }); diff --git a/static/app/components/events/eventStatisticalDetector/aggregateSpanDiff.tsx b/static/app/components/events/eventStatisticalDetector/aggregateSpanDiff.tsx index a89f7c2b83f78f..67ebf508806ca5 100644 --- a/static/app/components/events/eventStatisticalDetector/aggregateSpanDiff.tsx +++ b/static/app/components/events/eventStatisticalDetector/aggregateSpanDiff.tsx @@ -81,9 +81,6 @@ interface AggregateSpanDiffProps { export function AggregateSpanDiff({event, project}: AggregateSpanDiffProps) { const organization = useOrganization(); const location = useLocation(); - const isSpansOnly = organization.features.includes( - 'statistical-detectors-rca-spans-only' - ); const [causeType, setCauseType] = useState<'duration' | 'throughput'>('duration'); @@ -126,7 +123,6 @@ export function AggregateSpanDiff({event, project}: AggregateSpanDiffProps) { ], sorts: [{field: `regression_score(span.self_time,${breakpoint})`, kind: 'desc'}], limit: 10, - enabled: isSpansOnly, }, 'api.insights.transactions.statistical-detector-root-cause-analysis' ); @@ -141,12 +137,12 @@ export function AggregateSpanDiff({event, project}: AggregateSpanDiffProps) { end: endISO, breakpoint: new Date(breakpoint * 1000).toISOString(), projectId: project.id, - enabled: !isSpansOnly || isSpansDataError, + enabled: isSpansDataError, }); // The spans dataset may reject some legacy RCA fields/functions for certain orgs. // When that happens, fall back to the RCA endpoint so this section still renders. - const shouldUseSpansData = isSpansOnly && !isSpansDataError; + const shouldUseSpansData = !isSpansDataError; const tableData = useMemo(() => { if (shouldUseSpansData) { diff --git a/static/app/views/discover/results/issueListSeerComboBox.tsx b/static/app/views/discover/results/issueListSeerComboBox.tsx index 140af06374af5e..b145accf051a3b 100644 --- a/static/app/views/discover/results/issueListSeerComboBox.tsx +++ b/static/app/views/discover/results/issueListSeerComboBox.tsx @@ -295,12 +295,7 @@ export function IssueListSeerComboBox({onSearch}: IssueListSeerComboBoxProps) { ] ); - const areAiFeaturesAllowed = - enableAISearch && - !organization?.hideAiFeatures && - organization.features.includes('gen-ai-features'); - - if (!areAiFeaturesAllowed) { + if (!enableAISearch) { return null; } diff --git a/static/app/views/discover/results/resultsSearchQueryBuilder.tsx b/static/app/views/discover/results/resultsSearchQueryBuilder.tsx index e4316c61b57e60..6b77b7cc0a588e 100644 --- a/static/app/views/discover/results/resultsSearchQueryBuilder.tsx +++ b/static/app/views/discover/results/resultsSearchQueryBuilder.tsx @@ -143,8 +143,6 @@ export function ResultsSearchQueryBuilder(props: Props) { includeTransactions = true, } = props; - const organization = useOrganization(); - const placeholderText = useMemo(() => { return placeholder ?? t('Search for events, users, tags, and more'); }, [placeholder]); @@ -160,13 +158,12 @@ export function ResultsSearchQueryBuilder(props: Props) { includeTransactions, }); - // AI search is only enabled for Errors dataset + // AI search is only enabled for Errors dataset if translate endpoint is enabled const isErrorsDataset = dataset === DiscoverDatasets.ERRORS; - const areAiFeaturesAllowed = - isErrorsDataset && - !organization?.hideAiFeatures && - organization.features.includes('gen-ai-features') && - organization.features.includes('gen-ai-search-agent-translate'); + const organization = useOrganization(); + const hasTranslateEndpoint = organization.features.includes( + 'gen-ai-search-agent-translate' + ); const searchBarProps = { placeholderText, @@ -187,7 +184,7 @@ export function ResultsSearchQueryBuilder(props: Props) { return ( diff --git a/static/app/views/explore/logs/logsTabSeerComboBox.tsx b/static/app/views/explore/logs/logsTabSeerComboBox.tsx index 557db18c83d831..1b43b7565adcfd 100644 --- a/static/app/views/explore/logs/logsTabSeerComboBox.tsx +++ b/static/app/views/explore/logs/logsTabSeerComboBox.tsx @@ -259,11 +259,6 @@ export function LogsTabSeerComboBox() { ] ); - const areAiFeaturesAllowed = - enableAISearch && - !organization?.hideAiFeatures && - organization.features.includes('gen-ai-features'); - const usePollingEndpoint = organization.features.includes( 'gen-ai-search-agent-translate' ); @@ -311,7 +306,7 @@ export function LogsTabSeerComboBox() { [] ); - if (!areAiFeaturesAllowed) { + if (!enableAISearch) { return null; } diff --git a/static/app/views/explore/spans/spansTabSearchSection.tsx b/static/app/views/explore/spans/spansTabSearchSection.tsx index dbecf633a56e32..f38ba8865fb094 100644 --- a/static/app/views/explore/spans/spansTabSearchSection.tsx +++ b/static/app/views/explore/spans/spansTabSearchSection.tsx @@ -366,8 +366,6 @@ export function SpanTabSearchSection({datePageFilterProps}: SpanTabSearchSection const [caseInsensitive, setCaseInsensitive] = useCaseInsensitivity(); const organization = useOrganization(); - const areAiFeaturesAllowed = - !organization?.hideAiFeatures && organization.features.includes('gen-ai-features'); const hasRawSearchReplacement = organization.features.includes( 'search-query-builder-raw-search-replacement' ); @@ -449,10 +447,7 @@ export function SpanTabSearchSection({datePageFilterProps}: SpanTabSearchSection return ( - + tourContext={ExploreSpansTourContext} id={ExploreSpansTour.SEARCH_BAR} diff --git a/static/app/views/explore/spans/spansTabSeerComboBox.tsx b/static/app/views/explore/spans/spansTabSeerComboBox.tsx index 503d171782936d..c1382e057ceeb5 100644 --- a/static/app/views/explore/spans/spansTabSeerComboBox.tsx +++ b/static/app/views/explore/spans/spansTabSeerComboBox.tsx @@ -268,13 +268,8 @@ export function SpansTabSeerComboBox() { [askSeerSuggestedQueryRef, navigate, organization, pageFilters.selection] ); - const areAiFeaturesAllowed = - enableAISearch && - !organization?.hideAiFeatures && - organization.features.includes('gen-ai-features'); - useTraceExploreAiQuerySetup({ - enableAISearch: areAiFeaturesAllowed && !useTranslateEndpoint, + enableAISearch: enableAISearch && !useTranslateEndpoint, }); // Get selected project IDs for the polling variant diff --git a/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.spec.tsx b/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.spec.tsx index d31434d4184885..06126fa4eacb1b 100644 --- a/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.spec.tsx +++ b/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.spec.tsx @@ -72,17 +72,17 @@ describe('PageOverviewWebVitalsDetailPanel', () => { }); it('renders correctly with web vital', async () => { - render(, { + render(, { organization, }); await waitForElementToBeRemoved(() => screen.queryAllByTestId('loading-indicator')); - expect(screen.getAllByText('Largest Contentful Paint (P75)')).toHaveLength(2); + expect(screen.getAllByText('First Contentful Paint (P75)')).toHaveLength(2); expect(screen.getByText('Transaction')).toBeInTheDocument(); expect(screen.getByText('Profile')).toBeInTheDocument(); expect(screen.getByText('Replay')).toBeInTheDocument(); - expect(screen.getByText('lcp')).toBeInTheDocument(); - expect(screen.getByText('lcp Score')).toBeInTheDocument(); + expect(screen.getByText('fcp')).toBeInTheDocument(); + expect(screen.getByText('fcp Score')).toBeInTheDocument(); }); }); diff --git a/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx b/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx index a5322e543b4bf3..f16eabe6deb4dc 100644 --- a/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx +++ b/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx @@ -92,10 +92,6 @@ export function PageOverviewWebVitalsDetailPanel({ const browserTypes = decodeBrowserTypes(location.query[SpanFields.BROWSER_NAME]); const subregions = location.query[SpanFields.USER_GEO_SUBREGION] as SubregionCode[]; const isSpansWebVital = defined(webVital) && ['inp', 'cls', 'lcp'].includes(webVital); - const isInp = webVital === 'inp'; - const useSpansWebVitals = organization.features.includes( - 'performance-vitals-standalone-cls-lcp' - ); const replayLinkGenerator = generateReplayLink(routes); @@ -346,33 +342,18 @@ export function PageOverviewWebVitalsDetailPanel({ )} - {isInp ? ( - - ) : ( - - )} + diff --git a/static/app/views/insights/pages/mobile/am1OverviewPage.tsx b/static/app/views/insights/pages/mobile/am1OverviewPage.tsx index 18a6def8378693..4773346ca4d2eb 100644 --- a/static/app/views/insights/pages/mobile/am1OverviewPage.tsx +++ b/static/app/views/insights/pages/mobile/am1OverviewPage.tsx @@ -156,12 +156,10 @@ export function Am1MobileOverviewPage({datePageFilterProps}: Am1MobileOverviewPa if (organization.features.includes('insight-modules')) { doubleChartRowCharts[0] = PerformanceWidgetSetting.SLOW_SCREENS_BY_TTID; } - if (organization.features.includes('starfish-mobile-appstart')) { - doubleChartRowCharts.push( - PerformanceWidgetSetting.SLOW_SCREENS_BY_COLD_START, - PerformanceWidgetSetting.SLOW_SCREENS_BY_WARM_START - ); - } + doubleChartRowCharts.push( + PerformanceWidgetSetting.SLOW_SCREENS_BY_COLD_START, + PerformanceWidgetSetting.SLOW_SCREENS_BY_WARM_START + ); if (organization.features.includes('insight-modules')) { doubleChartRowCharts.push(PerformanceWidgetSetting.MOST_TIME_CONSUMING_DOMAINS); diff --git a/static/app/views/insights/pages/mobile/mobileOverviewPage.tsx b/static/app/views/insights/pages/mobile/mobileOverviewPage.tsx index 36abcac295d991..85feb873abfbca 100644 --- a/static/app/views/insights/pages/mobile/mobileOverviewPage.tsx +++ b/static/app/views/insights/pages/mobile/mobileOverviewPage.tsx @@ -172,12 +172,10 @@ function EAPMobileOverviewPage({datePageFilterProps}: EAPMobileOverviewPageProps if (organization.features.includes('insight-modules')) { doubleChartRowCharts[0] = PerformanceWidgetSetting.SLOW_SCREENS_BY_TTID; } - if (organization.features.includes('starfish-mobile-appstart')) { - doubleChartRowCharts.push( - PerformanceWidgetSetting.SLOW_SCREENS_BY_COLD_START, - PerformanceWidgetSetting.SLOW_SCREENS_BY_WARM_START - ); - } + doubleChartRowCharts.push( + PerformanceWidgetSetting.SLOW_SCREENS_BY_COLD_START, + PerformanceWidgetSetting.SLOW_SCREENS_BY_WARM_START + ); if (organization.features.includes('insight-modules')) { doubleChartRowCharts.push(PerformanceWidgetSetting.MOST_TIME_CONSUMING_DOMAINS); diff --git a/static/app/views/issueList/issueSearch.tsx b/static/app/views/issueList/issueSearch.tsx index 33946cf80f241c..d8b31c86a53bf4 100644 --- a/static/app/views/issueList/issueSearch.tsx +++ b/static/app/views/issueList/issueSearch.tsx @@ -37,16 +37,14 @@ function IssueSearchBar({query, onSearch, className}: IssueSearchProps) { } export function IssueSearch({query, onSearch, className}: IssueSearchProps) { - const organization = useOrganization(); const {selection: pageFilters} = usePageFilters(); const {getFilterKeys, getFilterKeySections, getTagValues} = useIssueListSearchBarDataProvider({pageFilters}); - // Gate behind gen-ai-search-agent-translate (internal only) plus standard AI consent checks - const areAiFeaturesAllowed = - !organization?.hideAiFeatures && - organization.features.includes('gen-ai-features') && - organization.features.includes('gen-ai-search-agent-translate'); + const organization = useOrganization(); + const hasTranslateEndpoint = organization.features.includes( + 'gen-ai-search-agent-translate' + ); return ( ( )} diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/sections/description.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/sections/description.tsx index e05fcac52b6331..4d6395d034ca7d 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/sections/description.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/sections/description.tsx @@ -89,12 +89,10 @@ export function SpanDescription({ return formatter.toString(span.description ?? ''); }, [span.description, resolvedModule, span.sentry_tags?.description, system]); - const hasNewSpansUIFlag = - organization.features.includes('performance-spans-new-ui') && - organization.features.includes('insight-modules'); + const hasInsightModules = organization.features.includes('insight-modules'); // The new spans UI relies on the group hash assigned by Relay, which is different from the hash available on the span itself - const groupHash = hasNewSpansUIFlag + const groupHash = hasInsightModules ? (span.sentry_tags?.group ?? '') : (span.hash ?? ''); const showAction = hasExploreEnabled ? !!span.description : !!span.op && !!span.hash; @@ -159,7 +157,7 @@ export function SpanDescription({ )} - ) : hasNewSpansUIFlag && + ) : hasInsightModules && resolvedModule === ModuleName.RESOURCE && span.op === 'resource.img' ? ( diff --git a/tests/js/sentry-test/snapshots/snapshot.ts b/tests/js/sentry-test/snapshots/snapshot.ts index 42cda35bd2f1eb..7833c66792aeb1 100644 --- a/tests/js/sentry-test/snapshots/snapshot.ts +++ b/tests/js/sentry-test/snapshots/snapshot.ts @@ -55,7 +55,7 @@ function renderToHTML(element: ReactElement): string { ${styleTags} diff --git a/uv.lock b/uv.lock index 1041e4bc62556c..a3bf1abf1a20ff 100644 --- a/uv.lock +++ b/uv.lock @@ -189,6 +189,20 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b" }, ] +[[package]] +name = "codeowners-coverage" +version = "0.3.0" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +dependencies = [ + { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pathspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/codeowners_coverage-0.3.0-py3-none-any.whl", hash = "sha256:d30abe67e36cdcee67b75f6364b04f61b382816e5040c84c3f85b0a1e96d2508" }, +] + [[package]] name = "confluent-kafka" version = "2.8.0" @@ -1470,10 +1484,10 @@ wheels = [ [[package]] name = "pathspec" -version = "0.9.0" +version = "1.0.4" source = { registry = "https://pypi.devinfra.sentry.io/simple" } wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a" }, + { url = "https://pypi.devinfra.sentry.io/wheels/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723" }, ] [[package]] @@ -2212,6 +2226,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "codeowners-coverage", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "covdefaults", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "devservices", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "django-stubs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2378,6 +2393,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "codeowners-coverage", specifier = ">=0.2.1" }, { name = "covdefaults", specifier = ">=2.3.0" }, { name = "devservices", specifier = ">=1.2.4" }, { name = "django-stubs", specifier = ">=5.2.9" },