diff --git a/src/sentry/release_health/base.py b/src/sentry/release_health/base.py index 448e8cdacf610d..2dd796317ac7df 100644 --- a/src/sentry/release_health/base.py +++ b/src/sentry/release_health/base.py @@ -36,6 +36,8 @@ "crash_free_rate(user)", "anr_rate()", "foreground_anr_rate()", + "unhandled_rate(session)", + "unhandled_rate(user)", ] GroupByFieldName = Literal[ @@ -182,6 +184,8 @@ class ReleaseHealthOverview(TypedDict, total=False): duration_p50: float | None duration_p90: float | None stats: Mapping[StatsPeriod, ReleaseHealthStats] + sessions_unhandled: int + handled_sessions: float | None class CrashFreeBreakdown(TypedDict): @@ -202,6 +206,7 @@ class UserCounts(TypedDict): users_healthy: int users_crashed: int users_abnormal: int + users_unhandled: int users_errored: int @@ -214,6 +219,7 @@ class SessionCounts(TypedDict): sessions_healthy: int sessions_crashed: int sessions_abnormal: int + sessions_unhandled: int sessions_errored: int diff --git a/src/sentry/release_health/metrics.py b/src/sentry/release_health/metrics.py index 0bcd5a424a5ce7..f80a18e8dbbd3a 100644 --- a/src/sentry/release_health/metrics.py +++ b/src/sentry/release_health/metrics.py @@ -720,7 +720,7 @@ def _get_errored_sessions_for_overview( end: datetime, ) -> Mapping[tuple[int, str], int]: """ - Count of errored sessions, incl fatal (abnormal, crashed) sessions, + Count of errored sessions, incl fatal (abnormal, unhandled, crashed) sessions, excl errored *preaggregated* sessions """ project_ids = [p.id for p in projects] @@ -774,12 +774,13 @@ def _get_session_by_status_for_overview( end: datetime, ) -> Mapping[tuple[int, str, str], int]: """ - Counts of init, abnormal and crashed sessions, purpose-built for overview + Counts of init, abnormal, unhandled and crashed sessions, purpose-built for overview """ project_ids = [p.id for p in projects] select = [ MetricField(metric_mri=SessionMRI.ABNORMAL.value, alias="abnormal", op=None), + MetricField(metric_mri=SessionMRI.UNHANDLED.value, alias="unhandled", op=None), MetricField(metric_mri=SessionMRI.CRASHED.value, alias="crashed", op=None), MetricField(metric_mri=SessionMRI.ALL.value, alias="init", op=None), MetricField( @@ -820,7 +821,13 @@ def _get_session_by_status_for_overview( release = by.get("release") totals = group.get("totals", {}) - for status in ["abnormal", "crashed", "init", "errored_preaggr"]: + for status in [ + "abnormal", + "unhandled", + "crashed", + "init", + "errored_preaggr", + ]: value = totals.get(status) if value is not None and value != 0.0: ret_val[(proj_id, release, status)] = value @@ -1037,10 +1044,13 @@ def get_release_health_data_overview( if not has_health_data and summary_stats_period != "90d": fetch_has_health_data_releases.add((project_id, release)) + sessions_unhandled = rv_sessions.get((project_id, release, "unhandled"), 0) sessions_crashed = rv_sessions.get((project_id, release, "crashed"), 0) users_crashed = rv_users.get((project_id, release, "crashed_users"), 0) + sum_unhandled = sessions_unhandled + sessions_crashed + rv_row = rv[project_id, release] = { "adoption": adoption_info.get("adoption"), "sessions_adoption": adoption_info.get("sessions_adoption"), @@ -1051,10 +1061,14 @@ def get_release_health_data_overview( "total_sessions": total_sessions, "total_users": total_users, "has_health_data": has_health_data, + "sessions_unhandled": sessions_unhandled, "sessions_crashed": sessions_crashed, "crash_free_users": ( 100 - users_crashed / total_users * 100 if total_users else None ), + "handled_sessions": ( + 100 - sum_unhandled / float(total_sessions) * 100 if total_sessions else None + ), "crash_free_sessions": ( 100 - sessions_crashed / float(total_sessions) * 100 if total_sessions else None ), @@ -1063,6 +1077,7 @@ def get_release_health_data_overview( rv_errored_sessions.get((project_id, release), 0) + rv_sessions.get((project_id, release, "errored_preaggr"), 0) - sessions_crashed + - sessions_unhandled - rv_sessions.get((project_id, release, "abnormal"), 0), ), "duration_p50": None, @@ -1427,6 +1442,9 @@ def get_project_release_stats( MetricField( metric_mri=SessionMRI.CRASHED_USER.value, alias="users_crashed", op=None ), + MetricField( + metric_mri=SessionMRI.UNHANDLED_USER.value, alias="users_unhandled", op=None + ), MetricField( metric_mri=SessionMRI.ERRORED_USER.value, alias="users_errored", op=None ), @@ -1441,6 +1459,11 @@ def get_project_release_stats( metric_mri=SessionMRI.ABNORMAL.value, alias="sessions_abnormal", op=None ), MetricField(metric_mri=SessionMRI.CRASHED.value, alias="sessions_crashed", op=None), + MetricField( + metric_mri=SessionMRI.UNHANDLED.value, + alias="sessions_unhandled", + op=None, + ), MetricField(metric_mri=SessionMRI.ERRORED.value, alias="sessions_errored", op=None), MetricField(metric_mri=SessionMRI.HEALTHY.value, alias="sessions_healthy", op=None), ] @@ -1500,6 +1523,7 @@ def get_project_release_stats( f"{stat}": 0, f"{stat}_abnormal": 0, f"{stat}_crashed": 0, + f"{stat}_unhandled": 0, f"{stat}_errored": 0, f"{stat}_healthy": 0, } diff --git a/src/sentry/release_health/metrics_sessions_v2.py b/src/sentry/release_health/metrics_sessions_v2.py index 5c47102fdad97c..f12d94eeb6689a 100644 --- a/src/sentry/release_health/metrics_sessions_v2.py +++ b/src/sentry/release_health/metrics_sessions_v2.py @@ -1,7 +1,7 @@ -""" This module offers the same functionality as sessions_v2, but pulls its data +"""This module offers the same functionality as sessions_v2, but pulls its data from the `metrics` dataset instead of `sessions`. -Do not call this module directly. Use the `release_health` service instead. """ +Do not call this module directly. Use the `release_health` service instead.""" import logging from abc import ABC, abstractmethod @@ -73,6 +73,7 @@ class SessionStatus(Enum): CRASHED = "crashed" ERRORED = "errored" HEALTHY = "healthy" + UNHANDLED = "unhandled" ALL_STATUSES = frozenset(iter(SessionStatus)) @@ -242,6 +243,7 @@ def _get_metric_fields( self.status_to_metric_field[SessionStatus.ABNORMAL], self.status_to_metric_field[SessionStatus.CRASHED], self.status_to_metric_field[SessionStatus.ERRORED], + self.status_to_metric_field[SessionStatus.UNHANDLED], ] return [self.get_all_field()] @@ -265,6 +267,7 @@ class SumSessionField(CountField): SessionStatus.ABNORMAL: MetricField(None, SessionMRI.ABNORMAL.value), SessionStatus.CRASHED: MetricField(None, SessionMRI.CRASHED.value), SessionStatus.ERRORED: MetricField(None, SessionMRI.ERRORED.value), + SessionStatus.UNHANDLED: MetricField(None, SessionMRI.UNHANDLED.value), None: MetricField(None, SessionMRI.ALL.value), } @@ -298,6 +301,7 @@ def __init__( SessionStatus.ABNORMAL: MetricField(None, SessionMRI.ABNORMAL_USER.value), SessionStatus.CRASHED: MetricField(None, SessionMRI.CRASHED_USER.value), SessionStatus.ERRORED: MetricField(None, SessionMRI.ERRORED_USER.value), + SessionStatus.UNHANDLED: MetricField(None, SessionMRI.UNHANDLED_USER.value), None: MetricField(None, SessionMRI.ALL_USER.value), } @@ -335,6 +339,8 @@ class SimpleForwardingField(Field): """ field_name_to_metric_name = { + "unhandled_rate(session)": SessionMRI.UNHANDLED_RATE, + "unhandled_rate(user)": SessionMRI.UNHANDLED_USER_RATE, "crash_rate(session)": SessionMRI.CRASH_RATE, "crash_rate(user)": SessionMRI.CRASH_USER_RATE, "crash_free_rate(session)": SessionMRI.CRASH_FREE_RATE, @@ -373,6 +379,8 @@ def _get_metric_fields( "p95(session.duration)": DurationField, "p99(session.duration)": DurationField, "max(session.duration)": DurationField, + "unhandled_rate(session)": SimpleForwardingField, + "unhandled_rate(user)": SimpleForwardingField, "crash_rate(session)": SimpleForwardingField, "crash_rate(user)": SimpleForwardingField, "crash_free_rate(session)": SimpleForwardingField, diff --git a/src/sentry/snuba/metrics/fields/snql.py b/src/sentry/snuba/metrics/fields/snql.py index 85925024144292..2d307e1bb499ca 100644 --- a/src/sentry/snuba/metrics/fields/snql.py +++ b/src/sentry/snuba/metrics/fields/snql.py @@ -246,6 +246,20 @@ def all_users(org_id: int, metric_ids: Sequence[int], alias: str | None = None) return uniq_aggregation_on_metric(metric_ids, alias) +def unhandled_sessions( + org_id: int, metric_ids: Sequence[int], alias: str | None = None +) -> Function: + return _counter_sum_aggregation_on_session_status_factory( + org_id, session_status="unhandled", metric_ids=metric_ids, alias=alias + ) + + +def unhandled_users(org_id: int, metric_ids: Sequence[int], alias: str | None = None) -> Function: + return _set_uniq_aggregation_on_session_status_factory( + org_id, session_status="unhandled", metric_ids=metric_ids, alias=alias + ) + + def crashed_sessions(org_id: int, metric_ids: Sequence[int], alias: str | None = None) -> Function: return _counter_sum_aggregation_on_session_status_factory( org_id, session_status="crashed", metric_ids=metric_ids, alias=alias diff --git a/src/sentry/snuba/metrics/naming_layer/mri.py b/src/sentry/snuba/metrics/naming_layer/mri.py index 33ae019c23f1f8..b19ef312ef3f95 100644 --- a/src/sentry/snuba/metrics/naming_layer/mri.py +++ b/src/sentry/snuba/metrics/naming_layer/mri.py @@ -72,20 +72,32 @@ class SessionMRI(Enum): ERRORED_PREAGGREGATED = "e:sessions/error.preaggr@none" ERRORED_SET = "e:sessions/error.unique@none" ERRORED_ALL = "e:sessions/all_errored@none" + HANDLED = "e:sessions/handled.unique@none" # all sessions excluding handled and crashed + UNHANDLED = "e:sessions/unhandled@none" # unhandled, does not include crashed CRASHED_AND_ABNORMAL = "e:sessions/crashed_abnormal@none" CRASHED = "e:sessions/crashed@none" CRASH_FREE = "e:sessions/crash_free@none" ABNORMAL = "e:sessions/abnormal@none" + HANDLED_RATE = "e:sessions/handled_rate@ratio" # all sessions excluding handled and crashed + UNHANDLED_RATE = "e:sessions/unhandled_rate@ratio" # unhandled, does not include crashed CRASH_RATE = "e:sessions/crash_rate@ratio" - CRASH_FREE_RATE = "e:sessions/crash_free_rate@ratio" + CRASH_FREE_RATE = "e:sessions/crash_free_rate@ratio" # includes handled and unhandled ALL_USER = "e:sessions/user.all@none" HEALTHY_USER = "e:sessions/user.healthy@none" ERRORED_USER = "e:sessions/user.errored@none" ERRORED_USER_ALL = "e:sessions/user.all_errored@none" + HANDLED_USER = "e:sessions/user.handled@none" # all sessions excluding handled and crashed + UNHANDLED_USER = "e:sessions/user.unhandled@none" # unhandled, does not include crashed CRASHED_AND_ABNORMAL_USER = "e:sessions/user.crashed_abnormal@none" CRASHED_USER = "e:sessions/user.crashed@none" CRASH_FREE_USER = "e:sessions/user.crash_free@none" ABNORMAL_USER = "e:sessions/user.abnormal@none" + HANDLED_USER_RATE = ( + "e:sessions/user.handled_rate@ratio" # all sessions excluding handled and crashed + ) + UNHANDLED_USER_RATE = ( + "e:sessions/user.unhandled_rate@ratio" # unhandled, does not include crashed + ) CRASH_USER_RATE = "e:sessions/user.crash_rate@ratio" CRASH_FREE_USER_RATE = "e:sessions/user.crash_free_rate@ratio" ANR_USER = "e:sessions/user.anr@none" diff --git a/src/sentry/snuba/metrics/naming_layer/public.py b/src/sentry/snuba/metrics/naming_layer/public.py index 40d262b90fab0b..a470064ce5c6b7 100644 --- a/src/sentry/snuba/metrics/naming_layer/public.py +++ b/src/sentry/snuba/metrics/naming_layer/public.py @@ -36,6 +36,7 @@ class SessionMetricKey(Enum): DURATION = "session.duration" ALL = "session.all" ABNORMAL = "session.abnormal" + UNHANDLED = "session.unhandled" CRASHED = "session.crashed" CRASH_FREE = "session.crash_free" ERRORED = "session.errored" @@ -45,6 +46,7 @@ class SessionMetricKey(Enum): CRASH_FREE_RATE = "session.crash_free_rate" ALL_USER = "session.all_user" ABNORMAL_USER = "session.abnormal_user" + UNHANDLED_USER = "session.unhandled_user" CRASHED_USER = "session.crashed_user" CRASH_FREE_USER = "session.crash_free_user" ERRORED_USER = "session.errored_user" diff --git a/src/sentry/snuba/sessions_v2.py b/src/sentry/snuba/sessions_v2.py index 0eded1186366bd..3147204ea8dc79 100644 --- a/src/sentry/snuba/sessions_v2.py +++ b/src/sentry/snuba/sessions_v2.py @@ -120,11 +120,16 @@ def extract_from_row(self, row, group): return max(healthy_sessions, 0) if status == "abnormal": return row["sessions_abnormal"] + if status == "unhandled": + return row["sessions_unhandled"] if status == "crashed": return row["sessions_crashed"] if status == "errored": errored_sessions = ( - row["sessions_errored"] - row["sessions_crashed"] - row["sessions_abnormal"] + row["sessions_errored"] + - row["sessions_unhandled"] + - row["sessions_crashed"] + - row["sessions_abnormal"] ) return max(errored_sessions, 0) return 0 @@ -133,7 +138,7 @@ def extract_from_row(self, row, group): class UsersField: def get_snuba_columns(self, raw_groupby): if "session.status" in raw_groupby: - return ["users", "users_abnormal", "users_crashed", "users_errored"] + return ["users", "users_abnormal", "users_crashed", "users_errored", "users_unhandled"] return ["users"] def extract_from_row(self, row, group): @@ -147,10 +152,17 @@ def extract_from_row(self, row, group): return max(healthy_users, 0) if status == "abnormal": return row["users_abnormal"] + if status == "unhandled": + return row["users_unhandled"] if status == "crashed": return row["users_crashed"] if status == "errored": - errored_users = row["users_errored"] - row["users_crashed"] - row["users_abnormal"] + errored_users = ( + row["users_errored"] + - row["users_crashed"] + - row["users_abnormal"] + - row["users_unhandled"] + ) return max(errored_users, 0) return 0 diff --git a/src/sentry/testutils/cases.py b/src/sentry/testutils/cases.py index 26087e7f41d513..7cf939cede775a 100644 --- a/src/sentry/testutils/cases.py +++ b/src/sentry/testutils/cases.py @@ -1454,7 +1454,7 @@ def push(mri: str, tags, value): elif not user_is_nil: push(SessionMRI.RAW_USER.value, {}, user) - if status in ("abnormal", "crashed"): # fatal + if status in ("abnormal", "unhandled", "crashed"): # fatal push(SessionMRI.RAW_SESSION.value, {"session.status": status}, +1) if not user_is_nil: push(SessionMRI.RAW_USER.value, {"session.status": status}, user) diff --git a/src/sentry/web/frontend/debug/debug_chart_renderer.py b/src/sentry/web/frontend/debug/debug_chart_renderer.py index 7f54e7d8071dec..1f909b9705a675 100644 --- a/src/sentry/web/frontend/debug/debug_chart_renderer.py +++ b/src/sentry/web/frontend/debug/debug_chart_renderer.py @@ -653,6 +653,11 @@ "totals": {"sum(session)": 963}, "series": {"sum(session)": [185, 170, 147, 170, 105, 133, 53]}, }, + { + "by": {"session.status": "unhandled"}, + "totals": {"sum(session)": 0}, + "series": {"sum(session)": [0, 0, 0, 0, 0, 0, 0]}, + }, { "by": {"session.status": "crashed"}, "totals": {"sum(session)": 401}, diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index ed840d81a19ebc..5749c83e74f0b0 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -390,6 +390,8 @@ export enum SessionFieldWithOperation { SESSIONS = 'sum(session)', USERS = 'count_unique(user)', DURATION = 'p50(session.duration)', + UNHANDLED = 'sum(session.unhandled)', + UNHANDLED_USER = 'count_unique(session.unhandled_user)', CRASH_FREE_RATE_USERS = 'crash_free_rate(user)', CRASH_FREE_RATE_SESSIONS = 'crash_free_rate(session)', } @@ -397,6 +399,7 @@ export enum SessionFieldWithOperation { export enum SessionStatus { HEALTHY = 'healthy', ABNORMAL = 'abnormal', + UNHANDLED = 'unhandled', ERRORED = 'errored', CRASHED = 'crashed', } diff --git a/tests/sentry/api/endpoints/test_organization_release_health_data.py b/tests/sentry/api/endpoints/test_organization_release_health_data.py index a577cad59bb57a..81c217c41f23b7 100644 --- a/tests/sentry/api/endpoints/test_organization_release_health_data.py +++ b/tests/sentry/api/endpoints/test_organization_release_health_data.py @@ -1765,6 +1765,7 @@ def test_errored_sessions(self) -> None: for tag_value, value in ( ("errored_preaggr", 10), ("crashed", 2), + ("unhandled", 1), ("abnormal", 4), ("init", 15), ): @@ -1787,7 +1788,7 @@ def test_errored_sessions(self) -> None: interval="1m", ) group = response.data["groups"][0] - assert group["totals"]["session.errored"] == 7 + assert group["totals"]["session.errored"] == 8 assert group["series"]["session.errored"] == [0, 4, 0, 0, 0, 3] def test_orderby_composite_entity_derived_metric(self) -> None: @@ -1839,6 +1840,10 @@ def test_abnormal_sessions(self) -> None: assert bar_group["totals"] == {"session.abnormal": 3} assert bar_group["series"] == {"session.abnormal": [0, 0, 0, 3, 0, 0]} + def test_unhandled_sessions(self) -> None: + # TODO: ryan953 + pass + def test_crashed_user_sessions(self) -> None: for tag_value, values in ( ("foo", [1, 2, 4]), diff --git a/tests/sentry/snuba/metrics/test_snql.py b/tests/sentry/snuba/metrics/test_snql.py index 63bf802935339b..00572bd497036a 100644 --- a/tests/sentry/snuba/metrics/test_snql.py +++ b/tests/sentry/snuba/metrics/test_snql.py @@ -33,6 +33,8 @@ session_duration_filters, subtraction, tolerated_count_transaction, + unhandled_sessions, + unhandled_users, uniq_aggregation_on_metric, uniq_if_column_snql, ) @@ -66,6 +68,7 @@ def setUp(self) -> None: self.org_id: { "abnormal", "crashed", + "unhandled", "errored_preaggr", "errored", "exited", @@ -98,6 +101,7 @@ def test_counter_sum_aggregation_on_session_status(self) -> None: ("crashed", crashed_sessions), ("errored_preaggr", errored_preaggr_sessions), ("abnormal", abnormal_sessions), + ("unhandled", unhandled_sessions), ]: assert func(self.org_id, self.metric_ids, alias=status) == Function( "sumIf", @@ -129,6 +133,7 @@ def test_set_uniq_aggregation_on_session_status(self) -> None: ("crashed", crashed_users), ("abnormal", abnormal_users), ("errored", errored_all_users), + ("unhandled", unhandled_users), ]: assert func(self.org_id, self.metric_ids, alias=status) == Function( "uniqIf", diff --git a/tests/snuba/api/endpoints/test_organization_sessions.py b/tests/snuba/api/endpoints/test_organization_sessions.py index 5ee25ef8794f10..7d5bef0c2cd239 100644 --- a/tests/snuba/api/endpoints/test_organization_sessions.py +++ b/tests/snuba/api/endpoints/test_organization_sessions.py @@ -539,7 +539,7 @@ def test_filter_unknown_release_in(self): "series": {"sum(session)": [0, 0]}, "totals": {"sum(session)": 0}, } - for status in ("abnormal", "crashed", "errored", "healthy") + for status in ("abnormal", "unhandled", "crashed", "errored", "healthy") ] @freeze_time(MOCK_DATETIME) @@ -649,6 +649,11 @@ def test_groupby_status(self): "series": {"sum(session)": [0, 0]}, "totals": {"sum(session)": 0}, }, + { + "by": {"session.status": "unhandled"}, + "series": {"sum(session)": [0, 0]}, + "totals": {"sum(session)": 0}, + }, { "by": {"session.status": "crashed"}, "series": {"sum(session)": [0, 1]}, @@ -739,6 +744,11 @@ def test_users_groupby(self): "series": {"count_unique(user)": [0, 0]}, "totals": {"count_unique(user)": 0}, }, + { + "by": {"session.status": "unhandled"}, + "series": {"count_unique(user)": [0, 0]}, + "totals": {"count_unique(user)": 0}, + }, { "by": {"session.status": "crashed"}, "series": {"count_unique(user)": [0, 0]}, @@ -798,9 +808,9 @@ def test_users_groupby_status_advanced(self): self.store_session( make_session(project, session_id=session2b, distinct_id=user2, status="ok") ) - self.store_session( - make_session(project, session_id=session2b, distinct_id=user2, status="abnormal") - ) + # self.store_session( + # make_session(project, session_id=session2b, distinct_id=user2, status="abnormal") + # ) self.store_session( make_session( @@ -848,6 +858,11 @@ def test_users_groupby_status_advanced(self): "series": {"count_unique(user)": [0, 1]}, "totals": {"count_unique(user)": 1}, }, + { + "by": {"session.status": "unhandled"}, + "series": {"count_unique(user)": [0, 1]}, + "totals": {"count_unique(user)": 1}, + }, { "by": {"session.status": "crashed"}, "series": {"count_unique(user)": [0, 1]}, @@ -935,7 +950,7 @@ def test_duration_percentiles_groupby(self): assert group["totals"] == {key: None for key in expected}, group["by"] assert group["series"] == {key: [None, None] for key in expected} - assert seen == {"abnormal", "crashed", "errored", "healthy"} + assert seen == {"abnormal", "unhandled", "crashed", "errored", "healthy"} @freeze_time(MOCK_DATETIME) def test_snuba_limit_exceeded(self): @@ -1007,6 +1022,14 @@ def test_snuba_limit_exceeded_groupby_status(self): "totals": {"sum(session)": 0, "count_unique(user)": 0}, "series": {"sum(session)": [0, 0, 0, 0], "count_unique(user)": [0, 0, 0, 0]}, }, + { + "by": { + "project": self.project1.id, + "release": "foo@1.0.0", + "session.status": "unhandled", + "environment": "production", + }, + }, { "by": { "project": self.project1.id, diff --git a/tests/snuba/sessions/test_sessions_v2.py b/tests/snuba/sessions/test_sessions_v2.py index b4393d72ac8594..196237f7c05987 100644 --- a/tests/snuba/sessions/test_sessions_v2.py +++ b/tests/snuba/sessions/test_sessions_v2.py @@ -654,6 +654,11 @@ def test_massage_virtual_groupby_timeseries(): "series": {"count_unique(user)": [0, 0, 0, 1], "sum(session)": [3, 4, 0, 1]}, "totals": {"count_unique(user)": 1, "sum(session)": 8}, }, + { + "by": {"session.status": "unhandled"}, + "series": {"count_unique(user)": [0, 0, 0, 0], "sum(session)": [0, 0, 0, 0]}, + "totals": {"count_unique(user)": 0, "sum(session)": 0}, + }, { "by": {"session.status": "errored"}, "series": {"count_unique(user)": [0, 0, 0, 0], "sum(session)": [0, 4, 0, 0]}, @@ -726,6 +731,11 @@ def test_clamping_in_massage_sessions_results_with_groupby_timeseries(): "series": {"count_unique(user)": [0, 2], "sum(session)": [0, 2]}, "totals": {"count_unique(user)": 0, "sum(session)": 0}, }, + { + "by": {"session.status": "unhandled"}, + "series": {"count_unique(user)": [0, 0], "sum(session)": [0, 0]}, + "totals": {"count_unique(user)": 0, "sum(session)": 0}, + }, { "by": {"session.status": "errored"}, "series": {"count_unique(user)": [10, 0], "sum(session)": [10, 0]},