Browse Source

fix(release_health): Adjust all code such that the current timestamp is determined exactly once (#31818)

Markus Unterwaditzer 3 years ago
parent
commit
110aff1ae1

+ 10 - 2
src/sentry/release_health/base.py

@@ -312,7 +312,9 @@ class ReleaseHealthBackend(Service):  # type: ignore
         raise NotImplementedError()
 
     def check_has_health_data(
-        self, projects_list: Sequence[ProjectOrRelease]
+        self,
+        projects_list: Sequence[ProjectOrRelease],
+        now: Optional[datetime] = None,
     ) -> Set[ProjectOrRelease]:
         """
         Function that returns a set of all project_ids or (project, release) if they have health data
@@ -345,6 +347,7 @@ class ReleaseHealthBackend(Service):  # type: ignore
         summary_stats_period: Optional[StatsPeriod] = None,
         health_stats_period: Optional[StatsPeriod] = None,
         stat: Optional[Literal["users", "sessions"]] = None,
+        now: Optional[datetime] = None,
     ) -> Mapping[ProjectRelease, ReleaseHealthOverview]:
         """Checks quickly for which of the given project releases we have
         health data available.  The argument is a tuple of `(project_id, release_name)`
@@ -360,12 +363,14 @@ class ReleaseHealthBackend(Service):  # type: ignore
         release: ReleaseName,
         start: datetime,
         environments: Optional[Sequence[EnvironmentName]] = None,
+        now: Optional[datetime] = None,
     ) -> Sequence[CrashFreeBreakdown]:
         """Get stats about crash free sessions and stats for the last 1, 2, 7, 14 and 30 days"""
 
     def get_changed_project_release_model_adoptions(
         self,
         project_ids: Sequence[ProjectId],
+        now: Optional[datetime] = None,
     ) -> Sequence[ProjectRelease]:
         """
         Returns a sequence of tuples (ProjectId, ReleaseName) with the
@@ -374,7 +379,9 @@ class ReleaseHealthBackend(Service):  # type: ignore
         raise NotImplementedError()
 
     def get_oldest_health_data_for_releases(
-        self, project_releases: Sequence[ProjectRelease]
+        self,
+        project_releases: Sequence[ProjectRelease],
+        now: Optional[datetime] = None,
     ) -> Mapping[ProjectRelease, str]:
         """Returns the oldest health data we have observed in a release
         in 90 days.  This is used for backfilling.
@@ -442,6 +449,7 @@ class ReleaseHealthBackend(Service):  # type: ignore
         scope: str,
         stats_period: Optional[str] = None,
         environments: Optional[Sequence[str]] = None,
