Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions api/app_analytics/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@
"Flagsmith-SDK-User-Agent": "sdk_user_agent",
"User-Agent": "user_agent",
}

SDK_USER_AGENT_HEADERS: list[str] = [
"Flagsmith-SDK-User-Agent", # Exists because browsers own `User-Agent`
"User-Agent",
]

LABELS: tuple[Label, ...] = get_args(Label)

NO_ANALYTICS_DATABASE_CONFIGURED_WARNING = (
Expand Down
12 changes: 11 additions & 1 deletion api/app_analytics/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from app_analytics.constants import (
LABELS,
SDK_INFLUX_IDS_BY_USER_AGENT,
SDK_USER_AGENT_HEADERS,
SDK_USER_AGENT_KNOWN_VERSIONS,
SDK_USER_AGENTS_BY_INFLUX_ID,
TRACK_HEADERS,
Expand Down Expand Up @@ -36,10 +37,19 @@ def map_user_agent_to_sdk_user_agent(value: str) -> str | None:
return None


def map_request_to_sdk_label(request: HttpRequest) -> KnownSDK | None:
for header in SDK_USER_AGENT_HEADERS:
if (value := request.headers.get(header)) and (
sdk_user_agent := map_user_agent_to_sdk_user_agent(value)
):
return cast(KnownSDK, sdk_user_agent.partition("/")[0])
return None


_request_header_labels_model_fields: dict[str, Any] = {
str(label): (
Annotated[str | None, BeforeValidator(map_user_agent_to_sdk_user_agent)]
if label in ("user_agent", "sdk_user_agent")
if header in SDK_USER_AGENT_HEADERS
else str | None,
Field(default=None, alias=header),
)
Expand Down
51 changes: 51 additions & 0 deletions api/environments/migrations/0038_add_first_evaluated_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Generated by Django 5.2.16 on 2026-07-20 22:50

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("environments", "0037_add_uuid_field"),
]

operations = [
migrations.AddField(
model_name="environment",
name="first_evaluated_at",
field=models.DateTimeField(
blank=True,
help_text="When the environment's flags were first evaluated by an SDK.",
null=True,
),
),
migrations.AddField(
model_name="environment",
name="first_evaluated_sdk_label",
field=models.CharField(
blank=True,
help_text="SDK that first evaluated the environment's flags.",
max_length=100,
null=True,
),
),
migrations.AddField(
model_name="historicalenvironment",
name="first_evaluated_at",
field=models.DateTimeField(
blank=True,
help_text="When the environment's flags were first evaluated by an SDK.",
null=True,
),
),
migrations.AddField(
model_name="historicalenvironment",
name="first_evaluated_sdk_label",
field=models.CharField(
blank=True,
help_text="SDK that first evaluated the environment's flags.",
max_length=100,
null=True,
),
),
]
17 changes: 17 additions & 0 deletions api/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import typing
import uuid
from copy import deepcopy
from datetime import datetime
from typing import TYPE_CHECKING, Literal

from common.core.utils import using_database_replica
Expand All @@ -24,6 +25,7 @@
from softdelete.models import SoftDeleteObject # type: ignore[import-untyped]

from app.utils import create_hash
from app_analytics.types import KnownSDK
from audit.constants import (
ENVIRONMENT_CREATED_MESSAGE,
ENVIRONMENT_UPDATED_MESSAGE,
Expand Down Expand Up @@ -160,6 +162,19 @@ class Environment(
help_text="Attribute used to indicate when an environment is still being created (via clone for example)",
)

first_evaluated_at = models.DateTimeField[datetime | None, datetime | None](
null=True,
blank=True,
help_text="When the environment's flags were first evaluated by an SDK.",
)

first_evaluated_sdk_label = models.CharField[KnownSDK | None, KnownSDK | None](
null=True,
blank=True,
max_length=100,
help_text="SDK that first evaluated the environment's flags.",
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
objects = EnvironmentManager()

class Meta:
Expand Down Expand Up @@ -240,6 +255,8 @@ def clone(
clone.name = name
clone.api_key = api_key if api_key else create_hash()
clone.is_creating = True
clone.first_evaluated_at = None
clone.first_evaluated_sdk_label = None
clone.save()

from environments.tasks import clone_environment_feature_states
Expand Down
Empty file.
16 changes: 16 additions & 0 deletions api/environments/onboarding/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import get_args

from rest_framework import serializers

from app_analytics.types import KnownSDK
from environments.models import Environment


class EnvironmentOnboardingStatusSerializer(serializers.ModelSerializer[Environment]):
class Meta:
model = Environment
fields = ("first_evaluated_at", "first_evaluated_sdk_label")


class EnvironmentOnboardingStatusUpdateSerializer(serializers.Serializer[None]):
first_evaluated_sdk_label = serializers.ChoiceField(choices=get_args(KnownSDK))
32 changes: 32 additions & 0 deletions api/environments/onboarding/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import structlog
from django.utils import timezone

from app_analytics.types import KnownSDK
from environments.models import Environment

logger = structlog.get_logger("onboarding")


def record_environment_first_evaluation(
environment: Environment,
sdk_label: KnownSDK,
) -> None:
"""Mark this environment as having been evaluated by a client SDK."""
log = logger.bind(
environment__id=environment.id,
project__id=environment.project_id,
organisation__id=environment.project.organisation_id,
sdk__label=sdk_label,
)

if environment.first_evaluated_at is not None:
log.info("environment.already_evaluated")
return

environment.first_evaluated_at = timezone.now()
Comment thread
emyller marked this conversation as resolved.
environment.first_evaluated_sdk_label = sdk_label
environment.save(update_fields=["first_evaluated_at", "first_evaluated_sdk_label"])

Environment.write_environment_documents(environment_id=environment.id)

log.info("environment.first_evaluated")
13 changes: 13 additions & 0 deletions api/environments/onboarding/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.urls import path

from environments.onboarding.views import EnvironmentOnboardingStatusAPIView

app_name = "onboarding"

urlpatterns = [
path(
"",
EnvironmentOnboardingStatusAPIView.as_view(),
name="onboarding-status",
),
]
36 changes: 36 additions & 0 deletions api/environments/onboarding/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.generics import RetrieveAPIView
from rest_framework.request import Request
from rest_framework.response import Response

from environments.models import Environment
from environments.onboarding.serializers import (
EnvironmentOnboardingStatusSerializer,
EnvironmentOnboardingStatusUpdateSerializer,
)
from environments.onboarding.services import record_environment_first_evaluation


class EnvironmentOnboardingStatusAPIView(RetrieveAPIView[Environment]):
"""Obtain information on whether features for this environment have been evaluated yet."""

authentication_classes = ()
permission_classes = ()
throttle_classes = []

queryset = Environment.objects.select_related("project")
lookup_field = "api_key"
lookup_url_kwarg = "environment_api_key"
serializer_class = EnvironmentOnboardingStatusSerializer

@extend_schema(exclude=True)
def put(self, request: Request, environment_api_key: str) -> Response:
Comment thread
khvn26 marked this conversation as resolved.
"""Mark this environment as having been evaluated by a client SDK."""
serializer = EnvironmentOnboardingStatusUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
record_environment_first_evaluation(
environment=self.get_object(),
sdk_label=serializer.validated_data["first_evaluated_sdk_label"],
)
return Response(status=status.HTTP_204_NO_CONTENT)
4 changes: 4 additions & 0 deletions api/environments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,8 @@
"<str:environment_api_key>/experiment-metrics/",
include("experimentation.metric_urls"),
),
path(
"<str:environment_api_key>/onboarding-status/",
include("environments.onboarding.urls"),
),
]
7 changes: 7 additions & 0 deletions api/features/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from app.pagination import CustomPagination
from app_analytics.analytics_db_service import get_feature_evaluation_data
from app_analytics.influxdb_wrapper import get_multiple_event_list_for_feature
from app_analytics.mappers import map_request_to_sdk_label
from app_analytics.throttles import InfluxQueryThrottle
from core.constants import FLAGSMITH_UPDATED_AT_HEADER, SDK_ENVIRONMENT_KEY_HEADER
from core.request_origin import RequestOrigin
Expand All @@ -55,6 +56,7 @@
IdentitySourceIdentityRequestSerializer,
)
from environments.models import Environment
from environments.onboarding.services import record_environment_first_evaluation
from environments.permissions.permissions import (
EnvironmentKeyPermissions,
NestedEnvironmentPermissions,
Expand Down Expand Up @@ -1007,6 +1009,11 @@ def get(self, request, identifier=None, *args, **kwargs): # type: ignore[no-unt
*Note*: using this endpoint with an identifier is deprecated.
Please use `/api/v1/identities/?identifier=<identifier>` instead.
"""
if request.environment.first_evaluated_at is None and (
sdk_label := map_request_to_sdk_label(request)
):
record_environment_first_evaluation(request.environment, sdk_label)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Comment thread
emyller marked this conversation as resolved.
if identifier:
return self._get_flags_response_with_identifier(request, identifier)

Expand Down
Empty file.
Loading
Loading