mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
2467 lines
89 KiB
Python
2467 lines
89 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
from functools import partial
|
|
from typing import Any
|
|
from unittest import mock
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from sentry.sentry_metrics import indexer
|
|
from sentry.sentry_metrics.use_case_id_registry import UseCaseID
|
|
from sentry.snuba.metrics.naming_layer.mri import ParsedMRI, SessionMRI, TransactionMRI
|
|
from sentry.snuba.metrics.naming_layer.public import (
|
|
SessionMetricKey,
|
|
TransactionMetricKey,
|
|
TransactionSatisfactionTagValue,
|
|
TransactionStatusTagValue,
|
|
TransactionTagsKey,
|
|
)
|
|
from sentry.testutils.cases import MetricsAPIBaseTestCase
|
|
from sentry.testutils.helpers.datetime import freeze_time
|
|
from sentry.testutils.silo import region_silo_test
|
|
from sentry.utils.cursors import Cursor
|
|
from tests.sentry.api.endpoints.test_organization_metrics import MOCKED_DERIVED_METRICS
|
|
|
|
|
|
def indexer_record(use_case_id: UseCaseID, org_id: int, string: str) -> int:
|
|
ret = indexer.record(use_case_id, org_id, string)
|
|
assert ret is not None
|
|
return ret
|
|
|
|
|
|
perf_indexer_record = partial(indexer_record, UseCaseID.TRANSACTIONS)
|
|
rh_indexer_record = partial(indexer_record, UseCaseID.SESSIONS)
|
|
|
|
pytestmark = [pytest.mark.sentry_metrics]
|
|
|
|
|
|
@region_silo_test
|
|
@freeze_time(MetricsAPIBaseTestCase.MOCK_DATETIME)
|
|
class OrganizationMetricsDataWithNewLayerTest(MetricsAPIBaseTestCase):
|
|
endpoint = "sentry-api-0-organization-metrics-data"
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.login_as(user=self.user)
|
|
|
|
@property
|
|
def now(self):
|
|
return MetricsAPIBaseTestCase.MOCK_DATETIME
|
|
|
|
@patch("sentry.api.endpoints.organization_metrics.run_metrics_query")
|
|
def test_query_with_feature_flag_enabled_but_param_missing(self, run_metrics_query):
|
|
run_metrics_query.return_value = {}
|
|
|
|
self.get_response(
|
|
self.project.organization.slug,
|
|
field=f"sum({TransactionMRI.DURATION.value})",
|
|
useCase="transactions",
|
|
useNewMetricsLayer="false",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
)
|
|
run_metrics_query.assert_not_called()
|
|
|
|
self.get_response(
|
|
self.project.organization.slug,
|
|
field=f"sum({TransactionMRI.DURATION.value})",
|
|
useCase="transactions",
|
|
useNewMetricsLayer="true",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
)
|
|
run_metrics_query.assert_called_once()
|
|
|
|
def test_query_with_invalid_query(self):
|
|
self.get_error_response(
|
|
self.project.organization.slug,
|
|
status_code=400,
|
|
field=f"sum({TransactionMRI.DURATION.value})",
|
|
query="foo:foz < bar:baz",
|
|
useCase="transactions",
|
|
useNewMetricsLayer="true",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
)
|
|
|
|
def test_query_with_invalid_percentile(self):
|
|
self.get_error_response(
|
|
self.project.organization.slug,
|
|
status_code=500,
|
|
field=f"p30({TransactionMRI.DURATION.value})",
|
|
useCase="transactions",
|
|
useNewMetricsLayer="true",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
)
|
|
|
|
def test_compare_query_with_transactions_metric(self):
|
|
for transaction, value in (("/hello", 10), ("/world", 5), ("/foo", 3)):
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.DURATION.value,
|
|
tags={"transaction": transaction},
|
|
value=value,
|
|
)
|
|
|
|
responses = []
|
|
for flag_value in False, True:
|
|
field = f"sum({TransactionMRI.DURATION.value})"
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field=field,
|
|
groupBy="transaction",
|
|
orderBy=field,
|
|
useCase="transactions",
|
|
# We test the limit in both.
|
|
limit="2",
|
|
per_page="2",
|
|
useNewMetricsLayer="true" if flag_value else "false",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
)
|
|
responses.append(response)
|
|
|
|
response_old = responses[0].data
|
|
response_new = responses[1].data
|
|
|
|
for group_index in (0, 1):
|
|
# We want to only compare a subset of the fields since the APIs have some differences.
|
|
assert (
|
|
response_old["groups"][group_index]["by"]
|
|
== response_new["groups"][group_index]["by"]
|
|
)
|
|
assert list(response_old["groups"][group_index]["series"].values()) == list(
|
|
response_new["groups"][group_index]["series"].values()
|
|
)
|
|
assert list(response_old["groups"][group_index]["totals"].values()) == list(
|
|
response_new["groups"][group_index]["totals"].values()
|
|
)
|
|
|
|
assert response_old["intervals"] == response_new["intervals"]
|
|
assert response_old["start"] == response_new["start"]
|
|
assert response_old["end"] == response_new["end"]
|
|
|
|
|
|
@region_silo_test
|
|
@freeze_time(MetricsAPIBaseTestCase.MOCK_DATETIME)
|
|
class OrganizationMetricDataTest(MetricsAPIBaseTestCase):
|
|
endpoint = "sentry-api-0-organization-metrics-data"
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.project2 = self.create_project()
|
|
self.login_as(user=self.user)
|
|
|
|
self.transaction_lcp_metric = perf_indexer_record(
|
|
self.project.organization.id, TransactionMRI.MEASUREMENTS_LCP.value
|
|
)
|
|
org_id = self.organization.id
|
|
self.session_metric = rh_indexer_record(org_id, SessionMRI.RAW_SESSION.value)
|
|
self.session_duration = rh_indexer_record(org_id, SessionMRI.DURATION.value)
|
|
self.session_error_metric = rh_indexer_record(org_id, SessionMRI.RAW_ERROR.value)
|
|
|
|
@property
|
|
def now(self):
|
|
return MetricsAPIBaseTestCase.MOCK_DATETIME
|
|
|
|
def test_missing_field(self):
|
|
response = self.get_response(self.project.organization.slug)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == 'Request is missing a "field"'
|
|
|
|
def test_incorrect_use_case_id_value(self):
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
groupBy="environment",
|
|
useCase="unknown",
|
|
)
|
|
assert response.status_code == 400
|
|
assert (
|
|
response.json()["detail"]
|
|
== f"Invalid useCase parameter. Please use one of: {[uc.value for uc in UseCaseID]}"
|
|
)
|
|
|
|
def test_invalid_field(self):
|
|
for field in ["", "(*&%", "foo(session", "foo(session)"]:
|
|
response = self.get_response(self.project.organization.slug, field=field)
|
|
assert response.status_code == 400
|
|
|
|
def test_groupby_single(self):
|
|
rh_indexer_record(self.project.organization_id, "environment")
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
groupBy="environment",
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
def test_groupby_session_status(self):
|
|
for status in ["ok", "crashed"]:
|
|
for minute in range(4):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status=status,
|
|
)
|
|
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
groupBy="session.status",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
)
|
|
assert response.data["detail"] == (
|
|
"Tag name session.status cannot be used in groupBy query"
|
|
)
|
|
|
|
def test_filter_session_status(self):
|
|
for status in ["ok", "crashed"]:
|
|
for minute in range(4):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status=status,
|
|
)
|
|
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
query="session.status:crashed",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
)
|
|
assert response.data["detail"] == ("Tag name session.status is not a valid query filter")
|
|
|
|
def test_invalid_filter(self):
|
|
query = "release:foo or "
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
groupBy="environment",
|
|
query=query,
|
|
)
|
|
assert response.status_code == 400, query
|
|
|
|
def test_valid_filter(self):
|
|
self.create_release(version="foo", project=self.project)
|
|
for tag in ("release", "environment"):
|
|
rh_indexer_record(self.project.organization_id, tag)
|
|
query = "release:latest"
|
|
response = self.get_success_response(
|
|
self.project.organization.slug,
|
|
project=self.project.id,
|
|
field="sum(sentry.sessions.session)",
|
|
groupBy="environment",
|
|
query=query,
|
|
)
|
|
assert response.data.keys() == {"start", "end", "query", "intervals", "groups", "meta"}
|
|
|
|
def test_validate_include_meta_not_enabled_by_default(self):
|
|
self.create_release(version="foo", project=self.project)
|
|
for tag in ("release", "environment"):
|
|
rh_indexer_record(self.project.organization_id, tag)
|
|
response = self.get_success_response(
|
|
self.project.organization.slug,
|
|
project=self.project.id,
|
|
field="sum(sentry.sessions.session)",
|
|
groupBy="environment",
|
|
query="",
|
|
)
|
|
assert response.data["meta"] == []
|
|
|
|
def test_orderby_unknown(self):
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
orderBy="foo",
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
def test_orderby_tag(self):
|
|
"""Order by tag is not supported (yet)"""
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field=["sum(sentry.sessions.session)", "environment"],
|
|
groupBy="environment",
|
|
orderBy="environment",
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
def test_date_range_too_long(self):
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field=["sum(sentry.sessions.session)"],
|
|
interval="10s",
|
|
statsPeriod="90d",
|
|
per_page=1,
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == (
|
|
"Your interval and date range would create too many results. Use a larger interval, "
|
|
"or a smaller date range."
|
|
)
|
|
|
|
def test_interval_must_be_multiple_of_smallest_interval(self):
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field=["sum(sentry.sessions.session)"],
|
|
interval="15s",
|
|
statsPeriod="1d",
|
|
per_page=1,
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == (
|
|
"The interval has to be a multiple of the minimum interval of ten seconds."
|
|
)
|
|
|
|
def test_interval_should_divide_day_with_no_remainder(self):
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field=["sum(sentry.sessions.session)"],
|
|
interval="3610s",
|
|
statsPeriod="2d",
|
|
per_page=1,
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == (
|
|
"The interval should divide one day without a remainder."
|
|
)
|
|
|
|
def test_filter_by_project_slug(self):
|
|
p = self.create_project(name="sentry2")
|
|
p2 = self.create_project(name="sentry3")
|
|
|
|
for minute in range(2):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
for minute in range(3):
|
|
self.build_and_store_session(
|
|
project_id=p.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
for minute in range(5):
|
|
self.build_and_store_session(
|
|
project_id=p2.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field=["sum(sentry.sessions.session)"],
|
|
project=[p.id, p2.id, self.project.id],
|
|
query="project:[sentry2,sentry3]",
|
|
interval="24h",
|
|
statsPeriod="24h",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.data["groups"] == [
|
|
{
|
|
"by": {},
|
|
"totals": {"sum(sentry.sessions.session)": 8},
|
|
"series": {"sum(sentry.sessions.session)": [0, 8]},
|
|
}
|
|
]
|
|
|
|
def test_filter_by_project_slug_negation(self):
|
|
p = self.create_project(name="sentry2")
|
|
p2 = self.create_project(name="sentry3")
|
|
|
|
for minute in range(2):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
for minute in range(3):
|
|
self.build_and_store_session(
|
|
project_id=p.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
for minute in range(5):
|
|
self.build_and_store_session(
|
|
project_id=p2.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field=["sum(sentry.sessions.session)"],
|
|
project=[p.id, p2.id, self.project.id],
|
|
query="!project:[sentry2,sentry3]",
|
|
interval="24h",
|
|
statsPeriod="24h",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.data["groups"] == [
|
|
{
|
|
"by": {},
|
|
"totals": {"sum(sentry.sessions.session)": 2},
|
|
"series": {"sum(sentry.sessions.session)": [0, 2]},
|
|
}
|
|
]
|
|
|
|
def test_filter_by_single_project_slug(self):
|
|
p = self.create_project(name="sentry2")
|
|
|
|
for minute in range(2):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
for minute in range(3):
|
|
self.build_and_store_session(
|
|
project_id=p.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field=["sum(sentry.sessions.session)"],
|
|
project=[p.id, self.project.id],
|
|
query="project:sentry2",
|
|
interval="24h",
|
|
statsPeriod="24h",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.data["groups"] == [
|
|
{
|
|
"by": {},
|
|
"totals": {"sum(sentry.sessions.session)": 3},
|
|
"series": {"sum(sentry.sessions.session)": [0, 3]},
|
|
}
|
|
]
|
|
|
|
def test_filter_by_single_project_slug_negation(self):
|
|
p = self.create_project(name="sentry2")
|
|
|
|
for minute in range(2):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
for minute in range(3):
|
|
self.build_and_store_session(
|
|
project_id=p.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field=["sum(sentry.sessions.session)"],
|
|
project=[p.id, self.project.id],
|
|
query="!project:sentry2",
|
|
interval="24h",
|
|
statsPeriod="24h",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.data["groups"] == [
|
|
{
|
|
"by": {},
|
|
"totals": {"sum(sentry.sessions.session)": 2},
|
|
"series": {"sum(sentry.sessions.session)": [0, 2]},
|
|
}
|
|
]
|
|
|
|
def test_group_by_project(self):
|
|
prj_foo = self.create_project(name="foo")
|
|
prj_boo = self.create_project(name="boo")
|
|
|
|
for minute in range(2):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
for minute in range(3):
|
|
self.build_and_store_session(
|
|
project_id=prj_foo.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
for minute in range(5):
|
|
self.build_and_store_session(
|
|
project_id=prj_boo.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
)
|
|
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field=["sum(sentry.sessions.session)"],
|
|
project=[prj_foo.id, prj_boo.id, self.project.id],
|
|
interval="24h",
|
|
statsPeriod="24h",
|
|
groupBy="project",
|
|
)
|
|
assert response.status_code == 200
|
|
expected_output = {
|
|
prj_foo.id: {
|
|
"by": {"project": prj_foo.id},
|
|
"series": {"sum(sentry.sessions.session)": [0, 3.0]},
|
|
"totals": {"sum(sentry.sessions.session)": 3.0},
|
|
},
|
|
self.project.id: {
|
|
"by": {"project": self.project.id},
|
|
"series": {"sum(sentry.sessions.session)": [0, 2.0]},
|
|
"totals": {"sum(sentry.sessions.session)": 2.0},
|
|
},
|
|
prj_boo.id: {
|
|
"by": {"project": prj_boo.id},
|
|
"series": {"sum(sentry.sessions.session)": [0, 5.0]},
|
|
"totals": {"sum(sentry.sessions.session)": 5.0},
|
|
},
|
|
}
|
|
for grp in response.data["groups"]:
|
|
prj_id = grp["by"]["project"]
|
|
assert grp == expected_output[prj_id]
|
|
|
|
def test_pagination_limit_without_orderby(self):
|
|
"""
|
|
Test that ensures a successful response is returned even when sending a per_page
|
|
without an orderBy
|
|
"""
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
field=f"count({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
groupBy="transaction",
|
|
per_page=2,
|
|
useCase="transactions",
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def test_query_with_wildcard(self):
|
|
rh_indexer_record(self.organization.id, "session.crash_free_user_rate")
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
)
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
field="session.crash_free_user_rate",
|
|
groupBy="release",
|
|
environment="Release",
|
|
query='!release:"0.99.0 (*)"',
|
|
statsPeriod="14d",
|
|
interval="1h",
|
|
includeTotals="1",
|
|
includeSeries="0",
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert (
|
|
response.data["detail"]
|
|
== "Failed to parse conditions: Release Health Queries don't support wildcards"
|
|
)
|
|
|
|
def test_pagination_offset_without_orderby(self):
|
|
"""
|
|
Test that ensures a successful response is returned even when requesting an offset
|
|
without an orderBy
|
|
"""
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
field=f"count({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
groupBy="transaction",
|
|
cursor=Cursor(0, 1),
|
|
statsPeriod="1h",
|
|
useCase="transactions",
|
|
)
|
|
assert response.status_code == 200, response.data
|
|
|
|
def test_statsperiod_invalid(self):
|
|
response = self.get_response(
|
|
self.project.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="",
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
def test_separate_projects(self):
|
|
# Insert session metrics:
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
)
|
|
self.build_and_store_session(
|
|
project_id=self.project2.id,
|
|
)
|
|
|
|
def count_sessions(project_id: int | None) -> int:
|
|
kwargs: dict[str, Any] = dict(
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
)
|
|
if project_id is not None:
|
|
kwargs["project"] = project_id
|
|
|
|
response = self.get_success_response(self.organization.slug, **kwargs)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 1
|
|
|
|
return groups[0]["totals"]["sum(sentry.sessions.session)"]
|
|
|
|
# Request for entire org gives a counter of two:
|
|
assert count_sessions(project_id=None) == 2
|
|
|
|
# Request for single project gives a counter of one:
|
|
assert count_sessions(project_id=self.project2.id) == 1
|
|
|
|
def test_max_and_min_on_distributions(self):
|
|
for v_transaction, count in (("/foo", 1), ("/bar", 3), ("/baz", 2)):
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={"transaction": v_transaction},
|
|
value=123.4 * count,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
f"max({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"min({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
],
|
|
query="",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
per_page=3,
|
|
useCase="transactions",
|
|
includeSeries="0",
|
|
)
|
|
groups = response.data["groups"]
|
|
|
|
assert len(groups) == 1
|
|
assert groups == [
|
|
{
|
|
"by": {},
|
|
"totals": {
|
|
"max(transaction.measurements.lcp)": pytest.approx(3 * 123.4),
|
|
"min(transaction.measurements.lcp)": pytest.approx(1 * 123.4),
|
|
},
|
|
}
|
|
]
|
|
|
|
def test_orderby(self):
|
|
for v_transaction, count in (("/foo", 1), ("/bar", 3), ("/baz", 2)):
|
|
for v_rating in ("good", "meh", "poor"):
|
|
# count decides the cardinality of this distribution bucket
|
|
for value in [123.4] * count:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={"transaction": v_transaction, "measurement_rating": v_rating},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=f"count({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
query="measurement_rating:poor",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy="transaction",
|
|
orderBy=f"-count({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
per_page=2,
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 2
|
|
|
|
expected = [
|
|
("/bar", 3),
|
|
("/baz", 2),
|
|
]
|
|
for (expected_transaction, expected_count), group in zip(expected, groups):
|
|
# With orderBy, you only get totals:
|
|
assert group["by"] == {"transaction": expected_transaction}
|
|
assert group["series"] == {
|
|
f"count({TransactionMetricKey.MEASUREMENTS_LCP.value})": [expected_count]
|
|
}
|
|
assert group["totals"] == {
|
|
f"count({TransactionMetricKey.MEASUREMENTS_LCP.value})": expected_count
|
|
}
|
|
|
|
def test_multi_field_orderby(self):
|
|
for v_transaction, count in (("/foo", 1), ("/bar", 3), ("/baz", 2)):
|
|
for v_rating in ("good", "meh", "poor"):
|
|
# count decides the cardinality of this distribution bucket
|
|
for value in [123.4] * count:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={"transaction": v_transaction, "measurement_rating": v_rating},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
f"count({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"count({TransactionMetricKey.MEASUREMENTS_FCP.value})",
|
|
],
|
|
query="measurement_rating:poor",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy="transaction",
|
|
orderBy=[
|
|
f"-count({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"-count({TransactionMetricKey.MEASUREMENTS_FCP.value})",
|
|
],
|
|
per_page=2,
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 2
|
|
|
|
expected = [
|
|
("/bar", 3),
|
|
("/baz", 2),
|
|
]
|
|
for (expected_transaction, expected_count), group in zip(expected, groups):
|
|
# With orderBy, you only get totals:
|
|
assert group["by"] == {"transaction": expected_transaction}
|
|
assert group["series"] == {
|
|
f"count({TransactionMetricKey.MEASUREMENTS_LCP.value})": [expected_count],
|
|
f"count({TransactionMetricKey.MEASUREMENTS_FCP.value})": [0],
|
|
}
|
|
assert group["totals"] == {
|
|
f"count({TransactionMetricKey.MEASUREMENTS_LCP.value})": expected_count,
|
|
f"count({TransactionMetricKey.MEASUREMENTS_FCP.value})": 0,
|
|
}
|
|
|
|
def test_orderby_percentile(self):
|
|
for tag, value, numbers in (
|
|
("tag1", "value1", [4, 5, 6]),
|
|
("tag1", "value2", [1, 2, 3]),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy="tag1",
|
|
orderBy=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 2
|
|
|
|
expected = [
|
|
("value2", 2), # value2 comes first because it has the smaller median
|
|
("value1", 5),
|
|
]
|
|
for (expected_tag_value, expected_count), group in zip(expected, groups):
|
|
# With orderBy, you only get totals:
|
|
assert group["by"] == {"tag1": expected_tag_value}
|
|
assert group["totals"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": expected_count
|
|
}
|
|
assert group["series"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": [expected_count]
|
|
}
|
|
|
|
def test_orderby_percentile_with_pagination(self):
|
|
for tag, value, numbers in (
|
|
("tag1", "value1", [4, 5, 6]),
|
|
("tag1", "value2", [1, 2, 3]),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy="tag1",
|
|
orderBy=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
per_page=1,
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 1
|
|
assert groups[0]["by"] == {"tag1": "value2"}
|
|
assert groups[0]["totals"] == {f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": 2}
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy="tag1",
|
|
orderBy=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
per_page=1,
|
|
cursor=Cursor(0, 1),
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 1
|
|
assert groups[0]["by"] == {"tag1": "value1"}
|
|
assert groups[0]["totals"] == {f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": 5}
|
|
|
|
def test_limit_with_orderby_is_overridden_by_paginator_limit(self):
|
|
"""
|
|
Test that ensures when an `orderBy` clause is set, then the paginator limit overrides the
|
|
`limit` parameter
|
|
"""
|
|
for tag, value, numbers in (
|
|
("tag1", "value1", [4, 5, 6]),
|
|
("tag1", "value2", [1, 2, 3]),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy="tag1",
|
|
orderBy=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
per_page=1,
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 1
|
|
|
|
def test_orderby_percentile_with_many_fields_one_entity_no_data(self):
|
|
"""
|
|
Test that ensures that when metrics data is available then an empty response is returned
|
|
gracefully
|
|
"""
|
|
for metric in [
|
|
TransactionMRI.MEASUREMENTS_FCP.value,
|
|
"transaction",
|
|
]:
|
|
perf_indexer_record(self.organization.id, metric)
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})",
|
|
],
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy=["project_id", "transaction"],
|
|
orderBy=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 0
|
|
|
|
def test_orderby_percentile_with_many_fields_one_entity(self):
|
|
"""
|
|
Test that ensures when transactions are ordered correctly when all the fields requested
|
|
are from the same entity
|
|
"""
|
|
for tag, value, numbers in (
|
|
("transaction", "/foo/", [10, 11, 12]),
|
|
("transaction", "/bar/", [4, 5, 6]),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
for tag, value, numbers in (
|
|
("transaction", "/foo/", [1, 2, 3]),
|
|
("transaction", "/bar/", [13, 14, 15]),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_FCP.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})",
|
|
],
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy=["project_id", "transaction"],
|
|
orderBy=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 2
|
|
|
|
expected = [
|
|
("/bar/", 5.0, 14.0),
|
|
("/foo/", 11.0, 2.0),
|
|
]
|
|
for (expected_tag_value, expected_lcp_count, expected_fcp_count), group in zip(
|
|
expected, groups
|
|
):
|
|
# With orderBy, you only get totals:
|
|
assert group["by"] == {"transaction": expected_tag_value, "project_id": self.project.id}
|
|
assert group["totals"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": expected_lcp_count,
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})": expected_fcp_count,
|
|
}
|
|
assert group["series"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": [expected_lcp_count],
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})": [expected_fcp_count],
|
|
}
|
|
|
|
def test_multi_field_orderby_percentile_with_many_fields_one_entity(self):
|
|
"""
|
|
Test that ensures when transactions are ordered correctly when all the fields requested
|
|
are from the same entity
|
|
"""
|
|
for tag, value, numbers in (
|
|
("transaction", "/foo/", [10, 11, 12]),
|
|
("transaction", "/bar/", [4, 5, 6]),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
for tag, value, numbers in (
|
|
("transaction", "/foo/", [1, 2, 3]),
|
|
("transaction", "/bar/", [13, 14, 15]),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_FCP.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
kwargs = dict(
|
|
field=[
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})",
|
|
],
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy=["project_id", "transaction"],
|
|
useCase="transactions",
|
|
)
|
|
|
|
# Test order by DESC
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
**kwargs,
|
|
orderBy=[
|
|
f"-p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"-p50({TransactionMetricKey.MEASUREMENTS_FCP.value})",
|
|
],
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 2
|
|
|
|
expected = [
|
|
("/foo/", 11.0, 2.0),
|
|
("/bar/", 5.0, 14.0),
|
|
]
|
|
for (expected_tag_value, expected_lcp_count, expected_fcp_count), group in zip(
|
|
expected, groups
|
|
):
|
|
# With orderBy, you only get totals:
|
|
assert group["by"] == {"transaction": expected_tag_value, "project_id": self.project.id}
|
|
assert group["totals"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": expected_lcp_count,
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})": expected_fcp_count,
|
|
}
|
|
assert group["series"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": [expected_lcp_count],
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})": [expected_fcp_count],
|
|
}
|
|
|
|
# Test order by ASC
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
**kwargs,
|
|
orderBy=[
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})",
|
|
],
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 2
|
|
|
|
expected = [
|
|
("/bar/", 5.0, 14.0),
|
|
("/foo/", 11.0, 2.0),
|
|
]
|
|
for (expected_tag_value, expected_lcp_count, expected_fcp_count), group in zip(
|
|
expected, groups
|
|
):
|
|
# With orderBy, you only get totals:
|
|
assert group["by"] == {"transaction": expected_tag_value, "project_id": self.project.id}
|
|
assert group["totals"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": expected_lcp_count,
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})": expected_fcp_count,
|
|
}
|
|
assert group["series"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": [expected_lcp_count],
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})": [expected_fcp_count],
|
|
}
|
|
|
|
def test_orderby_percentile_with_many_fields_multiple_entities(self):
|
|
"""
|
|
Test that ensures when transactions are ordered correctly when all the fields requested
|
|
are from multiple entities
|
|
"""
|
|
for tag, value, numbers in (
|
|
("transaction", "/foo/", [10, 11, 12]),
|
|
("transaction", "/bar/", [4, 5, 6]),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
for tag, value, numbers in (
|
|
("transaction", "/foo/", list(range(1))),
|
|
("transaction", "/bar/", list(range(5))),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.USER.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"count_unique({TransactionMetricKey.USER.value})",
|
|
],
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy=["project_id", "transaction"],
|
|
orderBy=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 2
|
|
|
|
expected = [
|
|
("/bar/", 5.0, 5),
|
|
("/foo/", 11.0, 1),
|
|
]
|
|
for (expected_tag_value, expected_lcp_count, users), group in zip(expected, groups):
|
|
# With orderBy, you only get totals:
|
|
assert group["by"] == {"transaction": expected_tag_value, "project_id": self.project.id}
|
|
assert group["totals"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": expected_lcp_count,
|
|
f"count_unique({TransactionMetricKey.USER.value})": users,
|
|
}
|
|
assert group["series"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": [expected_lcp_count],
|
|
f"count_unique({TransactionMetricKey.USER.value})": [users],
|
|
}
|
|
|
|
def test_orderby_percentile_with_many_fields_multiple_entities_with_paginator(self):
|
|
"""
|
|
Test that ensures when transactions are ordered correctly when all the fields requested
|
|
are from multiple entities
|
|
"""
|
|
for tag, value, numbers in (
|
|
("transaction", "/foo/", [10, 11, 12]),
|
|
("transaction", "/bar/", [4, 5, 6]),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
for minutes, ranges in [
|
|
(1, [range(4, 5), range(6, 11)]),
|
|
(15, [range(3), range(6)]),
|
|
]:
|
|
for tag, value, numbers in (
|
|
("transaction", "/foo/", list(ranges[0])),
|
|
("transaction", "/bar/", list(ranges[1])),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.USER.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
minutes_before_now=minutes,
|
|
)
|
|
|
|
request_args = {
|
|
"field": [
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"count_unique({TransactionMetricKey.USER.value})",
|
|
],
|
|
"statsPeriod": "1h",
|
|
"interval": "10m",
|
|
"datasource": "snuba",
|
|
"groupBy": ["project_id", "transaction"],
|
|
"orderBy": f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
"per_page": 1,
|
|
"useCase": "transactions",
|
|
}
|
|
|
|
response = self.get_success_response(self.organization.slug, **request_args)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 1
|
|
assert groups[0]["by"]["transaction"] == "/bar/"
|
|
assert groups[0]["totals"] == {
|
|
f"count_unique({TransactionMetricKey.USER.value})": 11,
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": 5.0,
|
|
}
|
|
assert groups[0]["series"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": [
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
5.0,
|
|
],
|
|
f"count_unique({TransactionMetricKey.USER.value})": [
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
6,
|
|
5,
|
|
],
|
|
}
|
|
|
|
request_args["cursor"] = Cursor(0, 1)
|
|
|
|
response = self.get_success_response(self.organization.slug, **request_args)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 1
|
|
assert groups[0]["by"]["transaction"] == "/foo/"
|
|
assert groups[0]["totals"] == {
|
|
f"count_unique({TransactionMetricKey.USER.value})": 4,
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": 11.0,
|
|
}
|
|
assert groups[0]["series"] == {
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": [
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
11.0,
|
|
],
|
|
f"count_unique({TransactionMetricKey.USER.value})": [
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
3,
|
|
1,
|
|
],
|
|
}
|
|
|
|
def test_series_are_limited_to_total_order_in_case_with_one_field_orderby(self):
|
|
# Create time series [1, 2, 3, 4] for every release:
|
|
for minute in range(4):
|
|
for _ in range(minute + 1):
|
|
# One for each release
|
|
for release in ("foo", "bar", "baz"):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=3 - minute,
|
|
release=release,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="4m",
|
|
interval="1m",
|
|
groupBy="release",
|
|
orderBy="-sum(sentry.sessions.session)",
|
|
per_page=1, # limit to a single page
|
|
)
|
|
|
|
for group in response.data["groups"]:
|
|
assert group["series"]["sum(sentry.sessions.session)"] == [1, 2, 3, 4]
|
|
|
|
assert len(response.data["groups"]) == 1
|
|
|
|
def test_one_field_orderby_with_no_groupby_returns_one_row(self):
|
|
# Create time series [1, 2, 3, 4] for every release:
|
|
for minute in range(4):
|
|
for _ in range(minute + 1):
|
|
# One for each release
|
|
for release in ("foo", "bar", "baz"):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=3 - minute,
|
|
release=release,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
"sum(sentry.sessions.session)",
|
|
"count_unique(sentry.sessions.user)",
|
|
],
|
|
statsPeriod="4m",
|
|
interval="1m",
|
|
orderBy="-sum(sentry.sessions.session)",
|
|
per_page=1, # limit to a single page
|
|
)
|
|
|
|
for group in response.data["groups"]:
|
|
assert group["series"]["sum(sentry.sessions.session)"] == [3, 6, 9, 12]
|
|
|
|
assert len(response.data["groups"]) == 1
|
|
|
|
def test_orderby_percentile_with_many_fields_multiple_entities_with_missing_data(self):
|
|
"""
|
|
Test that ensures when transactions table has null values for some fields (i.e. fields
|
|
with a different entity than the entity of the field in the order by), then the table gets
|
|
populated accordingly
|
|
"""
|
|
for tag, value, numbers in (
|
|
("transaction", "/foo/", [10, 11, 12]),
|
|
("transaction", "/bar/", [4, 5, 6]),
|
|
):
|
|
for subvalue in numbers:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_LCP.value,
|
|
tags={tag: value},
|
|
value=subvalue,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"count_unique({TransactionMetricKey.USER.value})",
|
|
],
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy=["project_id", "transaction"],
|
|
orderBy=f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 2
|
|
|
|
expected = [
|
|
("/bar/", 5.0, 5),
|
|
("/foo/", 11.0, 1),
|
|
]
|
|
for (expected_tag_value, expected_lcp_count, users), group in zip(expected, groups):
|
|
# With orderBy, you only get totals:
|
|
assert group["by"] == {"transaction": expected_tag_value, "project_id": self.project.id}
|
|
assert group["totals"] == {
|
|
f"count_unique({TransactionMetricKey.USER.value})": 0,
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": expected_lcp_count,
|
|
}
|
|
assert group["series"] == {
|
|
f"count_unique({TransactionMetricKey.USER.value})": [0],
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})": [expected_lcp_count],
|
|
}
|
|
|
|
def test_limit_without_orderby(self):
|
|
"""
|
|
Test that ensures when an `orderBy` clause is not set, then we still get groups that fit
|
|
within the limit, and that are also with complete data from across the entities
|
|
"""
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_SESSION.value,
|
|
tags={"tag3": "value1"},
|
|
value=10,
|
|
)
|
|
|
|
for value in (
|
|
"value2",
|
|
"value3",
|
|
"value4",
|
|
):
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.MEASUREMENTS_FCP.value,
|
|
tags={"tag3": value},
|
|
value=1,
|
|
)
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_LCP.value})",
|
|
f"p50({TransactionMetricKey.MEASUREMENTS_FCP.value})",
|
|
],
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy="tag3",
|
|
per_page=2,
|
|
useCase="transactions",
|
|
)
|
|
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 2
|
|
|
|
# we don't know which of {value2, value3, value4} is returned, just that
|
|
# it can't be `value1`, which is only on the other metric
|
|
returned_values = {group["by"]["tag3"] for group in groups}
|
|
assert "value1" not in returned_values, returned_values
|
|
|
|
def test_limit_without_orderby_excess_groups_pruned(self):
|
|
"""
|
|
Test that ensures that when requesting series data that is not ordered, if the limit of
|
|
each query is not met, thereby a limit is not applied to the aueries and we end up with
|
|
more groups than the limit then the excess number of groups should be pruned
|
|
"""
|
|
for tag, tag_value in (("tag1", "group1"), ("tag1", "group2")):
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_SESSION.value,
|
|
tags={tag: tag_value},
|
|
value=10,
|
|
)
|
|
|
|
for tag, tag_value, numbers in (
|
|
("tag1", "group2", list(range(3))),
|
|
("tag1", "group3", list(range(3, 6))),
|
|
):
|
|
for value in numbers:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_ERROR.value,
|
|
tags={tag: tag_value},
|
|
value=value,
|
|
)
|
|
|
|
for tag, tag_value, numbers in (
|
|
("tag1", "group4", list(range(3))),
|
|
("tag1", "group5", list(range(3, 6))),
|
|
):
|
|
for value in numbers:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.DURATION.value,
|
|
tags={tag: tag_value},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
f"p50({SessionMetricKey.DURATION.value})",
|
|
SessionMetricKey.ERRORED.value,
|
|
"sum(sentry.sessions.session)",
|
|
],
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy="tag1",
|
|
per_page=3,
|
|
)
|
|
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 3
|
|
|
|
def test_limit_without_orderby_partial_groups_pruned(self):
|
|
"""
|
|
Test that ensures that when requesting series data that is not ordered, if the limit of
|
|
each query is met, thereby a limit is applied to the queries and we end up with
|
|
with groups that have complete data across all entities
|
|
"""
|
|
for tag, tag_value in (
|
|
("tag2", "A1"),
|
|
("tag2", "B1"),
|
|
("tag2", "C1"),
|
|
):
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_SESSION.value,
|
|
tags={tag: tag_value},
|
|
value=10,
|
|
minutes_before_now=4,
|
|
)
|
|
|
|
for tag, tag_value, numbers in (
|
|
("tag2", "B2", list(range(3))),
|
|
("tag2", "B3", list(range(3, 6))),
|
|
("tag2", "C1", list(range(6, 9))),
|
|
("tag2", "B1", list(range(18, 21))),
|
|
):
|
|
for value in numbers:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_ERROR.value,
|
|
tags={tag: tag_value},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
f"p50({SessionMetricKey.DURATION.value})",
|
|
SessionMetricKey.ERRORED.value,
|
|
"sum(sentry.sessions.session)",
|
|
],
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy="tag2",
|
|
per_page=3,
|
|
)
|
|
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 3
|
|
returned_values = {group["by"]["tag2"] for group in groups}
|
|
# We need to make sure that returned groups are a subset of the groups returned by the
|
|
# metrics_sets to ensure we get groups with no partial results
|
|
assert returned_values.issubset({"B1", "B2", "B3", "C1"})
|
|
|
|
# TODO: check whether we leave - 1 seconds or whether we will abstract build_session like store_*_metric.
|
|
def test_groupby_project(self):
|
|
self.build_and_store_session(project_id=self.project2.id)
|
|
|
|
for _ in range(2):
|
|
self.build_and_store_session(project_id=self.project.id)
|
|
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
field="sum(sentry.sessions.session)",
|
|
groupBy=["project_id"],
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
groups = response.data["groups"]
|
|
assert len(groups) >= 2 and all(group["by"].keys() == {"project_id"} for group in groups)
|
|
|
|
expected = {
|
|
self.project2.id: 1,
|
|
self.project.id: 2,
|
|
}
|
|
for group in groups:
|
|
expected_count = expected[group["by"]["project_id"]]
|
|
totals = group["totals"]
|
|
assert totals == {"sum(sentry.sessions.session)": expected_count}
|
|
|
|
def test_unknown_groupby(self):
|
|
"""Use a tag name in groupby that does not exist in the indexer"""
|
|
# Insert session metrics:
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
)
|
|
|
|
# "foo" is known by indexer, "bar" is not
|
|
rh_indexer_record(self.organization.id, "foo")
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy=["foo"],
|
|
)
|
|
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 1
|
|
assert groups[0]["by"] == {"foo": None}
|
|
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy=["bar"],
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
@mock.patch(
|
|
"sentry.api.endpoints.organization_metrics.OrganizationMetricsDataEndpoint.default_per_page",
|
|
1,
|
|
)
|
|
def test_no_limit_with_series(self):
|
|
"""Pagination args do not apply to series"""
|
|
rh_indexer_record(self.organization.id, "session.status")
|
|
for minute in range(4):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="4m",
|
|
interval="1m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"]["sum(sentry.sessions.session)"] == 4
|
|
assert group["series"]["sum(sentry.sessions.session)"] == [1, 1, 1, 1]
|
|
|
|
def test_unknown_filter(self):
|
|
"""Use a tag key/value in filter that does not exist in the indexer"""
|
|
# Insert session metrics:
|
|
self.build_and_store_session(project_id=self.project.id)
|
|
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
query="foo:123", # Unknown tag key
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
query="release:123", # Unknown tag value is fine.
|
|
)
|
|
groups = response.data["groups"]
|
|
assert len(groups) == 1
|
|
assert groups[0]["totals"]["sum(sentry.sessions.session)"] == 0
|
|
assert groups[0]["series"]["sum(sentry.sessions.session)"] == [0]
|
|
|
|
def test_request_too_granular(self):
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="24h",
|
|
interval="5m",
|
|
per_page=50,
|
|
orderBy="-sum(sentry.sessions.session)",
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == (
|
|
f"Requested intervals (288) of timedelta of {timedelta(minutes=5)} with statsPeriod "
|
|
f"timedelta of {timedelta(hours=24)} is too granular "
|
|
f"for a per_page of 51 elements. Increase your interval, decrease your statsPeriod, "
|
|
f"or decrease your per_page parameter."
|
|
)
|
|
|
|
def test_include_series(self):
|
|
rh_indexer_record(self.organization.id, "session.status")
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
)
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
includeTotals="0",
|
|
)
|
|
assert response.data["groups"] == [
|
|
{"by": {}, "series": {"sum(sentry.sessions.session)": [1.0]}}
|
|
]
|
|
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
field="sum(sentry.sessions.session)",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
includeSeries="0",
|
|
includeTotals="0",
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
def test_transaction_status_unknown_error(self):
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.DURATION.value,
|
|
tags={"transaction.status": "unknown"},
|
|
value=10.0,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=f"sum({TransactionMetricKey.DURATION.value})",
|
|
query="transaction.status:unknown_error",
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
per_page=1,
|
|
useCase="transactions",
|
|
)
|
|
groups = response.data["groups"]
|
|
assert groups == [
|
|
{
|
|
"by": {},
|
|
"series": {"sum(transaction.duration)": [10.0]},
|
|
"totals": {"sum(transaction.duration)": 10.0},
|
|
}
|
|
]
|
|
|
|
def test_gauges(self):
|
|
mri = "g:custom/page_load@millisecond"
|
|
|
|
gauge_1 = {
|
|
"min": 1.0,
|
|
"max": 20.0,
|
|
"sum": 21.0,
|
|
"count": 2,
|
|
"last": 20.0,
|
|
}
|
|
|
|
gauge_2 = {
|
|
"min": 2.0,
|
|
"max": 21.0,
|
|
"sum": 21.0,
|
|
"count": 3,
|
|
"last": 4.0,
|
|
}
|
|
|
|
for value, minutes in ((gauge_1, 35), (gauge_2, 5)):
|
|
self.store_custom_metric(name=mri, tags={}, value=value, minutes_before_now=minutes)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
f"count({mri})",
|
|
f"min({mri})",
|
|
f"max({mri})",
|
|
f"last({mri})",
|
|
f"sum({mri})",
|
|
f"avg({mri})",
|
|
],
|
|
query="",
|
|
statsPeriod="1h",
|
|
interval="30m",
|
|
per_page=3,
|
|
useCase="custom",
|
|
includeSeries="1",
|
|
)
|
|
groups = response.data["groups"]
|
|
|
|
assert len(groups) == 1
|
|
assert groups == [
|
|
{
|
|
"by": {},
|
|
"series": {
|
|
"count(page_load)": [2, 3],
|
|
"max(page_load)": [20.0, 21.0],
|
|
"min(page_load)": [1.0, 2.0],
|
|
"last(page_load)": [20.0, 4.0],
|
|
"sum(page_load)": [21.0, 21.0],
|
|
"avg(page_load)": [10.5, 7.0],
|
|
},
|
|
"totals": {
|
|
"count(page_load)": 5,
|
|
"max(page_load)": 21.0,
|
|
"min(page_load)": 1.0,
|
|
"last(page_load)": 4.0,
|
|
"sum(page_load)": 42.0,
|
|
"avg(page_load)": 8.4,
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
@freeze_time(MetricsAPIBaseTestCase.MOCK_DATETIME)
|
|
class DerivedMetricsDataTest(MetricsAPIBaseTestCase):
|
|
endpoint = "sentry-api-0-organization-metrics-data"
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.login_as(user=self.user)
|
|
org_id = self.organization.id
|
|
self.session_duration_metric = rh_indexer_record(org_id, SessionMRI.RAW_DURATION.value)
|
|
self.session_metric = rh_indexer_record(org_id, SessionMRI.RAW_SESSION.value)
|
|
self.session_user_metric = rh_indexer_record(org_id, SessionMRI.RAW_USER.value)
|
|
self.session_error_metric = rh_indexer_record(org_id, SessionMRI.RAW_ERROR.value)
|
|
self.session_status_tag = rh_indexer_record(org_id, "session.status")
|
|
self.release_tag = rh_indexer_record(self.organization.id, "release")
|
|
self.tx_metric = perf_indexer_record(org_id, TransactionMRI.DURATION.value)
|
|
self.tx_status = perf_indexer_record(org_id, TransactionTagsKey.TRANSACTION_STATUS.value)
|
|
self.transaction_lcp_metric = perf_indexer_record(
|
|
self.organization.id, TransactionMRI.MEASUREMENTS_LCP.value
|
|
)
|
|
self.tx_satisfaction = perf_indexer_record(
|
|
self.organization.id, TransactionTagsKey.TRANSACTION_SATISFACTION.value
|
|
)
|
|
self.tx_user_metric = perf_indexer_record(self.organization.id, TransactionMRI.USER.value)
|
|
|
|
@property
|
|
def now(self):
|
|
return MetricsAPIBaseTestCase.MOCK_DATETIME
|
|
|
|
@patch("sentry.snuba.metrics.fields.base.DERIVED_METRICS", MOCKED_DERIVED_METRICS)
|
|
@patch("sentry.snuba.metrics.query.parse_mri")
|
|
@patch("sentry.snuba.metrics.fields.base.get_public_name_from_mri")
|
|
@patch("sentry.snuba.metrics.query_builder.get_mri")
|
|
@patch("sentry.snuba.metrics.query.get_public_name_from_mri")
|
|
def test_derived_metric_incorrectly_defined_as_singular_entity(
|
|
self,
|
|
mocked_get_public_name_from_mri,
|
|
mocked_get_mri_query,
|
|
mocked_reverse_mri,
|
|
mocked_parse_mri,
|
|
):
|
|
mocked_get_public_name_from_mri.return_value = "crash_free_fake"
|
|
mocked_get_mri_query.return_value = "crash_free_fake"
|
|
mocked_reverse_mri.return_value = "crash_free_fake"
|
|
mocked_parse_mri.return_value = ParsedMRI("e", "sessions", "crash_free_fake", "none")
|
|
for status in ["ok", "crashed"]:
|
|
for minute in range(4):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status=status,
|
|
)
|
|
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
field=["crash_free_fake"],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
useCase="sessions",
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == (
|
|
"Derived Metric crash_free_fake cannot be calculated from a single entity"
|
|
)
|
|
|
|
def test_derived_metric_does_not_exist(self):
|
|
"""
|
|
Test that ensures appropriate exception is raised when a request is made for a field with no
|
|
operation and a field that is not a valid derived metric
|
|
"""
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
project=[self.project.id],
|
|
field=["crash_free_fake"],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == (
|
|
"Failed to parse 'crash_free_fake'. The metric name must belong to a public metric."
|
|
)
|
|
|
|
def test_crash_free_percentage(self):
|
|
for status in ["ok", "crashed"]:
|
|
for minute in range(4):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status=status,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.crash_free_rate", "session.all", "session.crashed"],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"]["session.crash_free_rate"] == 0.5
|
|
assert group["totals"]["session.all"] == 8
|
|
assert group["totals"]["session.crashed"] == 4
|
|
assert group["series"]["session.crash_free_rate"] == [None, None, 0.5, 0.5, 0.5, 0.5]
|
|
|
|
def test_crash_free_percentage_with_orderby(self):
|
|
for status in ["ok", "crashed"]:
|
|
for minute in range(4):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status=status,
|
|
release="[email protected]",
|
|
)
|
|
|
|
for minute in range(4):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
minutes_before_now=minute,
|
|
status="ok",
|
|
release="[email protected]",
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.crash_free_rate"],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
groupBy="release",
|
|
orderBy="-session.crash_free_rate",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["by"]["release"] == "[email protected]"
|
|
assert group["totals"]["session.crash_free_rate"] == 1
|
|
assert group["series"]["session.crash_free_rate"] == [None, None, 1, 1, 1, 1]
|
|
|
|
group = response.data["groups"][1]
|
|
assert group["by"]["release"] == "[email protected]"
|
|
assert group["totals"]["session.crash_free_rate"] == 0.5
|
|
assert group["series"]["session.crash_free_rate"] == [None, None, 0.5, 0.5, 0.5, 0.5]
|
|
|
|
def test_crash_free_rate_when_no_session_metrics_data_exist(self):
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
project=[self.project.id],
|
|
field=["session.crash_free_rate", "sum(sentry.sessions.session)"],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
orderBy="-session.crash_free_rate",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"]["session.crash_free_rate"] is None
|
|
assert group["totals"]["sum(sentry.sessions.session)"] == 0
|
|
assert group["series"]["sum(sentry.sessions.session)"] == [0]
|
|
assert group["series"]["session.crash_free_rate"] == [None]
|
|
|
|
def test_crash_free_rate_when_no_session_metrics_data_with_orderby_and_groupby(self):
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
project=[self.project.id],
|
|
field=[
|
|
SessionMetricKey.CRASH_FREE_RATE.value,
|
|
"sum(sentry.sessions.session)",
|
|
],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
groupBy=["release"],
|
|
orderBy="-session.crash_free_rate",
|
|
)
|
|
assert response.data["groups"] == []
|
|
|
|
def test_incorrect_crash_free_rate(self):
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
project=[self.project.id],
|
|
field=[f"sum({SessionMetricKey.CRASH_FREE_RATE.value})"],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
)
|
|
assert (response.json()["detail"]) == (
|
|
"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."
|
|
)
|
|
|
|
def test_errored_sessions(self):
|
|
for tag_value, value in (
|
|
("errored_preaggr", 10),
|
|
("crashed", 2),
|
|
("abnormal", 4),
|
|
("init", 15),
|
|
):
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_SESSION.value,
|
|
tags={"session.status": tag_value},
|
|
value=value,
|
|
minutes_before_now=4,
|
|
)
|
|
for value in range(3):
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_ERROR.value,
|
|
tags={"release": "foo"},
|
|
value=value,
|
|
)
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[SessionMetricKey.ERRORED.value],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"]["session.errored"] == 7
|
|
assert group["series"]["session.errored"] == [0, 4, 0, 0, 0, 3]
|
|
|
|
def test_orderby_composite_entity_derived_metric(self):
|
|
self.build_and_store_session(
|
|
project_id=self.project.id,
|
|
status="ok",
|
|
release="[email protected]",
|
|
errors=2,
|
|
)
|
|
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
field=["session.errored"],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
groupBy=["release"],
|
|
orderBy=["session.errored"],
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.data["detail"] == (
|
|
"Selected 'orderBy' columns must belongs to the same entity"
|
|
)
|
|
|
|
def test_abnormal_sessions(self):
|
|
for tag_value, value, minutes in (
|
|
("foo", 4, 4),
|
|
("bar", 3, 2),
|
|
):
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_SESSION.value,
|
|
tags={"session.status": "abnormal", "release": tag_value},
|
|
value=value,
|
|
minutes_before_now=minutes,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.abnormal"],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
groupBy=["release"],
|
|
orderBy=["-session.abnormal"],
|
|
)
|
|
foo_group, bar_group = response.data["groups"][0], response.data["groups"][1]
|
|
assert foo_group["by"]["release"] == "foo"
|
|
assert foo_group["totals"] == {"session.abnormal": 4}
|
|
assert foo_group["series"] == {"session.abnormal": [0, 4, 0, 0, 0, 0]}
|
|
assert bar_group["by"]["release"] == "bar"
|
|
assert bar_group["totals"] == {"session.abnormal": 3}
|
|
assert bar_group["series"] == {"session.abnormal": [0, 0, 0, 3, 0, 0]}
|
|
|
|
def test_crashed_user_sessions(self):
|
|
for tag_value, values in (
|
|
("foo", [1, 2, 4]),
|
|
("bar", [1, 2, 4, 8, 9, 5]),
|
|
):
|
|
for value in values:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_USER.value,
|
|
tags={"session.status": "crashed", "release": tag_value},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.crashed_user"],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
groupBy=["release"],
|
|
orderBy=["-session.crashed_user"],
|
|
)
|
|
foo_group, bar_group = response.data["groups"][1], response.data["groups"][0]
|
|
assert foo_group["by"]["release"] == "foo"
|
|
assert foo_group["totals"] == {"session.crashed_user": 3}
|
|
assert foo_group["series"] == {"session.crashed_user": [0, 0, 0, 0, 0, 3]}
|
|
assert bar_group["by"]["release"] == "bar"
|
|
assert bar_group["totals"] == {"session.crashed_user": 6}
|
|
assert bar_group["series"] == {"session.crashed_user": [0, 0, 0, 0, 0, 6]}
|
|
|
|
def test_all_user_sessions(self):
|
|
for value in [1, 2, 4]:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_USER.value,
|
|
tags={},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.all_user"],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"] == {"session.all_user": 3}
|
|
assert group["series"] == {"session.all_user": [0, 0, 0, 0, 0, 3]}
|
|
|
|
def test_abnormal_user_sessions(self):
|
|
cases: tuple[tuple[dict[str, str], list[int]], ...] = (
|
|
({"session.status": "abnormal"}, [1, 2, 4]),
|
|
({}, [1, 2, 4, 7, 9]),
|
|
)
|
|
for tags, values in cases:
|
|
for value in values:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_USER.value,
|
|
tags=tags,
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.abnormal_user"],
|
|
statsPeriod="6m",
|
|
interval="1m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"] == {"session.abnormal_user": 3}
|
|
assert group["series"] == {"session.abnormal_user": [0, 0, 0, 0, 0, 3]}
|
|
|
|
def test_crash_free_user_percentage_with_orderby(self):
|
|
for tags, values in (
|
|
({"release": "[email protected]"}, [1, 2, 4, 8]),
|
|
({"session.status": "crashed", "release": "[email protected]"}, [1, 2]),
|
|
({"release": "[email protected]"}, [3, 5]),
|
|
):
|
|
for value in values:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_USER.value,
|
|
tags=tags,
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.crash_free_user_rate"],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
groupBy="release",
|
|
orderBy="-session.crash_free_user_rate",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["by"]["release"] == "[email protected]"
|
|
assert group["totals"]["session.crash_free_user_rate"] == 1
|
|
assert group["series"]["session.crash_free_user_rate"] == [1]
|
|
|
|
group = response.data["groups"][1]
|
|
assert group["by"]["release"] == "[email protected]"
|
|
assert group["totals"]["session.crash_free_user_rate"] == 0.5
|
|
assert group["series"]["session.crash_free_user_rate"] == [0.5]
|
|
|
|
def test_crash_free_user_rate_orderby_crash_free_rate(self):
|
|
# Users crash free rate
|
|
# [email protected] -> 0.5
|
|
# [email protected] -> 1
|
|
for tags, values in (
|
|
({"release": "[email protected]"}, [1, 2, 4, 8]),
|
|
({"session.status": "crashed", "release": "[email protected]"}, [1, 2]),
|
|
({"release": "[email protected]"}, [3, 5]),
|
|
):
|
|
for value in values:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_USER.value,
|
|
tags=tags,
|
|
value=value,
|
|
)
|
|
|
|
# Crash free rate
|
|
# [email protected] -> 0.75
|
|
# [email protected] -> 0.25
|
|
|
|
for tag_value, release_tag_value, value, second in (
|
|
("init", "[email protected]", 4, 4),
|
|
("crashed", "[email protected]", 1, 2),
|
|
("init", "[email protected]", 4, 4),
|
|
("crashed", "[email protected]", 3, 2),
|
|
):
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_SESSION.value,
|
|
tags={"session.status": tag_value, "release": release_tag_value},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=[
|
|
"session.crash_free_user_rate",
|
|
"session.crash_free_rate",
|
|
"session.crash_user_rate",
|
|
"session.crash_rate",
|
|
],
|
|
statsPeriod="1h",
|
|
interval="1h",
|
|
groupBy="release",
|
|
orderBy="-session.crash_free_rate",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["by"]["release"] == "[email protected]"
|
|
assert group["totals"]["session.crash_free_rate"] == 0.75
|
|
assert group["totals"]["session.crash_free_user_rate"] == 0.5
|
|
assert group["totals"]["session.crash_rate"] == 0.25
|
|
assert group["totals"]["session.crash_user_rate"] == 0.5
|
|
|
|
group = response.data["groups"][1]
|
|
assert group["by"]["release"] == "[email protected]"
|
|
assert group["totals"]["session.crash_free_rate"] == 0.25
|
|
assert group["totals"]["session.crash_free_user_rate"] == 1.0
|
|
assert group["totals"]["session.crash_rate"] == 0.75
|
|
assert group["totals"]["session.crash_user_rate"] == 0.0
|
|
|
|
def test_healthy_sessions(self):
|
|
for tags, value in (
|
|
({"session.status": "errored_preaggr", "release": "foo"}, 4),
|
|
({"session.status": "init", "release": "foo"}, 10),
|
|
):
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_SESSION.value,
|
|
tags=tags,
|
|
value=value,
|
|
)
|
|
|
|
for value in range(3):
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_ERROR.value,
|
|
tags={"release": "foo"},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.healthy", "session.errored", "session.all"],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"]["session.healthy"] == 3
|
|
assert group["series"]["session.healthy"] == [3]
|
|
|
|
def test_healthy_sessions_preaggr(self):
|
|
"""Healthy sessions works also when there are no individual errors"""
|
|
for tag_value, value in (
|
|
("errored_preaggr", 4),
|
|
("init", 10),
|
|
):
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_SESSION.value,
|
|
tags={"session.status": tag_value, "release": "foo"},
|
|
value=value,
|
|
)
|
|
|
|
# Can get session healthy even before all components exist
|
|
# (projects that send errored_preaggr usually do not send individual errors)
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.healthy"],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"]["session.healthy"] == 6
|
|
assert group["series"]["session.healthy"] == [6]
|
|
|
|
def test_errored_user_sessions(self):
|
|
# Crashed 3
|
|
# Abnormal 6
|
|
# Errored all 9
|
|
# Errored = 3
|
|
|
|
for tag_value, values in (
|
|
("crashed", [1, 2, 4]),
|
|
("errored", [1, 2, 4]),
|
|
("abnormal", [99, 3, 6, 8, 9, 5]),
|
|
("errored", [99, 3, 6, 8, 9, 5]),
|
|
("errored", [22, 33, 44]),
|
|
):
|
|
for value in values:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_USER.value,
|
|
tags={"session.status": tag_value},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.errored_user"],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"]["session.errored_user"] == 3
|
|
assert group["series"]["session.errored_user"] == [3]
|
|
|
|
def test_errored_user_sessions_clamped_to_zero(self):
|
|
# Crashed 3
|
|
# Errored all 0
|
|
# Errored = -3
|
|
for value in [1, 2, 4]:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_USER.value,
|
|
tags={"session.status": "crashed"},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.errored_user"],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"]["session.errored_user"] == 0
|
|
assert group["series"]["session.errored_user"] == [0]
|
|
|
|
def test_healthy_user_sessions(self):
|
|
cases: tuple[tuple[dict[str, str], list[int]], ...] = (
|
|
({}, [1, 2, 4, 5, 7]), # 3 and 6 did not recorded at init
|
|
({"session.status": "ok"}, [3]), # 3 was not in init, but still counts
|
|
({"session.status": "errored"}, [1, 2, 6]), # 6 was not in init, but still counts
|
|
)
|
|
for tags, values in cases:
|
|
for value in values:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_USER.value,
|
|
tags=tags,
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.healthy_user"],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"]["session.healthy_user"] == 4
|
|
assert group["series"]["session.healthy_user"] == [4]
|
|
|
|
def test_healthy_user_sessions_clamped_to_zero(self):
|
|
# init = 0
|
|
# errored_all = 1
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_USER.value,
|
|
tags={"session.status": "errored"},
|
|
value=1,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["session.healthy_user"],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group["totals"]["session.healthy_user"] == 0
|
|
assert group["series"]["session.healthy_user"] == [0]
|
|
|
|
def test_private_transactions_derived_metric(self):
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
project=[self.project.id],
|
|
field=["transaction.all"],
|
|
statsPeriod="1m",
|
|
interval="1m",
|
|
)
|
|
|
|
assert response.data["detail"] == (
|
|
"Failed to parse 'transaction.all'. The metric name must belong to a public metric."
|
|
)
|
|
|
|
def test_failure_rate_transaction(self):
|
|
for value, tag_value in (
|
|
(3.4, TransactionStatusTagValue.OK.value),
|
|
(0.3, TransactionStatusTagValue.CANCELLED.value),
|
|
(2.3, TransactionStatusTagValue.UNKNOWN.value),
|
|
(0.5, TransactionStatusTagValue.ABORTED.value),
|
|
):
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.DURATION.value,
|
|
tags={TransactionTagsKey.TRANSACTION_STATUS.value: tag_value},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["transaction.failure_rate"],
|
|
statsPeriod="1m",
|
|
interval="1m",
|
|
useCase="transactions",
|
|
)
|
|
|
|
assert len(response.data["groups"]) == 1
|
|
group = response.data["groups"][0]
|
|
assert group["by"] == {}
|
|
assert group["totals"] == {"transaction.failure_rate": 0.25}
|
|
assert group["series"] == {"transaction.failure_rate": [0.25]}
|
|
|
|
def test_failure_rate_without_transactions(self):
|
|
"""
|
|
Ensures the absence of transactions isn't an issue to calculate the rate.
|
|
|
|
The `nan` a division by 0 may produce must not be in the response, yet
|
|
they are an issue in javascript:
|
|
```
|
|
$ node
|
|
Welcome to Node.js v16.13.1.
|
|
Type ".help" for more information.
|
|
> JSON.parse('NaN')
|
|
Uncaught SyntaxError: Unexpected token N in JSON at position 0
|
|
> JSON.parse('nan')
|
|
Uncaught SyntaxError: Unexpected token a in JSON at position 1
|
|
```
|
|
"""
|
|
# Not sending buckets means no project is created automatically. We need
|
|
# a project without transaction data, so create one:
|
|
self.project
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["transaction.failure_rate"],
|
|
statsPeriod="1m",
|
|
interval="1m",
|
|
useCase="transactions",
|
|
)
|
|
|
|
assert response.data["groups"] == [
|
|
{
|
|
"by": {},
|
|
"series": {"transaction.failure_rate": [None]},
|
|
"totals": {"transaction.failure_rate": None},
|
|
},
|
|
]
|
|
|
|
def test_request_private_derived_metric(self):
|
|
for private_name in [
|
|
"session.crashed_and_abnormal_user",
|
|
"session.errored_set",
|
|
"session.errored_user_all",
|
|
]:
|
|
response = self.get_response(
|
|
self.organization.slug,
|
|
project=[self.project.id],
|
|
field=[private_name],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
)
|
|
assert response.data["detail"] == (
|
|
f"Failed to parse '{private_name}'. The metric name must belong to a public metric."
|
|
)
|
|
|
|
def test_apdex_transactions(self):
|
|
# See https://docs.sentry.io/product/performance/metrics/#apdex
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.DURATION.value,
|
|
tags={
|
|
TransactionTagsKey.TRANSACTION_SATISFACTION.value: TransactionSatisfactionTagValue.SATISFIED.value
|
|
},
|
|
value=3.4,
|
|
)
|
|
|
|
for subvalue in [0.3, 2.3]:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.DURATION.value,
|
|
tags={
|
|
TransactionTagsKey.TRANSACTION_SATISFACTION.value: TransactionSatisfactionTagValue.TOLERATED.value
|
|
},
|
|
value=subvalue,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["transaction.apdex"],
|
|
statsPeriod="1m",
|
|
interval="1m",
|
|
useCase="transactions",
|
|
)
|
|
|
|
assert len(response.data["groups"]) == 1
|
|
assert response.data["groups"][0]["totals"] == {"transaction.apdex": 0.6666666666666666}
|
|
|
|
def test_miserable_users(self):
|
|
for subvalue in [1, 2]:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.USER.value,
|
|
tags={
|
|
TransactionTagsKey.TRANSACTION_SATISFACTION.value: TransactionSatisfactionTagValue.FRUSTRATED.value
|
|
},
|
|
value=subvalue,
|
|
)
|
|
|
|
for subvalue in [1, 3]:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.USER.value,
|
|
tags={
|
|
TransactionTagsKey.TRANSACTION_SATISFACTION.value: TransactionSatisfactionTagValue.SATISFIED.value
|
|
},
|
|
value=subvalue,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["transaction.miserable_user"],
|
|
statsPeriod="1m",
|
|
interval="1m",
|
|
useCase="transactions",
|
|
)
|
|
|
|
assert len(response.data["groups"]) == 1
|
|
assert response.data["groups"][0]["totals"] == {"transaction.miserable_user": 2}
|
|
|
|
def test_user_misery(self):
|
|
for subvalue in [3, 4]:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.USER.value,
|
|
tags={
|
|
TransactionTagsKey.TRANSACTION_SATISFACTION.value: TransactionSatisfactionTagValue.FRUSTRATED.value
|
|
},
|
|
value=subvalue,
|
|
)
|
|
|
|
for subvalue in [5, 6]:
|
|
self.store_performance_metric(
|
|
name=TransactionMRI.USER.value,
|
|
tags={
|
|
TransactionTagsKey.TRANSACTION_SATISFACTION.value: TransactionSatisfactionTagValue.SATISFIED.value
|
|
},
|
|
value=subvalue,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["transaction.user_misery"],
|
|
statsPeriod="1m",
|
|
interval="1m",
|
|
useCase="transactions",
|
|
)
|
|
assert len(response.data["groups"]) == 1
|
|
assert response.data["groups"][0]["totals"] == {
|
|
"transaction.user_misery": 0.06478439425051336
|
|
}
|
|
|
|
def test_session_duration_derived_alias(self):
|
|
for tag_value, numbers in (
|
|
("exited", [2, 6, 8]),
|
|
("crashed", [11, 13, 15]),
|
|
):
|
|
for value in numbers:
|
|
self.store_release_health_metric(
|
|
name=SessionMRI.RAW_DURATION.value,
|
|
tags={"session.status": tag_value},
|
|
value=value,
|
|
)
|
|
|
|
response = self.get_success_response(
|
|
self.organization.slug,
|
|
field=["p50(session.duration)"],
|
|
statsPeriod="6m",
|
|
interval="6m",
|
|
)
|
|
group = response.data["groups"][0]
|
|
assert group == {
|
|
"by": {},
|
|
"totals": {"p50(session.duration)": 6.0},
|
|
"series": {"p50(session.duration)": [6.0]},
|
|
}
|