Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cirrus): V2 api #12008

Merged
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions cirrus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,23 @@ The following are the available commands for working with Cirrus:

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

## Endpoint

`POST /v1/features/`
## Endpoint: `POST /v1/features/`

- When making a POST request, please make sure to set headers content type as JSON
```javascript
headers: {
"Content-Type": "application/json",
}
```
# Endpoint: `POST /v2/features/`

The v2 endpoint extends the functionality of v1 by also returning enrollments data alongside features.

```javascript
headers: {
"Content-Type": "application/json",
}
```

## Input

Expand Down Expand Up @@ -204,7 +211,7 @@ curl -X POST "http://localhost:8001/v1/features/?nimbus_preview=true" -H 'Conten
}
}'
```
## Output
### Output

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

Expand All @@ -229,7 +236,48 @@ Example output:
}
```

```shell
curl -X POST "http://localhost:8001/v2/features/?nimbus_preview=true" -H 'Content-Type: application/json' -d '{
"client_id": "4a1d71ab-29a2-4c5f-9e1d-9d9df2e6e449",
"context": {
"language": "en",
"region": "US"
}
}'
```
### Output

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

- `features` (object): An object that contains the set of features. Each feature is represented as a sub-object with its own set of variables.
- `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.

Example output:

```json
{
"Features": {
"Feature1": {"Variable1.1": "valueA", "Variable1.2": "valueB"},
"Feature2": {"Variable2.1": "valueC", "Variable2.2": "valueD"}
},
"Enrollments": [
{
"nimbus_user_id": "4a1d71ab-29a2-4c5f-9e1d-9d9df2e6e449",
"app_id": "test_app_id",
"experiment": "experiment-slug",
"branch": "control",
"experiment_type": "rollout",
"is_preview": false
}
]
}

