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/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..9ec82421fcf1 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. 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 + + 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: serializers.ListField = 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..076786b9bcb3 --- /dev/null +++ b/src/sentry/workflow_engine/endpoints/validators/metric_data_source_schema.py @@ -0,0 +1,183 @@ +""" +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. + +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. + +`EAP_MONITOR_FUNCTIONS` is also written to +`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. +""" + +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()), +] +# 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 +) + + +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/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/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) 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, 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..5bad984f170b --- /dev/null +++ b/tests/sentry/workflow_engine/endpoints/validators/test_metric_data_source_schema.py @@ -0,0 +1,138 @@ +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, +) +from tools.monitor_aggregates_to_json import OUTPUT_PATH, render + + +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_span"], + "opportunity_score(measurements.score.lcp)", + ), + "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_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()"), + ], +) +def test_rejected_aggregates(data_source: dict[str, Any]) -> None: + assert _matching_titles(data_source) == [] + + +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_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())