From 108b68700a0ade30c12a2dd6d1a886bc66c2b2d6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:39:49 +0000 Subject: [PATCH 1/8] feat(workflow_engine): Document metric monitor aggregates in the API schema The detector data_sources field was a plain ListField, so the generated OpenAPI schema typed it as an array of unknown items. Clients copied the accepted aggregate values from the backend source and drifted from it. Document each metric data source (errors, spans, logs, application metrics, crash rate) as a oneOf branch keyed by dataset and event types. Build each aggregate enum or pattern from the constants the validator checks against: EAP_FUNCTIONS intersected with the dataset definitions, TRANSLATABLE_COLUMNS, and CRASH_RATE_ALERT_AGGREGATE_RE. Drop the stale percentile() examples from the help text, since EAP spans don't define percentile. Refs #129450 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- src/sentry/snuba/entity_subscription.py | 2 +- .../validators/api_docs_help_text.py | 6 +- .../endpoints/validators/base/detector.py | 13 +- .../validators/metric_data_source_schema.py | 156 ++++++++++++++++++ .../test_metric_data_source_schema.py | 114 +++++++++++++ 5 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py create mode 100644 tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py diff --git a/src/sentry/snuba/entity_subscription.py b/src/sentry/snuba/entity_subscription.py index edf8fa687618..2177dfdb67e1 100644 --- a/src/sentry/snuba/entity_subscription.py +++ b/src/sentry/snuba/entity_subscription.py @@ -57,7 +57,7 @@ EntityKey.EAPItems: "timestamp", } CRASH_RATE_ALERT_AGGREGATE_RE = ( - r"^percentage\([ ]*(sessions_crashed|users_crashed)[ ]*\,[ ]*(sessions|users)[ ]*\)" + r"^percentage\([ ]*(sessions_crashed|users_crashed)[ ]*,[ ]*(sessions|users)[ ]*\)" ) ALERT_BLOCKED_FIELDS = { "start", diff --git a/src/sentry/workflow_engine/endpoints/validators/api_docs_help_text.py b/src/sentry/workflow_engine/endpoints/validators/api_docs_help_text.py index 973f1a311deb..54ba6541c7c7 100644 --- a/src/sentry/workflow_engine/endpoints/validators/api_docs_help_text.py +++ b/src/sentry/workflow_engine/endpoints/validators/api_docs_help_text.py @@ -655,6 +655,9 @@ DATA_SOURCES_HELP_TEXT = """ The data sources for the metric monitor to use based on what you want to measure. + The schema of each data source lists the `aggregate` values that its `dataset` and + `eventTypes` accept. + `timeWindow` is measured in seconds. Cron Monitors use schedules instead of this field. Legacy metric alert rules expressed this value in minutes, so multiply a legacy value by 60 when migrating it to a monitor. For example, a legacy 5-minute window becomes @@ -743,7 +746,7 @@ **Largest Contentful Paint Metric Monitor** - `dataset`: Use `events_analytics_platform`. To query transaction spans, add `is_transaction:true` to `query`. - - `aggregate`: Valid values are `avg(measurements.lcp)`, `p50(measurements.lcp)`, `p75(measurements.lcp)`, `p95(measurements.lcp)`, `p99(measurements.lcp)`, `p100(measurements.lcp)`, and `percentile(measurements.lcp,x)`, where `x` is your custom percentile. + - `aggregate`: Valid values are `avg(measurements.lcp)`, `p50(measurements.lcp)`, `p75(measurements.lcp)`, `p95(measurements.lcp)`, `p99(measurements.lcp)`, and `p100(measurements.lcp)`. ```json [ @@ -769,7 +772,6 @@ `p95(x)`, where x is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`. `p99(x)`, where x is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`. `p100(x)`, where `x` is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`. - `percentile(x,y)`, where `x` is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`, and `y` is the custom percentile. `failure_rate()` `apdex(x)`, where `x` is the value of the Apdex score. `count()` diff --git a/src/sentry/workflow_engine/endpoints/validators/base/detector.py b/src/sentry/workflow_engine/endpoints/validators/base/detector.py index bfaab3a7d8ea..f63080e6f336 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/detector.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/detector.py @@ -3,6 +3,7 @@ from typing import Any, NotRequired, TypedDict from django.db import router, transaction +from drf_spectacular.utils import extend_schema_field from jsonschema import ValidationError as JSONSchemaValidationError from rest_framework import serializers @@ -29,6 +30,9 @@ DataConditionGroupInput, ) from sentry.workflow_engine.endpoints.validators.base.data_source import DataSourceInput +from sentry.workflow_engine.endpoints.validators.metric_data_source_schema import ( + METRIC_DATA_SOURCE_SCHEMA, +) from sentry.workflow_engine.endpoints.validators.utils import ( connect_detectors_to_workflows, get_unknown_detector_type_error, @@ -64,6 +68,13 @@ class DetectorInput(TypedDict): enabled: NotRequired[bool] +# Each detector type validates its own data sources, so this only documents the metric monitor +# data sources that the public API describes. +@extend_schema_field({"type": "array", "items": METRIC_DATA_SOURCE_SCHEMA}) +class DataSourcesField(serializers.ListField): + pass + + class BaseDetectorTypeValidator(CamelSnakeSerializer[Any]): enforce_single_datasource = False """ @@ -88,7 +99,7 @@ class BaseDetectorTypeValidator(CamelSnakeSerializer[Any]): required=False, help_text="The IDs of the alerts to connect this monitor to. Use the 'Fetch Alerts' endpoint to find the IDs.", ) - data_sources = serializers.ListField( + data_sources = DataSourcesField( required=False, help_text=DATA_SOURCES_HELP_TEXT, ) diff --git a/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py b/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py new file mode 100644 index 000000000000..a965c9dc0a0c --- /dev/null +++ b/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py @@ -0,0 +1,156 @@ +""" +OpenAPI schema for the data sources of a metric monitor. + +Each entry is built from the constants that `SnubaQueryValidator` validates against, so that API +clients can read the accepted `aggregate` values from the schema instead of copying them. +""" + +import re +from collections.abc import Iterable +from typing import Any + +from sentry.incidents.logic import EAP_FUNCTIONS, TRANSLATABLE_COLUMNS +from sentry.search.eap.columns import ColumnDefinitions +from sentry.search.eap.ourlogs.definitions import OURLOG_DEFINITIONS +from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS +from sentry.search.eap.trace_metrics.definitions import TRACE_METRICS_DEFINITIONS +from sentry.snuba.dataset import Dataset +from sentry.snuba.entity_subscription import CRASH_RATE_ALERT_AGGREGATE_RE +from sentry.snuba.models import SnubaQuery, SnubaQueryEventType + + +def _defined_functions(definitions: ColumnDefinitions) -> set[str]: + return set(definitions.aggregates) | set(definitions.formulas) + + +def _alertable_eap_functions(definitions: ColumnDefinitions) -> list[str]: + defined = _defined_functions(definitions) + return [function for function in EAP_FUNCTIONS if function in defined] + + +def _function_call_pattern(functions: Iterable[str], allow_equations: bool = False) -> str: + call = r"(?:{})\(.*\)".format("|".join(re.escape(function) for function in functions)) + if allow_equations: + return rf"^(?:{call}|equation\|.+)$" + return rf"^{call}$" + + +ERRORS_AGGREGATES = [ + "count()", + *(f"count_unique({column})" for column in TRANSLATABLE_COLUMNS), + *(f"count_unique({column})" for column in TRANSLATABLE_COLUMNS.values()), +] +_SPANS_AGGREGATE_PATTERN = _function_call_pattern(_alertable_eap_functions(SPAN_DEFINITIONS)) +_LOGS_AGGREGATE_PATTERN = _function_call_pattern(_alertable_eap_functions(OURLOG_DEFINITIONS)) +_TRACE_METRICS_AGGREGATE_PATTERN = _function_call_pattern( + sorted(_defined_functions(TRACE_METRICS_DEFINITIONS)), allow_equations=True +) + + +def _data_source_schema( + *, + title: str, + description: str, + query_type: SnubaQuery.Type, + dataset: Dataset, + event_types: list[SnubaQueryEventType.EventType] | None, + aggregate: dict[str, Any], +) -> dict[str, Any]: + if event_types is None: + event_types_schema: dict[str, Any] = { + "type": "array", + "items": {"type": "string"}, + "description": "Ignored for this dataset. Send an empty list.", + } + else: + event_types_schema = { + "type": "array", + "items": { + "type": "string", + "enum": [event_type.name.lower() for event_type in event_types], + }, + "minItems": 1, + "maxItems": len(event_types), + } + + return { + "type": "object", + "title": title, + "description": description, + "properties": { + "query_type": {"type": "integer", "enum": [query_type.value]}, + "dataset": {"type": "string", "enum": [dataset.value]}, + "query": { + "type": "string", + "description": "A search query to filter the events, such as `is:unresolved`.", + }, + "aggregate": aggregate, + "time_window": { + "type": "integer", + "description": "The time window to aggregate over, in seconds.", + }, + "environment": {"type": "string", "nullable": True}, + "event_types": event_types_schema, + "extrapolation_mode": {"type": "string", "nullable": True}, + }, + "required": ["dataset", "query", "aggregate", "time_window", "environment", "event_types"], + } + + +METRIC_DATA_SOURCE_SCHEMA: dict[str, Any] = { + "oneOf": [ + _data_source_schema( + title="Errors", + description="Counts error events or the users they affect.", + query_type=SnubaQuery.Type.ERROR, + dataset=Dataset.Events, + event_types=[ + SnubaQueryEventType.EventType.ERROR, + SnubaQueryEventType.EventType.DEFAULT, + ], + aggregate={"type": "string", "enum": ERRORS_AGGREGATES}, + ), + _data_source_schema( + title="Spans", + description=( + "Aggregates span attributes, such as `p95(span.duration)`. " + "To query transaction spans, add `is_transaction:true` to `query`." + ), + query_type=SnubaQuery.Type.PERFORMANCE, + dataset=Dataset.EventsAnalyticsPlatform, + event_types=[SnubaQueryEventType.EventType.TRACE_ITEM_SPAN], + aggregate={"type": "string", "pattern": _SPANS_AGGREGATE_PATTERN}, + ), + _data_source_schema( + title="Logs", + description="Aggregates log attributes, such as `count(message)`.", + query_type=SnubaQuery.Type.PERFORMANCE, + dataset=Dataset.EventsAnalyticsPlatform, + event_types=[SnubaQueryEventType.EventType.TRACE_ITEM_LOG], + aggregate={"type": "string", "pattern": _LOGS_AGGREGATE_PATTERN}, + ), + _data_source_schema( + title="Application Metrics", + description=( + "Aggregates an application metric. The function arguments name the metric, " + "its type, and its unit, such as `per_second(value,metric_name,counter,-)`. " + "An `equation|` prefix combines several aggregates." + ), + query_type=SnubaQuery.Type.PERFORMANCE, + dataset=Dataset.EventsAnalyticsPlatform, + event_types=[SnubaQueryEventType.EventType.TRACE_ITEM_METRIC], + aggregate={"type": "string", "pattern": _TRACE_METRICS_AGGREGATE_PATTERN}, + ), + _data_source_schema( + title="Crash Rate", + description=( + "Measures the percentage of crashed sessions or users, such as " + "`percentage(sessions_crashed, sessions) AS _crash_rate_alert_aggregate`." + ), + query_type=SnubaQuery.Type.CRASH_RATE, + dataset=Dataset.Metrics, + event_types=None, + aggregate={"type": "string", "pattern": CRASH_RATE_ALERT_AGGREGATE_RE}, + ), + ] +} diff --git a/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py new file mode 100644 index 000000000000..d86155fbca9f --- /dev/null +++ b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py @@ -0,0 +1,114 @@ +import re +from typing import Any + +import jsonschema +import pytest + +from sentry.api.serializers.rest_framework.base import camel_to_snake_case, convert_dict_key_case +from sentry.incidents.logic import check_aggregate_column_support +from sentry.utils import json +from sentry.workflow_engine.endpoints.validators.api_docs_help_text import DATA_SOURCES_HELP_TEXT +from sentry.workflow_engine.endpoints.validators.metric_data_source_schema import ( + ERRORS_AGGREGATES, + METRIC_DATA_SOURCE_SCHEMA, +) + + +def _matching_titles(data_source: dict[str, Any]) -> list[str]: + return [ + branch["title"] + for branch in METRIC_DATA_SOURCE_SCHEMA["oneOf"] + if jsonschema.Draft7Validator(branch).is_valid(data_source) + ] + + +def _data_source(dataset: str, event_types: list[str], aggregate: str) -> dict[str, Any]: + return { + "dataset": dataset, + "query": "", + "aggregate": aggregate, + "time_window": 3600, + "environment": "prod", + "event_types": event_types, + } + + +def _help_text_examples() -> list[dict[str, Any]]: + blocks = re.findall(r"```json(.*?)```", DATA_SOURCES_HELP_TEXT, re.DOTALL) + return [ + convert_dict_key_case(data_source, camel_to_snake_case) + for block in blocks + for data_source in json.loads(block) + ] + + +@pytest.mark.parametrize("example", _help_text_examples(), ids=lambda example: example["aggregate"]) +def test_help_text_examples_match_one_data_source(example: dict[str, Any]) -> None: + assert len(_matching_titles(example)) == 1 + + +@pytest.mark.parametrize("aggregate", ERRORS_AGGREGATES) +def test_errors_aggregates_pass_column_support_check(aggregate: str) -> None: + assert check_aggregate_column_support(aggregate) + + +@pytest.mark.parametrize( + ("data_source", "title"), + [ + (_data_source("events", ["error"], "count_unique(user)"), "Errors"), + (_data_source("events_analytics_platform", ["trace_item_span"], "count()"), "Spans"), + ( + _data_source("events_analytics_platform", ["trace_item_span"], "p100(span.duration)"), + "Spans", + ), + (_data_source("events_analytics_platform", ["trace_item_log"], "count(message)"), "Logs"), + ( + _data_source( + "events_analytics_platform", + ["trace_item_metric"], + "per_second(value,metric_name_one,counter,-)", + ), + "Application Metrics", + ), + ( + _data_source( + "events_analytics_platform", + ["trace_item_metric"], + "equation|sum(value,a,counter,none) / sum(value,b,counter,none)", + ), + "Application Metrics", + ), + ( + _data_source( + "metrics", + [], + "percentage(sessions_crashed, sessions) AS _crash_rate_alert_aggregate", + ), + "Crash Rate", + ), + ( + _data_source( + "metrics", [], "percentage(users_crashed, users) AS _crash_rate_alert_aggregate" + ), + "Crash Rate", + ), + ], +) +def test_accepted_aggregates(data_source: dict[str, Any], title: str) -> None: + assert _matching_titles(data_source) == [title] + + +@pytest.mark.parametrize( + "data_source", + [ + _data_source("events", ["error"], "p95(transaction.duration)"), + _data_source( + "events_analytics_platform", ["trace_item_span"], "percentile(measurements.lcp,0.95)" + ), + _data_source("events_analytics_platform", ["trace_item_log"], "p100(severity)"), + _data_source("events_analytics_platform", ["trace_item_log"], "failure_rate()"), + _data_source("metrics", [], "count()"), + ], +) +def test_rejected_aggregates(data_source: dict[str, Any]) -> None: + assert _matching_titles(data_source) == [] From dc076eb24efb58d79b91b5421a067636ad6ad5d4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:48:49 +0000 Subject: [PATCH 2/8] fix(workflow_engine): Type data_sources as ListField for subclass overrides Detector validator subclasses override data_sources with their own ListField, which mypy rejected once the base field became DataSourcesField. --- .../workflow_engine/endpoints/validators/base/detector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/workflow_engine/endpoints/validators/base/detector.py b/src/sentry/workflow_engine/endpoints/validators/base/detector.py index f63080e6f336..2a190d7f2e4e 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/detector.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/detector.py @@ -99,7 +99,7 @@ class BaseDetectorTypeValidator(CamelSnakeSerializer[Any]): required=False, help_text="The IDs of the alerts to connect this monitor to. Use the 'Fetch Alerts' endpoint to find the IDs.", ) - data_sources = DataSourcesField( + data_sources: serializers.ListField = DataSourcesField( required=False, help_text=DATA_SOURCES_HELP_TEXT, ) From 3e1bd27f41f7830be50594b88b5846e8b0364658 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:00:47 +0000 Subject: [PATCH 3/8] fix(incidents): Accept performance_score and opportunity_score on span monitors The monitor UI offers both functions for span monitors since #112146, but they were missing from EAP_FUNCTIONS. opportunity_score fell through to legacy Discover validation and was rejected as an invalid function. performance_score passed only through the older Insights shortcut. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- src/sentry/incidents/logic.py | 2 ++ tests/sentry/snuba/test_validators.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/sentry/incidents/logic.py b/src/sentry/incidents/logic.py index 4af9fdfbee94..16fef26de1af 100644 --- a/src/sentry/incidents/logic.py +++ b/src/sentry/incidents/logic.py @@ -156,6 +156,8 @@ def update_detector(detector: Detector, enabled: bool) -> None: "eps", "apdex", "user_misery", + "performance_score", + "opportunity_score", ] diff --git a/tests/sentry/snuba/test_validators.py b/tests/sentry/snuba/test_validators.py index 77fadfc07197..ffaa62acc390 100644 --- a/tests/sentry/snuba/test_validators.py +++ b/tests/sentry/snuba/test_validators.py @@ -185,6 +185,30 @@ def test_eap_user_misery_aggregate(self) -> None: assert validator.is_valid(), validator.errors assert validator.validated_data["aggregate"] == "user_misery(span.duration,300)" + def test_eap_performance_score_aggregate(self) -> None: + data = { + "dataset": Dataset.EventsAnalyticsPlatform.value, + "query": "", + "aggregate": "performance_score(measurements.score.lcp)", + "timeWindow": 3600, + "environment": self.environment.name, + "eventTypes": [SnubaQueryEventType.EventType.TRACE_ITEM_SPAN.name.lower()], + } + validator = SnubaQueryValidator(data=data, context=self.context) + assert validator.is_valid(), validator.errors + + def test_eap_opportunity_score_aggregate(self) -> None: + data = { + "dataset": Dataset.EventsAnalyticsPlatform.value, + "query": "", + "aggregate": "opportunity_score(measurements.score.lcp)", + "timeWindow": 3600, + "environment": self.environment.name, + "eventTypes": [SnubaQueryEventType.EventType.TRACE_ITEM_SPAN.name.lower()], + } + validator = SnubaQueryValidator(data=data, context=self.context) + assert validator.is_valid(), validator.errors + @with_feature( { "organizations:performance-view": True, From 2d44aacf1d8cf766e138123d71cd172c925b112a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:00:48 +0000 Subject: [PATCH 4/8] docs(workflow_engine): Explain the scope of the metric data source schema Document why only metric data sources are described, and which aggregates the validator accepts that the schema leaves out on purpose. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../endpoints/validators/base/detector.py | 4 ++-- .../validators/metric_data_source_schema.py | 17 +++++++++++++++++ .../test_metric_data_source_schema.py | 14 ++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/sentry/workflow_engine/endpoints/validators/base/detector.py b/src/sentry/workflow_engine/endpoints/validators/base/detector.py index 2a190d7f2e4e..9ec82421fcf1 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/detector.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/detector.py @@ -68,8 +68,8 @@ class DetectorInput(TypedDict): enabled: NotRequired[bool] -# Each detector type validates its own data sources, so this only documents the metric monitor -# data sources that the public API describes. +# Each detector type validates its own data sources. The public API only documents metric +# monitors, so the schema only describes metric data sources; see metric_data_source_schema. @extend_schema_field({"type": "array", "items": METRIC_DATA_SOURCE_SCHEMA}) class DataSourcesField(serializers.ListField): pass diff --git a/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py b/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py index a965c9dc0a0c..1cb7b2b7206b 100644 --- a/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py +++ b/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py @@ -3,6 +3,23 @@ Each entry is built from the constants that `SnubaQueryValidator` validates against, so that API clients can read the accepted `aggregate` values from the schema instead of copying them. + +Scope: the detector endpoints use one request schema for every detector type, but the public API +only documents `metric_issue` monitors, so only metric data sources are described here. Cron and +uptime detectors still send their own data sources, and their validators accept them, but the +schema doesn't describe them. Adding a catch-all branch for them would make the `oneOf` match +any object, so clients could no longer use it to check aggregates. + +The aggregate lists document what metric monitors support, which is narrower than what the +validator lets through: + +- Errors: the validator accepts any Discover function whose column is empty or allowlisted, as + long as a test query runs. The schema lists `count()` and `count_unique()` over the + allowlisted columns. The monitor UI offers `count()` and `count_unique(user)`. +- Spans: `get_column_from_aggregate` also lets through Insights functions such as + `http_response_rate`, `cache_miss_rate`, and `count_scores`. That shortcut dates from 2024 + alerts on the span metrics dataset. Since 2025, Insights creates monitors with comparable + `count()` queries instead, because Explore and alerts don't support these functions. """ import re diff --git a/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py index d86155fbca9f..be44301240f0 100644 --- a/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py +++ b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py @@ -61,6 +61,14 @@ def test_errors_aggregates_pass_column_support_check(aggregate: str) -> None: _data_source("events_analytics_platform", ["trace_item_span"], "p100(span.duration)"), "Spans", ), + ( + _data_source( + "events_analytics_platform", + ["trace_item_span"], + "opportunity_score(measurements.score.lcp)", + ), + "Spans", + ), (_data_source("events_analytics_platform", ["trace_item_log"], "count(message)"), "Logs"), ( _data_source( @@ -105,6 +113,12 @@ def test_accepted_aggregates(data_source: dict[str, Any], title: str) -> None: _data_source( "events_analytics_platform", ["trace_item_span"], "percentile(measurements.lcp,0.95)" ), + _data_source("events_analytics_platform", ["trace_item_span"], "http_response_rate(5)"), + _data_source( + "events_analytics_platform", + ["trace_item_log"], + "performance_score(measurements.score.lcp)", + ), _data_source("events_analytics_platform", ["trace_item_log"], "p100(severity)"), _data_source("events_analytics_platform", ["trace_item_log"], "failure_rate()"), _data_source("metrics", [], "count()"), From 4d8c93e91992d0409e6e9e29adf799b3934e0be8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:09:49 +0000 Subject: [PATCH 5/8] test(workflow_engine): Check monitor UI aggregates against the data source schema The monitor form lists its span and log aggregates in TypeScript, separately from EAP_FUNCTIONS, and the two drifted: the UI offered opportunity_score while the backend rejected it. Parse the frontend lists and check that each offered function matches the schema branch for its dataset. The schema is built from EAP_FUNCTIONS and the dataset definitions, so this covers both. The logs form also offers epm, eps, p100, performance_score, and opportunity_score, which the logs definitions don't define. They are listed as known gaps until the UI stops offering them. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../test_metric_data_source_schema.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py index be44301240f0..293486a0ae75 100644 --- a/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py +++ b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py @@ -1,4 +1,5 @@ import re +from pathlib import Path from typing import Any import jsonschema @@ -126,3 +127,63 @@ def test_accepted_aggregates(data_source: dict[str, Any], title: str) -> None: ) def test_rejected_aggregates(data_source: dict[str, Any]) -> None: assert _matching_titles(data_source) == [] + + +_REPO_ROOT = Path(__file__).parents[5] +_FIELDS_TS = _REPO_ROOT / "static/app/utils/fields/index.ts" +_MONITOR_VISUALIZE_TSX = ( + _REPO_ROOT / "static/app/views/detectors/components/forms/metric/visualize.tsx" +) + +# The logs monitor UI offers these, but the logs definitions don't define them, so building the +# subscription request fails with "Unknown function". Remove entries as the UI stops offering them. +_OFFERED_BUT_UNSUPPORTED = { + "trace_item_log": {"epm", "eps", "p100", "performance_score", "opportunity_score"}, +} + + +def _ts_array(source: str, name: str) -> list[str]: + match = re.search(rf"const {name}(?:: \w+\[\])? = \[(.*?)\];", source, re.DOTALL) + assert match, f"{name} not found; update this parser to match the frontend source" + return re.findall(r"AggregationKey\.(\w+)", match.group(1)) + + +def _monitor_ui_aggregates() -> dict[str, set[str]]: + """Mirrors getEAPAllowedAggregates in the monitor form: the functions the UI offers per + event type, as backend function names.""" + fields_source = _FIELDS_TS.read_text() + enum_match = re.search(r"export enum AggregationKey \{(.*?)\}", fields_source, re.DOTALL) + assert enum_match, "AggregationKey not found; update this parser to match the frontend source" + function_names = dict(re.findall(r"(\w+) = '([^']+)'", enum_match.group(1))) + + visualize_source = _MONITOR_VISUALIZE_TSX.read_text() + offered = _ts_array(fields_source, "ALLOWED_EXPLORE_VISUALIZE_AGGREGATES") + _ts_array( + visualize_source, "ADDITIONAL_EAP_AGGREGATES" + ) + logs_excluded = _ts_array(visualize_source, "LOGS_EXCLUDED_AGGREGATES") + return { + "trace_item_span": {function_names[key] for key in offered}, + "trace_item_log": {function_names[key] for key in offered if key not in logs_excluded}, + } + + +@pytest.mark.parametrize( + ("event_type", "title"), [("trace_item_span", "Spans"), ("trace_item_log", "Logs")] +) +def test_monitor_ui_aggregates_are_in_schema(event_type: str, title: str) -> None: + """The monitor UI lists its aggregates in TypeScript, separately from EAP_FUNCTIONS. They + drifted before: the UI offered opportunity_score for months while the backend rejected it. + A failure means the UI offers a function that EAP_FUNCTIONS (src/sentry/incidents/logic.py) + or the dataset's definitions don't support. Add it there, or stop offering it.""" + offered = _monitor_ui_aggregates()[event_type] + assert offered + + unsupported = sorted( + function + for function in offered - _OFFERED_BUT_UNSUPPORTED.get(event_type, set()) + if _matching_titles( + _data_source("events_analytics_platform", [event_type], f"{function}()") + ) + != [title] + ) + assert unsupported == [] From af77ea079a4b7abbb9c2c58cab4637c13b44b91c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:49:47 +0000 Subject: [PATCH 6/8] test(incidents): Require a monitor decision for every EAP function EAP_FUNCTIONS has grown one function at a time, each after a monitor using it was rejected. Check that every span and log function the EAP definitions add is either in EAP_FUNCTIONS or explicitly excluded from monitors, so a new function can't be left out by accident. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- tests/sentry/incidents/test_logic.py | 74 ++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/sentry/incidents/test_logic.py b/tests/sentry/incidents/test_logic.py index c741763eaeca..7e9b277d7941 100644 --- a/tests/sentry/incidents/test_logic.py +++ b/tests/sentry/incidents/test_logic.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterable from datetime import timedelta import pytest @@ -8,11 +9,17 @@ from sentry.incidents.logic import ( DEFAULT_ALERT_RULE_RESOLUTION, DEFAULT_ALERT_RULE_WINDOW_TO_RESOLUTION, + EAP_FUNCTIONS, get_alert_resolution, translate_aggregate_field, update_detector, ) from sentry.incidents.utils.constants import INCIDENTS_SNUBA_SUBSCRIPTION_TYPE +from sentry.search.eap.columns import ColumnDefinitions +from sentry.search.eap.ourlogs.aggregates import LOG_AGGREGATE_COMBINATORS +from sentry.search.eap.ourlogs.definitions import OURLOG_DEFINITIONS +from sentry.search.eap.spans.aggregates import SPAN_AGGREGATE_COMBINATORS +from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS from sentry.snuba.dataset import Dataset from sentry.snuba.models import QuerySubscription, SnubaQuery, SnubaQueryEventType from sentry.snuba.subscriptions import create_snuba_query, create_snuba_subscription @@ -163,3 +170,70 @@ def test_crazy_low_range(self) -> None: time_window = -5 result = get_alert_resolution(time_window, self.organization) assert result == timedelta(minutes=DEFAULT_ALERT_RULE_RESOLUTION) + + +# EAP functions that metric monitors don't accept. A function added to the span or log definitions +# fails test_eap_functions_cover_eap_definitions until it is added here or to EAP_FUNCTIONS. +EAP_FUNCTIONS_EXCLUDED_FROM_MONITORS = { + # Insights creates monitors with equivalent count() queries instead, because Explore and + # alerts don't support these. + "cache_miss_rate", + "count_scores", + "http_response_rate", + # Not reviewed for monitors. Move to EAP_FUNCTIONS when a monitor needs one. + "any", + "avg_compare", + "avg_sample", + "bounded_sample", + "collect_unique", + "count_op", + "count_sample", + "count_starts", + "division", + "elapsed_if", + "first", + "http_response_count", + "last", + "p50_sample", + "time_spent_percentage", + "tpm", + "trace_status_rate", + "ttfd_contribution_rate", + "ttid_contribution_rate", +} + + +@pytest.mark.parametrize( + ("definitions", "combinators"), + [ + (SPAN_DEFINITIONS, SPAN_AGGREGATE_COMBINATORS), + (OURLOG_DEFINITIONS, LOG_AGGREGATE_COMBINATORS), + ], + ids=["spans", "logs"], +) +def test_eap_functions_cover_eap_definitions( + definitions: ColumnDefinitions, combinators: Iterable[str] +) -> None: + defined = set(definitions.aggregates) | set(definitions.formulas) + # Combinator variants such as avg_if are covered by their base function. Monitors filter with + # the query instead. + combinator_variants = { + f"{function}_{combinator}" for function in defined for combinator in combinators + } + undecided = ( + defined - combinator_variants - set(EAP_FUNCTIONS) - EAP_FUNCTIONS_EXCLUDED_FROM_MONITORS + ) + assert not undecided, ( + f"Decide whether metric monitors should accept {sorted(undecided)}: add each to " + "EAP_FUNCTIONS in src/sentry/incidents/logic.py, or to EAP_FUNCTIONS_EXCLUDED_FROM_MONITORS." + ) + + +def test_eap_functions_excluded_from_monitors_are_defined() -> None: + defined = ( + set(SPAN_DEFINITIONS.aggregates) + | set(SPAN_DEFINITIONS.formulas) + | set(OURLOG_DEFINITIONS.aggregates) + ) + assert EAP_FUNCTIONS_EXCLUDED_FROM_MONITORS <= defined + assert not EAP_FUNCTIONS_EXCLUDED_FROM_MONITORS & set(EAP_FUNCTIONS) From c1f6973c7ab4ad368c5d909bd0be588e25039876 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:49:49 +0000 Subject: [PATCH 7/8] feat(workflow_engine): Generate the monitor aggregate list for the frontend Write the functions metric monitors accept for spans and logs to static/app/views/detectors/monitorAggregates.generated.ts, from the same EAP_MONITOR_FUNCTIONS the API schema uses. A test fails when the file is out of date. This replaces the test that parsed the monitor form's TypeScript lists: the frontend can now filter its options by the generated list instead of keeping its own. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../validators/metric_data_source_schema.py | 14 +++- .../detectors/monitorAggregates.generated.ts | 47 ++++++++++++++ .../test_metric_data_source_schema.py | 65 ++----------------- tools/monitor_aggregates_to_typescript.py | 42 ++++++++++++ 4 files changed, 108 insertions(+), 60 deletions(-) create mode 100644 static/app/views/detectors/monitorAggregates.generated.ts create mode 100644 tools/monitor_aggregates_to_typescript.py diff --git a/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py b/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py index 1cb7b2b7206b..e72f2bf0999e 100644 --- a/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py +++ b/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py @@ -20,6 +20,11 @@ `http_response_rate`, `cache_miss_rate`, and `count_scores`. That shortcut dates from 2024 alerts on the span metrics dataset. Since 2025, Insights creates monitors with comparable `count()` queries instead, because Explore and alerts don't support these functions. + +`EAP_MONITOR_FUNCTIONS` is also written to +`static/app/views/detectors/monitorAggregates.generated.ts` by +`python3 -m tools.monitor_aggregates_to_typescript`, so the monitor form can't offer a function +that the backend rejects. """ import re @@ -57,8 +62,13 @@ def _function_call_pattern(functions: Iterable[str], allow_equations: bool = Fal *(f"count_unique({column})" for column in TRANSLATABLE_COLUMNS), *(f"count_unique({column})" for column in TRANSLATABLE_COLUMNS.values()), ] -_SPANS_AGGREGATE_PATTERN = _function_call_pattern(_alertable_eap_functions(SPAN_DEFINITIONS)) -_LOGS_AGGREGATE_PATTERN = _function_call_pattern(_alertable_eap_functions(OURLOG_DEFINITIONS)) +# Keyed by the frontend's DetectorDataset values. +EAP_MONITOR_FUNCTIONS = { + "spans": _alertable_eap_functions(SPAN_DEFINITIONS), + "logs": _alertable_eap_functions(OURLOG_DEFINITIONS), +} +_SPANS_AGGREGATE_PATTERN = _function_call_pattern(EAP_MONITOR_FUNCTIONS["spans"]) +_LOGS_AGGREGATE_PATTERN = _function_call_pattern(EAP_MONITOR_FUNCTIONS["logs"]) _TRACE_METRICS_AGGREGATE_PATTERN = _function_call_pattern( sorted(_defined_functions(TRACE_METRICS_DEFINITIONS)), allow_equations=True ) diff --git a/static/app/views/detectors/monitorAggregates.generated.ts b/static/app/views/detectors/monitorAggregates.generated.ts new file mode 100644 index 000000000000..a889d42a8ce0 --- /dev/null +++ b/static/app/views/detectors/monitorAggregates.generated.ts @@ -0,0 +1,47 @@ +/** + * GENERATED FILE. Do not edit manually. + * To update it run `python3 -m tools.monitor_aggregates_to_typescript` + * + * The functions that metric monitors accept for each EAP dataset, from + * EAP_MONITOR_FUNCTIONS in metric_data_source_schema.py. + * + * DEPLOYMENT: This is safe to deploy alongside backend changes. + */ + +export const MONITOR_AGGREGATES = { + spans: [ + 'count', + 'count_unique', + 'avg', + 'p50', + 'p75', + 'p90', + 'p95', + 'p99', + 'p100', + 'max', + 'min', + 'sum', + 'epm', + 'failure_count', + 'failure_rate', + 'eps', + 'apdex', + 'user_misery', + 'performance_score', + 'opportunity_score', + ], + logs: [ + 'count', + 'count_unique', + 'avg', + 'p50', + 'p75', + 'p90', + 'p95', + 'p99', + 'max', + 'min', + 'sum', + ], +} as const; diff --git a/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py index 293486a0ae75..3d65ee1b2d22 100644 --- a/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py +++ b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py @@ -1,5 +1,4 @@ import re -from pathlib import Path from typing import Any import jsonschema @@ -13,6 +12,7 @@ ERRORS_AGGREGATES, METRIC_DATA_SOURCE_SCHEMA, ) +from tools.monitor_aggregates_to_typescript import OUTPUT_PATH, render def _matching_titles(data_source: dict[str, Any]) -> list[str]: @@ -129,61 +129,10 @@ def test_rejected_aggregates(data_source: dict[str, Any]) -> None: assert _matching_titles(data_source) == [] -_REPO_ROOT = Path(__file__).parents[5] -_FIELDS_TS = _REPO_ROOT / "static/app/utils/fields/index.ts" -_MONITOR_VISUALIZE_TSX = ( - _REPO_ROOT / "static/app/views/detectors/components/forms/metric/visualize.tsx" -) - -# The logs monitor UI offers these, but the logs definitions don't define them, so building the -# subscription request fails with "Unknown function". Remove entries as the UI stops offering them. -_OFFERED_BUT_UNSUPPORTED = { - "trace_item_log": {"epm", "eps", "p100", "performance_score", "opportunity_score"}, -} - - -def _ts_array(source: str, name: str) -> list[str]: - match = re.search(rf"const {name}(?:: \w+\[\])? = \[(.*?)\];", source, re.DOTALL) - assert match, f"{name} not found; update this parser to match the frontend source" - return re.findall(r"AggregationKey\.(\w+)", match.group(1)) - - -def _monitor_ui_aggregates() -> dict[str, set[str]]: - """Mirrors getEAPAllowedAggregates in the monitor form: the functions the UI offers per - event type, as backend function names.""" - fields_source = _FIELDS_TS.read_text() - enum_match = re.search(r"export enum AggregationKey \{(.*?)\}", fields_source, re.DOTALL) - assert enum_match, "AggregationKey not found; update this parser to match the frontend source" - function_names = dict(re.findall(r"(\w+) = '([^']+)'", enum_match.group(1))) - - visualize_source = _MONITOR_VISUALIZE_TSX.read_text() - offered = _ts_array(fields_source, "ALLOWED_EXPLORE_VISUALIZE_AGGREGATES") + _ts_array( - visualize_source, "ADDITIONAL_EAP_AGGREGATES" - ) - logs_excluded = _ts_array(visualize_source, "LOGS_EXCLUDED_AGGREGATES") - return { - "trace_item_span": {function_names[key] for key in offered}, - "trace_item_log": {function_names[key] for key in offered if key not in logs_excluded}, - } - - -@pytest.mark.parametrize( - ("event_type", "title"), [("trace_item_span", "Spans"), ("trace_item_log", "Logs")] -) -def test_monitor_ui_aggregates_are_in_schema(event_type: str, title: str) -> None: - """The monitor UI lists its aggregates in TypeScript, separately from EAP_FUNCTIONS. They - drifted before: the UI offered opportunity_score for months while the backend rejected it. - A failure means the UI offers a function that EAP_FUNCTIONS (src/sentry/incidents/logic.py) - or the dataset's definitions don't support. Add it there, or stop offering it.""" - offered = _monitor_ui_aggregates()[event_type] - assert offered - - unsupported = sorted( - function - for function in offered - _OFFERED_BUT_UNSUPPORTED.get(event_type, set()) - if _matching_titles( - _data_source("events_analytics_platform", [event_type], f"{function}()") - ) - != [title] +def test_generated_typescript_is_current() -> None: + with open(OUTPUT_PATH) as f: + current = f.read() + assert current == render(), ( + f"{OUTPUT_PATH} is out of date. " + "Run `python3 -m tools.monitor_aggregates_to_typescript` to update it." ) - assert unsupported == [] diff --git a/tools/monitor_aggregates_to_typescript.py b/tools/monitor_aggregates_to_typescript.py new file mode 100644 index 000000000000..6dcafcad3f2c --- /dev/null +++ b/tools/monitor_aggregates_to_typescript.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +OUTPUT_PATH = "static/app/views/detectors/monitorAggregates.generated.ts" + + +def render() -> str: + from sentry.workflow_engine.endpoints.validators.metric_data_source_schema import ( + EAP_MONITOR_FUNCTIONS, + ) + + lines = [ + "/**", + " * GENERATED FILE. Do not edit manually.", + " * To update it run `python3 -m tools.monitor_aggregates_to_typescript`", + " *", + " * The functions that metric monitors accept for each EAP dataset, from", + " * EAP_MONITOR_FUNCTIONS in metric_data_source_schema.py.", + " *", + " * DEPLOYMENT: This is safe to deploy alongside backend changes.", + " */", + "", + "export const MONITOR_AGGREGATES = {", + ] + for dataset, functions in EAP_MONITOR_FUNCTIONS.items(): + lines.append(f" {dataset}: [") + lines.extend(f" '{function}'," for function in functions) + lines.append(" ],") + lines.append("} as const;") + return "\n".join(lines) + "\n" + + +def main() -> int: + from sentry.runner import configure + + configure() + + with open(OUTPUT_PATH, "w") as f: + f.write(render()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From fa2958876e25c5e7c33382a210273dd105e8f05e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:10:22 +0000 Subject: [PATCH 8/8] ref(workflow_engine): Write the monitor aggregate list as JSON Generate the list with json.dumps into a .json file instead of hand-assembling TypeScript lines. The frontend can import it directly because resolveJsonModule is enabled. Refs #129450 --- .../validators/metric_data_source_schema.py | 4 +- .../monitorAggregates.generated.json | 37 +++++++++++++++ .../detectors/monitorAggregates.generated.ts | 47 ------------------- .../test_metric_data_source_schema.py | 6 +-- tools/monitor_aggregates_to_json.py | 26 ++++++++++ tools/monitor_aggregates_to_typescript.py | 42 ----------------- 6 files changed, 68 insertions(+), 94 deletions(-) create mode 100644 static/app/views/detectors/monitorAggregates.generated.json delete mode 100644 static/app/views/detectors/monitorAggregates.generated.ts create mode 100644 tools/monitor_aggregates_to_json.py delete mode 100644 tools/monitor_aggregates_to_typescript.py diff --git a/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py b/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py index e72f2bf0999e..076786b9bcb3 100644 --- a/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py +++ b/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py @@ -22,8 +22,8 @@ `count()` queries instead, because Explore and alerts don't support these functions. `EAP_MONITOR_FUNCTIONS` is also written to -`static/app/views/detectors/monitorAggregates.generated.ts` by -`python3 -m tools.monitor_aggregates_to_typescript`, so the monitor form can't offer a function +`static/app/views/detectors/monitorAggregates.generated.json` by +`python3 -m tools.monitor_aggregates_to_json`, so the monitor form can't offer a function that the backend rejects. """ diff --git a/static/app/views/detectors/monitorAggregates.generated.json b/static/app/views/detectors/monitorAggregates.generated.json new file mode 100644 index 000000000000..da58dc8749f8 --- /dev/null +++ b/static/app/views/detectors/monitorAggregates.generated.json @@ -0,0 +1,37 @@ +{ + "spans": [ + "count", + "count_unique", + "avg", + "p50", + "p75", + "p90", + "p95", + "p99", + "p100", + "max", + "min", + "sum", + "epm", + "failure_count", + "failure_rate", + "eps", + "apdex", + "user_misery", + "performance_score", + "opportunity_score" + ], + "logs": [ + "count", + "count_unique", + "avg", + "p50", + "p75", + "p90", + "p95", + "p99", + "max", + "min", + "sum" + ] +} diff --git a/static/app/views/detectors/monitorAggregates.generated.ts b/static/app/views/detectors/monitorAggregates.generated.ts deleted file mode 100644 index a889d42a8ce0..000000000000 --- a/static/app/views/detectors/monitorAggregates.generated.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * GENERATED FILE. Do not edit manually. - * To update it run `python3 -m tools.monitor_aggregates_to_typescript` - * - * The functions that metric monitors accept for each EAP dataset, from - * EAP_MONITOR_FUNCTIONS in metric_data_source_schema.py. - * - * DEPLOYMENT: This is safe to deploy alongside backend changes. - */ - -export const MONITOR_AGGREGATES = { - spans: [ - 'count', - 'count_unique', - 'avg', - 'p50', - 'p75', - 'p90', - 'p95', - 'p99', - 'p100', - 'max', - 'min', - 'sum', - 'epm', - 'failure_count', - 'failure_rate', - 'eps', - 'apdex', - 'user_misery', - 'performance_score', - 'opportunity_score', - ], - logs: [ - 'count', - 'count_unique', - 'avg', - 'p50', - 'p75', - 'p90', - 'p95', - 'p99', - 'max', - 'min', - 'sum', - ], -} as const; diff --git a/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py index 3d65ee1b2d22..5bad984f170b 100644 --- a/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py +++ b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py @@ -12,7 +12,7 @@ ERRORS_AGGREGATES, METRIC_DATA_SOURCE_SCHEMA, ) -from tools.monitor_aggregates_to_typescript import OUTPUT_PATH, render +from tools.monitor_aggregates_to_json import OUTPUT_PATH, render def _matching_titles(data_source: dict[str, Any]) -> list[str]: @@ -129,10 +129,10 @@ def test_rejected_aggregates(data_source: dict[str, Any]) -> None: assert _matching_titles(data_source) == [] -def test_generated_typescript_is_current() -> None: +def test_generated_aggregates_are_current() -> None: with open(OUTPUT_PATH) as f: current = f.read() assert current == render(), ( f"{OUTPUT_PATH} is out of date. " - "Run `python3 -m tools.monitor_aggregates_to_typescript` to update it." + "Run `python3 -m tools.monitor_aggregates_to_json` to update it." ) diff --git a/tools/monitor_aggregates_to_json.py b/tools/monitor_aggregates_to_json.py new file mode 100644 index 000000000000..e61c64e77918 --- /dev/null +++ b/tools/monitor_aggregates_to_json.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +import json + +OUTPUT_PATH = "static/app/views/detectors/monitorAggregates.generated.json" + + +def render() -> str: + from sentry.workflow_engine.endpoints.validators.metric_data_source_schema import ( + EAP_MONITOR_FUNCTIONS, + ) + + return json.dumps(EAP_MONITOR_FUNCTIONS, indent=2) + "\n" + + +def main() -> int: + from sentry.runner import configure + + configure() + + with open(OUTPUT_PATH, "w") as f: + f.write(render()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/monitor_aggregates_to_typescript.py b/tools/monitor_aggregates_to_typescript.py deleted file mode 100644 index 6dcafcad3f2c..000000000000 --- a/tools/monitor_aggregates_to_typescript.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -OUTPUT_PATH = "static/app/views/detectors/monitorAggregates.generated.ts" - - -def render() -> str: - from sentry.workflow_engine.endpoints.validators.metric_data_source_schema import ( - EAP_MONITOR_FUNCTIONS, - ) - - lines = [ - "/**", - " * GENERATED FILE. Do not edit manually.", - " * To update it run `python3 -m tools.monitor_aggregates_to_typescript`", - " *", - " * The functions that metric monitors accept for each EAP dataset, from", - " * EAP_MONITOR_FUNCTIONS in metric_data_source_schema.py.", - " *", - " * DEPLOYMENT: This is safe to deploy alongside backend changes.", - " */", - "", - "export const MONITOR_AGGREGATES = {", - ] - for dataset, functions in EAP_MONITOR_FUNCTIONS.items(): - lines.append(f" {dataset}: [") - lines.extend(f" '{function}'," for function in functions) - lines.append(" ],") - lines.append("} as const;") - return "\n".join(lines) + "\n" - - -def main() -> int: - from sentry.runner import configure - - configure() - - with open(OUTPUT_PATH, "w") as f: - f.write(render()) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())