From 0ff5f3f87559cd9e400f7971f521f26b1abe28d3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 7 Aug 2026 16:54:41 +0100 Subject: [PATCH 01/10] feat(onboarding): Org-level onboarding experiment events The onboarding flow decision is now made in the backend, per organisation. Serving a multivariate split assignment records the experiment exposure; the first SDK evaluation of any of the organisation's environments records the conversion, both against the organisation targeting key. - flagsmith 6.2.0 and openfeature-provider-flagsmith from its experimentation-tracking PR branch - events enabled on the Flagsmith-on-Flagsmith client via FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL (set in staging and production ECS task definitions; never enabled in offline mode) - get_openfeature_client() attaches the provider's exposure hook, so any multivariate split evaluation records an exposure - onboarding_variant field on the organisation serialiser beep boop --- api/app/settings/common.py | 3 + api/environments/onboarding/services.py | 13 ++++ api/integrations/flagsmith/client.py | 11 ++- api/organisations/serializers.py | 7 ++ api/organisations/services.py | 17 +++++ api/pyproject.toml | 8 +- ...t_unit_environments_onboarding_services.py | 60 +++++++++++++++ .../flagsmith/test_unit_flagsmith_client.py | 58 ++++++++++++++ .../test_unit_organisations_serializers.py | 24 +++++- .../test_unit_organisations_services.py | 76 +++++++++++++++++++ api/uv.lock | 28 +++---- .../observability/_events-catalogue.md | 4 +- .../ecs-task-definition-admin-api.json | 4 + .../ecs-task-definition-sdk-api.json | 4 + .../ecs-task-definition-task-processor.json | 4 + .../ecs-task-definition-admin-api.json | 4 + .../staging/ecs-task-definition-sdk-api.json | 4 + .../ecs-task-definition-task-processor.json | 4 + 18 files changed, 310 insertions(+), 23 deletions(-) create mode 100644 api/organisations/services.py create mode 100644 api/tests/unit/environments/onboarding/test_unit_environments_onboarding_services.py create mode 100644 api/tests/unit/organisations/test_unit_organisations_services.py diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 572ea0a02d48..8dca1608e0e8 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1303,6 +1303,9 @@ FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL = env( "FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL", default=FLAGSMITH_ON_FLAGSMITH_API_URL ) +FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL = env( + "FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL", default=None +) FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID = env.int( "FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID", diff --git a/api/environments/onboarding/services.py b/api/environments/onboarding/services.py index 83b639b88aca..2a5a94bb3e54 100644 --- a/api/environments/onboarding/services.py +++ b/api/environments/onboarding/services.py @@ -1,8 +1,11 @@ import structlog from django.utils import timezone +from openfeature.evaluation_context import EvaluationContext +from openfeature.track import TrackingEventDetails from app_analytics.types import KnownSDK from environments.models import Environment +from integrations.flagsmith.client import get_openfeature_client logger = structlog.get_logger("onboarding") @@ -29,4 +32,14 @@ def record_environment_first_evaluation( Environment.write_environment_documents(environment_id=environment.id) + get_openfeature_client().track( + "environment.first_evaluated", + evaluation_context=EvaluationContext( + targeting_key=environment.project.organisation.openfeature_evaluation_context.targeting_key, + ), + tracking_event_details=TrackingEventDetails( + attributes={"sdk_label": sdk_label}, + ), + ) + log.info("environment.first_evaluated") diff --git a/api/integrations/flagsmith/client.py b/api/integrations/flagsmith/client.py index 4ada0eb41d4a..e1d94c0a62d1 100644 --- a/api/integrations/flagsmith/client.py +++ b/api/integrations/flagsmith/client.py @@ -18,8 +18,10 @@ import openfeature.api as openfeature_api from django.conf import settings from flagsmith import Flagsmith +from flagsmith.analytics import EventProcessorConfig from flagsmith.offline_handlers import LocalFileHandler from openfeature.client import OpenFeatureClient +from openfeature_flagsmith.hooks import FlagsmithExposureHook from openfeature_flagsmith.provider import FlagsmithProvider from integrations.flagsmith.exceptions import FlagsmithIntegrationError @@ -47,6 +49,7 @@ def initialise_provider( flagsmith_client = Flagsmith(**kwargs) provider = FlagsmithProvider(client=flagsmith_client) openfeature_api.set_provider(provider, domain=domain) + openfeature_api.add_hooks([FlagsmithExposureHook(provider)]) def get_provider_kwargs() -> dict[str, typing.Any]: @@ -61,11 +64,17 @@ def get_provider_kwargs() -> dict[str, typing.Any]: settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY and settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL ): - return { + kwargs = { "environment_key": settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY, "api_url": settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL, **common_kwargs, } + if events_api_url := settings.FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL: + kwargs["enable_events"] = True + kwargs["event_processor_config"] = EventProcessorConfig( + events_api_url=events_api_url, + ) + return kwargs raise FlagsmithIntegrationError( "Must either use offline mode, or provide " diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index baf70e32ebc7..805077c4ac29 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -11,6 +11,7 @@ get_subscription_data_from_hosted_page, ) from organisations.invites.models import Invite +from organisations.services import OnboardingVariant, get_onboarding_variant from users.models import FFAdminUser, UserPermissionGroup from .models import ( @@ -40,6 +41,7 @@ def get_has_active_billing_periods(self, obj): # type: ignore[no-untyped-def] class OrganisationSerializerFull(serializers.ModelSerializer): # type: ignore[type-arg] subscription = SubscriptionSerializer(required=False) role = serializers.SerializerMethodField() + onboarding_variant = serializers.SerializerMethodField() class Meta: model = Organisation @@ -56,6 +58,7 @@ class Meta: "block_access_to_admin", "restrict_project_create_to_admin", "force_2fa", + "onboarding_variant", ) read_only_fields = ( "id", @@ -72,6 +75,10 @@ def get_role(self, instance): # type: ignore[no-untyped-def] user = self.context["request"].user return user.get_organisation_role(instance) + @extend_schema_field({"type": "string", "enum": ["control", "single_page"]}) + def get_onboarding_variant(self, instance: Organisation) -> OnboardingVariant: + return get_onboarding_variant(instance) + class OrganisationSerializerBasic(serializers.ModelSerializer): # type: ignore[type-arg] class Meta: diff --git a/api/organisations/services.py b/api/organisations/services.py new file mode 100644 index 000000000000..3b7485cbd3d9 --- /dev/null +++ b/api/organisations/services.py @@ -0,0 +1,17 @@ +import typing + +from integrations.flagsmith.client import get_openfeature_client +from organisations.models import Organisation + +OnboardingVariant = typing.Literal["control", "single_page"] + + +def get_onboarding_variant(organisation: Organisation) -> OnboardingVariant: + details = get_openfeature_client().get_boolean_details( + "onboarding_quickstart_flow", + default_value=False, + evaluation_context=organisation.openfeature_evaluation_context, + ) + if not details.value or details.variant == "control": + return "control" + return "single_page" diff --git a/api/pyproject.toml b/api/pyproject.toml index a1c2b0800606..8a001a83fccf 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "pydantic>=2.12.0,<3.0.0", "pydantic-collections>=0.6.0,<0.7.0", "pyngo>=2.4.1,<2.5.0", - "flagsmith>=5.3.0,<6.0.0", + "flagsmith>=6.2.0,<7.0.0", "openfeature-sdk>=0.9.0,<0.10.0", "openfeature-provider-flagsmith>=0.2.0", "python-gnupg>=0.5.1,<0.6.0", @@ -159,6 +159,8 @@ explicit = true [tool.uv.sources] flagsmith-private = { index = "flagsmith-pypi-production" } django-clickhouse-backend = { git = "https://github.com/jayvynl/django-clickhouse-backend", rev = "d884c7d" } +# TODO: back to PyPI once Flagsmith/flagsmith-openfeature-provider-python#37 is released +openfeature-provider-flagsmith = { git = "https://github.com/Flagsmith/flagsmith-openfeature-provider-python", rev = "b8aa9310dbedc8658606c0958eae7a78f8dac6df" } [tool.uv] required-version = ">=0.11.18" # Ensure this matches the version in .pre-commit-config.yaml @@ -244,10 +246,10 @@ override-dependencies = [ "executing==2.2.1", "fancycompleter==0.9.1", "filelock==3.20.3", - "flagsmith==5.3.0", + "flagsmith==6.2.0", # flagsmith-common version is set via the [project] dependency spec which # also carries the extras; an override here would strip the extras. - "flagsmith-flag-engine==10.1.0", + "flagsmith-flag-engine==10.2.0", "freezegun==1.5.5", "genson==1.2.2", "google-api-core==2.29.0", diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_onboarding_services.py b/api/tests/unit/environments/onboarding/test_unit_environments_onboarding_services.py new file mode 100644 index 000000000000..003b5237346e --- /dev/null +++ b/api/tests/unit/environments/onboarding/test_unit_environments_onboarding_services.py @@ -0,0 +1,60 @@ +from unittest.mock import MagicMock, Mock + +import pytest +from django.utils import timezone +from pytest_mock import MockerFixture + +from environments.models import Environment +from environments.onboarding.services import record_environment_first_evaluation + + +@pytest.fixture(autouse=True) +def write_environment_documents(mocker: MockerFixture) -> Mock: + return mocker.patch.object(Environment, "write_environment_documents") + + +@pytest.fixture() +def mock_openfeature_client(mocker: MockerFixture) -> MagicMock: + mock_client: MagicMock = mocker.MagicMock() + mocker.patch( + "environments.onboarding.services.get_openfeature_client", + return_value=mock_client, + ) + return mock_client + + +def test_record_environment_first_evaluation__first_evaluation__tracks_conversion_event( + environment: Environment, + mock_openfeature_client: MagicMock, +) -> None: + # Given + organisation_id = environment.project.organisation_id + + # When + record_environment_first_evaluation(environment, "flagsmith-python-sdk") + + # Then + mock_openfeature_client.track.assert_called_once() + call_args = mock_openfeature_client.track.call_args + assert call_args.args == ("environment.first_evaluated",) + assert ( + call_args.kwargs["evaluation_context"].targeting_key == f"org.{organisation_id}" + ) + assert call_args.kwargs["tracking_event_details"].attributes == { + "sdk_label": "flagsmith-python-sdk", + } + + +def test_record_environment_first_evaluation__already_evaluated__does_not_track( + environment: Environment, + mock_openfeature_client: MagicMock, +) -> None: + # Given + environment.first_evaluated_at = timezone.now() + environment.save(update_fields=["first_evaluated_at"]) + + # When + record_environment_first_evaluation(environment, "flagsmith-python-sdk") + + # Then + mock_openfeature_client.track.assert_not_called() diff --git a/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py b/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py index 8af126f9c9cf..5586b24c1074 100644 --- a/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py +++ b/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py @@ -2,6 +2,7 @@ import openfeature.api as openfeature_api import pytest +from flagsmith.analytics import EventProcessorConfig from flagsmith.offline_handlers import LocalFileHandler from openfeature.provider.in_memory_provider import InMemoryProvider from openfeature.provider.metadata import Metadata @@ -230,3 +231,60 @@ def test_get_provider_kwargs__missing_server_key__raises_error( # When / Then with pytest.raises(FlagsmithIntegrationError): get_provider_kwargs() + + +def test_get_provider_kwargs__events_api_url_set__enables_events( + settings: SettingsWrapper, mock_local_file_handler_class: MagicMock +) -> None: + # Given + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE = False + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY = "ser.some-key" + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL = "https://my.flagsmith.api/api/v1/" + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL = ( + "https://events.my.flagsmith.api/" + ) + + # When + kwargs = get_provider_kwargs() + + # Then + assert kwargs["enable_events"] is True + assert kwargs["event_processor_config"] == EventProcessorConfig( + events_api_url="https://events.my.flagsmith.api/", + ) + + +def test_get_provider_kwargs__no_events_api_url__events_not_enabled( + settings: SettingsWrapper, mock_local_file_handler_class: MagicMock +) -> None: + # Given + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE = False + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_KEY = "ser.some-key" + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL = "https://my.flagsmith.api/api/v1/" + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL = None + + # When + kwargs = get_provider_kwargs() + + # Then + assert "enable_events" not in kwargs + assert "event_processor_config" not in kwargs + + +def test_get_provider_kwargs__offline_mode_with_events_api_url__events_not_enabled( + settings: SettingsWrapper, mock_local_file_handler_class: MagicMock +) -> None: + # Given + # The SDK never initialises events in offline mode; don't pretend otherwise. + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE = True + settings.FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL = ( + "https://events.my.flagsmith.api/" + ) + + # When + kwargs = get_provider_kwargs() + + # Then + assert kwargs["offline_mode"] is True + assert "enable_events" not in kwargs + assert "event_processor_config" not in kwargs diff --git a/api/tests/unit/organisations/test_unit_organisations_serializers.py b/api/tests/unit/organisations/test_unit_organisations_serializers.py index d9033659f800..080fd505cb1e 100644 --- a/api/tests/unit/organisations/test_unit_organisations_serializers.py +++ b/api/tests/unit/organisations/test_unit_organisations_serializers.py @@ -2,7 +2,29 @@ from pytest_mock import MockerFixture from organisations.models import Organisation -from organisations.serializers import UpdateSubscriptionSerializer +from organisations.serializers import ( + OrganisationSerializerFull, + UpdateSubscriptionSerializer, +) + + +def test_organisation_serializer_full__onboarding_org__serialises_onboarding_variant( + organisation: Organisation, + mocker: MockerFixture, +) -> None: + # Given + get_onboarding_variant_mock = mocker.patch( + "organisations.serializers.get_onboarding_variant", + return_value="single_page", + autospec=True, + ) + + # When + data = OrganisationSerializerFull(instance=organisation).data + + # Then + assert data["onboarding_variant"] == "single_page" + get_onboarding_variant_mock.assert_called_once_with(organisation) def test_update_subscription_serializer__create__updates_subscription( diff --git a/api/tests/unit/organisations/test_unit_organisations_services.py b/api/tests/unit/organisations/test_unit_organisations_services.py new file mode 100644 index 000000000000..315a2d13b1d2 --- /dev/null +++ b/api/tests/unit/organisations/test_unit_organisations_services.py @@ -0,0 +1,76 @@ +import openfeature.api as openfeature_api +from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider + +from integrations.flagsmith.client import DEFAULT_OPENFEATURE_DOMAIN +from organisations.models import Organisation +from organisations.services import get_onboarding_variant + + +def test_get_onboarding_variant__flag_not_configured__returns_control( + organisation: Organisation, +) -> None: + # Given + # the flag doesn't exist + + # When / Then + assert get_onboarding_variant(organisation) == "control" + + +def test_get_onboarding_variant__flag_disabled__returns_control( + organisation: Organisation, +) -> None: + # Given + openfeature_api.set_provider( + InMemoryProvider( + { + "onboarding_quickstart_flow": InMemoryFlag( + variants={"off": False}, + default_variant="off", + ) + } + ), + domain=DEFAULT_OPENFEATURE_DOMAIN, + ) + + # When / Then + assert get_onboarding_variant(organisation) == "control" + + +def test_get_onboarding_variant__variant_assigned__returns_single_page( + organisation: Organisation, +) -> None: + # Given + openfeature_api.set_provider( + InMemoryProvider( + { + "onboarding_quickstart_flow": InMemoryFlag( + variants={"control": True, "single_page": True}, + default_variant="single_page", + ) + } + ), + domain=DEFAULT_OPENFEATURE_DOMAIN, + ) + + # When / Then + assert get_onboarding_variant(organisation) == "single_page" + + +def test_get_onboarding_variant__control_variant_assigned__returns_control( + organisation: Organisation, +) -> None: + # Given + openfeature_api.set_provider( + InMemoryProvider( + { + "onboarding_quickstart_flow": InMemoryFlag( + variants={"control": True, "single_page": True}, + default_variant="control", + ) + } + ), + domain=DEFAULT_OPENFEATURE_DOMAIN, + ) + + # When / Then + assert get_onboarding_variant(organisation) == "control" diff --git a/api/uv.lock b/api/uv.lock index e961a2f4535c..cf2af47c9245 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -85,8 +85,8 @@ overrides = [ { name = "executing", specifier = "==2.2.1" }, { name = "fancycompleter", specifier = "==0.9.1" }, { name = "filelock", specifier = "==3.20.3" }, - { name = "flagsmith", specifier = "==5.3.0" }, - { name = "flagsmith-flag-engine", specifier = "==10.1.0" }, + { name = "flagsmith", specifier = "==6.2.0" }, + { name = "flagsmith-flag-engine", specifier = "==10.2.0" }, { name = "freezegun", specifier = "==1.5.5" }, { name = "genson", specifier = "==1.2.2" }, { name = "google-api-core", specifier = "==2.29.0" }, @@ -133,7 +133,7 @@ overrides = [ { name = "nodeenv", specifier = "==1.9.1" }, { name = "oauth2client", specifier = "==4.1.3" }, { name = "oauthlib", specifier = "==3.2.2" }, - { name = "openfeature-provider-flagsmith", specifier = "==0.2.0" }, + { name = "openfeature-provider-flagsmith", git = "https://github.com/Flagsmith/flagsmith-openfeature-provider-python?rev=b8aa9310dbedc8658606c0958eae7a78f8dac6df" }, { name = "openfeature-sdk", specifier = "==0.9.0" }, { name = "opentelemetry-api", specifier = "==1.40.0" }, { name = "opentelemetry-exporter-otlp-proto-common", specifier = "==1.40.0" }, @@ -1527,7 +1527,7 @@ wheels = [ [[package]] name = "flagsmith" -version = "5.3.0" +version = "6.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flagsmith-flag-engine" }, @@ -1536,9 +1536,9 @@ dependencies = [ { name = "sseclient-py" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/4d/d4e61cddab5cdc62878aeffd4e4947e03470baeea4a29dcd8b3d2fb7de82/flagsmith-5.3.0.tar.gz", hash = "sha256:79befb381fb759d13402ae471fe00c0539ba7799a93edea4b7fa82b7835d0f41", size = 16115, upload-time = "2026-04-28T18:05:33.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/0e/061ecf0eaf8c3dfd6b45b6d403c9dc5d89eff921ed5fa3de91f41cb91e05/flagsmith-6.2.0.tar.gz", hash = "sha256:be0c144016c3cd5ea5c23de86df1d96b97c71a98594cf2b9a31a775f75ee8b11", size = 17209, upload-time = "2026-08-07T10:24:21.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/54/e9bb3dab6d802b76c2ff9ef26f2e6fc43a58d392598aafa60e9347b9eab3/flagsmith-5.3.0-py3-none-any.whl", hash = "sha256:47a589a6e55cf9151f03092c11aa344e68df44d9b081d83716ca44cf79fc8be1", size = 21089, upload-time = "2026-04-28T18:05:31.953Z" }, + { url = "https://files.pythonhosted.org/packages/f7/61/8972a9d4840306ffa8796fc95c257445a0f46e91a3088446c9368827aab8/flagsmith-6.2.0-py3-none-any.whl", hash = "sha256:2147c751461e8a056ad2a792105b9012938ee299b9108f92e457d396473aca1d", size = 22329, upload-time = "2026-08-07T10:24:20.559Z" }, ] [[package]] @@ -1717,7 +1717,7 @@ requires-dist = [ { name = "drf-writable-nested", specifier = ">=0.6.2,<0.7.0" }, { name = "email-validator", marker = "extra == 'dev'", specifier = ">=2.0.0" }, { name = "environs", specifier = ">=14.1.1,<15.0.0" }, - { name = "flagsmith", specifier = ">=5.3.0,<6.0.0" }, + { name = "flagsmith", specifier = ">=6.2.0,<7.0.0" }, { name = "flagsmith-common", extras = ["common-core", "flagsmith-schemas", "task-processor"], specifier = ">=3.12.1,<4" }, { name = "flagsmith-common", extras = ["test-tools"], marker = "extra == 'dev'" }, { name = "flagsmith-flag-engine", specifier = ">=10.1.0,<11.0.0" }, @@ -1734,7 +1734,7 @@ requires-dist = [ { name = "mypy-boto3-dynamodb", marker = "extra == 'dev'", specifier = ">=1.33.0,<2.0.0" }, { name = "mypy-boto3-s3", marker = "extra == 'dev'", specifier = ">=1.36.0,<2.0.0" }, { name = "oauth2client", specifier = ">=4.1.3,<4.2.0" }, - { name = "openfeature-provider-flagsmith", specifier = ">=0.2.0" }, + { name = "openfeature-provider-flagsmith", git = "https://github.com/Flagsmith/flagsmith-openfeature-provider-python?rev=b8aa9310dbedc8658606c0958eae7a78f8dac6df" }, { name = "openfeature-sdk", specifier = ">=0.9.0,<0.10.0" }, { name = "pdbpp", marker = "extra == 'dev'", specifier = ">=0.10.3,<0.11.0" }, { name = "pep8", marker = "extra == 'dev'", specifier = ">=1.7.1,<1.8.0" }, @@ -1845,16 +1845,16 @@ test-tools = [ [[package]] name = "flagsmith-flag-engine" -version = "10.1.0" +version = "10.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpath-rfc9535" }, { name = "semver" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/07/548dcb463fc6dba513b595e49043167efdf787cd1f277ff383390735cbdd/flagsmith_flag_engine-10.1.0.tar.gz", hash = "sha256:fcb7e6833a874001c4ad3b91a66a4c31f050d53d94b116f88ad5c7ecd9650e8a", size = 10992, upload-time = "2026-05-06T08:26:07.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/11/aa67ecac85d1879ff116f94c8518c922ed687908dc11f71aebb1a5be5a87/flagsmith_flag_engine-10.2.0.tar.gz", hash = "sha256:d935c9fb639e8acc5b9ff4599ec570e1b2f3f7b7874fc789a6eca3db5665a31b", size = 11056, upload-time = "2026-06-09T07:06:25.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/55/cfc11f6bd20209c1b77e8449c01d369d12a87d38c3ac064e3742d1a53389/flagsmith_flag_engine-10.1.0-py3-none-any.whl", hash = "sha256:767dcf2f32586948eaa7816b5cbdae272d76d89e30c4642cbd74894c89a2d469", size = 14181, upload-time = "2026-05-06T08:26:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/62/e5/1eb841fa1dfe6a0e3f53d54ac1cd9e8cdac0ab5cc96dfa58c1c70e8fe897/flagsmith_flag_engine-10.2.0-py3-none-any.whl", hash = "sha256:c9bed3ee15487057dc61144d34d101d98db255f17d2c739f02794841a5c98502", size = 14254, upload-time = "2026-06-09T07:06:24.487Z" }, ] [[package]] @@ -2640,15 +2640,11 @@ wheels = [ [[package]] name = "openfeature-provider-flagsmith" version = "0.2.0" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/Flagsmith/flagsmith-openfeature-provider-python?rev=b8aa9310dbedc8658606c0958eae7a78f8dac6df#b8aa9310dbedc8658606c0958eae7a78f8dac6df" } dependencies = [ { name = "flagsmith" }, { name = "openfeature-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/98/3db0f72e19114a9e259889ab4ed425899a2448cc839e949b977e56581010/openfeature_provider_flagsmith-0.2.0.tar.gz", hash = "sha256:b5411613fa24efde96356d987b15c3efbb0455c4771306c26d73d526a0021a73", size = 5296, upload-time = "2026-04-30T07:17:21.773Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/d7/12486d65e1ff168b5485dba850f55732d77c370a321a5d64a8d0d3937d60/openfeature_provider_flagsmith-0.2.0-py3-none-any.whl", hash = "sha256:5a87df0ddaf0efaa664f0a71a9422733ac4ab7bd47782503039022c9c3206d8e", size = 6557, upload-time = "2026-04-30T07:17:20.57Z" }, -] [[package]] name = "openfeature-sdk" diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 591712fced24..b5c5d016cedb 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -466,7 +466,7 @@ Attributes: ### `onboarding.environment.already_evaluated` Logged at `info` from: - - `api/environments/onboarding/services.py:23` + - `api/environments/onboarding/services.py:26` Attributes: - `environment.id` @@ -477,7 +477,7 @@ Attributes: ### `onboarding.environment.first_evaluated` Logged at `info` from: - - `api/environments/onboarding/services.py:32` + - `api/environments/onboarding/services.py:45` Attributes: - `environment.id` diff --git a/infrastructure/aws/production/ecs-task-definition-admin-api.json b/infrastructure/aws/production/ecs-task-definition-admin-api.json index 697d657b39d7..9aeb52d20dee 100644 --- a/infrastructure/aws/production/ecs-task-definition-admin-api.json +++ b/infrastructure/aws/production/ecs-task-definition-admin-api.json @@ -212,6 +212,10 @@ "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL", "value": "https://edge.api.flagsmith.com/api/v1/" }, + { + "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL", + "value": "https://events.api.flagsmith.com/" + }, { "name": "FLAGSMITH_API_URL", "value": "https://api.flagsmith.com" diff --git a/infrastructure/aws/production/ecs-task-definition-sdk-api.json b/infrastructure/aws/production/ecs-task-definition-sdk-api.json index d3051ea4dac7..4051db72835d 100644 --- a/infrastructure/aws/production/ecs-task-definition-sdk-api.json +++ b/infrastructure/aws/production/ecs-task-definition-sdk-api.json @@ -229,6 +229,10 @@ "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL", "value": "https://edge.api.flagsmith.com/api/v1/" }, + { + "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL", + "value": "https://events.api.flagsmith.com/" + }, { "name": "FLAGSMITH_API_URL", "value": "https://api.flagsmith.com" diff --git a/infrastructure/aws/production/ecs-task-definition-task-processor.json b/infrastructure/aws/production/ecs-task-definition-task-processor.json index 84093a250c52..52784ee018c3 100644 --- a/infrastructure/aws/production/ecs-task-definition-task-processor.json +++ b/infrastructure/aws/production/ecs-task-definition-task-processor.json @@ -154,6 +154,10 @@ "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL", "value": "https://edge.api.flagsmith.com/api/v1/" }, + { + "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL", + "value": "https://events.api.flagsmith.com/" + }, { "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE", "value": "False" diff --git a/infrastructure/aws/staging/ecs-task-definition-admin-api.json b/infrastructure/aws/staging/ecs-task-definition-admin-api.json index 197ce6dc362b..726ee1f7fb2d 100644 --- a/infrastructure/aws/staging/ecs-task-definition-admin-api.json +++ b/infrastructure/aws/staging/ecs-task-definition-admin-api.json @@ -225,6 +225,10 @@ "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL", "value": "https://edge.api.flagsmith.com/api/v1/" }, + { + "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL", + "value": "https://events.bullet-train-staging.win/" + }, { "name": "FLAGSMITH_API_URL", "value": "https://api-staging.flagsmith.com" diff --git a/infrastructure/aws/staging/ecs-task-definition-sdk-api.json b/infrastructure/aws/staging/ecs-task-definition-sdk-api.json index 3286cd98c4e6..ce504e87c97e 100644 --- a/infrastructure/aws/staging/ecs-task-definition-sdk-api.json +++ b/infrastructure/aws/staging/ecs-task-definition-sdk-api.json @@ -240,6 +240,10 @@ "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL", "value": "https://edge.api.flagsmith.com/api/v1/" }, + { + "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL", + "value": "https://events.bullet-train-staging.win/" + }, { "name": "FLAGSMITH_API_URL", "value": "https://api-staging.flagsmith.com" diff --git a/infrastructure/aws/staging/ecs-task-definition-task-processor.json b/infrastructure/aws/staging/ecs-task-definition-task-processor.json index c588ebf331ef..6da5dc76270e 100644 --- a/infrastructure/aws/staging/ecs-task-definition-task-processor.json +++ b/infrastructure/aws/staging/ecs-task-definition-task-processor.json @@ -152,6 +152,10 @@ "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL", "value": "https://edge.api.flagsmith.com/api/v1/" }, + { + "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_EVENTS_API_URL", + "value": "https://events.bullet-train-staging.win/" + }, { "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE", "value": "False" From 1b4d68c8d5be0ec9f5f327b1bfc16ddf367d3162 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 7 Aug 2026 16:58:19 +0100 Subject: [PATCH 02/10] feat(onboarding): Read onboarding variant from the organisation API The gate no longer evaluates the Flagsmith-on-Flagsmith flag client-side; the backend decides per organisation and the exposure is recorded server-side. beep boop --- frontend/common/types/responses.ts | 2 ++ frontend/common/utils/getOnboardingVariant.ts | 13 ---------- .../pages/onboarding/GettingStartedGate.tsx | 24 +++++++------------ 3 files changed, 10 insertions(+), 29 deletions(-) delete mode 100644 frontend/common/utils/getOnboardingVariant.ts diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 0d8a5ba0096b..f50c81d9269a 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -516,6 +516,7 @@ export type Subscription = { notes: string | null } +export type OnboardingVariant = 'control' | 'single_page' export type Organisation = { id: number name: string @@ -527,6 +528,7 @@ export type Organisation = { persist_trait_data: boolean block_access_to_admin: boolean restrict_project_create_to_admin: boolean + onboarding_variant: OnboardingVariant } export type Identity = { id: string diff --git a/frontend/common/utils/getOnboardingVariant.ts b/frontend/common/utils/getOnboardingVariant.ts deleted file mode 100644 index c684e37ac7fc..000000000000 --- a/frontend/common/utils/getOnboardingVariant.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Utils from './utils' - -export type OnboardingVariant = 'control' | 'single_page' - -// The served variant name picks the arm ('control' is the reserved key the -// API reports for the default arm). Enabled with no variant is the -// pre-conversion boolean flag, which means the new flow. -export const isSinglePageOnboarding = (): boolean => - Utils.getFlagsmithHasFeature('onboarding_quickstart_flow') && - Utils.getFlagsmithVariant('onboarding_quickstart_flow') !== 'control' - -export const getOnboardingVariant = (): OnboardingVariant => - isSinglePageOnboarding() ? 'single_page' : 'control' diff --git a/frontend/web/components/pages/onboarding/GettingStartedGate.tsx b/frontend/web/components/pages/onboarding/GettingStartedGate.tsx index 97291785b515..1e622985aaf2 100644 --- a/frontend/web/components/pages/onboarding/GettingStartedGate.tsx +++ b/frontend/web/components/pages/onboarding/GettingStartedGate.tsx @@ -1,30 +1,22 @@ import React, { FC, useEffect } from 'react' -import flagsmith from '@flagsmith/flagsmith' import ConfigProvider from 'common/providers/ConfigProvider' -import { - getOnboardingVariant, - isSinglePageOnboarding, -} from 'common/utils/getOnboardingVariant' +import AccountStore from 'common/stores/account-store' import API from 'project/api' import GettingStartedPage from 'components/pages/GettingStartedPage' import OnboardingFlow from './OnboardingFlow' const GettingStartedGate: FC = () => { - // ConfigProvider re-renders the gate on every SDK fetch; only tag the - // variant once the server has answered for this identity. - const trustworthy = - !flagsmith.loadingState?.isFetching && - flagsmith.loadingState?.source === 'SERVER' && - !!flagsmith.getContext().identity - - const variant = getOnboardingVariant() + // The backend decides the variant per organisation and serves it on the + // organisation payload; the exposure is recorded server-side. + const organisation = AccountStore.getOrganisation() + const variant = organisation?.onboarding_variant ?? 'control' useEffect(() => { - if (!trustworthy) return + if (!organisation?.id) return API.trackTraits({ onboarding_variant: variant }) - }, [trustworthy, variant]) + }, [organisation?.id, variant]) - return isSinglePageOnboarding() ? : + return variant === 'single_page' ? : } export default ConfigProvider(GettingStartedGate) From 015d7fd0072525629c589b9eaa290019b88fdf78 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Fri, 7 Aug 2026 16:06:34 +0000 Subject: [PATCH 03/10] chore: Update documentation artefacts --- mcp/src/flagsmith_mcp/openapi.json | 15 +++++++++++++++ openapi.yaml | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/mcp/src/flagsmith_mcp/openapi.json b/mcp/src/flagsmith_mcp/openapi.json index 330b2f25c097..328286cade66 100644 --- a/mcp/src/flagsmith_mcp/openapi.json +++ b/mcp/src/flagsmith_mcp/openapi.json @@ -5714,6 +5714,13 @@ "NullEnum": { "type": "null" }, + "OnboardingVariantEnum": { + "type": "string", + "enum": [ + "control", + "single_page" + ] + }, "OrganisationSerializerFull": { "type": "object", "properties": { @@ -5773,6 +5780,14 @@ }, "force_2fa": { "type": "boolean" + }, + "onboarding_variant": { + "allOf": [ + { + "$ref": "#/components/schemas/OnboardingVariantEnum" + } + ], + "readOnly": true } }, "required": [ diff --git a/openapi.yaml b/openapi.yaml index ccfdf0f08579..320aff928940 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -22162,6 +22162,11 @@ components: is_new_user: type: boolean readOnly: true + OnboardingVariantEnum: + type: string + enum: + - control + - single_page OrganisationAPIUsageNotification: type: object properties: @@ -22233,6 +22238,10 @@ components: type: boolean force_2fa: type: boolean + onboarding_variant: + allOf: + - $ref: '#/components/schemas/OnboardingVariantEnum' + readOnly: true required: - name OrganisationWebhook: @@ -24185,6 +24194,10 @@ components: type: boolean force_2fa: type: boolean + onboarding_variant: + allOf: + - $ref: '#/components/schemas/OnboardingVariantEnum' + readOnly: true PatchedOrganisationWebhook: type: object properties: From 1935afa1af410e213b3059b8d4371d5a0591c9f1 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Fri, 7 Aug 2026 16:11:01 +0000 Subject: [PATCH 04/10] chore: Update documentation artefacts --- .../observability/_events-catalogue.md | 2 +- openapi.yaml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index b5c5d016cedb..2b86c1557e47 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -103,7 +103,7 @@ Attributes: ### `core.encrypted_field.decrypt_failed` Logged at `warning` from: - - `api/core/fields.py:37` + - `api/core/fields.py:62` Attributes: - `exc_info` diff --git a/openapi.yaml b/openapi.yaml index 320aff928940..cd9aec24854b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -22252,6 +22252,7 @@ components: readOnly: true url: type: string + format: uri maxLength: 200 enabled: type: boolean @@ -24206,6 +24207,7 @@ components: readOnly: true url: type: string + format: uri maxLength: 200 enabled: type: boolean @@ -25058,6 +25060,7 @@ components: url: type: string format: uri + maxLength: 200 enabled: type: boolean created_at: @@ -25079,6 +25082,7 @@ components: readOnly: true url: type: string + format: uri maxLength: 200 secret: type: string @@ -28048,6 +28052,7 @@ components: url: type: string format: uri + maxLength: 200 enabled: type: boolean created_at: @@ -28071,6 +28076,7 @@ components: readOnly: true url: type: string + format: uri maxLength: 200 secret: type: string From c3944b19e273e09006dbb6150279948402f28b20 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 7 Aug 2026 17:24:57 +0100 Subject: [PATCH 05/10] build(deps): openfeature-provider-flagsmith 1.0.0 from PyPI Also refreshes a stale line reference in the generated events catalogue. beep boop --- api/pyproject.toml | 6 ++---- api/uv.lock | 12 ++++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 8a001a83fccf..d45d0b870854 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "pyngo>=2.4.1,<2.5.0", "flagsmith>=6.2.0,<7.0.0", "openfeature-sdk>=0.9.0,<0.10.0", - "openfeature-provider-flagsmith>=0.2.0", + "openfeature-provider-flagsmith>=1.0.0,<2.0.0", "python-gnupg>=0.5.1,<0.6.0", "django-redis>=5.4.0,<6.0.0", "pygithub>=2.8,<2.9.0", @@ -159,8 +159,6 @@ explicit = true [tool.uv.sources] flagsmith-private = { index = "flagsmith-pypi-production" } django-clickhouse-backend = { git = "https://github.com/jayvynl/django-clickhouse-backend", rev = "d884c7d" } -# TODO: back to PyPI once Flagsmith/flagsmith-openfeature-provider-python#37 is released -openfeature-provider-flagsmith = { git = "https://github.com/Flagsmith/flagsmith-openfeature-provider-python", rev = "b8aa9310dbedc8658606c0958eae7a78f8dac6df" } [tool.uv] required-version = ">=0.11.18" # Ensure this matches the version in .pre-commit-config.yaml @@ -296,7 +294,7 @@ override-dependencies = [ "nodeenv==1.9.1", "oauth2client==4.1.3", "oauthlib==3.2.2", - "openfeature-provider-flagsmith==0.2.0", + "openfeature-provider-flagsmith==1.0.0", "openfeature-sdk==0.9.0", "opentelemetry-api==1.40.0", "opentelemetry-exporter-otlp-proto-common==1.40.0", diff --git a/api/uv.lock b/api/uv.lock index cf2af47c9245..ff9795ab4c61 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -133,7 +133,7 @@ overrides = [ { name = "nodeenv", specifier = "==1.9.1" }, { name = "oauth2client", specifier = "==4.1.3" }, { name = "oauthlib", specifier = "==3.2.2" }, - { name = "openfeature-provider-flagsmith", git = "https://github.com/Flagsmith/flagsmith-openfeature-provider-python?rev=b8aa9310dbedc8658606c0958eae7a78f8dac6df" }, + { name = "openfeature-provider-flagsmith", specifier = "==1.0.0" }, { name = "openfeature-sdk", specifier = "==0.9.0" }, { name = "opentelemetry-api", specifier = "==1.40.0" }, { name = "opentelemetry-exporter-otlp-proto-common", specifier = "==1.40.0" }, @@ -1734,7 +1734,7 @@ requires-dist = [ { name = "mypy-boto3-dynamodb", marker = "extra == 'dev'", specifier = ">=1.33.0,<2.0.0" }, { name = "mypy-boto3-s3", marker = "extra == 'dev'", specifier = ">=1.36.0,<2.0.0" }, { name = "oauth2client", specifier = ">=4.1.3,<4.2.0" }, - { name = "openfeature-provider-flagsmith", git = "https://github.com/Flagsmith/flagsmith-openfeature-provider-python?rev=b8aa9310dbedc8658606c0958eae7a78f8dac6df" }, + { name = "openfeature-provider-flagsmith", specifier = ">=1.0.0,<2.0.0" }, { name = "openfeature-sdk", specifier = ">=0.9.0,<0.10.0" }, { name = "pdbpp", marker = "extra == 'dev'", specifier = ">=0.10.3,<0.11.0" }, { name = "pep8", marker = "extra == 'dev'", specifier = ">=1.7.1,<1.8.0" }, @@ -2639,12 +2639,16 @@ wheels = [ [[package]] name = "openfeature-provider-flagsmith" -version = "0.2.0" -source = { git = "https://github.com/Flagsmith/flagsmith-openfeature-provider-python?rev=b8aa9310dbedc8658606c0958eae7a78f8dac6df#b8aa9310dbedc8658606c0958eae7a78f8dac6df" } +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flagsmith" }, { name = "openfeature-sdk" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/f2/c2/73618a78380b3996cecb71827ee58dcaab699f54b428b40ed1a037fe1fbc/openfeature_provider_flagsmith-1.0.0.tar.gz", hash = "sha256:21b3ef02e6c0716a81500e39b1f489028765e13ccf76df191fc2d2315ae2bbee", size = 8177, upload-time = "2026-08-07T16:18:54.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/7b/1ded625a9c1bacc0564378fe309c8af19282474e4e35d3f1fa71db99a2c5/openfeature_provider_flagsmith-1.0.0-py3-none-any.whl", hash = "sha256:9cc510ec288ae38a1ae8b7d0cad38ea46a802c8b5a20e7df197d5c8dfc1a01bd", size = 10529, upload-time = "2026-08-07T16:18:52.96Z" }, +] [[package]] name = "openfeature-sdk" From 9d61fbe3135afa1c581134427dbda454f7cb6881 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 7 Aug 2026 17:51:52 +0100 Subject: [PATCH 06/10] improve test_initialise_provider tests --- .../flagsmith/test_unit_flagsmith_client.py | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py b/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py index 5586b24c1074..ba607afb9a08 100644 --- a/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py +++ b/api/tests/unit/integrations/flagsmith/test_unit_flagsmith_client.py @@ -156,11 +156,22 @@ def test_initialise_provider__offline_mode_disabled__initialises_with_server_key settings.FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL = api_url settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE = False - mock_flagsmith_class = mocker.patch("integrations.flagsmith.client.Flagsmith") + mock_flagsmith_class = mocker.patch( + "integrations.flagsmith.client.Flagsmith", + autospec=True, + ) mock_provider_class = mocker.patch( - "integrations.flagsmith.client.FlagsmithProvider" + "integrations.flagsmith.client.FlagsmithProvider", + autospec=True, + ) + mock_exposure_hook_class = mocker.patch( + "integrations.flagsmith.client.FlagsmithExposureHook", + autospec=True, + ) + mock_openfeature_api = mocker.patch( + "integrations.flagsmith.client.openfeature_api", + autospec=True, ) - mock_openfeature_api = mocker.patch("integrations.flagsmith.client.openfeature_api") # When initialise_provider(**get_provider_kwargs()) @@ -182,6 +193,11 @@ def test_initialise_provider__offline_mode_disabled__initialises_with_server_key domain=DEFAULT_OPENFEATURE_DOMAIN, ) + mock_exposure_hook_class.assert_called_once_with(mock_provider_class.return_value) + mock_openfeature_api.add_hooks.assert_called_once_with( + [mock_exposure_hook_class.return_value] + ) + mock_local_file_handler_class.assert_called_once_with(ENVIRONMENT_JSON_PATH) @@ -194,11 +210,18 @@ def test_initialise_provider__offline_mode_enabled__initialises_with_offline_han # Given settings.FLAGSMITH_ON_FLAGSMITH_SERVER_OFFLINE_MODE = True - mock_flagsmith_class = mocker.patch("integrations.flagsmith.client.Flagsmith") + mock_flagsmith_class = mocker.patch( + "integrations.flagsmith.client.Flagsmith", + autospec=True, + ) mock_provider_class = mocker.patch( - "integrations.flagsmith.client.FlagsmithProvider" + "integrations.flagsmith.client.FlagsmithProvider", + autospec=True, + ) + mock_openfeature_api = mocker.patch( + "integrations.flagsmith.client.openfeature_api", + autospec=True, ) - mock_openfeature_api = mocker.patch("integrations.flagsmith.client.openfeature_api") # When initialise_provider(**get_provider_kwargs()) From fad23283ab0154b0dfcfe7bba2f084a3a97e3b0b Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 10 Aug 2026 19:16:57 +0100 Subject: [PATCH 07/10] feat(onboarding): Organisation targeting key for experiment bucketing The frontend decides the onboarding variant before the organisation exists, evaluating with an anonymous identity. Storing that identity's identifier as the organisation's targeting key keeps bucketing and experiment events on one identifier from the first anonymous evaluation onward. Write-only on create and immutable after; org. when unset. beep boop --- .../migrations/0060_add_targeting_key.py | 23 +++++++++++ api/organisations/models.py | 8 +++- api/organisations/serializers.py | 14 +++++++ ...t_unit_environments_onboarding_services.py | 17 +++++++++ .../test_unit_organisations_models.py | 24 ++++++++++++ .../test_unit_organisations_serializers.py | 38 +++++++++++++++++++ 6 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 api/organisations/migrations/0060_add_targeting_key.py diff --git a/api/organisations/migrations/0060_add_targeting_key.py b/api/organisations/migrations/0060_add_targeting_key.py new file mode 100644 index 000000000000..7747306c8c8b --- /dev/null +++ b/api/organisations/migrations/0060_add_targeting_key.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("organisations", "0059_use_no_ssrf_url_field"), + ] + + operations = [ + migrations.AddField( + model_name="organisation", + name="targeting_key", + field=models.CharField( + blank=True, + help_text=( + "Flagsmith-on-Flagsmith targeting key. Immutable; org. " + "is used when unset." + ), + max_length=64, + null=True, + ), + ), + ] diff --git a/api/organisations/models.py b/api/organisations/models.py index 540e4f9b9355..c35df34c27f0 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -90,6 +90,12 @@ class Organisation(LifecycleModelMixin, SoftDeleteExportableModel): # type: ign default=False, help_text="Record feature analytics in InfluxDB" ) force_2fa = models.BooleanField(default=False) + targeting_key = models.CharField( + max_length=64, + null=True, + blank=True, + help_text="Flagsmith-on-Flagsmith targeting key. Immutable; org. is used when unset.", + ) class Meta: ordering = ["id"] @@ -134,7 +140,7 @@ def has_enterprise_subscription(self) -> bool: @property def openfeature_evaluation_context(self) -> EvaluationContext: return EvaluationContext( - targeting_key=f"org.{self.id}", + targeting_key=self.targeting_key or f"org.{self.id}", attributes={ "organisation.id": self.id, "organisation.name": self.name, diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index 805077c4ac29..c6e8abdb1b3b 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -59,6 +59,7 @@ class Meta: "restrict_project_create_to_admin", "force_2fa", "onboarding_variant", + "targeting_key", ) read_only_fields = ( "id", @@ -68,6 +69,19 @@ class Meta: "persist_trait_data", "block_access_to_admin", ) + extra_kwargs = { + "targeting_key": {"write_only": True}, + } + + def update( + self, + instance: Organisation, + validated_data: dict[str, typing.Any], + ) -> Organisation: + # The targeting key pins the organisation's experiment bucketing; + # accepting it on update would make experiment arms mutable. + validated_data.pop("targeting_key", None) + return super().update(instance, validated_data) # type: ignore[no-any-return] @extend_schema_field({"type": "string", "nullable": True}) def get_role(self, instance): # type: ignore[no-untyped-def] diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_onboarding_services.py b/api/tests/unit/environments/onboarding/test_unit_environments_onboarding_services.py index 003b5237346e..ededd95337ae 100644 --- a/api/tests/unit/environments/onboarding/test_unit_environments_onboarding_services.py +++ b/api/tests/unit/environments/onboarding/test_unit_environments_onboarding_services.py @@ -45,6 +45,23 @@ def test_record_environment_first_evaluation__first_evaluation__tracks_conversio } +def test_record_environment_first_evaluation__org_targeting_key_set__tracks_with_stored_key( + environment: Environment, + mock_openfeature_client: MagicMock, +) -> None: + # Given + organisation = environment.project.organisation + organisation.targeting_key = "a" * 32 + organisation.save(update_fields=["targeting_key"]) + + # When + record_environment_first_evaluation(environment, "flagsmith-python-sdk") + + # Then + call_args = mock_openfeature_client.track.call_args + assert call_args.kwargs["evaluation_context"].targeting_key == "a" * 32 + + def test_record_environment_first_evaluation__already_evaluated__does_not_track( environment: Environment, mock_openfeature_client: MagicMock, diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index 91656dd991ae..7fc638eb6e92 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -998,3 +998,27 @@ def test_organisation__delete_with_created_infrastructure__deprovisions_aws_reso # Then deprovision.assert_called_once_with(organisation_id) + + +def test_organisation_openfeature_evaluation_context__no_targeting_key__uses_org_id( + organisation: Organisation, +) -> None: + # Given / When + context = organisation.openfeature_evaluation_context + + # Then + assert context.targeting_key == f"org.{organisation.id}" + + +def test_organisation_openfeature_evaluation_context__targeting_key_set__uses_it( + organisation: Organisation, +) -> None: + # Given + organisation.targeting_key = "a" * 32 + organisation.save(update_fields=["targeting_key"]) + + # When + context = organisation.openfeature_evaluation_context + + # Then + assert context.targeting_key == "a" * 32 diff --git a/api/tests/unit/organisations/test_unit_organisations_serializers.py b/api/tests/unit/organisations/test_unit_organisations_serializers.py index 080fd505cb1e..0871dbeacb57 100644 --- a/api/tests/unit/organisations/test_unit_organisations_serializers.py +++ b/api/tests/unit/organisations/test_unit_organisations_serializers.py @@ -27,6 +27,44 @@ def test_organisation_serializer_full__onboarding_org__serialises_onboarding_var get_onboarding_variant_mock.assert_called_once_with(organisation) +def test_organisation_serializer_full__create_with_targeting_key__persists_write_only( + db: None, +) -> None: + # Given + serializer = OrganisationSerializerFull( + data={"name": "Test Org", "targeting_key": "a" * 32} + ) + + # When + serializer.is_valid(raise_exception=True) + organisation = serializer.save() + + # Then + assert organisation.targeting_key == "a" * 32 + assert "targeting_key" not in serializer.data + + +def test_organisation_serializer_full__update_targeting_key__ignored( + organisation: Organisation, +) -> None: + # Given + organisation.targeting_key = "a" * 32 + organisation.save(update_fields=["targeting_key"]) + + serializer = OrganisationSerializerFull( + instance=organisation, + data={"name": organisation.name, "targeting_key": "b" * 32}, + ) + + # When + serializer.is_valid(raise_exception=True) + serializer.save() + + # Then + organisation.refresh_from_db() + assert organisation.targeting_key == "a" * 32 + + def test_update_subscription_serializer__create__updates_subscription( organisation: Organisation, mocker: MockerFixture, From 5dbfaa288ff99bfc19a91877bec19030e341be51 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 10 Aug 2026 19:46:28 +0100 Subject: [PATCH 08/10] feat(onboarding): Decide the onboarding flow client-side The entry decision is made under a server-assigned anonymous identity before the organisation exists: the frontend identifies with an empty identifier, reads the flag under the returned one (recording the exposure), and stores identifier and variant. The identifier becomes the organisation's targeting key at creation in both arms, and the stored variant drives routing, the chromeless layout and the gate. The backend no longer serves onboarding_variant: with one Flagsmith-on-Flagsmith project across frontend and API, the flag decision needs no API surface. beep boop --- api/organisations/serializers.py | 7 -- api/organisations/services.py | 17 ----- .../test_unit_organisations_serializers.py | 19 ----- .../test_unit_organisations_services.py | 76 ------------------- frontend/common/dispatcher/app-actions.js | 3 +- frontend/common/safeLocalStorage.ts | 9 +++ frontend/common/stores/account-store.js | 11 ++- frontend/common/types/responses.ts | 1 - frontend/common/utils/onboardingEntry.ts | 40 ++++++++++ frontend/web/components/App.js | 30 +++++--- .../pages/CreateOrganisationPage.tsx | 6 +- .../pages/onboarding/GettingStartedGate.tsx | 17 +++-- .../createOrganisationViaAccountStore.ts | 6 +- 13 files changed, 96 insertions(+), 146 deletions(-) delete mode 100644 api/organisations/services.py delete mode 100644 api/tests/unit/organisations/test_unit_organisations_services.py create mode 100644 frontend/common/utils/onboardingEntry.ts diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index c6e8abdb1b3b..e2bf21729266 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -11,7 +11,6 @@ get_subscription_data_from_hosted_page, ) from organisations.invites.models import Invite -from organisations.services import OnboardingVariant, get_onboarding_variant from users.models import FFAdminUser, UserPermissionGroup from .models import ( @@ -41,7 +40,6 @@ def get_has_active_billing_periods(self, obj): # type: ignore[no-untyped-def] class OrganisationSerializerFull(serializers.ModelSerializer): # type: ignore[type-arg] subscription = SubscriptionSerializer(required=False) role = serializers.SerializerMethodField() - onboarding_variant = serializers.SerializerMethodField() class Meta: model = Organisation @@ -58,7 +56,6 @@ class Meta: "block_access_to_admin", "restrict_project_create_to_admin", "force_2fa", - "onboarding_variant", "targeting_key", ) read_only_fields = ( @@ -89,10 +86,6 @@ def get_role(self, instance): # type: ignore[no-untyped-def] user = self.context["request"].user return user.get_organisation_role(instance) - @extend_schema_field({"type": "string", "enum": ["control", "single_page"]}) - def get_onboarding_variant(self, instance: Organisation) -> OnboardingVariant: - return get_onboarding_variant(instance) - class OrganisationSerializerBasic(serializers.ModelSerializer): # type: ignore[type-arg] class Meta: diff --git a/api/organisations/services.py b/api/organisations/services.py deleted file mode 100644 index 3b7485cbd3d9..000000000000 --- a/api/organisations/services.py +++ /dev/null @@ -1,17 +0,0 @@ -import typing - -from integrations.flagsmith.client import get_openfeature_client -from organisations.models import Organisation - -OnboardingVariant = typing.Literal["control", "single_page"] - - -def get_onboarding_variant(organisation: Organisation) -> OnboardingVariant: - details = get_openfeature_client().get_boolean_details( - "onboarding_quickstart_flow", - default_value=False, - evaluation_context=organisation.openfeature_evaluation_context, - ) - if not details.value or details.variant == "control": - return "control" - return "single_page" diff --git a/api/tests/unit/organisations/test_unit_organisations_serializers.py b/api/tests/unit/organisations/test_unit_organisations_serializers.py index 0871dbeacb57..8b3e13d8c451 100644 --- a/api/tests/unit/organisations/test_unit_organisations_serializers.py +++ b/api/tests/unit/organisations/test_unit_organisations_serializers.py @@ -8,25 +8,6 @@ ) -def test_organisation_serializer_full__onboarding_org__serialises_onboarding_variant( - organisation: Organisation, - mocker: MockerFixture, -) -> None: - # Given - get_onboarding_variant_mock = mocker.patch( - "organisations.serializers.get_onboarding_variant", - return_value="single_page", - autospec=True, - ) - - # When - data = OrganisationSerializerFull(instance=organisation).data - - # Then - assert data["onboarding_variant"] == "single_page" - get_onboarding_variant_mock.assert_called_once_with(organisation) - - def test_organisation_serializer_full__create_with_targeting_key__persists_write_only( db: None, ) -> None: diff --git a/api/tests/unit/organisations/test_unit_organisations_services.py b/api/tests/unit/organisations/test_unit_organisations_services.py deleted file mode 100644 index 315a2d13b1d2..000000000000 --- a/api/tests/unit/organisations/test_unit_organisations_services.py +++ /dev/null @@ -1,76 +0,0 @@ -import openfeature.api as openfeature_api -from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider - -from integrations.flagsmith.client import DEFAULT_OPENFEATURE_DOMAIN -from organisations.models import Organisation -from organisations.services import get_onboarding_variant - - -def test_get_onboarding_variant__flag_not_configured__returns_control( - organisation: Organisation, -) -> None: - # Given - # the flag doesn't exist - - # When / Then - assert get_onboarding_variant(organisation) == "control" - - -def test_get_onboarding_variant__flag_disabled__returns_control( - organisation: Organisation, -) -> None: - # Given - openfeature_api.set_provider( - InMemoryProvider( - { - "onboarding_quickstart_flow": InMemoryFlag( - variants={"off": False}, - default_variant="off", - ) - } - ), - domain=DEFAULT_OPENFEATURE_DOMAIN, - ) - - # When / Then - assert get_onboarding_variant(organisation) == "control" - - -def test_get_onboarding_variant__variant_assigned__returns_single_page( - organisation: Organisation, -) -> None: - # Given - openfeature_api.set_provider( - InMemoryProvider( - { - "onboarding_quickstart_flow": InMemoryFlag( - variants={"control": True, "single_page": True}, - default_variant="single_page", - ) - } - ), - domain=DEFAULT_OPENFEATURE_DOMAIN, - ) - - # When / Then - assert get_onboarding_variant(organisation) == "single_page" - - -def test_get_onboarding_variant__control_variant_assigned__returns_control( - organisation: Organisation, -) -> None: - # Given - openfeature_api.set_provider( - InMemoryProvider( - { - "onboarding_quickstart_flow": InMemoryFlag( - variants={"control": True, "single_page": True}, - default_variant="control", - ) - } - ), - domain=DEFAULT_OPENFEATURE_DOMAIN, - ) - - # When / Then - assert get_onboarding_variant(organisation) == "control" diff --git a/frontend/common/dispatcher/app-actions.js b/frontend/common/dispatcher/app-actions.js index b5cb62951124..e2a4ea8c1d34 100644 --- a/frontend/common/dispatcher/app-actions.js +++ b/frontend/common/dispatcher/app-actions.js @@ -45,10 +45,11 @@ const AppActions = Object.assign({}, BaseAppActions, { segmentOverrides, }) }, - createOrganisation(name) { + createOrganisation(name, targetingKey) { Dispatcher.handleViewAction({ actionType: Actions.CREATE_ORGANISATION, name, + targetingKey, }) }, createProject(name) { diff --git a/frontend/common/safeLocalStorage.ts b/frontend/common/safeLocalStorage.ts index 08af68d773cd..e1dc7669ba49 100644 --- a/frontend/common/safeLocalStorage.ts +++ b/frontend/common/safeLocalStorage.ts @@ -16,3 +16,12 @@ export function storageSet(key: string, value: string): void { console.error(err) } } + +export function storageRemove(key: string): void { + try { + localStorage.removeItem(key) + } catch (err) { + //Storage / privacy errors + console.error(err) + } +} diff --git a/frontend/common/stores/account-store.js b/frontend/common/stores/account-store.js index 0d4e733e852a..a28601e7901f 100644 --- a/frontend/common/stores/account-store.js +++ b/frontend/common/stores/account-store.js @@ -4,6 +4,7 @@ import find from 'lodash/find' import findIndex from 'lodash/findIndex' import get from 'lodash/get' import { storageGet, storageSet } from 'common/safeLocalStorage' +import { clearOnboardingTargetingKey } from 'common/utils/onboardingEntry' import Dispatcher from 'common/dispatcher/dispatcher' import BaseStore from './base/_store' import data from 'common/data/base/_data' @@ -77,7 +78,7 @@ const controller = { API.ajaxHandler(store, e) }) }, - createOrganisation: (name) => { + createOrganisation: (name, targetingKey) => { store.saving() if ( !AccountStore.model?.organisations || @@ -100,8 +101,12 @@ const controller = { return data .post(`${Project.api}organisations/`, { name, + ...(targetingKey ? { targeting_key: targetingKey } : {}), }) .then(async (res) => { + if (targetingKey) { + clearOnboardingTargetingKey() + } if (store.model) { store.model.organisations = store.model.organisations.concat([ { ...res, role: 'ADMIN' }, @@ -299,7 +304,7 @@ const controller = { ) } if (organisation_name) { - await controller.createOrganisation(organisation_name, true) + await controller.createOrganisation(organisation_name) } store.isSaving = false @@ -559,7 +564,7 @@ store.dispatcherIndex = Dispatcher.register(store, (payload) => { controller.selectOrganisation(action.id) break case Actions.CREATE_ORGANISATION: - controller.createOrganisation(action.name) + controller.createOrganisation(action.name, action.targetingKey) break case Actions.ACCEPT_INVITE: controller.acceptInvite(action.id) diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index f50c81d9269a..26be29a3cbea 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -528,7 +528,6 @@ export type Organisation = { persist_trait_data: boolean block_access_to_admin: boolean restrict_project_create_to_admin: boolean - onboarding_variant: OnboardingVariant } export type Identity = { id: string diff --git a/frontend/common/utils/onboardingEntry.ts b/frontend/common/utils/onboardingEntry.ts new file mode 100644 index 000000000000..cc1ed6232cfc --- /dev/null +++ b/frontend/common/utils/onboardingEntry.ts @@ -0,0 +1,40 @@ +import flagsmith from '@flagsmith/flagsmith' +import { OnboardingVariant } from 'common/types/responses' +import { storageGet, storageRemove, storageSet } from 'common/safeLocalStorage' + +const TARGETING_KEY_STORAGE_KEY = 'onboarding_targeting_key' +const VARIANT_STORAGE_KEY = 'onboarding_variant' + +/** + * Decide which onboarding flow a new user enters, before their organisation + * exists. Identifies with an empty identifier so the API assigns a + * pseudorandom one, reads the flag under it (recording the exposure), and + * stores identifier and variant: the identifier becomes the organisation's + * `targeting_key` at creation, pinning its bucketing to this decision. + */ +export async function decideOnboardingEntry(): Promise { + // @ts-expect-error transient is missing from the SDK's identify type + await flagsmith.identify('', {}, true) + const flag = flagsmith.getExperimentFlag('onboarding_quickstart_flow') + const identifier = flagsmith.getContext().identity?.identifier + const variant: OnboardingVariant = + flag?.enabled && flag.variant !== 'control' ? 'single_page' : 'control' + if (identifier) { + storageSet(TARGETING_KEY_STORAGE_KEY, String(identifier)) + } + storageSet(VARIANT_STORAGE_KEY, variant) + return variant +} + +export const getStoredOnboardingVariant = (): OnboardingVariant | null => { + const variant = storageGet(VARIANT_STORAGE_KEY) + return variant === 'single_page' || variant === 'control' ? variant : null +} + +export const getStoredOnboardingTargetingKey = (): string | null => + storageGet(TARGETING_KEY_STORAGE_KEY) + +// Called once an organisation owns the key; a later organisation must not +// reuse it, or two organisations would share one experiment subject. +export const clearOnboardingTargetingKey = (): void => + storageRemove(TARGETING_KEY_STORAGE_KEY) diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index 35028fe553dc..0e4ce7c88f92 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -12,6 +12,10 @@ import AppLoader from './AppLoader' import ButterBar from './ButterBar' import AccountSettingsPage from './pages/AccountSettingsPage' import ProjectStore from 'common/stores/project-store' +import { + decideOnboardingEntry, + getStoredOnboardingVariant, +} from 'common/utils/onboardingEntry' import { Provider } from 'react-redux' import { getStore } from 'common/store' import ConfigProvider from 'common/providers/ConfigProvider' @@ -139,18 +143,20 @@ const App = class extends Component { // New users with no organisation go through the single-page onboarding // flow when it's enabled - it creates the organisation itself, so it // replaces the legacy /create page. Everyone else still gets /create. - // The flag must be evaluated for the identified user, not whatever the - // SDK last held, so wait for identify to settle before routing. - // Capped at 2s: a degraded flags API falls back to routing with the - // already-loaded flags instead of blocking the redirect. + // The entry decision is made under a server-assigned anonymous + // identity whose identifier later becomes the organisation's + // targeting key, so bucketing never diverges from this decision. + // Capped at 2s: a degraded flags API falls back to the legacy page + // instead of blocking the redirect. Promise.race([ - Promise.resolve(API.flagsmithIdentify()).catch(() => {}), - new Promise((resolve) => setTimeout(resolve, 2000)), - ]).then(() => { - if ( - AccountStore.getUser()?.isGettingStarted && - Utils.getFlagsmithHasFeature('onboarding_quickstart_flow') - ) { + AccountStore.getUser()?.isGettingStarted + ? decideOnboardingEntry().catch(() => 'control') + : Promise.resolve('control'), + new Promise((resolve) => setTimeout(() => resolve('control'), 2000)), + ]).then((variant) => { + // Restore the logged-in identity for the rest of the app. + Promise.resolve(API.flagsmithIdentify()).catch(() => {}) + if (variant === 'single_page') { this.props.history.replace('/getting-started') } else { this.props.history.replace(`/create${query}`) @@ -241,7 +247,7 @@ const App = class extends Component { const pathname = location.pathname const isOnboardingFlow = pathname === '/getting-started' && - Utils.getFlagsmithHasFeature('onboarding_quickstart_flow') + getStoredOnboardingVariant() === 'single_page' const projectId = this.getProjectId(this.props) const environmentId = this.getEnvironmentId(this.props) diff --git a/frontend/web/components/pages/CreateOrganisationPage.tsx b/frontend/web/components/pages/CreateOrganisationPage.tsx index 8cc44e0bdb56..f78e812ed7ab 100644 --- a/frontend/web/components/pages/CreateOrganisationPage.tsx +++ b/frontend/web/components/pages/CreateOrganisationPage.tsx @@ -8,6 +8,7 @@ import InputGroup from 'components/base/forms/InputGroup' import Button from 'components/base/forms/Button' import API from 'project/api' import AppActions from 'common/dispatcher/app-actions' +import { getStoredOnboardingTargetingKey } from 'common/utils/onboardingEntry' import Utils from 'common/utils/utils' // @ts-ignore import Project from 'common/project' @@ -115,7 +116,10 @@ const CreateOrganisationPage: React.FC = () => { `https://ct.capterra.com/capterra_tracker.gif?vid=${parts[0]}&vkey=${parts[1]}`, ) } - AppActions.createOrganisation(name) + AppActions.createOrganisation( + name, + getStoredOnboardingTargetingKey() ?? undefined, + ) }} > diff --git a/frontend/web/components/pages/onboarding/GettingStartedGate.tsx b/frontend/web/components/pages/onboarding/GettingStartedGate.tsx index 1e622985aaf2..d5624b9b1fd1 100644 --- a/frontend/web/components/pages/onboarding/GettingStartedGate.tsx +++ b/frontend/web/components/pages/onboarding/GettingStartedGate.tsx @@ -1,20 +1,21 @@ import React, { FC, useEffect } from 'react' import ConfigProvider from 'common/providers/ConfigProvider' -import AccountStore from 'common/stores/account-store' +import { getStoredOnboardingVariant } from 'common/utils/onboardingEntry' import API from 'project/api' import GettingStartedPage from 'components/pages/GettingStartedPage' import OnboardingFlow from './OnboardingFlow' const GettingStartedGate: FC = () => { - // The backend decides the variant per organisation and serves it on the - // organisation payload; the exposure is recorded server-side. - const organisation = AccountStore.getOrganisation() - const variant = organisation?.onboarding_variant ?? 'control' + // The entry decision made at routing time (Flagsmith-on-Flagsmith, + // anonymous identity) decides the flow; users without one get the + // legacy page. + const storedVariant = getStoredOnboardingVariant() + const variant = storedVariant ?? 'control' useEffect(() => { - if (!organisation?.id) return - API.trackTraits({ onboarding_variant: variant }) - }, [organisation?.id, variant]) + if (!storedVariant) return + API.trackTraits({ onboarding_variant: storedVariant }) + }, [storedVariant]) return variant === 'single_page' ? : } diff --git a/frontend/web/components/pages/onboarding/hooks/createOrganisationViaAccountStore.ts b/frontend/web/components/pages/onboarding/hooks/createOrganisationViaAccountStore.ts index 130e6e06600e..20418f5ed11b 100644 --- a/frontend/web/components/pages/onboarding/hooks/createOrganisationViaAccountStore.ts +++ b/frontend/web/components/pages/onboarding/hooks/createOrganisationViaAccountStore.ts @@ -1,5 +1,6 @@ import AccountStore from 'common/stores/account-store' import AppActions from 'common/dispatcher/app-actions' +import { getStoredOnboardingTargetingKey } from 'common/utils/onboardingEntry' /** * Create an organisation through the legacy account store rather than the RTK @@ -37,5 +38,8 @@ export const createOrganisationViaAccountStore = ( }, 20000) AccountStore.on('saved', onSaved) AccountStore.on('problem', onProblem) - AppActions.createOrganisation(name) + AppActions.createOrganisation( + name, + getStoredOnboardingTargetingKey() ?? undefined, + ) }) From 4169bcaf4924cf8d55b547e85739ea8a4bae37a4 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Mon, 10 Aug 2026 18:58:35 +0000 Subject: [PATCH 09/10] chore: Update documentation artefacts --- mcp/src/flagsmith_mcp/openapi.json | 20 +++++++------------- openapi.yaml | 27 ++++++++++++++------------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/mcp/src/flagsmith_mcp/openapi.json b/mcp/src/flagsmith_mcp/openapi.json index 328286cade66..1e848029f3a5 100644 --- a/mcp/src/flagsmith_mcp/openapi.json +++ b/mcp/src/flagsmith_mcp/openapi.json @@ -5714,13 +5714,6 @@ "NullEnum": { "type": "null" }, - "OnboardingVariantEnum": { - "type": "string", - "enum": [ - "control", - "single_page" - ] - }, "OrganisationSerializerFull": { "type": "object", "properties": { @@ -5781,13 +5774,14 @@ "force_2fa": { "type": "boolean" }, - "onboarding_variant": { - "allOf": [ - { - "$ref": "#/components/schemas/OnboardingVariantEnum" - } + "targeting_key": { + "description": "Flagsmith-on-Flagsmith targeting key. Immutable; org. is used when unset.", + "type": [ + "string", + "null" ], - "readOnly": true + "maxLength": 64, + "writeOnly": true } }, "required": [ diff --git a/openapi.yaml b/openapi.yaml index cd9aec24854b..aacbc2ad9c0a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -22162,11 +22162,6 @@ components: is_new_user: type: boolean readOnly: true - OnboardingVariantEnum: - type: string - enum: - - control - - single_page OrganisationAPIUsageNotification: type: object properties: @@ -22238,10 +22233,13 @@ components: type: boolean force_2fa: type: boolean - onboarding_variant: - allOf: - - $ref: '#/components/schemas/OnboardingVariantEnum' - readOnly: true + targeting_key: + description: Flagsmith-on-Flagsmith targeting key. Immutable; org. is used when unset. + type: + - string + - 'null' + maxLength: 64 + writeOnly: true required: - name OrganisationWebhook: @@ -24195,10 +24193,13 @@ components: type: boolean force_2fa: type: boolean - onboarding_variant: - allOf: - - $ref: '#/components/schemas/OnboardingVariantEnum' - readOnly: true + targeting_key: + description: Flagsmith-on-Flagsmith targeting key. Immutable; org. is used when unset. + type: + - string + - 'null' + maxLength: 64 + writeOnly: true PatchedOrganisationWebhook: type: object properties: From 47e7017c1db476c234507aca67ed739256b35fa0 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 10 Aug 2026 20:12:39 +0100 Subject: [PATCH 10/10] fix(onboarding): Losing entry decision persisted after fallback routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the routing timeout won the race, the late decision still wrote its variant and targeting key — read from whatever identity the SDK held by then, possibly the logged-in user. Decisions are now persisted only when they win the race. beep boop --- .../utils/__tests__/onboardingEntry.test.ts | 98 +++++++++++++++++++ frontend/common/utils/onboardingEntry.ts | 37 +++++-- frontend/web/components/App.js | 12 ++- 3 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 frontend/common/utils/__tests__/onboardingEntry.test.ts diff --git a/frontend/common/utils/__tests__/onboardingEntry.test.ts b/frontend/common/utils/__tests__/onboardingEntry.test.ts new file mode 100644 index 000000000000..cf2ff8c3d5f6 --- /dev/null +++ b/frontend/common/utils/__tests__/onboardingEntry.test.ts @@ -0,0 +1,98 @@ +import flagsmith from '@flagsmith/flagsmith' +import { + decideOnboardingEntry, + getStoredOnboardingTargetingKey, + getStoredOnboardingVariant, + persistOnboardingEntry, +} from 'common/utils/onboardingEntry' + +jest.mock('@flagsmith/flagsmith', () => ({ + getContext: jest.fn(), + getExperimentFlag: jest.fn(), + identify: jest.fn(), +})) + +const storage = new Map() +jest.mock('common/safeLocalStorage', () => ({ + storageGet: (key: string) => storage.get(key) ?? null, + storageRemove: (key: string) => storage.delete(key), + storageSet: (key: string, value: string) => storage.set(key, value), +})) + +const mockFlagsmith = flagsmith as jest.Mocked + +describe('decideOnboardingEntry', () => { + beforeEach(() => { + storage.clear() + jest.resetAllMocks() + mockFlagsmith.identify.mockResolvedValue(undefined as any) + mockFlagsmith.getContext.mockReturnValue({ + identity: { identifier: 'anon-123' }, + } as any) + }) + + it('returns the decision without persisting anything', async () => { + // Given + mockFlagsmith.getExperimentFlag.mockReturnValue({ + enabled: true, + variant: 'single_page', + } as any) + + // When + const decision = await decideOnboardingEntry() + + // Then + // A decision losing the caller's timeout race must leave no trace. + expect(decision).toEqual({ + targetingKey: 'anon-123', + variant: 'single_page', + }) + expect(getStoredOnboardingVariant()).toBeNull() + expect(getStoredOnboardingTargetingKey()).toBeNull() + }) + + it('maps a disabled flag to control', async () => { + // Given + mockFlagsmith.getExperimentFlag.mockReturnValue({ + enabled: false, + } as any) + + // When + const decision = await decideOnboardingEntry() + + // Then + expect(decision.variant).toBe('control') + }) +}) + +describe('persistOnboardingEntry', () => { + beforeEach(() => { + storage.clear() + }) + + it('stores an accepted decision', () => { + // When + const variant = persistOnboardingEntry({ + targetingKey: 'anon-123', + variant: 'single_page', + }) + + // Then + expect(variant).toBe('single_page') + expect(getStoredOnboardingVariant()).toBe('single_page') + expect(getStoredOnboardingTargetingKey()).toBe('anon-123') + }) + + it('downgrades a non-control variant without an identifier to control', () => { + // When + const variant = persistOnboardingEntry({ + targetingKey: null, + variant: 'single_page', + }) + + // Then + expect(variant).toBe('control') + expect(getStoredOnboardingVariant()).toBe('control') + expect(getStoredOnboardingTargetingKey()).toBeNull() + }) +}) diff --git a/frontend/common/utils/onboardingEntry.ts b/frontend/common/utils/onboardingEntry.ts index cc1ed6232cfc..df45626196e1 100644 --- a/frontend/common/utils/onboardingEntry.ts +++ b/frontend/common/utils/onboardingEntry.ts @@ -5,22 +5,47 @@ import { storageGet, storageRemove, storageSet } from 'common/safeLocalStorage' const TARGETING_KEY_STORAGE_KEY = 'onboarding_targeting_key' const VARIANT_STORAGE_KEY = 'onboarding_variant' +export type OnboardingEntryDecision = { + variant: OnboardingVariant + targetingKey: string | null +} + /** * Decide which onboarding flow a new user enters, before their organisation * exists. Identifies with an empty identifier so the API assigns a - * pseudorandom one, reads the flag under it (recording the exposure), and - * stores identifier and variant: the identifier becomes the organisation's - * `targeting_key` at creation, pinning its bucketing to this decision. + * pseudorandom one and reads the flag under it (recording the exposure). + * + * Persists nothing: the caller races this against a timeout, and a late + * decision must not be stored — by then the SDK identity may already be + * the logged-in user, and the routing it should have driven has happened. + * Call `persistOnboardingEntry` with an accepted decision. */ -export async function decideOnboardingEntry(): Promise { +export async function decideOnboardingEntry(): Promise { // @ts-expect-error transient is missing from the SDK's identify type await flagsmith.identify('', {}, true) const flag = flagsmith.getExperimentFlag('onboarding_quickstart_flow') const identifier = flagsmith.getContext().identity?.identifier const variant: OnboardingVariant = flag?.enabled && flag.variant !== 'control' ? 'single_page' : 'control' - if (identifier) { - storageSet(TARGETING_KEY_STORAGE_KEY, String(identifier)) + return { targetingKey: identifier ? String(identifier) : null, variant } +} + +/** + * Store an accepted entry decision. The identifier becomes the + * organisation's `targeting_key` at creation, pinning its bucketing to + * this decision. Returns the effective variant: a non-control variant + * without an assigned identifier cannot be pinned, so it downgrades to + * `control`. + */ +export function persistOnboardingEntry( + decision: OnboardingEntryDecision, +): OnboardingVariant { + const variant = + decision.variant !== 'control' && !decision.targetingKey + ? 'control' + : decision.variant + if (decision.targetingKey) { + storageSet(TARGETING_KEY_STORAGE_KEY, decision.targetingKey) } storageSet(VARIANT_STORAGE_KEY, variant) return variant diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index 0e4ce7c88f92..4faaf15696a6 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -15,6 +15,7 @@ import ProjectStore from 'common/stores/project-store' import { decideOnboardingEntry, getStoredOnboardingVariant, + persistOnboardingEntry, } from 'common/utils/onboardingEntry' import { Provider } from 'react-redux' import { getStore } from 'common/store' @@ -150,10 +151,13 @@ const App = class extends Component { // instead of blocking the redirect. Promise.race([ AccountStore.getUser()?.isGettingStarted - ? decideOnboardingEntry().catch(() => 'control') - : Promise.resolve('control'), - new Promise((resolve) => setTimeout(() => resolve('control'), 2000)), - ]).then((variant) => { + ? decideOnboardingEntry().catch(() => null) + : Promise.resolve(null), + new Promise((resolve) => setTimeout(() => resolve(null), 2000)), + ]).then((decision) => { + // Only an accepted decision is persisted: a decision losing the + // race must not store its assignment after routing has happened. + const variant = decision ? persistOnboardingEntry(decision) : 'control' // Restore the logged-in identity for the rest of the app. Promise.resolve(API.flagsmithIdentify()).catch(() => {}) if (variant === 'single_page') {