+        now: Optional[datetime] = None,
     ) -> Sequence[ProjectRelease]:
         """Given some project IDs returns adoption rates that should be updated
         on the postgres tables.

+ 50 - 17
src/sentry/release_health/duplex.py

@@ -615,9 +615,11 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
             "project_users_24h": ComparatorType.Counter,
             "project_sessions_24h": ComparatorType.Counter,
         }
-        should_compare = lambda _: datetime.now(timezone.utc) - timedelta(hours=24) > _coerce_utc(
-            self.metrics_start
-        )
+
+        if now is None:
+            now = datetime.now(pytz.utc)
+
+        should_compare = lambda _: now - timedelta(hours=24) > self.metrics_start
 
         if org_id is not None:
             organization = self._org_from_id(org_id)
@@ -729,16 +731,25 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
         )
 
     def check_has_health_data(
-        self, projects_list: Sequence[ProjectOrRelease]
+        self,
+        projects_list: Sequence[ProjectOrRelease],
+        now: Optional[datetime] = None,
     ) -> Set[ProjectOrRelease]:
+        if now is None:
+            now = datetime.now(pytz.utc)
+
         rollup = self.DEFAULT_ROLLUP  # not used
         schema = {ComparatorType.Exact}
-        should_compare = (
-            lambda _: datetime.now(timezone.utc) - timedelta(days=90) > self.metrics_start
-        )
+        should_compare = lambda _: now - timedelta(days=90) > self.metrics_start
         organization = self._org_from_projects(projects_list)
         return self._dispatch_call(  # type: ignore
-            "check_has_health_data", should_compare, rollup, organization, schema, projects_list
+            "check_has_health_data",
+            should_compare,
+            rollup,
+            organization,
+            schema,
+            projects_list,
+            now,
         )
 
     def check_releases_have_health_data(
@@ -775,6 +786,7 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
         summary_stats_period: Optional[StatsPeriod] = None,
         health_stats_period: Optional[StatsPeriod] = None,
         stat: Optional[Literal["users", "sessions"]] = None,
+        now: Optional[datetime] = None,
     ) -> Mapping[ProjectRelease, ReleaseHealthOverview]:
         rollup = self.DEFAULT_ROLLUP  # not used
         # ignore all fields except the 24h ones (the others go to the beginning of time)
@@ -785,9 +797,11 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
             "total_project_sessions_24h": ComparatorType.Counter
             # TODO still need to look into stats field and find out what compare conditions it has
         }
-        should_compare = (
-            lambda _: datetime.now(timezone.utc) - timedelta(days=1) > self.metrics_start
-        )
+
+        if now is None:
+            now = datetime.now(pytz.utc)
+
+        should_compare = lambda _: now - timedelta(days=1) > self.metrics_start
         organization = self._org_from_projects(project_releases)
         return self._dispatch_call(  # type: ignore
             "get_release_health_data_overview",
@@ -800,6 +814,7 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
             summary_stats_period,
             health_stats_period,
             stat,
+            now,
         )
 
     def get_crash_free_breakdown(
@@ -808,7 +823,11 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
         release: ReleaseName,
         start: datetime,
         environments: Optional[Sequence[EnvironmentName]] = None,
+        now: Optional[datetime] = None,
     ) -> Sequence[CrashFreeBreakdown]:
+        if now is None:
+            now = datetime.now(pytz.utc)
+
         rollup = self.DEFAULT_ROLLUP  # TODO Check if this is the rollup we want
         schema = [
             {
@@ -831,11 +850,13 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
             release,
             start,
             environments,
+            now,
         )
 
     def get_changed_project_release_model_adoptions(
         self,
         project_ids: Sequence[ProjectId],
+        now: Optional[datetime] = None,
     ) -> Sequence[ProjectRelease]:
         rollup = self.DEFAULT_ROLLUP  # not used
         schema = ListSet(schema=ComparatorType.Exact, index_by=lambda x: x)
@@ -844,6 +865,9 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
             lambda _: datetime.now(timezone.utc) - timedelta(days=3) > self.metrics_start
         )
 
+        if now is None:
+            now = datetime.now(pytz.utc)
+
         organization = self._org_from_projects(project_ids)
         return self._dispatch_call(  # type: ignore
             "get_changed_project_release_model_adoptions",
@@ -852,16 +876,20 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
             organization,
             schema,
             project_ids,
+            now,
         )
 
     def get_oldest_health_data_for_releases(
-        self, project_releases: Sequence[ProjectRelease]
+        self,
+        project_releases: Sequence[ProjectRelease],
+        now: Optional[datetime] = None,
     ) -> Mapping[ProjectRelease, str]:
+        if now is None:
+            now = datetime.now(pytz.utc)
+
         rollup = self.DEFAULT_ROLLUP  # TODO check if this is correct ?
         schema = {"*": ComparatorType.DateTime}
-        should_compare = (
-            lambda _: datetime.now(timezone.utc) - timedelta(days=90) > self.metrics_start
-        )
+        should_compare = lambda _: now - timedelta(days=90) > self.metrics_start
         organization = self._org_from_projects(project_releases)
         return self._dispatch_call(  # type: ignore
             "get_oldest_health_data_for_releases",
@@ -870,6 +898,7 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
             organization,
             schema,
             project_releases,
+            now,
         )
 
     def get_project_releases_count(
@@ -1004,6 +1033,7 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
         scope: str,
         stats_period: Optional[str] = None,
         environments: Optional[Sequence[str]] = None,
+        now: Optional[datetime] = None,
     ) -> Sequence[ProjectRelease]:
         schema = ListSet(schema=ComparatorType.Exact, index_by=lambda x: x)
 
@@ -1017,8 +1047,11 @@ class DuplexReleaseHealthBackend(ReleaseHealthBackend):
         if scope.endswith("_24h"):
             stats_period = "24h"
 
-        rollup, stats_start, _ = get_rollup_starts_and_buckets(stats_period)
-        should_compare = lambda _: _coerce_utc(stats_start) > self.metrics_start
+        if now is None:
+            now = datetime.now(pytz.utc)
+
+        rollup, stats_start, _ = get_rollup_starts_and_buckets(stats_period, now=now)
+        should_compare = lambda _: now > self.metrics_start
         organization = self._org_from_projects(project_ids)
 
         return self._dispatch_call(  # type: ignore

+ 37 - 13
src/sentry/release_health/metrics.py

@@ -428,7 +428,9 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
                 Condition(
                     Column("timestamp"), Op.GTE, datetime(2008, 5, 8)
                 ),  # Date of sentry's first commit
-                Condition(Column("timestamp"), Op.LT, datetime.now(pytz.utc)),
+                Condition(
+                    Column("timestamp"), Op.LT, datetime.now(pytz.utc) + timedelta(seconds=10)
+                ),
             ]
 
             if environments is not None:
@@ -542,9 +544,13 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
         }
 
     def check_has_health_data(
-        self, projects_list: Sequence[ProjectOrRelease]
+        self,
+        projects_list: Sequence[ProjectOrRelease],
+        now: Optional[datetime] = None,
     ) -> Set[ProjectOrRelease]:
-        now = datetime.now(pytz.utc)
+        if now is None:
+            now = datetime.now(pytz.utc)
+
         start = now - timedelta(days=3)
 
         projects_list = list(projects_list)
@@ -849,7 +855,7 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
         session_init_tag_value = resolve_weak("init")
 
         stats_rollup, stats_start, stats_buckets = get_rollup_starts_and_buckets(
-            health_stats_period
+            health_stats_period, now=now
         )
 
         aggregates: List[SelectableExpression] = [
@@ -911,12 +917,15 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
         summary_stats_period: Optional[StatsPeriod] = None,
         health_stats_period: Optional[StatsPeriod] = None,
         stat: Optional[OverviewStat] = None,
+        now: Optional[datetime] = None,
     ) -> Mapping[ProjectRelease, ReleaseHealthOverview]:
         if stat is None:
             stat = "sessions"
         assert stat in ("sessions", "users")
-        now = datetime.now(pytz.utc)
-        _, summary_start, _ = get_rollup_starts_and_buckets(summary_stats_period or "24h")
+        if now is None:
+            now = datetime.now(pytz.utc)
+
+        _, summary_start, _ = get_rollup_starts_and_buckets(summary_stats_period or "24h", now=now)
         rollup = LEGACY_SESSIONS_DEFAULT_ROLLUP
 
         org_id = self._get_org_id([x for x, _ in project_releases])
@@ -1152,11 +1161,14 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
         release: ReleaseName,
         start: datetime,
         environments: Optional[Sequence[EnvironmentName]] = None,
+        now: Optional[datetime] = None,
     ) -> Sequence[CrashFreeBreakdown]:
 
         org_id = self._get_org_id([project_id])
 
-        now = datetime.now(pytz.utc)
+        if now is None:
+            now = datetime.now(pytz.utc)
+
         query_fn = self._get_crash_free_breakdown_fn(
             org_id, project_id, release, start, environments
         )
@@ -1187,9 +1199,12 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
     def get_changed_project_release_model_adoptions(
         self,
         project_ids: Sequence[ProjectId],
+        now: Optional[datetime] = None,
     ) -> Sequence[ProjectRelease]:
 
-        now = datetime.now(pytz.utc)
+        if now is None:
+            now = datetime.now(pytz.utc)
+
         start = now - timedelta(days=3)
 
         project_ids = list(project_ids)
@@ -1232,9 +1247,11 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
     def get_oldest_health_data_for_releases(
         self,
         project_releases: Sequence[ProjectRelease],
+        now: Optional[datetime] = None,
     ) -> Mapping[ProjectRelease, str]:
+        if now is None:
+            now = datetime.now(pytz.utc)
 
-        now = datetime.now(pytz.utc)
         # TODO: assumption about retention?
         start = now - timedelta(days=90)
 
@@ -1292,8 +1309,12 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
         scope: str,
         stats_period: Optional[str] = None,
         environments: Optional[Sequence[EnvironmentName]] = None,
+        now: Optional[datetime] = None,
     ) -> int:
 
+        if now is None:
+            now = datetime.now(pytz.utc)
+
         if stats_period is None:
             stats_period = "24h"
 
@@ -1301,10 +1322,10 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
         if scope.endswith("_24h"):
             stats_period = "24h"
 
-        granularity, stats_start, _ = get_rollup_starts_and_buckets(stats_period)
+        granularity, stats_start, _ = get_rollup_starts_and_buckets(stats_period, now=now)
         where = [
             Condition(Column("timestamp"), Op.GTE, stats_start),
-            Condition(Column("timestamp"), Op.LT, datetime.now()),
+            Condition(Column("timestamp"), Op.LT, now),
             Condition(Column("project_id"), Op.IN, project_ids),
             Condition(Column("org_id"), Op.EQ, organization_id),
         ]
@@ -1813,6 +1834,7 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
         scope: str,
         stats_period: Optional[str] = None,
         environments: Optional[Sequence[str]] = None,
+        now: Optional[datetime] = None,
     ) -> Sequence[ProjectRelease]:
 
         if len(project_ids) == 0:
@@ -1836,8 +1858,10 @@ class MetricsReleaseHealthBackend(ReleaseHealthBackend):
             scope = scope[:-4]
             stats_period = "24h"
 
-        now = datetime.now(pytz.utc)
-        granularity, stats_start, _ = get_rollup_starts_and_buckets(stats_period)
+        if now is None:
+            now = datetime.now(pytz.utc)
+
+        granularity, stats_start, _ = get_rollup_starts_and_buckets(stats_period, now=now)
 
         query_cols = [
             Column("project_id"),

+ 14 - 6
src/sentry/release_health/sessions.py

@@ -105,9 +105,11 @@ class SessionsReleaseHealthBackend(ReleaseHealthBackend):
         )
 
     def check_has_health_data(
-        self, projects_list: Sequence[ProjectOrRelease]
+        self,
+        projects_list: Sequence[ProjectOrRelease],
+        now: Optional[datetime] = None,
     ) -> Set[ProjectOrRelease]:
-        return _check_has_health_data(projects_list)  # type: ignore
+        return _check_has_health_data(projects_list, now=now)  # type: ignore
 
     def check_releases_have_health_data(
         self,
@@ -132,6 +134,7 @@ class SessionsReleaseHealthBackend(ReleaseHealthBackend):
         summary_stats_period: Optional[StatsPeriod] = None,
         health_stats_period: Optional[StatsPeriod] = None,
         stat: Optional[OverviewStat] = None,
+        now: Optional[datetime] = None,
     ) -> Mapping[ProjectRelease, ReleaseHealthOverview]:
         return _get_release_health_data_overview(  # type: ignore
             project_releases=project_releases,
@@ -139,6 +142,7 @@ class SessionsReleaseHealthBackend(ReleaseHealthBackend):
             summary_stats_period=summary_stats_period,
             health_stats_period=health_stats_period,
             stat=stat,
+            now=now,
         )
 
     def get_crash_free_breakdown(
@@ -147,22 +151,25 @@ class SessionsReleaseHealthBackend(ReleaseHealthBackend):
         release: ReleaseName,
         start: datetime,
         environments: Optional[Sequence[EnvironmentName]] = None,
+        now: Optional[datetime] = None,
     ) -> Sequence[CrashFreeBreakdown]:
         return _get_crash_free_breakdown(  # type: ignore
-            project_id=project_id, release=release, start=start, environments=environments
+            project_id=project_id, release=release, start=start, environments=environments, now=now
         )
 
     def get_changed_project_release_model_adoptions(
         self,
         project_ids: Sequence[ProjectId],
+        now: Optional[datetime] = None,
     ) -> Sequence[ProjectRelease]:
-        return _get_changed_project_release_model_adoptions(project_ids)  # type: ignore
+        return _get_changed_project_release_model_adoptions(project_ids, now=now)  # type: ignore
 
     def get_oldest_health_data_for_releases(
         self,
         project_releases: Sequence[ProjectRelease],
+        now: Optional[datetime] = None,
     ) -> Mapping[ProjectRelease, str]:
-        return _get_oldest_health_data_for_releases(project_releases)  # type: ignore
+        return _get_oldest_health_data_for_releases(project_releases, now=now)  # type: ignore
 
     def get_project_releases_count(
         self,
@@ -240,7 +247,8 @@ class SessionsReleaseHealthBackend(ReleaseHealthBackend):
         scope: str,
         stats_period: Optional[str] = None,
         environments: Optional[Sequence[str]] = None,
+        now: Optional[datetime] = None,
     ) -> Sequence[ProjectRelease]:
         return _get_project_releases_by_stability(  # type: ignore
-            project_ids, offset, limit, scope, stats_period, environments
+            project_ids, offset, limit, scope, stats_period, environments, now
         )

+ 36 - 14
src/sentry/snuba/sessions.py

@@ -31,9 +31,12 @@ def _get_conditions_and_filter_keys(project_releases, environments):
     return conditions, filter_keys
 
 
-def _get_changed_project_release_model_adoptions(project_ids):
+def _get_changed_project_release_model_adoptions(project_ids, now=None):
     """Returns the last 72 hours worth of releases."""
-    start = datetime.now(pytz.utc) - timedelta(days=3)
+    if now is None:
+        now = datetime.now(pytz.utc)
+
+    start = now - timedelta(days=3)
     rv = []
 
     # Find all releases with adoption in the last 48 hours
@@ -51,17 +54,20 @@ def _get_changed_project_release_model_adoptions(project_ids):
     return rv
 
 
-def _get_oldest_health_data_for_releases(project_releases):
+def _get_oldest_health_data_for_releases(project_releases, now=None):
     """Returns the oldest health data we have observed in a release
     in 90 days.  This is used for backfilling.
     """
+    if now is None:
+        now = datetime.now(pytz.utc)
+
     conditions = [["release", "IN", [x[1] for x in project_releases]]]
     filter_keys = {"project_id": [x[0] for x in project_releases]}
     rows = raw_query(
         dataset=Dataset.Sessions,
         selected_columns=[["min", ["started"], "oldest"], "project_id", "release"],
         groupby=["release", "project_id"],
-        start=datetime.utcnow() - timedelta(days=90),
+        start=now - timedelta(days=90),
         conditions=conditions,
         referrer="sessions.oldest-data-backfill",
         filter_keys=filter_keys,
@@ -72,7 +78,7 @@ def _get_oldest_health_data_for_releases(project_releases):
     return rv
 
 
-def _check_has_health_data(projects_list):
+def _check_has_health_data(projects_list, now=None):
     """
     Function that returns a set of all project_ids or (project, release) if they have health data
     within the last 90 days based on a list of projects or a list of project, release combinations
