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 3e87b2177f74..9dede070dd09 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.constants import ENVIRONMENT_JSON_PATH @@ -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/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 baf70e32ebc7..e2bf21729266 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -56,6 +56,7 @@ class Meta: "block_access_to_admin", "restrict_project_create_to_admin", "force_2fa", + "targeting_key", ) read_only_fields = ( "id", @@ -65,6 +66,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/pyproject.toml b/api/pyproject.toml index a1c2b0800606..d45d0b870854 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -67,9 +67,9 @@ 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", + "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", @@ -244,10 +244,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", @@ -294,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/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..ededd95337ae --- /dev/null +++ b/api/tests/unit/environments/onboarding/test_unit_environments_onboarding_services.py @@ -0,0 +1,77 @@ +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__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, +) -> 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 7823e1206726..696bea5a56d4 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 @@ -155,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()) @@ -181,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) @@ -193,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()) @@ -230,3 +254,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_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 d9033659f800..8b3e13d8c451 100644 --- a/api/tests/unit/organisations/test_unit_organisations_serializers.py +++ b/api/tests/unit/organisations/test_unit_organisations_serializers.py @@ -2,7 +2,48 @@ 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__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( diff --git a/api/uv.lock b/api/uv.lock index e961a2f4535c..ff9795ab4c61 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", 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" }, @@ -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", 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" }, @@ -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]] @@ -2639,15 +2639,15 @@ wheels = [ [[package]] name = "openfeature-provider-flagsmith" -version = "0.2.0" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } 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" } +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/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" }, + { 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]] diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index af4f83009db0..2b86c1557e47 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/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 0d8a5ba0096b..26be29a3cbea 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 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/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/common/utils/onboardingEntry.ts b/frontend/common/utils/onboardingEntry.ts new file mode 100644 index 000000000000..df45626196e1 --- /dev/null +++ b/frontend/common/utils/onboardingEntry.ts @@ -0,0 +1,65 @@ +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' + +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 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 { + // @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' + 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 +} + +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..4faaf15696a6 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -12,6 +12,11 @@ import AppLoader from './AppLoader' import ButterBar from './ButterBar' import AccountSettingsPage from './pages/AccountSettingsPage' 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' import ConfigProvider from 'common/providers/ConfigProvider' @@ -139,18 +144,23 @@ 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(() => 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') { this.props.history.replace('/getting-started') } else { this.props.history.replace(`/create${query}`) @@ -241,7 +251,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 97291785b515..d5624b9b1fd1 100644 --- a/frontend/web/components/pages/onboarding/GettingStartedGate.tsx +++ b/frontend/web/components/pages/onboarding/GettingStartedGate.tsx @@ -1,30 +1,23 @@ 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 { getStoredOnboardingVariant } from 'common/utils/onboardingEntry' 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 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 (!trustworthy) return - API.trackTraits({ onboarding_variant: variant }) - }, [trustworthy, variant]) + if (!storedVariant) return + API.trackTraits({ onboarding_variant: storedVariant }) + }, [storedVariant]) - return isSinglePageOnboarding() ? : + return variant === 'single_page' ? : } export default ConfigProvider(GettingStartedGate) 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, + ) }) 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" diff --git a/mcp/src/flagsmith_mcp/openapi.json b/mcp/src/flagsmith_mcp/openapi.json index 330b2f25c097..1e848029f3a5 100644 --- a/mcp/src/flagsmith_mcp/openapi.json +++ b/mcp/src/flagsmith_mcp/openapi.json @@ -5773,6 +5773,15 @@ }, "force_2fa": { "type": "boolean" + }, + "targeting_key": { + "description": "Flagsmith-on-Flagsmith targeting key. Immutable; org. is used when unset.", + "type": [ + "string", + "null" + ], + "maxLength": 64, + "writeOnly": true } }, "required": [ diff --git a/openapi.yaml b/openapi.yaml index 86cd0495cb33..aacbc2ad9c0a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -22233,6 +22233,13 @@ components: type: boolean force_2fa: type: boolean + targeting_key: + description: Flagsmith-on-Flagsmith targeting key. Immutable; org. is used when unset. + type: + - string + - 'null' + maxLength: 64 + writeOnly: true required: - name OrganisationWebhook: @@ -24186,6 +24193,13 @@ components: type: boolean force_2fa: type: boolean + 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: