Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,8 +494,6 @@ def register_temporary_features(manager: FeatureManager) -> None:
# Enable lightweight RCA clustering write path (generate embeddings on new issues)
manager.add("organizations:supergroups-lightweight-rca-clustering-write", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)

manager.add("projects:workflow-engine-performance-detectors", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)

manager.add("organizations:relay-generate-billing-outcome", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)

manager.add("organizations:claude-code-vault-reuse", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
Expand Down
315 changes: 10 additions & 305 deletions src/sentry/issue_detection/performance_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@
import random
from collections.abc import Sequence
from dataclasses import dataclass
from enum import IntEnum
from typing import Any

import sentry_sdk
from django.db import router, transaction
from sentry_sdk.traces import StreamedSpan
from sentry_sdk.tracing import Span

from sentry import features, options, projectoptions
from sentry import options, projectoptions
from sentry.models.options.project_option import ProjectOption
from sentry.models.organization import Organization
from sentry.models.project import Project
Expand All @@ -21,7 +19,6 @@
from sentry.utils.event import is_event_from_browser_javascript_sdk
from sentry.utils.event_frames import get_sdk_name
from sentry.utils.tracing import set_span_tag, start_span
from sentry.workflow_engine.models import Detector

from .base import DetectorType, PerformanceDetector
from .detectors.consecutive_db_detector import ConsecutiveDBSpanDetector
Expand Down Expand Up @@ -144,34 +141,14 @@ def detect_performance_problems(
return []


class SettingsMode(IntEnum):
"""
Performance detection settings modes for gradual migration from ProjectOptions to WFE Detectors.

LEGACY: Use system defaults + well-known defaults + ProjectOption values.
Traditional behavior, no WFE Detector involvement.

COMPARE: Generate both legacy and WFE settings, log differences for monitoring,
return legacy settings. Used to detect config drift during migration.

WFE: Hybrid mode combining WFE Detector configs with ProjectOptions.
For detectors with WFE Detector objects: use WFE config (falls back to system defaults).
For detectors without WFE Detector objects: use ProjectOptions.
"""

LEGACY = 0
COMPARE = 1
WFE = 2


def get_merged_settings(
project: Project | None = None,
settings_mode: SettingsMode = SettingsMode.LEGACY,
) -> dict[str | Any, Any]:
"""
Get performance detection settings based on the specified mode.
Get performance detection settings.

Returns a dict with all performance detection settings.
Returns a dict with all performance detection settings, built from system
defaults, well-known project defaults, and stored ProjectOption values.
"""
system_settings = {
"n_plus_one_db_count": options.get("performance.issues.n_plus_one_db.count_threshold"),
Expand Down Expand Up @@ -255,318 +232,46 @@ def get_merged_settings(
else DEFAULT_PROJECT_PERFORMANCE_DETECTION_SETTINGS
)

legacy_settings = {
return {
**system_settings,
**default_project_settings,
**project_option_settings,
}

# If feature flag is disabled, always use LEGACY mode because we can't decide check the feature
# flag without a Project.
# TODO: WFE config should fall back to DEFAULT_PROJECT_PERFORMANCE_DETECTION_SETTINGS without a Project.
if not project or not features.has("projects:workflow-engine-performance-detectors", project):
settings_mode = SettingsMode.LEGACY

if settings_mode == SettingsMode.LEGACY:
return legacy_settings

assert project is not None

# Get WFE-specific settings (analogous to project_option_settings)
wfe_option_settings, wfe_managed_keys = _build_wfe_settings(project.id)

# Build complete WFE settings by merging in order
wfe_settings = {**system_settings, **default_project_settings, **wfe_option_settings}

if settings_mode == SettingsMode.COMPARE:
# Compare and log differences, but return legacy settings
_log_settings_diff(
project.id,
project.organization_id,
wfe_managed_keys,
legacy=legacy_settings,
wfe=wfe_settings,
)
return legacy_settings
elif settings_mode == SettingsMode.WFE:
# Build hybrid settings with clear priority:
# 1. Base: system + well-known defaults
# 2. For unmanaged keys: use project_option_settings
# 3. For managed keys: use wfe_option_settings (if specified, else base)
hybrid_settings = {**system_settings, **default_project_settings}

# Apply project options only for keys NOT managed by WFE
for key, value in project_option_settings.items():
if key not in wfe_managed_keys:
hybrid_settings[key] = value

# Apply WFE options for managed keys (overwrites base if specified)
hybrid_settings.update(wfe_option_settings)

return hybrid_settings

# Unreachable: all modes should be handled above
raise ValueError(f"Unknown settings_mode: {settings_mode}")


def _build_wfe_settings(project_id: int) -> tuple[dict[str, Any], set[str]]:
"""
Build WFE-specific settings (Detector.config-derived version of sentry:performance_issue_settings).
The returned settings dict will only contain keys that are managed by WFE Detectors.

Returns:
- wfe_option_settings: Dict with only WFE-specified config values
- wfe_managed_keys: Set of all setting keys managed by WFE; wfe_option_setings keys are a subset of this
"""
wfe_option_settings: dict[str, Any] = {}
wfe_managed_keys: set[str] = set()
wfe_configs = _get_wfe_detector_configs(project_id)

# Generate settings only for detectors that have WFE Detector objects
for detector_type, wfe_config in wfe_configs.items():
mapping = PERFORMANCE_DETECTOR_CONFIG_MAPPINGS[detector_type]

# All Detectors provide a detection_enabled key.
wfe_managed_keys.add(mapping.detection_enabled_key)
wfe_option_settings[mapping.detection_enabled_key] = wfe_config["detection_enabled"]

# For each config field: only set if present in WFE config
for field_name, option_key in mapping.option_keys.items():
wfe_managed_keys.add(option_key)

if field_name in wfe_config:
# TODO: Consider using None so we don't have to track managed keys.
wfe_option_settings[option_key] = wfe_config[field_name]

return wfe_option_settings, wfe_managed_keys


def _log_settings_diff(
project_id: int,
organization_id: int,
wfe_managed_keys: set[str],
*,
legacy: dict[str, Any],
wfe: dict[str, Any],
) -> None:
"""
Compare legacy and WFE settings and log any differences.

Only compares keys that are managed by WFE Detectors (detectors that have
WFE Detector objects). This avoids noise from detectors that haven't been
migrated yet.

This is used in COMPARE mode to monitor config drift between
ProjectOption-based settings and WFE Detector-based settings.
"""
differences: dict[str, dict[str, Any]] = {}

# Only compare keys that are managed by WFE Detectors
for key in wfe_managed_keys:
legacy_value = legacy.get(key)
wfe_value = wfe.get(key)

if legacy_value != wfe_value:
differences[key] = {
"legacy": legacy_value,
"wfe": wfe_value,
}

if differences:
logger.info(
"performance_detector.settings_diff",
extra={
"project_id": project_id,
"organization_id": organization_id,
"differences": differences,
"diff_count": len(differences),
},
)


def _get_wfe_detector_configs(project_id: int) -> dict[DetectorType, dict[str, Any]]:
"""
Fetch WFE Detector configs for performance detectors.

Returns a dict mapping DetectorType to config values from Detector.config
that should be used INSTEAD OF ProjectOption values for that detector.

When a WFE Detector exists, its config is always used (regardless of enabled state).
The Detector.enabled field controls detection_enabled, not whether we use WFE config.
"""
# Get all WFE detector types we can map.
wfe_types = [
mapping.wfe_detector_type for mapping in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.values()
]

# Per DetectorType configs
wfe_configs: dict[DetectorType, dict[str, Any]] = {}
for detector in Detector.objects.filter(
project_id=project_id,
type__in=wfe_types,
):
# Map WFE detector type to our DetectorType enum
detector_type = WFE_DETECTOR_TYPE_TO_CONFIG_MAPPING.get(detector.type)
if not detector_type:
continue

# Store raw Detector.config plus detection_enabled from Detector.enabled
wfe_configs[detector_type] = {
"detection_enabled": detector.enabled,
**detector.config, # Include all fields from Detector.config
}

return wfe_configs


def _get_wfe_detectors_by_type(project_id: int) -> dict[str, Detector]:
"""Fetch all performance WFE detectors for a project, keyed by detector type."""
return {
d.type: d
for d in Detector.objects.filter(
project_id=project_id, type__in=PERFORMANCE_WFE_DETECTOR_TYPES
)
}


def sync_project_options_to_wfe_detectors(
project_id: int, settings_data: dict[str, Any]
) -> dict[DetectorType, bool]:
"""
Sync ProjectOption settings to WFE Detector configs.

For each detector type with WFE mapping, updates an existing Detector with:
- Detector.enabled from detection_enabled setting
- Detector.config from mapped config fields

Returns dict of DetectorType -> bool indicating which detectors were updated.
"""
updated: dict[DetectorType, bool] = {}
existing_detectors = _get_wfe_detectors_by_type(project_id)

for detector_type, mapping in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.items():
detector = existing_detectors.get(mapping.wfe_detector_type)
if not detector:
continue

# Extract config fields (option_keys maps: wfe_field -> project_option_key)
new_config: dict[str, Any] = {}
for wfe_field, project_option_key in mapping.option_keys.items():
if project_option_key in settings_data:
new_config[wfe_field] = settings_data[project_option_key]

new_enabled: bool | None = None
if mapping.detection_enabled_key in settings_data:
new_enabled = settings_data[mapping.detection_enabled_key]

if new_enabled is None and not new_config:
continue

if new_enabled is not None and detector.enabled != new_enabled:
detector.update(enabled=new_enabled)
updated[detector_type] = True

if new_config:
merged_config = {**detector.config, **new_config}
if detector.config != merged_config:
detector.config = merged_config
detector.save(update_fields=["config"])
updated[detector_type] = True

return updated


def reset_wfe_detector_configs(project_id: int) -> dict[DetectorType, bool]:
"""
Reset WFE Detector configs to defaults by clearing config on enabled detectors.

Used when DELETE is called on performance settings. For enabled detectors, clears
custom config values (falls back to system defaults). For disabled detectors,
leaves config unchanged (assumes already in sync with ProjectOption).

Returns dict of DetectorType -> bool indicating which detectors were updated.
"""
updated: dict[DetectorType, bool] = {}
existing_detectors = _get_wfe_detectors_by_type(project_id)

for detector_type, mapping in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.items():
detector = existing_detectors.get(mapping.wfe_detector_type)
if not detector:
continue

# Clear config on enabled detectors (reset to defaults)
if detector.enabled and detector.config:
detector.config = {}
detector.save(update_fields=["config"])
updated[detector_type] = True

return updated


SETTINGS_PROJECT_OPTION_KEY = "sentry:performance_issue_settings"


@transaction.atomic(using=router.db_for_write(Detector))
def update_performance_settings(
project: Project,
settings: dict[str, Any],
sync_detectors: bool = False,
) -> dict[DetectorType, bool]:
def update_performance_settings(project: Project, settings: dict[str, Any]) -> None:
"""
Write performance issue settings to ProjectOption and optionally sync to WFE Detectors.
Write performance issue settings to ProjectOption.

Args:
project: The project to update settings for
settings: The full settings dict to store
sync_detectors: Whether to sync settings to WFE Detectors

Returns:
Dict of DetectorType -> bool indicating which detectors were updated
(empty if sync_detectors is False)
"""
# TODO: Fix potential cache inconsistency here. See ISWF-2156.
project.update_option(SETTINGS_PROJECT_OPTION_KEY, settings)

if sync_detectors:
return sync_project_options_to_wfe_detectors(project.id, settings)

return {}


@transaction.atomic(using=router.db_for_write(Detector))
def reset_performance_settings(
project: Project,
unchanged_options: dict[str, Any],
sync_detectors: bool = False,
) -> dict[DetectorType, bool]:
def reset_performance_settings(project: Project, unchanged_options: dict[str, Any]) -> None:
"""
Atomically reset ProjectOption and optionally reset WFE Detector configs.
Reset ProjectOption performance issue settings, preserving the given options.

Args:
project: The project to reset settings for
unchanged_options: Settings that should be preserved (not reset)
sync_detectors: Whether to reset WFE Detector configs

Returns:
Dict of DetectorType -> bool indicating which detectors were updated
(empty if sync_detectors is False)
"""
project.update_option(SETTINGS_PROJECT_OPTION_KEY, unchanged_options)

if sync_detectors:
return reset_wfe_detector_configs(project.id)

return {}


# Gets the thresholds to perform performance detection.
# Duration thresholds are in milliseconds.
# Allowed span ops are allowed span prefixes. (eg. 'http' would work for a span with 'http.client' as its op)
def get_detection_settings(
project: Project | None = None,
settings_mode: SettingsMode = SettingsMode.LEGACY,
) -> dict[DetectorType, dict[str, Any]]:
settings = get_merged_settings(project, settings_mode)
settings = get_merged_settings(project)

return {
DetectorType.SLOW_DB_QUERY: {
Expand Down
Loading
Loading