@@ -81,6 +87,10 @@ def _check_has_health_data(projects_list):
         * projects_list: Contains either a list of project ids or a list of tuple (project_id,
         release)
     """
+
+    if now is None:
+        now = datetime.now(pytz.utc)
+
     if len(projects_list) == 0:
         return set()
 
@@ -108,7 +118,7 @@ def _check_has_health_data(projects_list):
         "dataset": Dataset.Sessions,
         "selected_columns": query_cols,
         "groupby": query_cols,
-        "start": datetime.utcnow() - timedelta(days=90),
+        "start": now - timedelta(days=90),
         "referrer": "sessions.health-data-check",
         "filter_keys": filter_keys,
     }
@@ -151,7 +161,13 @@ def _check_releases_have_health_data(
 
 
 def _get_project_releases_by_stability(
-    project_ids, offset, limit, scope, stats_period=None, environments=None
+    project_ids,
+    offset,
+    limit,
+    scope,
+    stats_period=None,
+    environments=None,
+    now=None,
 ):
     """Given some project IDs returns adoption rates that should be updated
     on the postgres tables.
@@ -164,7 +180,7 @@ def _get_project_releases_by_stability(
         scope = scope[:-4]
         stats_period = "24h"
 
-    _, stats_start, _ = get_rollup_starts_and_buckets(stats_period)
+    _, stats_start, _ = get_rollup_starts_and_buckets(stats_period, now=now)
 
     orderby = {
         "crash_free_sessions": [["-divide", ["sessions_crashed", "sessions"]]],
@@ -274,13 +290,15 @@ STATS_PERIODS = {
 }
 
 
-def get_rollup_starts_and_buckets(period):
+def get_rollup_starts_and_buckets(period, now=None):
     if period is None:
         return None, None, None
     if period not in STATS_PERIODS:
         raise TypeError("Invalid stats period")
     seconds, buckets = STATS_PERIODS[period]
-    start = datetime.now(pytz.utc) - timedelta(seconds=seconds * buckets)
+    if now is None:
+        now = datetime.now(pytz.utc)
+    start = now - timedelta(seconds=seconds * buckets)
     return seconds, start, buckets
 
 
@@ -368,6 +386,7 @@ def _get_release_health_data_overview(
     summary_stats_period=None,
     health_stats_period=None,
     stat=None,
+    now=None,
 ):
     """Checks quickly for which of the given project releases we have
     health data available.  The argument is a tuple of `(project_id, release_name)`
@@ -378,10 +397,12 @@ def _get_release_health_data_overview(
         stat = "sessions"
     assert stat in ("sessions", "users")
 
-    _, summary_start, _ = get_rollup_starts_and_buckets(summary_stats_period or "24h")
+    _, summary_start, _ = get_rollup_starts_and_buckets(summary_stats_period or "24h", now=now)
     conditions, filter_keys = _get_conditions_and_filter_keys(project_releases, environments)
 
-    stats_rollup, stats_start, stats_buckets = get_rollup_starts_and_buckets(health_stats_period)
+    stats_rollup, stats_start, stats_buckets = get_rollup_starts_and_buckets(
+        health_stats_period, now=now
+    )
 
     missing_releases = set(project_releases)
     rv = {}
@@ -487,13 +508,14 @@ def _get_release_health_data_overview(
     return rv
 
 
-def _get_crash_free_breakdown(project_id, release, start, environments=None):
+def _get_crash_free_breakdown(project_id, release, start, environments=None, now=None):
     filter_keys = {"project_id": [project_id]}
     conditions = [["release", "=", release]]
     if environments is not None:
         conditions.append(["environment", "IN", environments])
 
-    now = datetime.now(pytz.utc)
+    if now is None:
+        now = datetime.now(pytz.utc)
 
     def _query_stats(end):
         row = raw_query(

+ 37 - 46
tests/snuba/sessions/test_sessions.py

@@ -5,6 +5,7 @@ import pytz
 from django.utils import timezone
 
 from sentry.release_health.base import OverviewStat
+from sentry.release_health.duplex import DuplexReleaseHealthBackend
 from sentry.release_health.metrics import MetricsReleaseHealthBackend
 from sentry.release_health.sessions import SessionsReleaseHealthBackend
 from sentry.snuba.sessions import _make_stats
@@ -13,6 +14,37 @@ from sentry.testutils.cases import SessionMetricsTestCase
 from sentry.utils.dates import to_timestamp
 
 
+def parametrize_backend(cls):
+    """
+    hack to parametrize test-classes by backend. Ideally we'd move
+    over to pytest-style tests so we can use `pytest.mark.parametrize`, but
+    hopefully we won't have more than one backend in the future.
+    """
+
+    assert not hasattr(cls, "backend")
+    cls.backend = SessionsReleaseHealthBackend()
+
+    class MetricsTest(SessionMetricsTestCase, cls):
+        __doc__ = f"Repeat tests from {cls} with metrics"
+        backend = MetricsReleaseHealthBackend()
+
+    MetricsTest.__name__ = f"{cls.__name__}Metrics"
+
+    globals()[MetricsTest.__name__] = MetricsTest
+
+    class DuplexTest(cls):
+        __doc__ = f"Repeat tests from {cls} with duplex backend"
+        backend = DuplexReleaseHealthBackend(
+            metrics_start=datetime.now(pytz.utc) - timedelta(days=120)
+        )
+
+    DuplexTest.__name__ = f"{cls.__name__}Duplex"
+
+    globals()[DuplexTest.__name__] = DuplexTest
+
+    return cls
+
+
 def format_timestamp(dt):
     if not isinstance(dt, datetime):
         dt = datetime.utcfromtimestamp(dt)
@@ -23,13 +55,8 @@ def make_24h_stats(ts):
     return _make_stats(datetime.utcfromtimestamp(ts).replace(tzinfo=pytz.utc), 3600, 24)
 
 
-class ReleaseHealthMetricsTestCase(SessionMetricsTestCase):
-    backend = MetricsReleaseHealthBackend()
-
-
+@parametrize_backend
 class SnubaSessionsTest(TestCase, SnubaTestCase):
-    backend = SessionsReleaseHealthBackend()
-
     def setUp(self):
         super().setUp()
         self.received = time.time()
@@ -874,14 +901,7 @@ class SnubaSessionsTest(TestCase, SnubaTestCase):
         )
 
 
-class SnubaSessionsTestMetrics(ReleaseHealthMetricsTestCase, SnubaSessionsTest):
-    """
-    Same tests as in SnunbaSessionsTest but using the Metrics backend
-    """
-
-    pass
-
-
+@parametrize_backend
 class GetCrashFreeRateTestCase(TestCase, SnubaTestCase):
     """
     TestClass that tests that `get_current_and_previous_crash_free_rates` returns the correct
@@ -901,8 +921,6 @@ class GetCrashFreeRateTestCase(TestCase, SnubaTestCase):
         In the previous 24h (>24h & <48h) -> 4 Exited + 1 Crashed / 5 Total Sessions -> 80%
     """
 
-    backend = SessionsReleaseHealthBackend()
-
     def setUp(self):
         super().setUp()
         self.session_started = time.time() // 60 * 60
@@ -1028,13 +1046,8 @@ class GetCrashFreeRateTestCase(TestCase, SnubaTestCase):
         }
 
 
-class GetCrashFreeRateTestCaseMetrics(ReleaseHealthMetricsTestCase, GetCrashFreeRateTestCase):
-    """Repeat tests with metrics backend"""
-
-
+@parametrize_backend
 class GetProjectReleasesCountTest(TestCase, SnubaTestCase):
-    backend = SessionsReleaseHealthBackend()
-
     def test_empty(self):
         # Test no errors when no session data
         org = self.create_organization()
@@ -1099,13 +1112,8 @@ class GetProjectReleasesCountTest(TestCase, SnubaTestCase):
         )
 
 
-class GetProjectReleasesCountTestMetrics(ReleaseHealthMetricsTestCase, GetProjectReleasesCountTest):
-    """Repeat tests with metric backend"""
-
-
+@parametrize_backend
 class CheckReleasesHaveHealthDataTest(TestCase, SnubaTestCase):
-    backend = SessionsReleaseHealthBackend()
-
     def run_test(self, expected, projects, releases, start=None, end=None):
         if not start:
             start = datetime.now() - timedelta(days=1)
@@ -1147,17 +1155,8 @@ class CheckReleasesHaveHealthDataTest(TestCase, SnubaTestCase):
         self.run_test([release_1, release_2], [self.project, other_project], [release_1, release_2])
 
 
-class CheckReleasesHaveHealthDataTestMetrics(
-    ReleaseHealthMetricsTestCase, CheckReleasesHaveHealthDataTest
-):
-    """Repeat tests with metrics backend"""
-
-    pass
-
-
+@parametrize_backend
 class CheckNumberOfSessions(TestCase, SnubaTestCase):
-    backend = SessionsReleaseHealthBackend()
-
     def setUp(self):
         super().setUp()
         self.dev_env = self.create_environment(name="development", project=self.project)
@@ -1387,11 +1386,3 @@ class CheckNumberOfSessions(TestCase, SnubaTestCase):
             )
 
             assert set(actual) == {(p1.id, 4), (p2.id, 2)}
-
-
-class CheckNumberOfSessionsMetrics(ReleaseHealthMetricsTestCase, CheckNumberOfSessions):
-    """
-    Repeat CheckNumberOfSessions tests with the release backend
-    """
-
-    pass