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" },