Skip to content
Open
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
155 changes: 155 additions & 0 deletions src/sentry/issue_detection/performance_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any

import sentry_sdk
from django.db import router, transaction

from sentry import features, nodestore, options, projectoptions
from sentry.models.options.project_option import ProjectOption
Expand Down Expand Up @@ -457,6 +458,160 @@ def _get_wfe_detector_configs(project: Project) -> dict[DetectorType, dict[str,
return wfe_configs


def _get_wfe_detectors_by_type(project: Project) -> 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=project, type__in=WFE_DETECTOR_TYPE_TO_CONFIG_MAPPING
)
}


def sync_project_options_to_wfe_detectors(
project: Project, 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)

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.toggle(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: Project, unchanged_options: dict[str, Any]
) -> dict[DetectorType, bool]:
"""
Reset WFE Detector configs to defaults, preserving only unchanged_options.

Used when DELETE is called on performance settings - clears custom config values
while preserving management/disabled options that shouldn't be reset.

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

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

# Build config with only fields that should be preserved
# option_keys maps: wfe_field -> project_option_key
preserved_config: dict[str, Any] = {}
for wfe_field, project_option_key in mapping.option_keys.items():
if project_option_key in unchanged_options:
if wfe_field in detector.config:
preserved_config[wfe_field] = detector.config[wfe_field]

if detector.config != preserved_config:
detector.config = preserved_config
detector.save(update_fields=["config"])
updated[detector_type] = True

# Reset detection_enabled if not in unchanged_options
if mapping.detection_enabled_key not in unchanged_options:
if detector.enabled:
detector.toggle(False)
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]:
"""
Write performance issue settings to ProjectOption and optionally sync to WFE Detectors.

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, 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]:
"""
Atomically reset ProjectOption and optionally reset WFE Detector configs.

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, unchanged_options)

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)
Expand Down
56 changes: 34 additions & 22 deletions src/sentry/issues/endpoints/project_performance_issue_settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from enum import Enum
from typing import Any

from rest_framework import serializers, status
from rest_framework.request import Request
Expand All @@ -10,7 +11,12 @@
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint, ProjectSettingPermission
from sentry.auth.superuser import superuser_has_permission
from sentry.issue_detection.performance_detection import get_merged_settings
from sentry.issue_detection.performance_detection import (
SETTINGS_PROJECT_OPTION_KEY,
get_merged_settings,
reset_performance_settings,
update_performance_settings,
)
from sentry.issues.grouptype import (
GroupType,
PerformanceConsecutiveDBQueriesGroupType,
Expand All @@ -29,11 +35,11 @@
QueryInjectionVulnerabilityGroupType,
WebVitalsGroup,
)
from sentry.models.project import Project

MAX_VALUE = 2147483647
TEN_SECONDS = 10000 # ten seconds in milliseconds
TEN_MB = 10000000 # ten MB in bytes
SETTINGS_PROJECT_OPTION_KEY = "sentry:performance_issue_settings"


class InternalProjectOptions(Enum):
Expand Down Expand Up @@ -223,6 +229,24 @@ def get_disabled_threshold_options(payload, current_settings):
return options


def get_current_performance_settings(project: Project) -> dict[str, Any]:
"""Return well-known defaults merged with project-level overrides."""
defaults: dict[str, Any] = projectoptions.get_well_known_default(
SETTINGS_PROJECT_OPTION_KEY,
project=project,
)
current: dict[str, Any] = project.get_option(SETTINGS_PROJECT_OPTION_KEY, default=defaults)
return {**defaults, **current}


def payload_contains_disabled_threshold_setting(
data: dict[str, Any], current_settings: dict[str, Any]
) -> bool:
"""Check if the payload contains threshold settings whose detector is disabled."""
disabled_options = get_disabled_threshold_options(data, current_settings)
return any(option in disabled_options for option in data)


@region_silo_endpoint
class ProjectPerformanceIssueSettingsEndpoint(ProjectEndpoint):
owner = ApiOwner.ISSUE_DETECTION_BACKEND
Expand Down Expand Up @@ -256,7 +280,7 @@ def get(self, request: Request, project) -> Response:

return Response(get_merged_settings(project))

def put(self, request: Request, project) -> Response:
def put(self, request: Request, project: Project) -> Response:
if not self.has_feature(project, request):
return self.respond(status=status.HTTP_404_NOT_FOUND)

Expand Down Expand Up @@ -295,31 +319,18 @@ def put(self, request: Request, project) -> Response:
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

performance_issue_settings_default = projectoptions.get_well_known_default(
SETTINGS_PROJECT_OPTION_KEY,
project=project,
)

performance_issue_settings = project.get_option(
SETTINGS_PROJECT_OPTION_KEY, default=performance_issue_settings_default
)

current_settings = {**performance_issue_settings_default, **performance_issue_settings}

data = serializer.validated_data
current_settings = get_current_performance_settings(project)

payload_contains_disabled_threshold_setting = any(
[option in get_disabled_threshold_options(data, current_settings) for option in data]
)
if payload_contains_disabled_threshold_setting:
if payload_contains_disabled_threshold_setting(data, current_settings):
return Response(
{"detail": "Disabled options can not be modified"},
status=status.HTTP_403_FORBIDDEN,
)

project.update_option(
SETTINGS_PROJECT_OPTION_KEY,
{**performance_issue_settings_default, **performance_issue_settings, **data},
sync_detectors = features.has("projects:workflow-engine-performance-detectors", project)
update_performance_settings(
project, {**current_settings, **data}, sync_detectors=sync_detectors
)

if body_has_admin_options or body_has_management_options:
Expand Down Expand Up @@ -349,6 +360,7 @@ def delete(self, request: Request, project) -> Response:
for option in project_settings
if option in management_options or option in disabled_options
}
project.update_option(SETTINGS_PROJECT_OPTION_KEY, unchanged_options)
sync_detectors = features.has("projects:workflow-engine-performance-detectors", project)
reset_performance_settings(project, unchanged_options, sync_detectors=sync_detectors)

return Response(status=status.HTTP_204_NO_CONTENT)
4 changes: 1 addition & 3 deletions src/sentry/workflow_engine/endpoints/validators/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from jsonschema import ValidationError as JsonValidationError
from jsonschema import validate

from sentry.constants import ObjectStatus
from sentry.issues import grouptype
from sentry.models.organization import Organization
from sentry.users.models.user import User
Expand Down Expand Up @@ -33,8 +32,7 @@ def log_alerting_quota_hit(


def toggle_detector(detector: Detector, enabled: bool) -> None:
updated_detector_status = ObjectStatus.ACTIVE if enabled else ObjectStatus.DISABLED
detector.update(status=updated_detector_status, enabled=enabled)
detector.toggle(enabled)


def validate_json_schema(value: Any, schema: Any) -> Any:
Expand Down
5 changes: 5 additions & 0 deletions src/sentry/workflow_engine/models/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ def get_snapshot(self) -> DetectorSnapshot:
def get_audit_log_data(self) -> dict[str, Any]:
return {"name": self.name}

def toggle(self, enabled: bool) -> None:
"""Toggle the detector's enabled state and update status accordingly."""
new_status = ObjectStatus.ACTIVE if enabled else ObjectStatus.DISABLED
self.update(enabled=enabled, status=new_status)

def get_option(
self, key: str, default: Any | None = None, validate: Callable[[object], bool] | None = None
) -> Any:
Expand Down
Loading
Loading