11from __future__ import annotations
22
3+ import enum
4+ import logging
5+ from collections .abc import Mapping
36from dataclasses import dataclass
7+ from typing import Any , override
48
9+ from django .db import IntegrityError , transaction
10+ from django .utils import timezone
511from sentry_redis_tools .sliding_windows_rate_limiter import Quota
612
713from sentry .issues .grouptype import GroupCategory , GroupType , NotificationConfig
14+ from sentry .issues .issue_occurrence import IssueEvidence
15+ from sentry .models .eventerror import EventErrorType
816from sentry .types .group import PriorityLevel
9- from sentry .workflow_engine .types import DetectorSettings
17+ from sentry .utils import metrics
18+ from sentry .workflow_engine .handlers .detector .base import (
19+ DetectorOccurrence ,
20+ EventData ,
21+ GroupedDetectorEvaluationResult ,
22+ )
23+ from sentry .workflow_engine .handlers .detector .stateful import (
24+ DetectorThresholds ,
25+ StatefulDetectorHandler ,
26+ )
27+ from sentry .workflow_engine .models import DataPacket , DetectorState
28+ from sentry .workflow_engine .processors .data_condition_group import ProcessedDataConditionGroup
29+ from sentry .workflow_engine .types import (
30+ DetectorEvaluationResult ,
31+ DetectorGroupKey ,
32+ DetectorPriorityLevel ,
33+ DetectorSettings ,
34+ )
35+
36+ logger = logging .getLogger (__name__ )
37+
38+ # Error types from symbolicator that indicate sourcemap configuration problems
39+ JS_SOURCEMAP_ERROR_TYPES = frozenset (
40+ {
41+ EventErrorType .JS_MISSING_SOURCE ,
42+ EventErrorType .JS_INVALID_SOURCEMAP ,
43+ EventErrorType .JS_MISSING_SOURCES_CONTENT ,
44+ EventErrorType .JS_SCRAPING_DISABLED ,
45+ EventErrorType .JS_INVALID_SOURCEMAP_LOCATION ,
46+ }
47+ )
48+
49+
50+ class SourcemapCheckStatus (enum .IntEnum ):
51+ """
52+ Status values used as the comparison value for detector conditions.
53+ These must match the values used in DataCondition.comparison when
54+ provisioning the detector.
55+ """
56+
57+ SUCCESS = 0
58+ FAILURE = 1
59+
60+
61+ @dataclass (frozen = True )
62+ class SourcemapPacketValue :
63+ """
64+ The data payload passed into the sourcemap detector via DataPacket.
65+ Represents the error event that triggered detection
66+ """
67+
68+ event_id : str
69+ event_data : Mapping [str , Any ]
70+
71+
72+ class SourcemapDetectorHandler (StatefulDetectorHandler [SourcemapPacketValue , SourcemapCheckStatus ]):
73+ @override
74+ @property
75+ def thresholds (self ) -> DetectorThresholds :
76+ return {
77+ DetectorPriorityLevel .OK : 1 ,
78+ DetectorPriorityLevel .HIGH : 1 ,
79+ }
80+
81+ @override
82+ def extract_value (self , data_packet : DataPacket [SourcemapPacketValue ]) -> SourcemapCheckStatus :
83+ errors = data_packet .packet .event_data .get ("errors" , [])
84+ has_js_errors = any (e .get ("type" ) in JS_SOURCEMAP_ERROR_TYPES for e in errors )
85+ return SourcemapCheckStatus .FAILURE if has_js_errors else SourcemapCheckStatus .SUCCESS
86+
87+ @override
88+ def extract_dedupe_value (self , data_packet : DataPacket [SourcemapPacketValue ]) -> int :
89+ # Not used — we override evaluate_impl and skip dedupe logic
90+ return 0
91+
92+ @override
93+ def build_issue_fingerprint (self , group_key : DetectorGroupKey = None ) -> list [str ]:
94+ return [f"{ self .detector .project_id } :sourcemap" ]
95+
96+ @override
97+ def create_occurrence (
98+ self ,
99+ evaluation_result : ProcessedDataConditionGroup ,
100+ data_packet : DataPacket [SourcemapPacketValue ],
101+ priority : DetectorPriorityLevel ,
102+ ) -> tuple [DetectorOccurrence , EventData ]:
103+ event_data_dict = data_packet .packet .event_data
104+ errors = event_data_dict .get ("errors" , [])
105+ js_errors = [e for e in errors if e .get ("type" ) in JS_SOURCEMAP_ERROR_TYPES ]
106+ error_types = {e .get ("type" , "unknown" ) for e in js_errors }
107+
108+ evidence_data : dict [str , Any ] = {
109+ "error_types" : sorted (error_types ),
110+ "sample_event_id" : data_packet .packet .event_id ,
111+ }
112+
113+ evidence_display = [
114+ IssueEvidence (
115+ name = "Error types" ,
116+ value = ", " .join (sorted (error_types )),
117+ important = True ,
118+ ),
119+ ]
120+
121+ occurrence = DetectorOccurrence (
122+ issue_title = "Broken source maps detected" ,
123+ subtitle = "Source maps are not configured correctly for this project" ,
124+ evidence_data = evidence_data ,
125+ evidence_display = evidence_display ,
126+ type = SourcemapConfigurationType ,
127+ level = "warning" ,
128+ culprit = "" ,
129+ priority = priority ,
130+ )
131+
132+ event_data : EventData = {
133+ "platform" : event_data_dict .get ("platform" , "other" ),
134+ "sdk" : event_data_dict .get ("sdk" ),
135+ }
136+
137+ return (occurrence , event_data )
138+
139+ @override
140+ def evaluate_impl (
141+ self , data_packet : DataPacket [SourcemapPacketValue ]
142+ ) -> GroupedDetectorEvaluationResult :
143+ """
144+ Custom evaluation that skips dedupe and threshold counting.
145+ Uses atomic DB updates for state transitions instead of the
146+ parent's batched state manager approach.
147+ """
148+ data_value = self .extract_value (data_packet )
149+ results : dict [DetectorGroupKey , DetectorEvaluationResult ] = {}
150+
151+ condition_results , evaluated_priority = self ._evaluation_detector_conditions (data_value )
152+
153+ if condition_results is None or condition_results .logic_result .triggered is False :
154+ return GroupedDetectorEvaluationResult (result = results , tainted = False )
155+
156+ # Only handle triggering (FAILURE → HIGH). Resolution is handled
157+ # by a separate periodic task, not by the detector handler.
158+ if evaluated_priority != DetectorPriorityLevel .HIGH :
159+ return GroupedDetectorEvaluationResult (result = results , tainted = False )
160+
161+ # Atomic state transition: use filter().update() as a lock.
162+ # If another process already triggered, rows_updated will be 0.
163+ rows_updated = self ._try_state_transition (DetectorPriorityLevel .HIGH )
164+
165+ if not rows_updated :
166+ metrics .incr ("workflow_engine.sourcemap_detector.state_transition_conflict" )
167+ return GroupedDetectorEvaluationResult (result = results , tainted = False )
168+
169+ results [None ] = self ._build_detector_evaluation_result (
170+ None ,
171+ DetectorPriorityLevel .HIGH ,
172+ condition_results ,
173+ data_packet ,
174+ data_value ,
175+ )
176+
177+ return GroupedDetectorEvaluationResult (result = results , tainted = False )
178+
179+ def _try_state_transition (self , new_priority : DetectorPriorityLevel ) -> int :
180+ """
181+ Attempt an atomic state transition on DetectorState.
182+
183+ Uses filter().update() so that concurrent processes racing to make
184+ the same transition will see rows_updated=0 and bail out.
185+ """
186+ detector_states = self .state_manager .bulk_get_detector_state ([None ])
187+ detector_state = detector_states .get (None )
188+
189+ if detector_state is None :
190+ try :
191+ with transaction .atomic ():
192+ DetectorState .objects .create (
193+ detector = self .detector ,
194+ detector_group_key = None ,
195+ is_triggered = True ,
196+ state = new_priority ,
197+ )
198+ return 1
199+ except IntegrityError :
200+ # Another process created the row first, just exit
201+ return 0
202+
203+ return DetectorState .objects .filter (
204+ id = detector_state .id ,
205+ is_triggered = False ,
206+ ).update (
207+ is_triggered = True ,
208+ state = new_priority ,
209+ date_updated = timezone .now (),
210+ )
10211
11212
12213@dataclass (frozen = True )
@@ -23,7 +224,7 @@ class SourcemapConfigurationType(GroupType):
23224 creation_quota = Quota (3600 , 60 , 100 )
24225 notification_config = NotificationConfig (context = [])
25226 detector_settings = DetectorSettings (
26- handler = None ,
227+ handler = SourcemapDetectorHandler ,
27228 validator = None ,
28229 config_schema = {},
29230 )
0 commit comments