Files
sentinel/tests/sentry/relay/config/test_metric_extraction.py
2024-02-15 17:01:50 +00:00

2001 lines
76 KiB
Python

from collections.abc import Sequence
from datetime import timedelta
from unittest import mock
import pytest
from django.utils import timezone
from sentry.incidents.models import AlertRule
from sentry.models.dashboard import Dashboard
from sentry.models.dashboard_widget import (
DashboardWidget,
DashboardWidgetDisplayTypes,
DashboardWidgetQuery,
DashboardWidgetTypes,
)
from sentry.models.environment import Environment
from sentry.models.project import Project
from sentry.models.transaction_threshold import ProjectTransactionThreshold, TransactionMetric
from sentry.relay.config.metric_extraction import get_metric_extraction_config
from sentry.search.events.constants import VITAL_THRESHOLDS
from sentry.snuba.dataset import Dataset
from sentry.snuba.metrics.extraction import (
MetricSpec,
MetricSpecType,
OnDemandMetricSpec,
RuleCondition,
TagSpec,
_deep_sorted,
fetch_on_demand_metric_spec,
)
from sentry.snuba.models import QuerySubscription, SnubaQuery
from sentry.tasks.on_demand_metrics import process_widget_specs
from sentry.testutils.helpers import Feature
from sentry.testutils.helpers.options import override_options
from sentry.testutils.pytest.fixtures import django_db_all
ON_DEMAND_METRICS = "organizations:on-demand-metrics-extraction"
ON_DEMAND_METRICS_WIDGETS = "organizations:on-demand-metrics-extraction-widgets"
ON_DEMAND_METRICS_PREFILL = "organizations:on-demand-metrics-prefill"
def create_alert(
aggregate: str,
query: str,
project: Project,
dataset: Dataset = Dataset.PerformanceMetrics,
environment: Environment | None = None,
) -> AlertRule:
snuba_query = SnubaQuery.objects.create(
aggregate=aggregate,
query=query,
dataset=dataset.value,
time_window=300,
resolution=60,
environment=environment,
type=SnubaQuery.Type.PERFORMANCE.value,
)
QuerySubscription.objects.create(
snuba_query=snuba_query,
project=project,
)
alert_rule = AlertRule.objects.create(
snuba_query=snuba_query, threshold_period=1, organization=project.organization
)
return alert_rule
def create_widget(
aggregates: Sequence[str],
query: str,
project: Project,
title: str | None = "Dashboard",
columns: Sequence[str] | None = None,
) -> DashboardWidgetQuery:
columns = columns or []
dashboard = Dashboard.objects.create(
organization=project.organization,
created_by_id=1,
title=title,
)
widget = DashboardWidget.objects.create(
dashboard=dashboard,
order=0,
widget_type=DashboardWidgetTypes.DISCOVER,
display_type=DashboardWidgetDisplayTypes.LINE_CHART,
)
widget_query = DashboardWidgetQuery.objects.create(
aggregates=aggregates, conditions=query, columns=columns, order=0, widget=widget
)
return widget_query
def create_project_threshold(
project: Project, threshold: int, metric: int
) -> ProjectTransactionThreshold:
return ProjectTransactionThreshold.objects.create(
project=project, organization=project.organization, threshold=threshold, metric=metric
)
@django_db_all
def test_get_metric_extraction_config_empty_no_alerts(default_project: Project) -> None:
with Feature(ON_DEMAND_METRICS):
assert not get_metric_extraction_config(default_project)
@django_db_all
def test_get_metric_extraction_config_empty_feature_flag_off(default_project: Project) -> None:
create_alert("count()", "transaction.duration:>=1000", default_project)
assert not get_metric_extraction_config(default_project)
@django_db_all
def test_get_metric_extraction_config_empty_standard_alerts(default_project: Project) -> None:
with Feature(ON_DEMAND_METRICS):
# standard alerts are not included in the config
create_alert("count()", "", default_project)
assert not get_metric_extraction_config(default_project)
@django_db_all
def test_get_metric_extraction_config_single_alert(default_project: Project) -> None:
with Feature(ON_DEMAND_METRICS):
create_alert("count()", "transaction.duration:>=1000", default_project)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "a312e0db"},
],
}
]
@django_db_all
def test_get_metric_extraction_config_with_double_write_env_alert(
default_project: Project, default_environment: Environment
) -> None:
with Feature(ON_DEMAND_METRICS):
create_alert(
"count()",
"device.platform:android OR device.platform:ios",
default_project,
environment=default_environment,
)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {
"inner": [
{"name": "event.environment", "op": "eq", "value": "development"},
{
"inner": [
{
"name": "event.tags.device.platform",
"op": "eq",
"value": "android",
},
{"name": "event.tags.device.platform", "op": "eq", "value": "ios"},
],
"op": "or",
},
],
"op": "and",
},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "ca87c609"},
],
}
]
@django_db_all
def test_get_metric_extraction_config_single_alert_with_mri(default_project: Project) -> None:
with Feature(ON_DEMAND_METRICS):
create_alert(
"sum(c:custom/page_load@millisecond)", "transaction.duration:>=1000", default_project
)
create_alert(
"count(d:transactions/measurements.fcp@millisecond)",
"transaction.duration:>=1000",
default_project,
)
config = get_metric_extraction_config(default_project)
assert config is None
@django_db_all
def test_get_metric_extraction_config_multiple_alerts(default_project: Project) -> None:
with Feature(ON_DEMAND_METRICS):
create_alert("count()", "transaction.duration:>=1000", default_project)
create_alert("count()", "transaction.duration:>=2000", default_project)
config = get_metric_extraction_config(default_project)
assert config
assert len(config["metrics"]) == 2
first_hash = config["metrics"][0]["tags"][0]["value"]
second_hash = config["metrics"][1]["tags"][0]["value"]
assert first_hash != second_hash
@django_db_all
@override_options({"on_demand.max_alert_specs": 1})
def test_get_metric_extraction_config_multiple_alerts_above_max_limit(
default_project: Project,
) -> None:
with Feature(ON_DEMAND_METRICS):
create_alert("count()", "transaction.duration:>=1000", default_project)
create_alert("count()", "transaction.duration:>=2000", default_project)
config = get_metric_extraction_config(default_project)
with mock.patch("sentry_sdk.capture_exception") as capture_exception:
config = get_metric_extraction_config(default_project)
assert config
assert capture_exception.call_count == 2
exception = capture_exception.call_args.args[0]
assert (
exception.args[0]
== "Spec version 2: Too many (2) on demand metric alerts for org baz"
)
# Since we have set a maximum of 1 we will not get 2
assert len(config["metrics"]) == 1
@django_db_all
def test_get_metric_extraction_config_multiple_alerts_duplicated(default_project: Project) -> None:
# alerts with the same query should be deduplicated
with Feature(ON_DEMAND_METRICS):
create_alert("count()", "transaction.duration:>=1000", default_project)
create_alert("count()", "transaction.duration:>=1000", default_project)
config = get_metric_extraction_config(default_project)
assert config
assert len(config["metrics"]) == 1
@django_db_all
def test_get_metric_extraction_config_environment(
default_project: Project, default_environment: Environment
) -> None:
with Feature(ON_DEMAND_METRICS):
create_alert("count()", "transaction.duration:>0", default_project)
create_alert("count()", "transaction.duration:>0", default_project, environment=None)
create_alert(
"count()", "transaction.duration:>0", default_project, environment=default_environment
)
config = get_metric_extraction_config(default_project)
assert config
# assert that the deduplication works with environments
assert len(config["metrics"]) == 2
no_env, default_env = config["metrics"]
# assert that the conditions are different
assert no_env["condition"] != default_env["condition"]
# assert that environment is part of the hash
assert no_env["tags"][0]["value"] != default_env["tags"][0]["value"]
@django_db_all
def test_get_metric_extraction_config_single_standard_widget(default_project: Project) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(["count()"], "", default_project)
assert not get_metric_extraction_config(default_project)
@django_db_all
def test_get_metric_extraction_config_single_widget(default_project: Project) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(["count()"], "transaction.duration:>=1000", default_project)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "a312e0db"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "cf5f5100"},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
def test_get_metric_extraction_config_single_widget_multiple_aggregates(
default_project: Project,
) -> None:
# widget with multiple fields should result in multiple metrics
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(
["count()", "avg(transaction.duration)"], "transaction.duration:>=1000", default_project
)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "a312e0db"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": "event.duration",
"mri": "d:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "10acc97f"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "cf5f5100"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": "event.duration",
"mri": "d:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "3a976c6d"},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
def test_get_metric_extraction_config_single_widget_multiple_count_if(
default_project: Project,
) -> None:
# widget with multiple fields should result in multiple metrics
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
aggregates = [
"count()",
"count_if(transaction.duration, greater, 2000)",
"count_if(transaction.duration, greaterOrEquals, 1000)",
]
create_widget(aggregates, "transaction.duration:>=1000", default_project)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "a312e0db"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {
"inner": [
{"name": "event.duration", "op": "gte", "value": 1000.0},
{"name": "event.duration", "op": "gt", "value": 2000.0},
],
"op": "and",
},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "e2977925"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {
"inner": [
{"name": "event.duration", "op": "gte", "value": 1000.0},
{"name": "event.duration", "op": "gte", "value": 1000.0},
],
"op": "and",
},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "c50b5bc7"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "cf5f5100"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {
"inner": [
{"name": "event.duration", "op": "gte", "value": 1000.0},
{"name": "event.duration", "op": "gt", "value": 2000.0},
],
"op": "and",
},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "0061cb28"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {
"inner": [
{"name": "event.duration", "op": "gte", "value": 1000.0},
{"name": "event.duration", "op": "gte", "value": 1000.0},
],
"op": "and",
},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "9e291845"},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
def test_get_metric_extraction_config_multiple_aggregates_single_field(
default_project: Project,
) -> None:
# widget with multiple aggregates on the same field in a single metric
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(
["sum(transaction.duration)", "avg(transaction.duration)"],
"transaction.duration:>=1000",
default_project,
)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": "event.duration",
"mri": "d:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "10acc97f"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": "event.duration",
"mri": "d:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "3a976c6d"},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
def test_get_metric_extraction_config_multiple_widgets_duplicated(default_project: Project) -> None:
# metrics should be deduplicated across widgets
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(
["count()", "avg(transaction.duration)"], "transaction.duration:>=1000", default_project
)
create_widget(["count()"], "transaction.duration:>=1000", default_project, "Dashboard 2")
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "a312e0db"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": "event.duration",
"mri": "d:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "10acc97f"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "cf5f5100"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": "event.duration",
"mri": "d:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "3a976c6d"},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
@override_options({"on_demand.max_widget_specs": 1})
def test_get_metric_extraction_config_multiple_widgets_above_max_limit(
default_project: Project,
) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(["count()"], "transaction.duration:>=1100", default_project)
create_widget(["count()"], "transaction.duration:>=1000", default_project, "Dashboard 2")
with mock.patch("sentry_sdk.capture_exception") as capture_exception:
config = get_metric_extraction_config(default_project)
assert config
assert capture_exception.call_count == 2
exception = capture_exception.call_args.args[0]
assert (
exception.args[0]
== "Spec version 2: Too many (2) on demand metric widgets for org baz"
)
# Revert to 1 after {"include_environment_tag"} becomes the default
# Since we have set a maximum of 1 we will not get 2
assert len(config["metrics"]) == 2
@django_db_all
@override_options({"on_demand.max_widget_specs": 1})
def test_get_metric_extraction_config_multiple_widgets_not_above_max_limit_identical_hashes(
default_project: Project,
) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(["count()"], "transaction.duration:>=1000", default_project)
create_widget(["count()"], "transaction.duration:>=1000", default_project, "Dashboard 2")
with mock.patch("sentry_sdk.capture_exception") as capture_exception:
config = get_metric_extraction_config(default_project)
assert config
assert capture_exception.call_count == 0
@django_db_all
@override_options({"on_demand.max_widget_specs": 4, "on_demand_metrics.check_widgets.enable": True})
def test_get_metric_extraction_config_multiple_widgets_above_max_limit_ordered_specs(
default_project: Project,
) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(["count()"], "transaction.duration:>=1000", default_project, "Dashboard 1")
create_widget(["count()"], "transaction.duration:>=1100", default_project, "Dashboard 2")
widget_query = create_widget(
["count()"], "transaction.duration:>=1200", default_project, "Dashboard 3"
)
create_widget(["count()"], "transaction.duration:>=1300", default_project, "Dashboard 4")
create_widget(["count()"], "transaction.duration:>=1400", default_project, "Dashboard 5")
widget_query.widget.dashboard.last_visited = timezone.now() - timedelta(days=1)
widget_query.widget.dashboard.save()
process_widget_specs([widget_query.id])
with mock.patch("sentry_sdk.capture_exception") as capture_exception:
config = get_metric_extraction_config(default_project)
assert config
assert len(config["metrics"]) == 8 # 4 * 2 spec versions
duration_conditions = [spec["condition"]["value"] for spec in config["metrics"]] # type: ignore
assert duration_conditions == [
1400.0,
1300.0,
1100.0,
1000.0,
1400.0,
1300.0,
1100.0,
1000.0,
] # We only exclude the oldest spec (1200.0 duration)
assert capture_exception.call_count == 2
exception = capture_exception.call_args.args[0]
assert (
exception.args[0]
== "Spec version 2: Too many (5) on demand metric widgets for org baz"
)
# Check that state was correctly updated.
on_demand_entries = widget_query.dashboardwidgetqueryondemand_set.all()
assert [entry.extraction_state for entry in on_demand_entries] == [
"disabled:spec-limit",
"disabled:spec-limit",
] # Only see the one entry disabled
@django_db_all
@override_options({"on_demand.max_widget_specs": 1, "on_demand.extended_max_widget_specs": 0})
def test_get_metric_extraction_config_multiple_widgets_not_using_extended_specs(
default_project: Project,
) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(["count()"], "transaction.duration:>=1100", default_project)
create_widget(["count()"], "transaction.duration:>=1000", default_project, "Dashboard 2")
with mock.patch("sentry_sdk.capture_exception") as capture_exception:
config = get_metric_extraction_config(default_project)
assert config
assert capture_exception.call_count == 2
exception = capture_exception.call_args.args[0]
assert (
exception.args[0]
== "Spec version 2: Too many (2) on demand metric widgets for org baz"
)
# Revert to 1 after {"include_environment_tag"} becomes the default
# Since we have set a maximum of 1 we will not get 2
assert len(config["metrics"]) == 2
@django_db_all
@override_options({"on_demand.max_widget_specs": 0, "on_demand.extended_max_widget_specs": 1})
def test_get_metric_extraction_config_multiple_widgets_above_extended_max_limit(
default_project: Project,
) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}), override_options(
{"on_demand.extended_widget_spec_orgs": [default_project.organization.id]}
):
create_widget(["count()"], "transaction.duration:>=1100", default_project)
create_widget(["count()"], "transaction.duration:>=1000", default_project, "Dashboard 2")
with mock.patch("sentry_sdk.capture_exception") as capture_exception:
config = get_metric_extraction_config(default_project)
assert config
assert capture_exception.call_count == 2
exception = capture_exception.call_args.args[0]
assert (
exception.args[0]
== "Spec version 2: Too many (2) on demand metric widgets for org baz"
)
# Revert to 1 after {"include_environment_tag"} becomes the default
# Since we have set a maximum of 1 we will not get 2
assert len(config["metrics"]) == 2
@django_db_all
@override_options({"on_demand.max_widget_specs": 0, "on_demand.extended_max_widget_specs": 2})
def test_get_metric_extraction_config_multiple_widgets_under_extended_max_limit(
default_project: Project,
) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}), override_options(
{"on_demand.extended_widget_spec_orgs": [default_project.organization.id]}
):
create_widget(["count()"], "transaction.duration:>=1100", default_project)
create_widget(["count()"], "transaction.duration:>=1000", default_project, "Dashboard 2")
config = get_metric_extraction_config(default_project)
assert config
# Revert to 2 after {"include_environment_tag"} becomes the default
assert len(config["metrics"]) == 4
@django_db_all
def test_get_metric_extraction_config_alerts_and_widgets_off(default_project: Project) -> None:
# widgets should be skipped if the feature is off
with Feature({ON_DEMAND_METRICS: True, ON_DEMAND_METRICS_WIDGETS: False}):
create_alert("count()", "transaction.duration:>=1000", default_project)
create_widget(["count()"], "transaction.duration:>=1000", default_project)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "a312e0db"},
],
}
]
@django_db_all
def test_get_metric_extraction_config_alerts_and_widgets(default_project: Project) -> None:
# deduplication should work across alerts and widgets
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_alert("count()", "transaction.duration:>=1000", default_project)
create_widget(
["count()", "avg(transaction.duration)"], "transaction.duration:>=1000", default_project
)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "a312e0db"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": "event.duration",
"mri": "d:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "10acc97f"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "cf5f5100"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": "event.duration",
"mri": "d:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "3a976c6d"},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
def test_get_metric_extraction_config_with_failure_count(default_project: Project) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(["failure_count()"], "transaction.duration:>=1000", default_project)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {
"inner": {
"name": "event.contexts.trace.status",
"op": "eq",
"value": ["ok", "cancelled", "unknown"],
},
"op": "not",
},
"key": "failure",
"value": "true",
},
{"key": "query_hash", "value": "c3a2ddea"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {
"inner": {
"name": "event.contexts.trace.status",
"op": "eq",
"value": ["ok", "cancelled", "unknown"],
},
"op": "not",
},
"key": "failure",
"value": "true",
},
{"key": "query_hash", "value": "4e66755b"},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
def test_get_metric_extraction_config_with_apdex(default_project: Project) -> None:
with Feature({ON_DEMAND_METRICS: True}):
threshold = 10
create_alert(f"apdex({threshold})", "transaction.duration:>=1000", default_project)
# The threshold stored in the database will not be considered and rather the one from the parameter will be
# preferred.
create_project_threshold(default_project, 200, TransactionMetric.DURATION.value)
config = get_metric_extraction_config(default_project)
assert config
assert len(config["metrics"]) == 1
assert config["metrics"][0] == {
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {"name": "event.duration", "op": "lte", "value": threshold},
"key": "satisfaction",
"value": "satisfactory",
},
{
"condition": {
"inner": [
{"name": "event.duration", "op": "gt", "value": threshold},
{"name": "event.duration", "op": "lte", "value": threshold * 4},
],
"op": "and",
},
"key": "satisfaction",
"value": "tolerable",
},
{
"condition": {"name": "event.duration", "op": "gt", "value": threshold * 4},
"key": "satisfaction",
"value": "frustrated",
},
{"key": "query_hash", "value": "4445a852"},
],
}
@django_db_all
@pytest.mark.parametrize("measurement_rating", ["good", "meh", "poor", "any"])
@pytest.mark.parametrize("measurement", ["measurements.lcp"])
def test_get_metric_extraction_config_with_count_web_vitals(
default_project: Project, measurement_rating: str, measurement: str
) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(
[f"count_web_vitals({measurement}, {measurement_rating})"],
"transaction.duration:>=1000",
default_project,
)
config = get_metric_extraction_config(default_project)
vital = measurement.split(".")[1]
assert config
if measurement_rating == "good":
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {
"name": f"event.{measurement}.value",
"op": "lt",
"value": VITAL_THRESHOLDS[vital]["meh"],
},
"key": "measurement_rating",
"value": "matches_hash",
},
{"key": "query_hash", "value": "30cb4ba5"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {
"name": f"event.{measurement}.value",
"op": "lt",
"value": VITAL_THRESHOLDS[vital]["meh"],
},
"key": "measurement_rating",
"value": "matches_hash",
},
{"key": "query_hash", "value": "e14212cf"},
{"key": "environment", "field": "event.environment"},
],
},
]
if measurement_rating == "meh":
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {
"inner": [
{
"name": f"event.{measurement}.value",
"op": "gte",
"value": VITAL_THRESHOLDS[vital]["meh"],
},
{
"name": f"event.{measurement}.value",
"op": "lt",
"value": VITAL_THRESHOLDS[vital]["poor"],
},
],
"op": "and",
},
"key": "measurement_rating",
"value": "matches_hash",
},
{"key": "query_hash", "value": "f207c139"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {
"inner": [
{
"name": f"event.{measurement}.value",
"op": "gte",
"value": VITAL_THRESHOLDS[vital]["meh"],
},
{
"name": f"event.{measurement}.value",
"op": "lt",
"value": VITAL_THRESHOLDS[vital]["poor"],
},
],
"op": "and",
},
"key": "measurement_rating",
"value": "matches_hash",
},
{"key": "query_hash", "value": "be0b73bb"},
{"key": "environment", "field": "event.environment"},
],
},
]
if measurement_rating == "poor":
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {
"name": f"event.{measurement}.value",
"op": "gte",
"value": VITAL_THRESHOLDS[vital]["poor"],
},
"key": "measurement_rating",
"value": "matches_hash",
},
{"key": "query_hash", "value": "051c26d1"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {
"name": f"event.{measurement}.value",
"op": "gte",
"value": VITAL_THRESHOLDS[vital]["poor"],
},
"key": "measurement_rating",
"value": "matches_hash",
},
{"key": "query_hash", "value": "57d48347"},
{"key": "environment", "field": "event.environment"},
],
},
]
if measurement_rating == "any":
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {
"name": f"event.{measurement}.value",
"op": "gte",
"value": 0,
},
"key": "measurement_rating",
"value": "matches_hash",
},
{"key": "query_hash", "value": "511aaa66"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{
"condition": {
"name": f"event.{measurement}.value",
"op": "gte",
"value": 0,
},
"key": "measurement_rating",
"value": "matches_hash",
},
{"key": "query_hash", "value": "401b8e0e"},
{"key": "environment", "field": "event.environment"},
],
},
]
if measurement_rating == "":
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "environment", "field": "event.environment"},
],
},
]
@pytest.mark.skip(reason="Re-enable when user misery is supported again.")
@django_db_all
def test_get_metric_extraction_config_with_user_misery(default_project: Project) -> None:
threshold = 100
duration = 1000
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(
[f"user_misery({threshold})"],
f"transaction.duration:>={duration}",
default_project,
)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": float(duration)},
# This is necessary for calculating unique users
"field": "event.user.id",
"mri": "s:transactions/on_demand@none",
"tags": [
{
"condition": {"name": "event.duration", "op": "gt", "value": threshold * 4},
"key": "satisfaction",
"value": "frustrated",
},
{"key": "query_hash", "value": "1394a552"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": float(duration)},
# This is necessary for calculating unique users
"field": "event.user.id",
"mri": "s:transactions/on_demand@none",
"tags": [
{
"condition": {"name": "event.duration", "op": "gt", "value": threshold * 4},
"key": "satisfaction",
"value": "frustrated",
},
{"key": "query_hash", "value": "9fbc729c"},
{"key": "environment", "field": "event.environment"},
],
},
]
@pytest.mark.skip(reason="Re-enable when user misery is supported again.")
@django_db_all
def test_get_metric_extraction_config_user_misery_with_tag_columns(
default_project: Project,
) -> None:
threshold = 100
duration = 1000
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(
[f"user_misery({threshold})"],
f"transaction.duration:>={duration}",
default_project,
"Dashboard",
columns=["lcp.element", "custom"],
)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": float(duration)},
# This is necessary for calculating unique users
"field": "event.user.id",
"mri": "s:transactions/on_demand@none",
"tags": [
{
"condition": {"name": "event.duration", "op": "gt", "value": threshold * 4},
"key": "satisfaction",
"value": "frustrated",
},
{"key": "query_hash", "value": "565e1845"},
{"key": "lcp.element", "field": "event.tags.lcp.element"},
{"key": "custom", "field": "event.tags.custom"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": float(duration)},
# This is necessary for calculating unique users
"field": "event.user.id",
"mri": "s:transactions/on_demand@none",
"tags": [
{
"condition": {"name": "event.duration", "op": "gt", "value": threshold * 4},
"key": "satisfaction",
"value": "frustrated",
},
{"key": "query_hash", "value": "d508d70d"},
{"key": "lcp.element", "field": "event.tags.lcp.element"},
{"key": "custom", "field": "event.tags.custom"},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
def test_get_metric_extraction_config_epm_with_non_tag_columns(default_project: Project) -> None:
duration = 1000
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(
["epm()"],
f"transaction.duration:>={duration}",
default_project,
"Dashboard",
columns=["user.id", "release"],
)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": float(duration)},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "d9f30df7"},
{"key": "user.id", "field": "event.user.id"},
{"key": "release", "field": "event.release"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": float(duration)},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "52427c0a"},
{"key": "user.id", "field": "event.user.id"},
{"key": "release", "field": "event.release"},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
@override_options({"on_demand.max_widget_cardinality.count": -1})
def test_get_metric_extraction_config_with_high_cardinality(default_project: Project) -> None:
duration = 1000
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(
["epm()"],
f"transaction.duration:>={duration}",
default_project,
columns=["user.id", "release", "count()"],
)
config = get_metric_extraction_config(default_project)
assert not config
@django_db_all
def test_get_metric_extraction_config_multiple_widgets_with_high_cardinality(
default_project: Project,
) -> None:
duration = 1000
with Feature({ON_DEMAND_METRICS_WIDGETS: True}), mock.patch(
"sentry.relay.config.metric_extraction._is_widget_query_low_cardinality"
) as mock_cardinality:
mock_cardinality.side_effect = [True, False, True]
create_widget(
["epm()"],
f"transaction.duration:>={duration}",
default_project,
columns=["user.id", "release", "count()"],
title="Widget1",
)
create_widget(
["epm()"],
f"transaction.duration:>={duration + 1}",
default_project,
columns=["user.id", "release", "count()"],
title="Widget2",
)
create_widget(
["epm()"],
f"transaction.duration:>={duration + 2}",
default_project,
columns=["user.id", "release", "count()"],
title="Widget3",
)
config = get_metric_extraction_config(default_project)
assert config
# Revert to 2 after {"include_environment_tag"} becomes the default
assert len(config["metrics"]) == 4
@django_db_all
@override_options({"on_demand.max_widget_cardinality.count": 1})
def test_get_metric_extraction_config_with_extraction_enabled(default_project: Project) -> None:
duration = 1000
with Feature({ON_DEMAND_METRICS_WIDGETS: True}), mock.patch(
"sentry.relay.config.metric_extraction._can_widget_query_use_stateful_extraction"
) as mock_can_use_stateful, mock.patch(
"sentry.relay.config.metric_extraction._widget_query_stateful_extraction_enabled"
) as mock_extraction_enabled:
mock_can_use_stateful.return_value = True
mock_extraction_enabled.return_value = True
create_widget(
["epm()"],
f"transaction.duration:>={duration}",
default_project,
columns=["user.id", "release", "count()"],
)
config = get_metric_extraction_config(default_project)
assert config
@django_db_all
@override_options(
{
"on_demand.max_widget_cardinality.count": -1,
"on_demand_metrics.widgets.use_stateful_extraction": True,
}
)
def test_stateful_get_metric_extraction_config_with_extraction_disabled(
default_project: Project,
) -> None:
duration = 1000
with Feature({ON_DEMAND_METRICS_WIDGETS: True}), mock.patch(
"sentry.relay.config.metric_extraction._can_widget_query_use_stateful_extraction"
) as mock_can_use_stateful, mock.patch(
"sentry.relay.config.metric_extraction._widget_query_stateful_extraction_enabled"
) as mock_extraction_enabled:
mock_can_use_stateful.return_value = True
mock_extraction_enabled.return_value = False
create_widget(
["epm()"],
f"transaction.duration:>={duration}",
default_project,
columns=["user.id", "release", "count()"],
)
config = get_metric_extraction_config(default_project)
assert not config
@django_db_all
@override_options({"on_demand_metrics.widgets.use_stateful_extraction": True})
def test_stateful_get_metric_extraction_config_multiple_widgets_with_extraction_partially_disabled(
default_project: Project,
) -> None:
duration = 1000
with Feature({ON_DEMAND_METRICS_WIDGETS: True}), mock.patch(
"sentry.relay.config.metric_extraction._can_widget_query_use_stateful_extraction"
) as mock_can_use_stateful, mock.patch(
"sentry.relay.config.metric_extraction._widget_query_stateful_extraction_enabled"
) as mock_extraction_enabled:
mock_can_use_stateful.return_value = True
mock_extraction_enabled.side_effect = [True, False, True]
create_widget(
["epm()"],
f"transaction.duration:>={duration}",
default_project,
columns=["user.id", "release", "count()"],
title="Widget1",
)
create_widget(
["epm()"],
f"transaction.duration:>={duration + 1}",
default_project,
columns=["user.id", "release", "count()"],
title="Widget2",
)
create_widget(
["epm()"],
f"transaction.duration:>={duration + 2}",
default_project,
columns=["user.id", "release", "count()"],
title="Widget3",
)
config = get_metric_extraction_config(default_project)
assert config
# Revert to 2 after {"include_environment_tag"} becomes the default
assert len(config["metrics"]) == 4
@django_db_all
@override_options(
{
"on_demand.max_widget_cardinality.count": 1,
"on_demand_metrics.check_widgets.enable": True,
"on_demand_metrics.widgets.use_stateful_extraction": True,
}
)
def test_stateful_get_metric_extraction_config_enabled_with_multiple_versions(
default_project: Project,
) -> None:
duration = 1000
with Feature(
{
ON_DEMAND_METRICS_WIDGETS: True,
"organizations:on-demand-metrics-query-spec-version-two": True,
}
):
widget_query = create_widget(
["epm()"],
f"transaction.duration:>={duration}",
default_project,
columns=["user.id", "release", "count()"],
)
process_widget_specs([widget_query.id])
# Check that state was correctly updated.
on_demand_entries = widget_query.dashboardwidgetqueryondemand_set.all()
assert [entry.extraction_state for entry in on_demand_entries] == [
"enabled:enrolled",
"enabled:enrolled",
]
config = get_metric_extraction_config(default_project)
# Check that the first version being enabled outputs both specs.
assert config
# Check that changing the default spec changes behaviour.
extraction_row_default = next(
filter(lambda row: row.spec_version == 1, on_demand_entries), None
)
if extraction_row_default:
extraction_row_default.extraction_state = "disabled:manual"
extraction_row_default.save()
config = get_metric_extraction_config(default_project)
# In the future with separate version decisions, assert that there is only one spec in config here.
assert not config
@django_db_all
@override_options(
{
"on_demand.max_widget_cardinality.count": 1,
"on_demand_metrics.widgets.use_stateful_extraction": True,
}
)
def test_stateful_get_metric_extraction_config_with_low_cardinality(
default_project: Project,
) -> None:
duration = 1000
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget(
["epm()"],
f"transaction.duration:>={duration}",
default_project,
columns=["user.id", "release", "count()"],
)
config = get_metric_extraction_config(default_project)
assert config
@django_db_all
def test_get_metric_extraction_config_with_unicode_character(default_project: Project) -> None:
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
# This will cause the Unicode bug to be raised for the current version
create_widget(["count()"], "user.name:Armén", default_project)
create_widget(["count()"], "user.name:Kevan", default_project, title="Dashboard Foo")
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.tags.user.name", "op": "eq", "value": "Kevan"},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "5142a1f7"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.tags.user.name", "op": "eq", "value": "Armén"},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "d3e07bdf"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.tags.user.name", "op": "eq", "value": "Kevan"},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "762b5dae"},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.tags.user.name", "op": "eq", "value": "Armén"},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "c57cc340"},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
@pytest.mark.parametrize("metric", [("epm()"), ("eps()")])
def test_get_metric_extraction_config_with_no_tag_spec(
default_project: Project, metric: str
) -> None:
query_hashes = ["8f8293cf", "5200e087"] if metric == "epm()" else ["9ffdd8ac", "162178e9"]
with Feature({ON_DEMAND_METRICS_WIDGETS: True}):
create_widget([metric], "transaction.duration:>=1000", default_project)
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": query_hashes[0]},
{"key": "environment", "field": "event.environment"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 1000.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": query_hashes[1]},
{"key": "environment", "field": "event.environment"},
],
},
]
@django_db_all
@pytest.mark.parametrize(
"enabled_features, number_of_metrics",
[
([ON_DEMAND_METRICS], 1), # Alerts.
([ON_DEMAND_METRICS_PREFILL], 1), # Alerts.
([ON_DEMAND_METRICS, ON_DEMAND_METRICS_PREFILL], 1), # Alerts.
# Revert to 2 after {"include_environment_tag"} becomes the default
([ON_DEMAND_METRICS, ON_DEMAND_METRICS_WIDGETS], 3), # Alerts and widgets.
# Revert to 1 after {"include_environment_tag"} becomes the default
([ON_DEMAND_METRICS_WIDGETS], 2), # Widgets.
# Revert to 2 after {"include_environment_tag"} becomes the default
([ON_DEMAND_METRICS_PREFILL, ON_DEMAND_METRICS_WIDGETS], 3), # Alerts and widget.
([], 0), # Nothing.
],
)
def test_get_metrics_extraction_config_features_combinations(
enabled_features: str, number_of_metrics: int, default_project: Project
) -> None:
create_alert("count()", "transaction.duration:>=10", default_project)
create_widget(["count()"], "transaction.duration:>=20", default_project)
features = {feature: True for feature in enabled_features}
with Feature(features):
config = get_metric_extraction_config(default_project)
if number_of_metrics == 0:
assert config is None
else:
assert config is not None
assert len(config["metrics"]) == number_of_metrics
@django_db_all
def test_get_metric_extraction_config_with_transactions_dataset(default_project: Project) -> None:
create_alert(
"count()", "transaction.duration:>=10", default_project, dataset=Dataset.PerformanceMetrics
)
create_alert(
"count()", "transaction.duration:>=20", default_project, dataset=Dataset.Transactions
)
# We test with prefilling, and we expect that both alerts are fetched since we support both datasets.
with Feature({ON_DEMAND_METRICS_PREFILL: True}):
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 10.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "f1353b0f"},
],
},
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 20.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "a547e4d9"},
],
},
]
# We test without prefilling, and we expect that only alerts for performance metrics are fetched.
with Feature({ON_DEMAND_METRICS: True}):
config = get_metric_extraction_config(default_project)
assert config
assert config["metrics"] == [
{
"category": "transaction",
"condition": {"name": "event.duration", "op": "gte", "value": 10.0},
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": [
{"key": "query_hash", "value": "f1353b0f"},
],
}
]
@django_db_all
def test_get_metric_extraction_config_with_no_spec(default_project: Project) -> None:
create_alert(
"apdex(300)",
"",
default_project,
dataset=Dataset.PerformanceMetrics,
)
with Feature({ON_DEMAND_METRICS: True}):
config = get_metric_extraction_config(default_project)
assert config
assert len(config["metrics"]) == 1
assert config["metrics"][0].get("condition") is None
def _on_demand_spec_from_widget(
project: Project, widget: DashboardWidgetQuery
) -> OnDemandMetricSpec:
field = widget.aggregates[0] if widget.aggregates else ""
return fetch_on_demand_metric_spec(
project.organization.id,
field=field,
query=widget.conditions,
groupbys=widget.columns,
spec_type=MetricSpecType.DYNAMIC_QUERY,
)
def _on_demand_spec_from_alert(project: Project, alert: AlertRule) -> OnDemandMetricSpec:
return fetch_on_demand_metric_spec(
project.organization.id,
field=alert.snuba_query.aggregate,
query=alert.snuba_query.query,
spec_type=MetricSpecType.SIMPLE_QUERY,
)
def widget_to_metric_spec(query_hash: str, condition: RuleCondition | None = None) -> MetricSpec:
_tags: Sequence[TagSpec] = [
{"key": "query_hash", "value": query_hash},
{"field": "event.environment", "key": "environment"},
]
spec: MetricSpec = {
"category": "transaction",
"field": None,
"mri": "c:transactions/on_demand@none",
"tags": _tags,
}
if condition is not None:
spec["condition"] = condition
return spec
@django_db_all
def test_include_environment_for_widgets(default_project: Project) -> None:
aggr = "count()"
query = "transaction.duration:>=10"
condition: RuleCondition = {"name": "event.duration", "op": "gte", "value": 10.0}
with Feature([ON_DEMAND_METRICS, ON_DEMAND_METRICS_WIDGETS]):
widget = create_widget([aggr], query, default_project)
config = get_metric_extraction_config(default_project)
# Because we have two specs we will have two metrics.
# The second spec includes the environment tag as part of the query hash.
assert config and config["metrics"] == [
widget_to_metric_spec("f1353b0f", condition),
widget_to_metric_spec("4fb5a472", condition),
]
# We now verify that the string used for hashing is what we expect
# Since we're using the current spec it will not include the environment tag
expected_query_str_hash = f"None;{condition}"
spec = _on_demand_spec_from_widget(default_project, widget)
assert spec.query_hash == "f1353b0f"
assert spec._query_str_for_hash == expected_query_str_hash
assert spec.spec_version.version == 1
assert spec.spec_version.flags == set()
with Feature("organizations:on-demand-metrics-query-spec-version-two"):
spec = _on_demand_spec_from_widget(default_project, widget)
assert spec._query_str_for_hash == f"{expected_query_str_hash};['environment']"
assert spec.query_hash == "4fb5a472"
assert spec.spec_version.version == 2
assert spec.spec_version.flags == {"include_environment_tag"}
@django_db_all
@override_options({"on_demand_metrics.check_widgets.enable": True})
def test_include_environment_for_widgets_with_multiple_env(default_project: Project) -> None:
aggrs = [
"count()",
"count_unique(user)",
"count_miserable(user,300)",
"count_if(transaction.duration,equals,300)",
"eps()",
"epm()",
"failure_count()",
]
query = 'transaction:"GET /api/chartcuterie/healthcheck/live"'
columns = [
"transaction",
"transaction",
"project",
"environment",
"transaction.op",
"transaction.status",
"query.error_reason",
"query.num_projects",
"discover.use_snql",
"query.period",
"query.num_projects.grouped",
"query.period.grouped",
"query_size_group",
]
with Feature([ON_DEMAND_METRICS, ON_DEMAND_METRICS_WIDGETS]):
widget_query = create_widget(aggrs, query, default_project, columns=columns)
config = get_metric_extraction_config(default_project)
assert config
with Feature("organizations:on-demand-metrics-query-spec-version-two"):
config = get_metric_extraction_config(default_project)
process_widget_specs([widget_query.id])
assert config
assert [
next(filter(lambda t: t["key"] == "query_hash", spec["tags"]))["value"]
for spec in config["metrics"]
] == [
"4b08d58c",
"470072b4",
"6bc4f99b",
"e50094f0",
"0a272be4",
]
on_demand_entries = widget_query.dashboardwidgetqueryondemand_set.all()
assert [entry.spec_hashes for entry in on_demand_entries if entry.spec_version == 1] == [
[
"4b08d58c",
"470072b4",
"6bc4f99b",
"e50094f0",
"0a272be4",
]
]
assert [entry.spec_hashes for entry in on_demand_entries if entry.spec_version == 2] == [
[
"4b08d58c",
"470072b4",
"6bc4f99b",
"e50094f0",
"0a272be4",
]
]
# Remove this test once we drop the current spec version
@django_db_all
def test_alert_and_widget_colliding(default_project: Project) -> None:
aggr = "count()"
query = "transaction.duration:>=10"
condition: RuleCondition = {"name": "event.duration", "op": "gte", "value": 10.0}
with Feature([ON_DEMAND_METRICS, ON_DEMAND_METRICS_WIDGETS]):
widget = create_widget([aggr], query, default_project)
config = get_metric_extraction_config(default_project)
# Because we have two specs we will have two metrics.
assert config and config["metrics"] == [
widget_to_metric_spec("f1353b0f", condition),
widget_to_metric_spec("4fb5a472", condition),
]
# Once we deprecate the current spec version, the widget will not create
# the f1353b0f, thus, there will be no more duplicated specs
alert = create_alert(aggr, query, default_project)
config = get_metric_extraction_config(default_project)
# Now that we iterate over the widgets first, we will pick the spec generated by the widget
# which includes the environment as a tag
assert config and config["metrics"] == [
widget_to_metric_spec("f1353b0f", condition),
widget_to_metric_spec("4fb5a472", condition),
]
widget_spec = _on_demand_spec_from_widget(default_project, widget)
alert_spec = _on_demand_spec_from_alert(default_project, alert)
expected_query_str_hash = f"None;{condition}"
assert widget_spec._query_str_for_hash == expected_query_str_hash
assert alert_spec._query_str_for_hash == expected_query_str_hash
with Feature("organizations:on-demand-metrics-query-spec-version-two"):
widget_spec = _on_demand_spec_from_widget(default_project, widget)
assert widget_spec._query_str_for_hash == f"{expected_query_str_hash};['environment']"
assert widget_spec.query_hash == "4fb5a472"
assert widget_spec.spec_version.version == 2
assert widget_spec.spec_version.flags == {"include_environment_tag"}
# With the new spec version they will not collide anymore
assert widget_spec.query_hash != alert_spec.query_hash
foo_bar_condition = {"name": "event.tags.foo", "op": "eq", "value": "bar"}
not_event_type_cond = {
"inner": {"op": "eq", "name": "event.tags.event.type", "value": "error"},
"op": "not",
}
@django_db_all
@pytest.mark.parametrize(
"query, config_assertion, expected_hashes, expected_condition",
[
("event.type:default", False, [], None),
("!event.type:transaction", False, [], None),
('event.type:"error"', False, [], None),
("event.type:error", False, [], None),
("!event.type:error", True, ["578e7911", "91f78a80"], not_event_type_cond),
("event.type:transaction", True, ["5367d030", "f7a47137"], None),
# These two have the same hashes because event.type:transaction is completely ignored
("event.type:transaction foo:bar", True, ["bdb73880", "54cee1ce"], foo_bar_condition),
("foo:bar", True, ["bdb73880", "54cee1ce"], foo_bar_condition),
],
)
def test_event_type(
default_project: Project,
query: str,
config_assertion: bool,
expected_hashes: list[str],
expected_condition: RuleCondition | None,
) -> None:
aggr = "count()"
with Feature([ON_DEMAND_METRICS, ON_DEMAND_METRICS_WIDGETS]):
widget = create_widget([aggr], query, default_project)
config = get_metric_extraction_config(default_project)
if not config_assertion:
assert config is None
else:
assert config and config["metrics"] == [
widget_to_metric_spec(expected_hashes[0], expected_condition),
widget_to_metric_spec(expected_hashes[1], expected_condition),
]
widget_spec = _on_demand_spec_from_widget(default_project, widget)
assert widget_spec._query_str_for_hash == f"None;{_deep_sorted(expected_condition)}"
@django_db_all
def test_level_field(default_project: Project) -> None:
aggr = "count()"
query = "level:irrelevant_value"
with Feature(ON_DEMAND_METRICS_WIDGETS):
create_widget([aggr], query, default_project)
config = get_metric_extraction_config(default_project)
assert config is None
@django_db_all
def test_widget_modifed_after_on_demand(default_project: Project) -> None:
duration = 1000
with Feature(
{
ON_DEMAND_METRICS_WIDGETS: True,
"organizations:on-demand-metrics-query-spec-version-two": True,
}
):
widget_query = create_widget(
["epm()"],
f"transaction.duration:>={duration}",
default_project,
columns=["user.id", "release", "count()"],
)
with mock.patch("sentry_sdk.capture_exception") as capture_exception:
process_widget_specs([widget_query.id])
config = get_metric_extraction_config(default_project)
assert config and config["metrics"]
assert capture_exception.call_count == 0