feat(workflow_engine): Add in hook for producing occurrences from the stateful detector - #10
Conversation
… stateful detector (#80168) This adds a hook that can be implemented to produce an occurrence specific to the detector that is subclassing the StatefulDetector. Also change the signature of evaluate to return a dict keyed by groupkey instead of a list. This helps avoid the chance of duplicate results for the same group key. <!-- Describe your PR here. -->
There was a problem hiding this comment.
Greptile Summary
This PR implements a significant architectural enhancement to Sentry's workflow engine by adding the ability for stateful detectors to produce issue occurrences. The changes span across four main files:
Core Model Enhancement (detector.py): A new group_type property is added to the Detector model, providing typed access to the GroupType instance associated with each detector. This refactors the existing detector_handler property to eliminate code duplication and improve type safety by centralizing the group type lookup logic.
Handler Migration (grouptype.py): The MetricAlertDetectorHandler is refactored to inherit from StatefulDetectorHandler instead of DetectorHandler, removing its concrete evaluate() method implementation. This aligns with the broader migration towards stateful detection capabilities.
Processor Core Logic (detector.py): The most substantial changes occur in the processor, where the detector evaluation pipeline is enhanced to support occurrence production. Key modifications include:
- Changing return types from lists to dictionaries keyed by
DetectorGroupKeyto eliminate duplicate handling - Adding a new abstract method
build_occurrence_and_event_datathat subclasses must implement - Integrating Kafka occurrence production when detectors transition to non-OK status
- Removing redundant duplicate detection logic in favor of dictionary-based deduplication
Test Infrastructure (test_detector.py): Comprehensive test updates validate the new occurrence production functionality, including mock implementations of the abstract method and verification of Kafka integration.
These changes enable the workflow engine to create and emit proper IssueOccurrence objects to Kafka when detectors identify problems, completing the integration between the detection system and Sentry's issue tracking infrastructure. The architecture now supports both issue creation (via occurrences) and resolution (via status change messages).
Confidence score: 2/5
- This PR introduces significant architectural changes but has several implementation gaps that make it risky to merge
- The score reflects incomplete abstract method implementations and potential runtime failures in the
MetricAlertDetectorHandler - Files needing attention:
src/sentry/incidents/grouptype.py(incomplete implementation),src/sentry/workflow_engine/processors/detector.py(verify abstract method contracts)
4 files reviewed, 3 comments
| class MetricAlertDetectorHandler(StatefulDetectorHandler[QuerySubscriptionUpdate]): | ||
| pass |
There was a problem hiding this comment.
logic: StatefulDetectorHandler is an abstract base class that requires implementing several abstract methods: counter_names, get_dedupe_value, get_group_key_values, and build_occurrence_and_event_data. This class will not be instantiable without these implementations.
| occurrence_2, event_data_2 = build_mock_occurrence_and_event( | ||
| detector.detector_handler, "group_2", 6, PriorityLevel.HIGH |
There was a problem hiding this comment.
logic: The hardcoded value 6 for group_2 seems inconsistent - the test data shows group_2 has value 10, but the expected occurrence is built with value 6
| occurrence_2, event_data_2 = build_mock_occurrence_and_event( | |
| detector.detector_handler, "group_2", 6, PriorityLevel.HIGH | |
| occurrence_2, event_data_2 = build_mock_occurrence_and_event( | |
| detector.detector_handler, "group_2", 10, PriorityLevel.HIGH |
| occurrence, event_data = build_mock_occurrence_and_event( | ||
| handler, "val1", 6, PriorityLevel.HIGH |
There was a problem hiding this comment.
logic: The test builds an expected result using build_mock_occurrence_and_event with 'val1' and value 6, but this doesn't match the test's group_key parameter 'group_key' - potential test logic error
Review Summary🏷️ Draft Comments (1)
|
ron-x5labs
left a comment
There was a problem hiding this comment.
Code Review: feat(workflow_engine): Add in hook for producing occurrences from the stateful detector
Problem
Stateful detectors could evaluate conditions and track state, but firing transitions produced no issue occurrence — evaluate_group_key_value left result = None with a TODO: Add hook here for generating occurrence. This PR adds that hook and switches detector evaluation results from lists to dicts keyed by DetectorGroupKey. (The PR body is content-free; intent inferred from the title and diff.)
Solution Reviewed
StatefulDetectorHandlergains an abstractbuild_occurrence_and_event_data(group_key, value, new_status);evaluate_group_key_valuecalls it on every non-OK status transition and forwards occurrence + event_data throughprocess_detectors→create_issue_occurrence_from_result→produce_occurrence_to_kafka. ThePriorityLevel(new_status)conversion is safe (DetectorPriorityLevelmirrorsPriorityLevelvalues;OKis handled by the resolution branch).process_detectors/DetectorHandler.evaluate/StatefulDetectorHandler.evaluatenow returndict[DetectorGroupKey, DetectorEvaluationResult]; the duplicate-group-key guard + error log inprocess_detectorswere removed — correct, dict keying makes duplicates structurally impossible.Detector.group_typeproperty extracted and reused; tests restructured around the new hook.
Summary
Solid refactor of the plumbing, and the list→dict migration breaks no in-repo callers (process_detectors has no production caller yet; handler.evaluate is only called from process_detectors). Three issues keep this short of mergeable: the new tests cannot actually detect wrong group_key/value/priority being passed into the hook (id-only IssueOccurrence equality + one shared hardcoded id), the only non-test handler became uninstantiable (a registered grouptype now raises TypeError on instantiation instead of no-opping), and the newly-activated fire path silently swallows Kafka produce failures before committing state. All are non-blocking today but become expensive once the pipeline is wired up.
Files Reviewed
src/sentry/workflow_engine/processors/detector.py— deeply reviewedsrc/sentry/incidents/grouptype.py— deeply reviewedtests/sentry/workflow_engine/processors/test_detector.py— deeply reviewedsrc/sentry/workflow_engine/models/detector.py— lightly reviewed (small property extraction)
Verification
python3 -m py_compileon all four changed files — passed- pytest — skipped: this environment has no Python 3.12 / sentry dependency stack or database; findings are grounded in direct source reads and cross-checked against upstream (the worktree is byte-identical to getsentry/sentry commit
de60b7fb01afor the core files)
Issues Found
🔴 Blocking
None.
🟡 Non-blocking
src/sentry/incidents/grouptype.py:11—MetricAlertDetectorHandleris now uninstantiable; see inline comment.tests/sentry/workflow_engine/processors/test_detector.py:252— hook-argument assertions are vacuous and the mock'sevent_datashape is not JSON-serializable; see inline comment.src/sentry/workflow_engine/processors/detector.py:62— produce-then-commit ordering silently loses alerts on Kafka failure; see inline comment.
💡 Suggestions
src/sentry/workflow_engine/processors/detector.py:230— stale docstring; see inline comment.
Verdict
Recommend changes before merge — nothing is broken in production today (the pipeline has no production caller yet), but the hook-argument tests are vacuous, the placeholder handler crashes on instantiation, and the fire path loses alerts silently; all three are cheap to fix now and expensive to debug later.
| ) -> list[DetectorEvaluationResult]: | ||
| # TODO: Implement | ||
| return [] | ||
| class MetricAlertDetectorHandler(StatefulDetectorHandler[QuerySubscriptionUpdate]): |
There was a problem hiding this comment.
Latent crash: this handler is now uninstantiable. StatefulDetectorHandler declares four abstract members (counter_names, get_dedupe_value, get_group_key_values, and the new build_occurrence_and_event_data) and this pass subclass implements none of them, so Detector.detector_handler (models/detector.py:86, group_type.detector_handler(self)) now raises TypeError for any metric_alert_fire detector instead of the previous instantiable no-op (evaluate -> []). That property handles its other two failure modes with logger.error + return None but has no guard for this one, so one such detector would abort the whole process_detectors batch. No production path creates these detectors yet, but the registered MetricAlertFire grouptype now points at a class that can never be constructed. Suggest keeping the placeholder instantiable until the real implementation lands:
class MetricAlertDetectorHandler(StatefulDetectorHandler[QuerySubscriptionUpdate]):
counter_names: list[str] = []
def get_dedupe_value(self, data_packet: DataPacket[QuerySubscriptionUpdate]) -> int:
raise NotImplementedError
def get_group_key_values(self, data_packet: DataPacket[QuerySubscriptionUpdate]) -> dict[str, int]:
raise NotImplementedError
def build_occurrence_and_event_data(self, group_key, value, new_status):
raise NotImplementedError(The # TODO: This will be a stateful detector when we build that abstraction comment above is also stale now — this PR is that abstraction.)
| ) -> tuple[IssueOccurrence, dict[str, Any]]: | ||
| assert handler.detector.group_type is not None | ||
| occurrence = IssueOccurrence( | ||
| id="eb4b0acffadb4d098d48cb14165ab578", |
There was a problem hiding this comment.
The new hook's argument propagation is untestable as mocked — and the mock's event_data shape would crash in production. Two issues with build_mock_occurrence_and_event:
IssueOccurrence.__eq__compares onlyself.id(issues/issue_occurrence.py:181-184), and every occurrence built here shares the hardcoded ideb4b0acf…. So assertions pass vacuously:TestEvaluateGroupKeyValue.test_dedupebuilds the expected occurrence with("val1", 6)but callsevaluate_group_key_value("group_key", 10, …), andtest_state_results_multi_groupbuildsgroup_2's expected occurrence with value 6 while the packet carries 10. A handler that passed the wronggroup_key,value, ornew_statusinto the hook would keep every assertion green — the core behavior this PR introduces is untested. Derive the id from the hook args and/or assert the fields directly:
id=uuid.uuid5(uuid.NAMESPACE_X500, f"{group_key}:{value}:{new_status.name}").hex
# and/or:
assert results[0][1]["group_2"].result.fingerprint == handler.build_fingerprint("group_2")event_dataembeds rawdatetimeobjects for"timestamp"/"received", butproduce_occurrence_to_kafkaruns plainjson.dumpson the payload (issues/producer.py:72) — a real implementation following this shape would raiseTypeError. Cf.uptime/issue_platform.py, which usesdetection_time.isoformat(). The shape is never exercised because the tests mock the producer.
|
|
||
| for result in detector_results.values(): | ||
| if result.result is not None: | ||
| create_issue_occurrence_from_result(result) |
There was a problem hiding this comment.
Fire-path delivery semantics: produce failures are silently swallowed, then state is committed anyway. create_issue_occurrence_from_result → produce_occurrence_to_kafka catches KafkaException and only logs it (issues/producer.py:82-90), so a dropped produce is indistinguishable from success here; handler.commit_state_updates() (line 68) then persists the new status/dedupe value, state_data.status == new_status on the next packet, and the occurrence is permanently lost — a silent missed alert with no metric. Conversely, a non-KafkaException raise (e.g. the event-id ValueError at producer.py:100-101) aborts before commit, so group keys already produced duplicate on the next packet. This ordering is now exercised for every occurrence the new hook produces. At minimum, make the at-least-once vs at-most-once choice explicit and emit a failure metric:
# make create_issue_occurrence_from_result return success (or re-raise)
produced = all(
create_issue_occurrence_from_result(result)
for result in detector_results.values()
if result.result is not None
)
if detector_results:
results.append((detector, detector_results))
if produced:
handler.commit_state_updates()| self, data_packet: DataPacket[T] | ||
| ) -> dict[DetectorGroupKey, DetectorEvaluationResult]: | ||
| """ | ||
| Evaluates a given data packet and returns a list of `DetectorEvaluationResult`. |
There was a problem hiding this comment.
nit (docs): this docstring still says "returns a list of DetectorEvaluationResult" — the signature and body now return a dict keyed by DetectorGroupKey:
Evaluates a given data packet and returns a dict of `DetectorEvaluationResult`
keyed by `DetectorGroupKey`.
Test [new PR number opened]