Skip to content

Commit 53dcf84

Browse files
feat(cirrus): V2 api (mozilla#12008)
Because - We want to return features under the key `Features` - We don't send back enrollment responses to the calling application and sometimes its hard to find if they are in the experiment or not This commit - Returns feature and enrollments as part of the new `v2 api` Fixes mozilla#12000 mozilla#12001
1 parent 3e487c6 commit 53dcf84

File tree

7 files changed

+762
-82
lines changed

7 files changed

+762
-82
lines changed

cirrus/README.md

+52-4
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,23 @@ The following are the available commands for working with Cirrus:
106106

107107
[Cirrus Api Doc](/cirrus/server/cirrus/docs/apidoc.html) for the Cirrus API
108108

109-
## Endpoint
110-
111-
`POST /v1/features/`
109+
## Endpoint: `POST /v1/features/`
112110

113111
- When making a POST request, please make sure to set headers content type as JSON
114112
```javascript
115113
headers: {
116114
"Content-Type": "application/json",
117115
}
118116
```
117+
# Endpoint: `POST /v2/features/`
118+
119+
The v2 endpoint extends the functionality of v1 by also returning enrollments data alongside features.
120+
121+
```javascript
122+
headers: {
123+
"Content-Type": "application/json",
124+
}
125+
```
119126

120127
## Input
121128

@@ -204,7 +211,7 @@ curl -X POST "http://localhost:8001/v1/features/?nimbus_preview=true" -H 'Conten
204211
}
205212
}'
206213
```
207-
## Output
214+
### Output
208215

209216
The output will be a JSON object with the following properties:
210217

@@ -229,7 +236,48 @@ Example output:
229236
}
230237
```
231238

239+
```shell
240+
curl -X POST "http://localhost:8001/v2/features/?nimbus_preview=true" -H 'Content-Type: application/json' -d '{
241+
"client_id": "4a1d71ab-29a2-4c5f-9e1d-9d9df2e6e449",
242+
"context": {
243+
"language": "en",
244+
"region": "US"
245+
}
246+
}'
247+
```
248+
### Output
249+
250+
The output will be a JSON object with the following properties:
251+
252+
- `features` (object): An object that contains the set of features. Each feature is represented as a sub-object with its own set of variables.
253+
- `Enrollments` (array): An array of objects representing the client's enrollment into experiments. Each enrollment object contains details about the experiment, such as the experiment ID, branch, and type.
254+
255+
Example output:
256+
257+
```json
258+
{
259+
"Features": {
260+
"Feature1": {"Variable1.1": "valueA", "Variable1.2": "valueB"},
261+
"Feature2": {"Variable2.1": "valueC", "Variable2.2": "valueD"}
262+
},
263+
"Enrollments": [
264+
{
265+
"nimbus_user_id": "4a1d71ab-29a2-4c5f-9e1d-9d9df2e6e449",
266+
"app_id": "test_app_id",
267+
"experiment": "experiment-slug",
268+
"branch": "control",
269+
"experiment_type": "rollout",
270+
"is_preview": false
271+
}
272+
]
273+
}
274+
275+
```
276+
277+
232278
## Notes
233279

234280
- This API only accepts POST requests.
235281
- All parameters should be supplied in the body as JSON.
282+
- `v2 Endpoint`: Returns both features and enrollments. Use this if you need detailed enrollment data.
283+
- Query Parameter: Use nimbus_preview=true to compute enrollments based on preview experiments.

