Skip to content

Commit f4b246e

Browse files
gagantrivediZaimwa9pre-commit-ci[bot]
authored
feat(experimentation): environment-scoped metrics & experiment results (#7674)
Co-authored-by: wadii <wadii.zaim@flagsmith.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent d0ac9b5 commit f4b246e

16 files changed

Lines changed: 1317 additions & 7 deletions

File tree

api/audit/related_object_type.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ class RelatedObjectType(enum.Enum):
1414
RELEASE_PIPELINE = "Release pipeline"
1515
WAREHOUSE_CONNECTION = "Warehouse connection"
1616
EXPERIMENT = "Experiment"
17+
METRIC = "Metric"

api/environments/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,8 @@
181181
"<str:environment_api_key>/experiments/",
182182
include("experimentation.experiment_urls"),
183183
),
184+
path(
185+
"<str:environment_api_key>/experiment-metrics/",
186+
include("experimentation.metric_urls"),
187+
),
184188
]
Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
from rest_framework.routers import DefaultRouter
1+
from rest_framework_nested import routers # type: ignore[import-untyped]
22

3-
from experimentation.views import ExperimentViewSet
3+
from experimentation.views import ExperimentMetricViewSet, ExperimentViewSet
44

55
app_name = "experiments"
66

7-
router = DefaultRouter()
7+
router = routers.DefaultRouter()
88
router.register(r"", ExperimentViewSet, basename="experiments")
99

