From a84ba8935880c10ffe549514e4635dd1c382ceca Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Fri, 28 Aug 2026 17:34:11 +0530 Subject: [PATCH 1/6] fix(cohorts): restore the response envelope Amplitude's production parser reads --- api/cohorts/sync_views.py | 21 ++++++++++++++----- api/tests/unit/cohorts/test_sync_views.py | 10 +++++---- .../observability/_events-catalogue.md | 2 +- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/api/cohorts/sync_views.py b/api/cohorts/sync_views.py index 3c4aa0fadb45..1f8e1c51f588 100644 --- a/api/cohorts/sync_views.py +++ b/api/cohorts/sync_views.py @@ -22,7 +22,13 @@ ) _LIST_RESPONSE = inline_serializer( - "AmplitudeListResponse", {"listId": serializers.UUIDField()} + "AmplitudeListResponse", + { + "list_id": serializers.UUIDField(), + "response": inline_serializer( + "AmplitudeListResponseEnvelope", {"list_id": serializers.UUIDField()} + ), + }, ) _MIXPANEL_RESPONSE = inline_serializer( @@ -73,10 +79,15 @@ def create(self, request: Request) -> Response: name=serializer.validated_data["name"], source_type=CohortSourceType.AMPLITUDE, ) - # Amplitude's production sync worker reads the list ID from its - # documented default key, camelCase "listId", ignoring the response - # path configured in the Integration Portal. - return Response({"listId": str(cohort.uuid)}) + # Amplitude reads the list ID from this body at the path configured + # in the Integration Portal, which their portal requires to start + # with "response." — but the two sides of Amplitude disagree on what + # that path is applied to. Their Testing tab wraps this body in a + # {"response": ...} envelope first, so it finds the flat copy; their + # production sync worker applies the same path to the raw body, so + # it needs the nested copy. Verified against both, 2026-08-28. + list_id = str(cohort.uuid) + return Response({"list_id": list_id, "response": {"list_id": list_id}}) @action(detail=True, methods=["POST"]) def add(self, request: Request, pk: str) -> Response: diff --git a/api/tests/unit/cohorts/test_sync_views.py b/api/tests/unit/cohorts/test_sync_views.py index cbc5a87bac97..a2556bea3458 100644 --- a/api/tests/unit/cohorts/test_sync_views.py +++ b/api/tests/unit/cohorts/test_sync_views.py @@ -50,7 +50,9 @@ def test_amplitude_create_list__valid_key__creates_amplitude_cohort( # Then assert response.status_code == status.HTTP_200_OK - cohort = Cohort.objects.get(uuid=response.json()["listId"]) + cohort = Cohort.objects.get(uuid=response.json()["list_id"]) + # Amplitude's Testing tab reads the flat copy, production the nested one. + assert response.json()["response"]["list_id"] == response.json()["list_id"] assert cohort.environment == key.environment assert cohort.source_type == CohortSourceType.AMPLITUDE assert cohort.segment.name == "[Amplitude] Beta users: 1234" @@ -74,7 +76,7 @@ def test_amplitude_create_list__postgres_environment__creates_cohort( # Then assert response.status_code == status.HTTP_200_OK - cohort = Cohort.objects.get(uuid=response.json()["listId"]) + cohort = Cohort.objects.get(uuid=response.json()["list_id"]) assert cohort.environment == environment @@ -350,7 +352,7 @@ def test_amplitude_create_list__valid_key__audits_and_queues_environment_update( # Then - the audit record carries no user, names the source, and is the # hook that rebuilds the environment document. assert response.status_code == status.HTTP_200_OK - cohort = Cohort.objects.get(uuid=response.json()["listId"]) + cohort = Cohort.objects.get(uuid=response.json()["list_id"]) audit_log = AuditLog.objects.get(related_object_id=cohort.segment_id) assert audit_log.author is None assert audit_log.master_api_key is None @@ -377,7 +379,7 @@ def test_amplitude_create_list__valid_key__history_records_no_user( # Then - a machine caller leaves no user on historical records; stamping # one would fail, since the sync key is not a Flagsmith user. assert response.status_code == status.HTTP_200_OK - cohort = Cohort.objects.get(uuid=response.json()["listId"]) + cohort = Cohort.objects.get(uuid=response.json()["list_id"]) history_record = cohort.segment.history.get() assert history_record.history_user is None assert history_record.master_api_key is None diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 4e77c78106c0..9c89874b5296 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -166,7 +166,7 @@ Attributes: ### `cohorts.sync_webhook.rejected` Logged at `warning` from: - - `api/cohorts/sync_views.py:217` + - `api/cohorts/sync_views.py:228` Attributes: - `action` From dcd30fb281be274ab025cd9200241d8d2ff865fb Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Fri, 28 Aug 2026 12:06:10 +0000 Subject: [PATCH 2/6] chore: Update documentation artefacts --- openapi.yaml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 78578f6e6a84..7481be959734 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -18943,11 +18943,22 @@ components: AmplitudeListResponse: type: object properties: - listId: + list_id: type: string format: uuid + response: + $ref: '#/components/schemas/AmplitudeListResponseEnvelope' required: - - listId + - list_id + - response + AmplitudeListResponseEnvelope: + type: object + properties: + list_id: + type: string + format: uuid + required: + - list_id AuditLogList: type: object properties: From 0752af0a272e2c13d39320204d5e64d29ba71f3c Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 31 Aug 2026 09:37:25 +0530 Subject: [PATCH 3/6] fix(cohorts): shape parameter to brute-force Amplitude's list ID contract --- api/cohorts/sync_views.py | 26 +++++++++++----- api/tests/unit/cohorts/test_sync_views.py | 31 +++++++++++++++++-- .../observability/_events-catalogue.md | 2 +- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/api/cohorts/sync_views.py b/api/cohorts/sync_views.py index 1f8e1c51f588..c1a3273a278b 100644 --- a/api/cohorts/sync_views.py +++ b/api/cohorts/sync_views.py @@ -79,15 +79,25 @@ def create(self, request: Request) -> Response: name=serializer.validated_data["name"], source_type=CohortSourceType.AMPLITUDE, ) - # Amplitude reads the list ID from this body at the path configured - # in the Integration Portal, which their portal requires to start - # with "response." — but the two sides of Amplitude disagree on what - # that path is applied to. Their Testing tab wraps this body in a - # {"response": ...} envelope first, so it finds the flat copy; their - # production sync worker applies the same path to the raw body, so - # it needs the nested copy. Verified against both, 2026-08-28. list_id = str(cohort.uuid) - return Response({"list_id": list_id, "response": {"list_id": list_id}}) + # TODO: temporary, while we pin down which copy of the list ID + # Amplitude's production sync worker actually reads. `?shape=` + # narrows the body to a single candidate so each portal-side retry + # tests one; the default carries every copy, which is known to work. + # Once the winning shape is confirmed, hardcode it and drop the + # parameter. + shapes = { + "flat_snake": {"list_id": list_id}, + "flat_camel": {"listId": list_id}, + "nested_snake": {"response": {"list_id": list_id}}, + "nested_camel": {"response": {"listId": list_id}}, + } + default = { + "list_id": list_id, + "listId": list_id, + "response": {"list_id": list_id, "listId": list_id}, + } + return Response(shapes.get(request.query_params.get("shape", ""), default)) @action(detail=True, methods=["POST"]) def add(self, request: Request, pk: str) -> Response: diff --git a/api/tests/unit/cohorts/test_sync_views.py b/api/tests/unit/cohorts/test_sync_views.py index a2556bea3458..635ee0f23bd0 100644 --- a/api/tests/unit/cohorts/test_sync_views.py +++ b/api/tests/unit/cohorts/test_sync_views.py @@ -51,8 +51,15 @@ def test_amplitude_create_list__valid_key__creates_amplitude_cohort( # Then assert response.status_code == status.HTTP_200_OK cohort = Cohort.objects.get(uuid=response.json()["list_id"]) - # Amplitude's Testing tab reads the flat copy, production the nested one. - assert response.json()["response"]["list_id"] == response.json()["list_id"] + # Without ?shape=, the body carries the list ID at every spelling and + # depth Amplitude might read. + body = response.json() + assert ( + body["listId"] + == body["response"]["list_id"] + == body["response"]["listId"] + == body["list_id"] + ) assert cohort.environment == key.environment assert cohort.source_type == CohortSourceType.AMPLITUDE assert cohort.segment.name == "[Amplitude] Beta users: 1234" @@ -1267,3 +1274,23 @@ def test_webhook_add_members__saas_free_plan__returns_403( # Then assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_amplitude_create_list__shape_param__returns_only_that_shape( + cohort_sync_key: _KeyAndPlaintext, + dynamodb_identity_wrapper: DynamoIdentityWrapper, +) -> None: + # Given + _, plaintext = cohort_sync_key + client = _authenticated_client(plaintext) + url = reverse("api-v1:cohort-sync:amplitude-list") + + # When + response = client.post( + f"{url}?shape=nested_camel", data={"name": "Beta users"}, format="json" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + cohort = Cohort.objects.get(source_type=CohortSourceType.AMPLITUDE) + assert response.json() == {"response": {"listId": str(cohort.uuid)}} diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 9c89874b5296..9b86a6f5deac 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -166,7 +166,7 @@ Attributes: ### `cohorts.sync_webhook.rejected` Logged at `warning` from: - - `api/cohorts/sync_views.py:228` + - `api/cohorts/sync_views.py:238` Attributes: - `action` From ac9a65f4d42ed8dbf7ab83b3b85c6925f8383a33 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 31 Aug 2026 10:10:47 +0530 Subject: [PATCH 4/6] fix(cohorts): pin the Amplitude list response to the verified dual shape --- api/cohorts/sync_views.py | 25 ++++----------- api/tests/unit/cohorts/test_sync_views.py | 32 ++----------------- .../observability/_events-catalogue.md | 2 +- 3 files changed, 11 insertions(+), 48 deletions(-) diff --git a/api/cohorts/sync_views.py b/api/cohorts/sync_views.py index c1a3273a278b..b845a9dde568 100644 --- a/api/cohorts/sync_views.py +++ b/api/cohorts/sync_views.py @@ -79,25 +79,14 @@ def create(self, request: Request) -> Response: name=serializer.validated_data["name"], source_type=CohortSourceType.AMPLITUDE, ) + # Amplitude reads the list ID at the portal-configured path + # ("response.list_id"), but its two sides apply that path to + # different objects: the production sync worker reads this body as + # is, so it needs the nested copy, while the Testing tab first wraps + # the body in a {"response": ...} envelope of its own, so it finds + # the flat copy. Both verified against staging, 2026-08-31. list_id = str(cohort.uuid) - # TODO: temporary, while we pin down which copy of the list ID - # Amplitude's production sync worker actually reads. `?shape=` - # narrows the body to a single candidate so each portal-side retry - # tests one; the default carries every copy, which is known to work. - # Once the winning shape is confirmed, hardcode it and drop the - # parameter. - shapes = { - "flat_snake": {"list_id": list_id}, - "flat_camel": {"listId": list_id}, - "nested_snake": {"response": {"list_id": list_id}}, - "nested_camel": {"response": {"listId": list_id}}, - } - default = { - "list_id": list_id, - "listId": list_id, - "response": {"list_id": list_id, "listId": list_id}, - } - return Response(shapes.get(request.query_params.get("shape", ""), default)) + return Response({"list_id": list_id, "response": {"list_id": list_id}}) @action(detail=True, methods=["POST"]) def add(self, request: Request, pk: str) -> Response: diff --git a/api/tests/unit/cohorts/test_sync_views.py b/api/tests/unit/cohorts/test_sync_views.py index 635ee0f23bd0..902f47a78436 100644 --- a/api/tests/unit/cohorts/test_sync_views.py +++ b/api/tests/unit/cohorts/test_sync_views.py @@ -51,15 +51,9 @@ def test_amplitude_create_list__valid_key__creates_amplitude_cohort( # Then assert response.status_code == status.HTTP_200_OK cohort = Cohort.objects.get(uuid=response.json()["list_id"]) - # Without ?shape=, the body carries the list ID at every spelling and - # depth Amplitude might read. - body = response.json() - assert ( - body["listId"] - == body["response"]["list_id"] - == body["response"]["listId"] - == body["list_id"] - ) + # Amplitude's production worker reads the nested copy, its Testing tab + # the flat one. + assert response.json()["response"]["list_id"] == response.json()["list_id"] assert cohort.environment == key.environment assert cohort.source_type == CohortSourceType.AMPLITUDE assert cohort.segment.name == "[Amplitude] Beta users: 1234" @@ -1274,23 +1268,3 @@ def test_webhook_add_members__saas_free_plan__returns_403( # Then assert response.status_code == status.HTTP_403_FORBIDDEN - - -def test_amplitude_create_list__shape_param__returns_only_that_shape( - cohort_sync_key: _KeyAndPlaintext, - dynamodb_identity_wrapper: DynamoIdentityWrapper, -) -> None: - # Given - _, plaintext = cohort_sync_key - client = _authenticated_client(plaintext) - url = reverse("api-v1:cohort-sync:amplitude-list") - - # When - response = client.post( - f"{url}?shape=nested_camel", data={"name": "Beta users"}, format="json" - ) - - # Then - assert response.status_code == status.HTTP_200_OK - cohort = Cohort.objects.get(source_type=CohortSourceType.AMPLITUDE) - assert response.json() == {"response": {"listId": str(cohort.uuid)}} diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 9b86a6f5deac..990158a564a2 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -166,7 +166,7 @@ Attributes: ### `cohorts.sync_webhook.rejected` Logged at `warning` from: - - `api/cohorts/sync_views.py:238` + - `api/cohorts/sync_views.py:227` Attributes: - `action` From d57ea31e5601cbf91e062e57b83144928cb7375a Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 31 Aug 2026 10:54:21 +0530 Subject: [PATCH 5/6] docs(cohorts): plainer comment on the Amplitude response shape --- api/cohorts/sync_views.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/api/cohorts/sync_views.py b/api/cohorts/sync_views.py index b845a9dde568..d3ab5a854663 100644 --- a/api/cohorts/sync_views.py +++ b/api/cohorts/sync_views.py @@ -79,12 +79,11 @@ def create(self, request: Request) -> Response: name=serializer.validated_data["name"], source_type=CohortSourceType.AMPLITUDE, ) - # Amplitude reads the list ID at the portal-configured path - # ("response.list_id"), but its two sides apply that path to - # different objects: the production sync worker reads this body as - # is, so it needs the nested copy, while the Testing tab first wraps - # the body in a {"response": ...} envelope of its own, so it finds - # the flat copy. Both verified against staging, 2026-08-31. + # Two different Amplitude systems read this response, and they look + # for the list ID in different places: the real cohort sync reads + # body["response"]["list_id"], the portal's Testing tab reads + # body["list_id"]. Send both so neither breaks. Verified on + # staging, 2026-08-31. list_id = str(cohort.uuid) return Response({"list_id": list_id, "response": {"list_id": list_id}}) From cbbbb43e91c6bb2d60db1151178a4acced1aa9a2 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Mon, 31 Aug 2026 05:25:23 +0000 Subject: [PATCH 6/6] chore: Update documentation artefacts --- .../deployment-self-hosting/observability/_events-catalogue.md | 2 +- 1 file changed, 1 insertion(+), 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 990158a564a2..733e47dbe5fd 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -166,7 +166,7 @@ Attributes: ### `cohorts.sync_webhook.rejected` Logged at `warning` from: - - `api/cohorts/sync_views.py:227` + - `api/cohorts/sync_views.py:226` Attributes: - `action`