```


## Notes

- This API only accepts POST requests.
- All parameters should be supplied in the body as JSON.
- `v2 Endpoint`: Returns both features and enrollments. Use this if you need detailed enrollment data.
- Query Parameter: Use nimbus_preview=true to compute enrollments based on preview experiments.
2 changes: 1 addition & 1 deletion cirrus/server/cirrus/docs/apidoc.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js">
</script>
<script>
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"}}}};
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"}}}};
Redoc.init(spec, {}, document.getElementById("redoc-container"));
</script>
</body>
Expand Down
2 changes: 1 addition & 1 deletion cirrus/server/cirrus/docs/openapi.json
Original file line number Diff line number Diff line change
@@ -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"}}}}
{"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"}}}}
4 changes: 1 addition & 3 deletions cirrus/server/cirrus/feature_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ def compute_feature_configurations(
"enrolledFeatureConfigMap" # slug, featureid, value,
].items()
}
merged_res: MergedJsonWithErrors = self.fml_client.merge( # type: ignore
feature_configs
)
merged_res: MergedJsonWithErrors = self.fml_client.merge(feature_configs)
self.merge_errors = merged_res.errors

if self.merge_errors:
Expand Down
106 changes: 74 additions & 32 deletions cirrus/server/cirrus/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, List, NamedTuple
from typing import Any, List, NamedTuple, TypedDict

import sentry_sdk
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore
Expand Down Expand Up @@ -161,53 +161,59 @@ def initialize_glean():


class EnrollmentMetricData(NamedTuple):
nimbus_user_id: str
app_id: str
experiment_slug: str
branch_slug: str
experiment_type: str
is_preview: bool


class ComputeFeaturesEnrollmentResult(TypedDict):
features: dict[str, dict[str, Any]]
enrollments: list[EnrollmentMetricData]


def collate_enrollment_metric_data(
enrolled_partial_configuration: dict[str, Any], nimbus_preview_flag: bool
enrolled_partial_configuration: dict[str, Any],
client_id: str,
nimbus_preview_flag: bool,
) -> list[EnrollmentMetricData]:
events: list[dict[str, Any]] = enrolled_partial_configuration.get("events", [])
remote_settings = (
app.state.remote_setting_preview
if nimbus_preview_flag
else app.state.remote_setting_live
)
data: list[EnrollmentMetricData] = []
for event in events:
if event.get("change") == "Enrollment":
experiment_slug = event.get("experiment_slug", "")
branch_slug = event.get("branch_slug", "")
experiment_type = None
remote_settings = app.state.remote_setting_live
if nimbus_preview_flag:
remote_settings = app.state.remote_setting_preview
experiment_type = remote_settings.get_recipe_type(experiment_slug)
data.append(
EnrollmentMetricData(
nimbus_user_id=client_id,
app_id=app_id,
experiment_slug=experiment_slug,
branch_slug=branch_slug,
experiment_type=experiment_type,
is_preview=nimbus_preview_flag,
)
)
return data


async def record_metrics(
enrolled_partial_configuration: dict[str, Any],
client_id: str,
nimbus_preview_flag: bool,
):
metrics = collate_enrollment_metric_data(
enrolled_partial_configuration=enrolled_partial_configuration,
nimbus_preview_flag=nimbus_preview_flag,
)
for experiment_slug, branch_slug, experiment_type in metrics:
async def record_metrics(enrollment_data: list[EnrollmentMetricData]):
for enrollment in enrollment_data:
app.state.metrics.cirrus_events.enrollment.record(
app.state.metrics.cirrus_events.EnrollmentExtra(
user_id=client_id,
app_id=app_id,
experiment=experiment_slug,
branch=branch_slug,
experiment_type=experiment_type,
is_preview=nimbus_preview_flag,
user_id=enrollment.nimbus_user_id,
app_id=enrollment.app_id,
experiment=enrollment.experiment_slug,
branch=enrollment.branch_slug,
experiment_type=enrollment.experiment_type,
is_preview=enrollment.is_preview,
)
)
app.state.pings.enrollment.submit()
Expand All @@ -221,11 +227,10 @@ def read_root():
return {"Hello": "World"}


@app.post("/v1/features/", status_code=status.HTTP_200_OK)
async def compute_features(
async def compute_features_enrollments(
request_data: FeatureRequest,
nimbus_preview: bool = Query(default=False, alias="nimbus_preview"),
):
) -> ComputeFeaturesEnrollmentResult:
if not request_data.client_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
Expand All @@ -246,9 +251,8 @@ async def compute_features(
"clientId": request_data.client_id,
"requestContext": request_data.context,
}
sdk = app.state.sdk_live
if nimbus_preview:
sdk = app.state.sdk_preview

sdk = app.state.sdk_preview if nimbus_preview else app.state.sdk_live
enrolled_partial_configuration: dict[str, Any] = sdk.compute_enrollments(
targeting_context
)
Expand All @@ -257,13 +261,51 @@ async def compute_features(
app.state.fml.compute_feature_configurations(enrolled_partial_configuration)
)

await record_metrics(
enrolled_partial_configuration=enrolled_partial_configuration,
# Enrollments data
enrollment_data = collate_enrollment_metric_data(
enrolled_partial_configuration,
client_id=request_data.client_id,
nimbus_preview_flag=nimbus_preview or False,
nimbus_preview_flag=nimbus_preview,
)

return client_feature_configuration
# Record metrics
await record_metrics(enrollment_data)

return {
"features": client_feature_configuration,
"enrollments": enrollment_data,
}


@app.post("/v1/features/", status_code=status.HTTP_200_OK)
async def compute_features_v1(
request_data: FeatureRequest,
nimbus_preview: bool = Query(default=False, alias="nimbus_preview"),
):
result = await compute_features_enrollments(request_data, nimbus_preview)
return result["features"]


@app.post("/v2/features/", status_code=status.HTTP_200_OK)
async def compute_features_enrollments_v2(
request_data: FeatureRequest,
nimbus_preview: bool = Query(default=False, alias="nimbus_preview"),
):
result = await compute_features_enrollments(request_data, nimbus_preview)
return {
"Features": result["features"],
"Enrollments": [
{
"nimbus_user_id": enrollment.nimbus_user_id,
"app_id": enrollment.app_id,
"experiment": enrollment.experiment_slug,
"branch": enrollment.branch_slug,
"experiment_type": enrollment.experiment_type,
"is_preview": enrollment.is_preview,
}
for enrollment in result["enrollments"]
],
}


async def fetch_schedule_recipes() -> None:
Expand Down
Loading