cirrus/server/cirrus/docs/apidoc.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js">
2020
</script>
2121
<script>
22-
var spec = {"openapi": "3.1.0", "info": {"title": "FastAPI", "version": "0.1.0"}, "paths": {"/": {"get": {"summary": "Read Root", "operationId": "read_root__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/v1/features/": {"post": {"summary": "Compute Features", "operationId": "compute_features_v1_features__post", "parameters": [{"name": "nimbus_preview", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Nimbus Preview"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FeatureRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/__lbheartbeat__": {"get": {"summary": "Health Check Lbheartbeat", "operationId": "health_check_lbheartbeat___lbheartbeat___get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/__heartbeat__": {"get": {"summary": "Health Check Heartbeat", "operationId": "health_check_heartbeat___heartbeat___get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}}, "components": {"schemas": {"FeatureRequest": {"properties": {"client_id": {"type": "string", "title": "Client Id"}, "context": {"type": "object", "title": "Context"}}, "type": "object", "required": ["client_id", "context"], "title": "FeatureRequest"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}}};
22+
var spec = {"openapi": "3.1.0", "info": {"title": "FastAPI", "version": "0.1.0"}, "paths": {"/": {"get": {"summary": "Read Root", "operationId": "read_root__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/v1/features/": {"post": {"summary": "Compute Features V1", "operationId": "compute_features_v1_v1_features__post", "parameters": [{"name": "nimbus_preview", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Nimbus Preview"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FeatureRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/features/": {"post": {"summary": "Compute Features Enrollments V2", "operationId": "compute_features_enrollments_v2_v2_features__post", "parameters": [{"name": "nimbus_preview", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Nimbus Preview"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FeatureRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/__lbheartbeat__": {"get": {"summary": "Health Check Lbheartbeat", "operationId": "health_check_lbheartbeat___lbheartbeat___get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/__heartbeat__": {"get": {"summary": "Health Check Heartbeat", "operationId": "health_check_heartbeat___heartbeat___get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}}, "components": {"schemas": {"FeatureRequest": {"properties": {"client_id": {"type": "string", "title": "Client Id"}, "context": {"type": "object", "title": "Context"}}, "type": "object", "required": ["client_id", "context"], "title": "FeatureRequest"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}}};
2323
Redoc.init(spec, {}, document.getElementById("redoc-container"));
2424
</script>
2525
</body>
+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"openapi": "3.1.0", "info": {"title": "FastAPI", "version": "0.1.0"}, "paths": {"/": {"get": {"summary": "Read Root", "operationId": "read_root__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/v1/features/": {"post": {"summary": "Compute Features", "operationId": "compute_features_v1_features__post", "parameters": [{"name": "nimbus_preview", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Nimbus Preview"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FeatureRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/__lbheartbeat__": {"get": {"summary": "Health Check Lbheartbeat", "operationId": "health_check_lbheartbeat___lbheartbeat___get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/__heartbeat__": {"get": {"summary": "Health Check Heartbeat", "operationId": "health_check_heartbeat___heartbeat___get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}}, "components": {"schemas": {"FeatureRequest": {"properties": {"client_id": {"type": "string", "title": "Client Id"}, "context": {"type": "object", "title": "Context"}}, "type": "object", "required": ["client_id", "context"], "title": "FeatureRequest"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}}}
1+
{"openapi": "3.1.0", "info": {"title": "FastAPI", "version": "0.1.0"}, "paths": {"/": {"get": {"summary": "Read Root", "operationId": "read_root__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/v1/features/": {"post": {"summary": "Compute Features V1", "operationId": "compute_features_v1_v1_features__post", "parameters": [{"name": "nimbus_preview", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Nimbus Preview"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FeatureRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v2/features/": {"post": {"summary": "Compute Features Enrollments V2", "operationId": "compute_features_enrollments_v2_v2_features__post", "parameters": [{"name": "nimbus_preview", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Nimbus Preview"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FeatureRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/__lbheartbeat__": {"get": {"summary": "Health Check Lbheartbeat", "operationId": "health_check_lbheartbeat___lbheartbeat___get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/__heartbeat__": {"get": {"summary": "Health Check Heartbeat", "operationId": "health_check_heartbeat___heartbeat___get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}}, "components": {"schemas": {"FeatureRequest": {"properties": {"client_id": {"type": "string", "title": "Client Id"}, "context": {"type": "object", "title": "Context"}}, "type": "object", "required": ["client_id", "context"], "title": "FeatureRequest"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}}}

cirrus/server/cirrus/feature_manifest.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ def compute_feature_configurations(
2222
"enrolledFeatureConfigMap" # slug, featureid, value,
2323
].items()
2424
}
25-
merged_res: MergedJsonWithErrors = self.fml_client.merge( # type: ignore
26-
feature_configs
27-
)
25+
merged_res: MergedJsonWithErrors = self.fml_client.merge(feature_configs)
2826
self.merge_errors = merged_res.errors
2927

3028
if self.merge_errors:

cirrus/server/cirrus/main.py

+74-32
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import sys
33
from contextlib import asynccontextmanager
44
from pathlib import Path
5-
from typing import Any, List, NamedTuple
5+
from typing import Any, List, NamedTuple, TypedDict
66

77
import sentry_sdk
88
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore
@@ -161,53 +161,59 @@ def initialize_glean():
161161

162162

163163
class EnrollmentMetricData(NamedTuple):
164+
nimbus_user_id: str
165+
app_id: str
164166
experiment_slug: str
165167
branch_slug: str
166168
experiment_type: str
169+
is_preview: bool
170+
171+
172+
class ComputeFeaturesEnrollmentResult(TypedDict):
173+
features: dict[str, dict[str, Any]]
174+
enrollments: list[EnrollmentMetricData]
167175

168176

169177
def collate_enrollment_metric_data(
170-
enrolled_partial_configuration: dict[str, Any], nimbus_preview_flag: bool
178+
enrolled_partial_configuration: dict[str, Any],
179+
client_id: str,
180+
nimbus_preview_flag: bool,
171181
) -> list[EnrollmentMetricData]:
172182
events: list[dict[str, Any]] = enrolled_partial_configuration.get("events", [])
183+
remote_settings = (
184+
app.state.remote_setting_preview
185+
if nimbus_preview_flag
186+
else app.state.remote_setting_live
187+
)
173188
data: list[EnrollmentMetricData] = []
174189
for event in events:
175190
if event.get("change") == "Enrollment":
176191
experiment_slug = event.get("experiment_slug", "")
177192
branch_slug = event.get("branch_slug", "")
178-
experiment_type = None
179-
remote_settings = app.state.remote_setting_live
180-
if nimbus_preview_flag:
181-
remote_settings = app.state.remote_setting_preview
182193
experiment_type = remote_settings.get_recipe_type(experiment_slug)
183194
data.append(
184195
EnrollmentMetricData(
196+
nimbus_user_id=client_id,
197+
app_id=app_id,
185198
experiment_slug=experiment_slug,
186199
branch_slug=branch_slug,
187200
experiment_type=experiment_type,
201+
is_preview=nimbus_preview_flag,
188202
)
189203
)
190204
return data
191205

192206

193-
async def record_metrics(
194-
enrolled_partial_configuration: dict[str, Any],
195-
client_id: str,
196-
nimbus_preview_flag: bool,
197-
):
198-
metrics = collate_enrollment_metric_data(
199-
enrolled_partial_configuration=enrolled_partial_configuration,
200-
nimbus_preview_flag=nimbus_preview_flag,
201-
)
202-
for experiment_slug, branch_slug, experiment_type in metrics:
207+
async def record_metrics(enrollment_data: list[EnrollmentMetricData]):
208+
for enrollment in enrollment_data:
203209
app.state.metrics.cirrus_events.enrollment.record(
204210
app.state.metrics.cirrus_events.EnrollmentExtra(
205-
user_id=client_id,
206-
app_id=app_id,
207-
experiment=experiment_slug,
208-
branch=branch_slug,
209-
experiment_type=experiment_type,
210-
is_preview=nimbus_preview_flag,
211+
user_id=enrollment.nimbus_user_id,
212+
app_id=enrollment.app_id,
213+
experiment=enrollment.experiment_slug,
214+
branch=enrollment.branch_slug,
215+
experiment_type=enrollment.experiment_type,
216+
is_preview=enrollment.is_preview,
211217
)
212218
)
213219
app.state.pings.enrollment.submit()
@@ -221,11 +227,10 @@ def read_root():
221227
return {"Hello": "World"}
222228

223229

224-
@app.post("/v1/features/", status_code=status.HTTP_200_OK)
225-
async def compute_features(
230+
async def compute_features_enrollments(
226231
request_data: FeatureRequest,
227232
nimbus_preview: bool = Query(default=False, alias="nimbus_preview"),
228-
):
233+
) -> ComputeFeaturesEnrollmentResult:
229234
if not request_data.client_id:
230235
raise HTTPException(
231236
status_code=status.HTTP_400_BAD_REQUEST,
@@ -246,9 +251,8 @@ async def compute_features(
246251
"clientId": request_data.client_id,
247252
"requestContext": request_data.context,
248253
}
249-
sdk = app.state.sdk_live
250-
if nimbus_preview:
251-
sdk = app.state.sdk_preview
254+
255+
sdk = app.state.sdk_preview if nimbus_preview else app.state.sdk_live
252256
enrolled_partial_configuration: dict[str, Any] = sdk.compute_enrollments(
253257
targeting_context
254258
)
@@ -257,13 +261,51 @@ async def compute_features(
257261
app.state.fml.compute_feature_configurations(enrolled_partial_configuration)
258262
)
259263

260-
await record_metrics(
261-
enrolled_partial_configuration=enrolled_partial_configuration,
264+
# Enrollments data
265+
enrollment_data = collate_enrollment_metric_data(
266+
enrolled_partial_configuration,
262267
client_id=request_data.client_id,
263-
nimbus_preview_flag=nimbus_preview or False,
268+
nimbus_preview_flag=nimbus_preview,
264269
)
265270

266-
return client_feature_configuration
271+
# Record metrics
272+
await record_metrics(enrollment_data)
273+
274+
return {
275+
"features": client_feature_configuration,
276+
"enrollments": enrollment_data,
277+
}
278+
279+
280+
@app.post("/v1/features/", status_code=status.HTTP_200_OK)
281+
async def compute_features_v1(
282+
request_data: FeatureRequest,
283+
nimbus_preview: bool = Query(default=False, alias="nimbus_preview"),
284+
):
285+
result = await compute_features_enrollments(request_data, nimbus_preview)
286+
return result["features"]
287+
288+
289+
@app.post("/v2/features/", status_code=status.HTTP_200_OK)
290+
async def compute_features_enrollments_v2(
291+
request_data: FeatureRequest,
292+
nimbus_preview: bool = Query(default=False, alias="nimbus_preview"),
293+
):
294+
result = await compute_features_enrollments(request_data, nimbus_preview)
295+
return {
296+
"Features": result["features"],
297+
"Enrollments": [
298+
{
299+
"nimbus_user_id": enrollment.nimbus_user_id,
300+
"app_id": enrollment.app_id,
301+
"experiment": enrollment.experiment_slug,
302+
"branch": enrollment.branch_slug,
303+
"experiment_type": enrollment.experiment_type,
304+
"is_preview": enrollment.is_preview,
305+
}
306+
for enrollment in result["enrollments"]
307+
],
308+
}
267309

268310

269311
async def fetch_schedule_recipes() -> None:

0 commit comments

Comments
 (0)