Files
2024-02-15 17:01:50 +00:00

679 lines
23 KiB
Python

from __future__ import annotations
import re
from collections.abc import Sequence
from datetime import datetime, timedelta
import pytest
from snuba_sdk import Direction, Granularity, Limit, Offset
from snuba_sdk.conditions import ConditionGroup
from sentry.exceptions import InvalidParams
from sentry.receivers import create_default_projects
from sentry.snuba.metrics import (
OPERATIONS,
DerivedMetricParseException,
Groupable,
MetricField,
MetricGroupByField,
MetricOrderByField,
MetricsQuery,
parse_conditions,
)
from sentry.snuba.metrics.naming_layer import SessionMRI, TransactionMRI
from sentry.testutils.helpers.datetime import freeze_time
from sentry.testutils.pytest.fixtures import django_db_all
from sentry.utils.dates import parse_stats_period
class MetricsQueryBuilder:
AVG_DURATION_METRIC = MetricField(op="avg", metric_mri=SessionMRI.DURATION.value)
def __init__(self):
now = datetime.now()
self.org_id: int = 1
self.project_ids: Sequence[int] = [1, 2]
self.select: Sequence[MetricField] = [self.AVG_DURATION_METRIC]
self.start: datetime | None = now - timedelta(hours=1)
self.end: datetime | None = now
self.granularity: Granularity = Granularity(3600)
self.orderby: ConditionGroup | None = None
self.where: Sequence[Groupable] | None = None
self.having: ConditionGroup | None = None
self.groupby: Sequence[MetricGroupByField] | None = None
self.limit: Limit | None = None
self.offset: Offset | None = None
self.include_series: bool = True
self.include_totals: bool = True
self.interval: int | None = None
self.is_alerts_query: bool = False
def with_select(self, select: Sequence[MetricField]) -> MetricsQueryBuilder:
self.select = select
return self
def with_start(self, start: datetime) -> MetricsQueryBuilder:
self.start = start
return self
def with_end(self, end: datetime) -> MetricsQueryBuilder:
self.end = end
return self
def with_where(self, where: Sequence[ConditionGroup]) -> MetricsQueryBuilder:
self.where = where
return self
def with_having(self, having: list[ConditionGroup]) -> MetricsQueryBuilder:
self.having = having
return self
def with_orderby(self, orderby: Sequence[MetricOrderByField]) -> MetricsQueryBuilder:
self.orderby = orderby
return self
def with_granularity(self, granularity: Granularity) -> MetricsQueryBuilder:
self.granularity = granularity
return self
def with_include_series(self, include_series: bool) -> MetricsQueryBuilder:
self.include_series = include_series
return self
def with_include_totals(self, include_totals: bool) -> MetricsQueryBuilder:
self.include_totals = include_totals
return self
def with_limit(self, limit: Limit) -> MetricsQueryBuilder:
self.limit = limit
return self
def with_groupby(self, groupby: Sequence[MetricGroupByField]) -> MetricsQueryBuilder:
self.groupby = groupby
return self
def with_interval(self, interval: int) -> MetricsQueryBuilder:
self.interval = interval
return self
def to_metrics_query_dict(self):
return {
"org_id": self.org_id,
"project_ids": self.project_ids,
"start": self.start,
"end": self.end,
"granularity": self.granularity,
"select": self.select,
"orderby": self.orderby,
"where": self.where,
"having": self.having,
"groupby": self.groupby,
"limit": self.limit,
"offset": self.offset,
"include_series": self.include_series,
"include_totals": self.include_totals,
"interval": self.interval,
"is_alerts_query": self.is_alerts_query,
}
def test_metric_field_equality_with_equal_fields():
ap_dex_with_alias_1 = MetricField(op=None, metric_mri=TransactionMRI.APDEX.value, alias="apdex")
ap_dex_with_alias_2 = MetricField(op=None, metric_mri=TransactionMRI.APDEX.value, alias="apdex")
assert ap_dex_with_alias_1 == ap_dex_with_alias_2
def test_metric_field_equality_with_different_aliases():
ap_dex_with_alias_1 = MetricField(op=None, metric_mri=TransactionMRI.APDEX.value, alias="apdex")
ap_dex_with_alias_2 = MetricField(
op=None, metric_mri=TransactionMRI.APDEX.value, alias="transaction.apdex"
)
assert ap_dex_with_alias_1 == ap_dex_with_alias_2
def test_metric_field_equality_with_different_mris():
ap_dex_with_alias_1 = MetricField(op=None, metric_mri=TransactionMRI.APDEX.value, alias="apdex")
ap_dex_with_alias_2 = MetricField(
op=None, metric_mri=TransactionMRI.DURATION.value, alias="duration"
)
assert not ap_dex_with_alias_1 == ap_dex_with_alias_2
def test_validate_select():
with pytest.raises(InvalidParams, match='Request is missing a "field"'):
MetricsQuery(**MetricsQueryBuilder().with_select([]).to_metrics_query_dict())
with pytest.raises(
InvalidParams,
match=(f"Invalid operation 'foo'. Must be one of {', '.join(OPERATIONS)}"),
):
MetricsQuery(
**MetricsQueryBuilder()
.with_select([MetricField(op="foo", metric_mri=SessionMRI.DURATION.value)]) # type: ignore[arg-type]
.to_metrics_query_dict()
)
with pytest.raises(
DerivedMetricParseException,
match=(
re.escape(
"Failed to parse sum(session.crash_free_rate). No operations can be "
"applied on this field as it is already a derived metric with an "
"aggregation applied to it."
)
),
):
MetricsQuery(
**MetricsQueryBuilder()
.with_select([MetricField(op="sum", metric_mri=SessionMRI.CRASH_FREE_RATE.value)])
.to_metrics_query_dict()
)
def test_validate_select_invalid_use_case_ids():
with pytest.raises(InvalidParams, match="All select fields should have the same use_case_id"):
metric_field_1 = MetricField(op=None, metric_mri=SessionMRI.CRASH_FREE_RATE.value)
metric_field_2 = MetricField(op="p50", metric_mri=TransactionMRI.DURATION.value)
MetricsQuery(
**MetricsQueryBuilder()
.with_select([metric_field_1, metric_field_2])
.to_metrics_query_dict()
)
def test_validate_order_by():
with pytest.raises(
InvalidParams,
match=(f"Invalid operation 'foo'. Must be one of {', '.join(OPERATIONS)}"),
):
MetricsQuery(
**MetricsQueryBuilder()
.with_orderby(
[
MetricOrderByField(
field=MetricField(op="foo", metric_mri=SessionMRI.DURATION.value), # type: ignore[arg-type]
direction=Direction.ASC,
)
]
)
.to_metrics_query_dict()
)
with pytest.raises(
DerivedMetricParseException,
match=(
re.escape(
"Failed to parse sum(session.crash_free_rate). No operations can be "
"applied on this field as it is already a derived metric with an "
"aggregation applied to it."
)
),
):
MetricsQuery(
**MetricsQueryBuilder()
.with_orderby(
[
MetricOrderByField(
field=MetricField(op="sum", metric_mri=SessionMRI.CRASH_FREE_RATE.value),
direction=Direction.ASC,
)
]
)
.to_metrics_query_dict()
)
@django_db_all
def test_validate_order_by_field_in_select():
create_default_projects()
metric_field_2 = MetricField(op=None, metric_mri=SessionMRI.ALL.value)
metrics_query_dict = (
MetricsQueryBuilder()
.with_orderby([MetricOrderByField(field=metric_field_2, direction=Direction.ASC)])
.to_metrics_query_dict()
)
# Test that ensures an instance of `InvalidParams` is raised when requesting an orderBy field
# that is not present in the select
with pytest.raises(InvalidParams, match="'orderBy' must be one of the provided 'fields'"):
MetricsQuery(**metrics_query_dict)
# Validate no exception is raised when orderBy is in the select
metrics_query_dict = (
MetricsQueryBuilder()
.with_select([MetricsQueryBuilder.AVG_DURATION_METRIC, metric_field_2])
.to_metrics_query_dict()
)
MetricsQuery(**metrics_query_dict)
@django_db_all
def test_validate_order_by_field_in_select_with_different_alias():
create_default_projects()
ap_dex_with_alias_1 = MetricField(op=None, metric_mri=TransactionMRI.APDEX.value, alias="apdex")
ap_dex_with_alias_2 = MetricField(
op=None, metric_mri=TransactionMRI.APDEX.value, alias="transaction.apdex"
)
try:
metrics_query_dict = (
MetricsQueryBuilder()
.with_select([ap_dex_with_alias_1])
.with_orderby([MetricOrderByField(field=ap_dex_with_alias_2, direction=Direction.ASC)])
.to_metrics_query_dict()
)
MetricsQuery(**metrics_query_dict)
except InvalidParams:
raise pytest.fail(
"the validation of orderby field in select with different alias is throwing an error but it "
"shouldn't."
)
@django_db_all
def test_validate_multiple_orderby_columns_not_specified_in_select():
create_default_projects()
metric_field_1 = MetricField(op=None, metric_mri=SessionMRI.ABNORMAL.value)
metric_field_2 = MetricField(op=None, metric_mri=SessionMRI.ALL.value)
metrics_query_dict = (
MetricsQueryBuilder()
.with_select([MetricsQueryBuilder.AVG_DURATION_METRIC, metric_field_1])
.with_orderby(
[
MetricOrderByField(field=metric_field_1, direction=Direction.ASC),
MetricOrderByField(field=metric_field_2, direction=Direction.ASC),
]
)
.to_metrics_query_dict()
)
# Test that ensures an instance of `InvalidParams` is raised when requesting an orderBy field
# that is not present in the select
with pytest.raises(InvalidParams, match="'orderBy' must be one of the provided 'fields'"):
MetricsQuery(**metrics_query_dict)
@django_db_all
def test_validate_multiple_order_by_fields_from_multiple_entities():
"""
The example should fail because session crash free rate is generated from
counters entity while p50 of duration will go to distribution
"""
create_default_projects()
metric_field_1 = MetricField(op=None, metric_mri=SessionMRI.CRASH_FREE_RATE.value)
metric_field_2 = MetricField(op=None, metric_mri=SessionMRI.CRASH_FREE_USER_RATE.value)
metric_field_3 = MetricField(op="p50", metric_mri=TransactionMRI.DURATION.value)
metrics_query_dict = (
MetricsQueryBuilder()
.with_select([metric_field_1, metric_field_2])
.with_orderby(
[
MetricOrderByField(field=metric_field_1, direction=Direction.ASC),
MetricOrderByField(field=metric_field_3, direction=Direction.ASC),
]
)
.to_metrics_query_dict()
)
# Test that ensures an instance of `InvalidParams` is raised when requesting an orderBy field
# that is not present in the select
with pytest.raises(
InvalidParams, match="Selected 'orderBy' columns must belongs to the same entity"
):
MetricsQuery(**metrics_query_dict)
@django_db_all
def test_validate_multiple_orderby_derived_metrics_from_different_entities():
"""
This example should fail because session crash free rate is generated from
counters while session user crash free rate is generated from sets
"""
create_default_projects()
metric_field_1 = MetricField(op=None, metric_mri=SessionMRI.CRASH_FREE_RATE.value)
metric_field_2 = MetricField(op=None, metric_mri=SessionMRI.CRASH_FREE_USER_RATE.value)
metrics_query_dict = (
MetricsQueryBuilder()
.with_select([metric_field_1, metric_field_2])
.with_orderby(
[
MetricOrderByField(field=metric_field_1, direction=Direction.ASC),
MetricOrderByField(field=metric_field_2, direction=Direction.ASC),
]
)
.to_metrics_query_dict()
)
# Test that ensures an instance of `InvalidParams` is raised when requesting an orderBy field
# that is not present in the select
with pytest.raises(
InvalidParams, match="Selected 'orderBy' columns must belongs to the same entity"
):
MetricsQuery(**metrics_query_dict)
@django_db_all
def test_validate_many_order_by_fields_are_in_select():
"""
Validate no exception is raised when all orderBy fields are presented the select
"""
create_default_projects()
metric_field_1 = MetricField(op=None, metric_mri=SessionMRI.ABNORMAL.value)
metric_field_2 = MetricField(op=None, metric_mri=SessionMRI.ALL.value)
metrics_query_dict = (
MetricsQueryBuilder()
.with_select([metric_field_1, metric_field_2])
.with_orderby(
[
MetricOrderByField(field=metric_field_1, direction=Direction.ASC),
MetricOrderByField(field=metric_field_2, direction=Direction.ASC),
]
)
.to_metrics_query_dict()
)
MetricsQuery(**metrics_query_dict)
# orderby should be subset of select
metrics_query_dict = (
MetricsQueryBuilder()
.with_select([metric_field_1, metric_field_2])
.with_orderby(
[
MetricOrderByField(field=metric_field_1, direction=Direction.ASC),
]
)
.to_metrics_query_dict()
)
MetricsQuery(**metrics_query_dict)
# This example should pass because both session crash free rate
# and sum(session) both go to the entity counters
metric_field_1 = MetricField(op=None, metric_mri=SessionMRI.CRASH_FREE_RATE.value)
metric_field_2 = MetricField(op="sum", metric_mri=SessionMRI.RAW_SESSION.value)
metrics_query_dict = (
MetricsQueryBuilder()
.with_select([metric_field_1, metric_field_2])
.with_orderby(
[
MetricOrderByField(field=metric_field_1, direction=Direction.ASC),
MetricOrderByField(field=metric_field_2, direction=Direction.ASC),
]
)
.to_metrics_query_dict()
)
MetricsQuery(**metrics_query_dict)
def test_validate_functions_from_multiple_entities_in_orderby():
# Validate exception is raised when orderBy fields have function from different snuba groups
# because:
# `avg` are in OP_TO_SNUBA_FUNCTION["metrics_distributions"].keys()
# but
# `count_unique` are in OP_TO_SNUBA_FUNCTION["metrics_sets"].keys()
metric_field_1 = MetricField(op="avg", metric_mri=TransactionMRI.DURATION.value)
metric_field_2 = MetricField(op="count_unique", metric_mri=TransactionMRI.USER.value)
metrics_query_dict = (
MetricsQueryBuilder()
.with_select([metric_field_1, metric_field_2])
.with_orderby(
[
MetricOrderByField(field=metric_field_1, direction=Direction.ASC),
MetricOrderByField(field=metric_field_2, direction=Direction.ASC),
]
)
.to_metrics_query_dict()
)
# Test that ensures an instance of `InvalidParams` is raised when requesting an orderBy field
# from different snuba function groups
with pytest.raises(
InvalidParams,
match="Selected 'orderBy' columns must belongs to the same entity",
):
MetricsQuery(**metrics_query_dict)
def test_validate_distribution_functions_in_orderby():
# Validate no exception is raised when all orderBy fields are presented the select
metric_field_1 = MetricField(op="avg", metric_mri=TransactionMRI.DURATION.value)
metric_field_2 = MetricField(op="p50", metric_mri=TransactionMRI.DURATION.value)
metrics_query_dict = (
MetricsQueryBuilder()
.with_select([metric_field_1, metric_field_2])
.with_orderby(
[
MetricOrderByField(field=metric_field_1, direction=Direction.ASC),
MetricOrderByField(field=metric_field_2, direction=Direction.ASC),
]
)
.to_metrics_query_dict()
)
MetricsQuery(**metrics_query_dict)
@django_db_all
def test_validate_where():
query = "session.status:crashed"
where = parse_conditions(query, [], [])
with pytest.raises(InvalidParams, match="Tag name session.status is not a valid query filter"):
MetricsQuery(**MetricsQueryBuilder().with_where(where).to_metrics_query_dict())
def test_validate_groupby():
with pytest.raises(
InvalidParams, match="Tag name session.status cannot be used in groupBy query"
):
MetricsQuery(
**MetricsQueryBuilder()
.with_groupby([MetricGroupByField("session.status")])
.to_metrics_query_dict()
)
def test_validate_limit():
with pytest.raises(
InvalidParams,
match=(
r"Requested intervals \(61\) of timedelta of 0:01:00 with statsPeriod "
"timedelta of 1:00:00 is too granular for a per_page of 200 "
"elements. Increase your interval, decrease your statsPeriod, or decrease your per_page "
"parameter."
),
):
MetricsQuery(
**MetricsQueryBuilder()
.with_granularity(Granularity(60))
.with_limit(Limit(200))
.to_metrics_query_dict()
)
def test_validate_end():
with pytest.raises(InvalidParams, match="start must be before end"):
MetricsQuery(
**MetricsQueryBuilder()
.with_start(datetime.now())
.with_end(datetime.now() - timedelta(hours=1))
.to_metrics_query_dict()
)
def test_series_and_totals_validation():
metrics_query_dict = (
MetricsQueryBuilder()
.with_include_series(False)
.with_include_totals(False)
.to_metrics_query_dict()
)
with pytest.raises(InvalidParams, match="Cannot omit both series and totals"):
MetricsQuery(**metrics_query_dict)
@pytest.mark.parametrize(
"stats_period, interval, error_message",
[
pytest.param(
"6h",
"59m",
"The interval should divide one day without a remainder.",
id="should divide one day",
),
pytest.param(
"4d",
"5h",
"The interval should divide one day without a remainder.",
id="should divide one day 2",
),
pytest.param(
"1h",
"9s",
"The interval has to be a multiple of the minimum interval of ten seconds.",
id="interval multiple of min allowed resolution",
),
pytest.param(
"90d",
"10s",
"Your interval and date range would create too many results.",
id="too many results",
),
],
)
def test_granularity_validation(stats_period, interval, error_message):
period_t = parse_stats_period(stats_period)
assert period_t is not None
interval_t = parse_stats_period(interval)
assert interval_t is not None
metrics_query_dict = (
MetricsQueryBuilder()
.with_start(datetime.now() - period_t)
.with_granularity(Granularity(int(interval_t.total_seconds())))
.to_metrics_query_dict()
)
# Test that ensures an instance of `InvalidParams` is raised when requesting an orderBy field
# that is not present in the select
with pytest.raises(InvalidParams, match=error_message):
MetricsQuery(**metrics_query_dict)
def test_validate_metric_field_mri():
with pytest.raises(InvalidParams, match="Invalid Metric MRI: transaction-metric-duration"):
MetricField(
op="avg",
metric_mri="transaction-metric-duration",
)
@pytest.mark.parametrize(
"select, interval, series",
[
pytest.param(
None,
3600,
False,
id="release health query, not series, interval provided",
),
pytest.param(
None,
3600,
True,
id="release health query, series, interval provided",
),
pytest.param(
[MetricField(op="p95", metric_mri=TransactionMRI.DURATION.value)],
3600,
False,
id="performance query, not series, interval provided",
),
],
)
def test_validate_interval(select, interval, series):
metrics_query = MetricsQueryBuilder().with_include_series(series)
if select:
metrics_query = metrics_query.with_select(select)
if interval:
metrics_query = metrics_query.with_interval(interval)
metrics_query_dict = metrics_query.to_metrics_query_dict()
with pytest.raises(
InvalidParams, match="Interval is only supported for timeseries performance queries"
):
MetricsQuery(**metrics_query_dict)
def test_validate_is_alerts_query():
metrics_query = MetricsQueryBuilder()
metrics_query.start = None
metrics_query.end = None
metrics_query_dict = metrics_query.to_metrics_query_dict()
with pytest.raises(
InvalidParams,
match="start and env fields can only be None if the query is needed by alerts",
):
MetricsQuery(**metrics_query_dict)
def test_ensure_interval_set_to_granularity_in_performance_queries():
metrics_query = (
MetricsQueryBuilder()
.with_select([MetricField(op="p95", metric_mri=TransactionMRI.DURATION.value)])
.with_include_series(True)
)
metrics_query_dict = metrics_query.to_metrics_query_dict()
mq = MetricsQuery(**metrics_query_dict)
assert mq.interval == mq.granularity.granularity
@freeze_time("2022-11-03 10:00:00")
@pytest.mark.parametrize(
"start_time_delta, granularity, interval, expected_granularity",
[
pytest.param(
timedelta(hours=2),
86400,
7200,
3600,
id="day granularity with 2 hour interval",
),
pytest.param(
timedelta(hours=1),
86400,
3600,
60,
id="day granularity with 1 hour interval",
),
pytest.param(
timedelta(hours=1),
3600,
1800,
60,
id="hour granularity with 30 minute interval",
),
],
)
def test_start_end_interval_greater_than_interval_is_successful(
start_time_delta, granularity, interval, expected_granularity
):
metrics_query = (
MetricsQueryBuilder()
.with_select([MetricField(op="p95", metric_mri=TransactionMRI.DURATION.value)])
.with_include_series(True)
.with_granularity(Granularity(granularity))
.with_interval(interval)
)
# We set the start time with a different value from default in order to have the start and end interval greater
# than the specified interval (e.g., if you have an interval of 2 hours and the difference between start and end is
# less than 2 hours, it doesn't make sense to query the data).
metrics_query.start = metrics_query.end - start_time_delta
metrics_query_dict = metrics_query.to_metrics_query_dict()
mq = MetricsQuery(**metrics_query_dict)
assert mq.granularity.granularity == expected_granularity