Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/sentry/incidents/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ def update_detector(detector: Detector, enabled: bool) -> None:
"eps",
"apdex",
"user_misery",
"performance_score",
"opportunity_score",
]


Expand Down
2 changes: 1 addition & 1 deletion src/sentry/snuba/entity_subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
[
Expand All @@ -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()`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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
"""
Expand All @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""
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.
"""

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},
),
]
}
24 changes: 24 additions & 0 deletions tests/sentry/snuba/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading