Skip to content

Commit f9cd263

Browse files
authored
Merge 7c2c2ed into e471971
2 parents e471971 + 7c2c2ed commit f9cd263

File tree

2 files changed

+350
-2
lines changed

2 files changed

+350
-2
lines changed

src/sentry/processing_errors/grouptype.py

Lines changed: 203 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,213 @@
11
from __future__ import annotations
22

3+
import enum
4+
import logging
5+
from collections.abc import Mapping
36
from dataclasses import dataclass
7+
from typing import Any, override
48

9+
from django.db import IntegrityError, transaction
10+
from django.utils import timezone
511
from sentry_redis_tools.sliding_windows_rate_limiter import Quota
612

713
from sentry.issues.grouptype import GroupCategory, GroupType, NotificationConfig
14+
from sentry.issues.issue_occurrence import IssueEvidence
15+
from sentry.models.eventerror import EventErrorType
816
from 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
)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from typing import Any
2+
3+
from sentry.issues.issue_occurrence import IssueOccurrence
4+
from sentry.processing_errors.grouptype import (
5+
SourcemapCheckStatus,
6+
SourcemapConfigurationType,
7+
SourcemapDetectorHandler,
8+
SourcemapPacketValue,
9+
)
10+
from sentry.testutils.cases import TestCase
11+
from sentry.workflow_engine.models.data_condition import Condition
12+
from sentry.workflow_engine.models.data_source import DataPacket
13+
from sentry.workflow_engine.models.detector import Detector
14+
from sentry.workflow_engine.models.detector_state import DetectorState
15+
from sentry.workflow_engine.types import DetectorEvaluationResult, DetectorPriorityLevel
16+
17+
18+
class TestSourcemapDetectorHandler(TestCase):
19+
def create_sourcemap_detector(
20+
self,
21+
detector_state: DetectorPriorityLevel = DetectorPriorityLevel.OK,
22+
) -> Detector:
23+
condition_group = self.create_data_condition_group(
24+
organization=self.project.organization,
25+
)
26+
self.create_data_condition(
27+
comparison=SourcemapCheckStatus.FAILURE,
28+
type=Condition.EQUAL,
29+
condition_result=DetectorPriorityLevel.HIGH,
30+
condition_group=condition_group,
31+
)
32+
self.create_data_condition(
33+
comparison=SourcemapCheckStatus.SUCCESS,
34+
type=Condition.EQUAL,
35+
condition_result=DetectorPriorityLevel.OK,
36+
condition_group=condition_group,
37+
)
38+
detector = self.create_detector(
39+
type=SourcemapConfigurationType.slug,
40+
project=self.project,
41+
name="Sourcemap Configuration",
42+
config={},
43+
workflow_condition_group=condition_group,
44+
)
45+
self.create_detector_state(
46+
detector=detector,
47+
state=detector_state,
48+
is_triggered=detector_state == DetectorPriorityLevel.HIGH,
49+
)
50+
return detector
51+
52+
def make_packet(
53+
self,
54+
errors: list | None = None,
55+
event_id: str = "abc123",
56+
platform: str = "javascript",
57+
) -> DataPacket[SourcemapPacketValue]:
58+
if errors is None:
59+
errors = []
60+
event_data: dict[str, Any] = {
61+
"errors": errors,
62+
"platform": platform,
63+
"sdk": {"name": "sentry.javascript.browser", "version": "7.0.0"},
64+
}
65+
return DataPacket(
66+
source_id=str(self.project.id),
67+
packet=SourcemapPacketValue(
68+
event_id=event_id,
69+
event_data=event_data,
70+
),
71+
)
72+
73+
def handle_result(
74+
self, detector: Detector, data_packet: DataPacket[SourcemapPacketValue]
75+
) -> DetectorEvaluationResult | None:
76+
handler = SourcemapDetectorHandler(detector)
77+
evaluation = handler.evaluate(data_packet)
78+
if None not in evaluation:
79+
return None
80+
return evaluation[None]
81+
82+
def test_failure_creates_occurrence(self) -> None:
83+
detector = self.create_sourcemap_detector()
84+
85+
errors = [
86+
{"type": "js_no_source", "url": "https://example.com/app.js"},
87+
{"type": "js_invalid_source", "url": "https://example.com/vendor.js"},
88+
]
89+
90+
result = self.handle_result(
91+
detector,
92+
self.make_packet(errors=errors, event_id="test-event-123", platform="javascript"),
93+
)
94+
95+
assert result is not None
96+
assert result.priority == DetectorPriorityLevel.HIGH
97+
assert isinstance(result.result, IssueOccurrence)
98+
assert result.result.issue_title == "Broken source maps detected"
99+
assert result.result.evidence_data["error_types"] == ["js_invalid_source", "js_no_source"]
100+
assert result.result.evidence_data["sample_event_id"] == "test-event-123"
101+
assert result.event_data is not None
102+
assert result.event_data["platform"] == "javascript"
103+
104+
state = DetectorState.objects.get(detector=detector)
105+
assert state.is_triggered is True
106+
assert state.state == str(DetectorPriorityLevel.HIGH)
107+
108+
def test_duplicate_failure_does_not_trigger(self) -> None:
109+
detector = self.create_sourcemap_detector()
110+
packet = self.make_packet(
111+
errors=[{"type": "js_no_source", "url": "https://example.com/app.js"}],
112+
)
113+
114+
result = self.handle_result(detector, packet)
115+
assert result is not None
116+
assert isinstance(result.result, IssueOccurrence)
117+
118+
result = self.handle_result(detector, packet)
119+
assert result is None
120+
121+
def test_no_sourcemap_errors_does_not_trigger(self) -> None:
122+
detector = self.create_sourcemap_detector()
123+
124+
assert self.handle_result(detector, self.make_packet(errors=[])) is None
125+
assert (
126+
self.handle_result(
127+
detector,
128+
self.make_packet(errors=[{"type": "native_missing_dsym", "image": "libfoo.so"}]),
129+
)
130+
is None
131+
)
132+
133+
def test_failure_without_detector_state_creates_it(self) -> None:
134+
detector = self.create_sourcemap_detector()
135+
DetectorState.objects.filter(detector=detector).delete()
136+
137+
result = self.handle_result(
138+
detector,
139+
self.make_packet(
140+
errors=[{"type": "js_no_source", "url": "https://example.com/app.js"}],
141+
),
142+
)
143+
144+
assert result is not None
145+
assert isinstance(result.result, IssueOccurrence)
146+
state = DetectorState.objects.get(detector=detector)
147+
assert state.is_triggered is True

0 commit comments

Comments
 (0)