10-
urlpatterns = router.urls
10+
experiments_router = routers.NestedSimpleRouter(router, r"", lookup="experiment")
11+
experiments_router.register(
12+
r"metrics", ExperimentMetricViewSet, basename="experiment-metrics"
13+
)
14+
15+
urlpatterns = router.urls + experiments_router.urls
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Versioned schemas for ``Metric.definition``.
2+
3+
``definition`` is a schema-less JSON column whose shape is versioned so it can
4+
evolve without breaking stored rows. Each supported version has a validator
5+
here; the client sends the version it built the definition with. To introduce a
6+
new shape, add an entry to ``METRIC_DEFINITION_VALIDATORS``.
7+
"""
8+
9+
from collections.abc import Callable
10+
11+
DefinitionValidator = Callable[[dict[str, object]], "str | None"]
12+
13+
14+
def _validate_v1(definition: dict[str, object]) -> str | None:
15+
event = definition.get("event")
16+
if not event or not isinstance(event, str):
17+
return "Definition must specify a non-empty 'event'."
18+
return None
19+
20+
21+
METRIC_DEFINITION_VALIDATORS: dict[int, DefinitionValidator] = {
22+
1: _validate_v1,
23+
}
24+
25+
26+
def validate_metric_definition(definition: object) -> str | None:
27+
"""Return an error message if ``definition`` is invalid, else ``None``."""
28+
if not isinstance(definition, dict):
29+
return "Definition must be an object."
30+
31+
version = definition.get("version")
32+
validator = (
33+
METRIC_DEFINITION_VALIDATORS.get(version) if isinstance(version, int) else None
34+
)
35+
if validator is None:
36+
return "Definition must specify a supported 'version'."
37+
38+
return validator(definition)

api/experimentation/metric_urls.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from rest_framework.routers import DefaultRouter
2+
3+
from experimentation.views import MetricViewSet
4+
5+
app_name = "experiment_metrics"
6+
7+
router = DefaultRouter()
8+
router.register(r"", MetricViewSet, basename="metrics")
9+
10+
urlpatterns = router.urls
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Generated by Django 5.2.14 on 2026-06-02 10:47
2+
3+
import django.db.models.deletion
4+
import uuid
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("environments", "0037_add_uuid_field"),
12+
("experimentation", "0004_experiment"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="Metric",
18+
fields=[
19+
(
20+
"id",
21+
models.AutoField(
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
26+
),
27+
),
28+
(
29+
"deleted_at",
30+
models.DateTimeField(
31+
blank=True,
32+
db_index=True,
33+
default=None,
34+
editable=False,
35+
null=True,
36+
),
37+
),
38+
(
39+
"uuid",
40+
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
41+
),
42+
("name", models.CharField(max_length=255)),
43+
("description", models.TextField(blank=True, default="")),
44+
(
45+
"aggregation",
46+
models.CharField(
47+
choices=[
48+
("count", "Count"),
49+
("sum", "Sum"),
50+
("mean", "Mean"),
51+
("occurrence", "Occurrence (event happened at least once)"),
52+
],
53+
default="mean",
54+
max_length=20,
55+
),
56+
),
57+
(
58+
"direction",
59+
models.CharField(
60+
choices=[
61+
("up", "Higher is better"),
62+
("down", "Lower is better"),
63+
("informational", "Informational only"),
64+
],
65+
default="up",
66+
max_length=20,
67+
),
68+
),
69+
("definition", models.JSONField()),
70+
("created_at", models.DateTimeField(auto_now_add=True)),
71+
("updated_at", models.DateTimeField(auto_now=True)),
72+
(
73+
"environment",
74+
models.ForeignKey(
75+
on_delete=django.db.models.deletion.CASCADE,
76+
related_name="metrics",
77+
to="environments.environment",
78+
),
79+
),
80+
],
81+
options={
82+
"abstract": False,
83+
},
84+
),
85+
migrations.CreateModel(
86+
name="ExperimentMetric",
87+
fields=[
88+
(
89+
"id",
90+
models.AutoField(
91+
auto_created=True,
92+
primary_key=True,
93+
serialize=False,
94+
verbose_name="ID",
95+
),
96+
),
97+
(
98+
"expected_direction",
99+
models.CharField(
100+
choices=[
101+
("increase", "Increase"),
102+
("decrease", "Decrease"),
103+
("not_increase", "Should not increase"),
104+
("not_decrease", "Should not decrease"),
105+
],
106+
max_length=20,
107+
),
108+
),
109+
("created_at", models.DateTimeField(auto_now_add=True)),
110+
(
111+
"experiment",
112+
models.ForeignKey(
113+
on_delete=django.db.models.deletion.CASCADE,
114+
related_name="experiment_metrics",
115+
to="experimentation.experiment",
116+
),
117+
),
118+
(
119+
"metric",
120+
models.ForeignKey(
121+
on_delete=django.db.models.deletion.CASCADE,
122+
related_name="experiment_metrics",
123+
to="experimentation.metric",
124+
),
125+
),
126+
],
127+
options={
128+
"constraints": [
129+
models.UniqueConstraint(
130+
fields=("experiment", "metric"),
131+
name="metric_attached_once_per_experiment",
132+
)
133+
],
134+
},
135+
),
136+
]

api/experimentation/models.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
add_environment_key_to_ingestion,
1616
delete_environment_key_from_ingestion,
1717
)
18+
from experimentation.types import MetricDefinition
1819

1920
if typing.TYPE_CHECKING:
2021
from experimentation.dataclasses import WarehouseEventStats
@@ -126,3 +127,78 @@ class Meta:
126127
name="unique_active_experiment_per_feature_env",
127128
),
128129
]
130+
131+
132+
class MetricAggregation(models.TextChoices):
133+
COUNT = "count", "Count"
134+
SUM = "sum", "Sum"
135+
MEAN = "mean", "Mean"
136+
OCCURRENCE = "occurrence", "Occurrence (event happened at least once)"
137+
138+
139+
class MetricDirection(models.TextChoices):
140+
"""A metric's inherent polarity — which way is "better"."""
141+
142+
UP = "up", "Higher is better"
143+
DOWN = "down", "Lower is better"
144+
INFORMATIONAL = "informational", "Informational only"
145+
146+
147+
class ExpectedDirection(models.TextChoices):
148+
"""The guardrail direction expected of a metric within an experiment."""
149+
150+
INCREASE = "increase", "Increase"
151+
DECREASE = "decrease", "Decrease"
152+
NOT_INCREASE = "not_increase", "Should not increase"
153+
NOT_DECREASE = "not_decrease", "Should not decrease"
154+
155+
156+
class Metric(SoftDeleteExportableModel):
157+
environment = models.ForeignKey(
158+
Environment,
159+
on_delete=models.CASCADE,
160+
related_name="metrics",
161+
)
162+
name = models.CharField(max_length=255)
163+
description = models.TextField(blank=True, default="")
164+
aggregation = models.CharField(
165+
max_length=20,
166+
choices=MetricAggregation.choices,
167+
default=MetricAggregation.MEAN,
168+
)
169+
direction = models.CharField(
170+
max_length=20,
171+
choices=MetricDirection.choices,
172+
default=MetricDirection.UP,
173+
)
174+
definition: models.JSONField[MetricDefinition, MetricDefinition] = (
175+
models.JSONField()
176+
)
177+
created_at = models.DateTimeField(auto_now_add=True)
178+
updated_at = models.DateTimeField(auto_now=True)
179+
180+
181+
class ExperimentMetric(models.Model):
182+
experiment = models.ForeignKey(
183+
Experiment,
184+
on_delete=models.CASCADE,
185+
related_name="experiment_metrics",
186+
)
187+
metric = models.ForeignKey(
188+
Metric,
189+
on_delete=models.CASCADE,
190+
related_name="experiment_metrics",
191+
)
192+
expected_direction = models.CharField(
193+
max_length=20,
194+
choices=ExpectedDirection.choices,
195+
)
196+
created_at = models.DateTimeField(auto_now_add=True)
197+
198+
class Meta:
199+
constraints = [
200+
models.UniqueConstraint(
201+
fields=["experiment", "metric"],
202+
name="metric_attached_once_per_experiment",
203+
),
204+
]

api/experimentation/permissions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,7 @@ def has_permission(self, request: Request, view: APIView) -> bool:
4040

4141
user: FFAdminUser = request.user # type: ignore[assignment]
4242
return user.is_environment_admin(environment)
43+
44+
45+
# Metrics are gated identically to experiments; aliased until the rules diverge.
46+
MetricPermission = ExperimentPermission

0 commit comments

Comments
 (0)