From f38dc0c5be2a1427f426ed2488d4bc1c66f6e8d2 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:50:08 +0000 Subject: [PATCH 1/2] fix: drop alias() calls with a missing identity (sdk-specs alias) The sdk-specs `alias` contract requires both identities to be present, and its @both scenario "Alias is dropped when required identities are missing" says no event should be enqueued and a validation warning should be recorded. posthog-python did neither for `previous_id`: `alias(None, "user-123")` (or an empty string) enqueued a `$create_alias` event with a null/empty `distinct_id`, which links nothing. The missing-alias-target case already dropped the call but logged nothing. Both cases now log a warning and return None. `previous_id` is also stringified once so `distinct_id` and `properties.distinct_id` agree for non-string ids. Generated-By: PostHog Code Task-Id: 0f22ca69-60f9-4887-908d-efef6c63b603 --- .../changesets/alias-validates-identities.md | 5 +++ posthog/client.py | 20 ++++++++-- posthog/test/test_client.py | 37 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 .sampo/changesets/alias-validates-identities.md diff --git a/.sampo/changesets/alias-validates-identities.md b/.sampo/changesets/alias-validates-identities.md new file mode 100644 index 00000000..80d503ba --- /dev/null +++ b/.sampo/changesets/alias-validates-identities.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +`alias()` now validates both identities before enqueuing. Previously `alias(None, "user-123")` (or an empty-string `previous_id`) sent a `$create_alias` event with a null/empty `distinct_id`, which cannot link anything and just adds an unusable event to the project. Missing identities are now dropped with a warning instead, matching the sdk-specs `alias` contract. The drop that already happened when no alias target could be resolved now logs a warning too, and a non-string `previous_id` such as `0` is stringified consistently in both `distinct_id` and `properties.distinct_id`. diff --git a/posthog/client.py b/posthog/client.py index 74a5d9eb..06751bbf 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1678,8 +1678,11 @@ def alias( Create an alias between two distinct IDs. Args: - previous_id: The previous distinct ID. - distinct_id: The new distinct ID to alias to. + previous_id: The previous distinct ID. Required - the call is dropped + with a warning if it is missing or empty. + distinct_id: The new distinct ID to alias to. Falls back to the + context distinct ID; the call is dropped with a warning if + neither is available. timestamp: The timestamp of the event. uuid: A unique identifier for the event. If provided, it must be a valid UUID string or uuid.UUID instance; invalid values are @@ -1696,10 +1699,21 @@ def alias( Note: This method will not raise exceptions. Errors are logged. """ + previous_id = stringify_id(previous_id) + if not previous_id: + self.log.warning( + "alias() called without a previous_id, dropping the $create_alias event" + ) + return None + (distinct_id, personless) = get_identity_state(distinct_id) if personless: - return None # Personless alias() does nothing - should this throw? + # No alias target was passed and none is available from context. + self.log.warning( + "alias() called without a distinct_id, dropping the $create_alias event" + ) + return None msg: Dict[str, Any] = { "properties": { diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 4d0919cd..383d1e81 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -1853,6 +1853,43 @@ def test_basic_alias(self): self.assertEqual(msg["properties"]["distinct_id"], "previousId") self.assertEqual(msg["properties"]["alias"], "distinct_id") + @parameterized.expand( + [ + ("none", None), + ("empty_string", ""), + ] + ) + def test_alias_without_previous_id_is_dropped(self, _name, previous_id): + with mock.patch("posthog.client.batch_post") as mock_post: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + with self.assertLogs("posthog", level="WARNING") as logs: + msg_uuid = client.alias(previous_id, "distinct_id") + + self.assertIsNone(msg_uuid) + mock_post.assert_not_called() + self.assertIn("previous_id", logs.output[0]) + + def test_alias_accepts_non_string_previous_id(self): + with mock.patch("posthog.client.batch_post") as mock_post: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + msg_uuid = client.alias(0, "distinct_id") + self.assertIsNotNone(msg_uuid) + + mock_post.assert_called_once() + msg = mock_post.call_args[1]["batch"][0] + self.assertEqual(msg["distinct_id"], "0") + self.assertEqual(msg["properties"]["distinct_id"], "0") + + def test_alias_without_distinct_id_is_dropped(self): + with mock.patch("posthog.client.batch_post") as mock_post: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True) + with self.assertLogs("posthog", level="WARNING") as logs: + msg_uuid = client.alias("previousId", None) + + self.assertIsNone(msg_uuid) + mock_post.assert_not_called() + self.assertIn("distinct_id", logs.output[0]) + @parameterized.expand( [ # test_name, session_id, additional_properties, expected_properties From 3b2439bc21da8e058de14d4eeb9209b5d677c62e Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 5 Aug 2026 13:14:35 +0200 Subject: [PATCH 2/2] fix: align alias previous_id typing --- posthog/__init__.py | 2 +- posthog/client.py | 2 +- references/public_api_snapshot.txt | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/posthog/__init__.py b/posthog/__init__.py index 4e553162..e45fae7e 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -612,7 +612,7 @@ def group_identify( def alias( - previous_id: str, + previous_id: ID_TYPES, distinct_id: str, timestamp: Optional[datetime.datetime] = None, uuid: Optional[str] = None, diff --git a/posthog/client.py b/posthog/client.py index 06751bbf..7f92a0bb 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1668,7 +1668,7 @@ def group_identify( @no_throw() def alias( self, - previous_id: str, + previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 779f262b..c1b88ba8 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1032,7 +1032,7 @@ function posthog.ai.utils.merge_system_prompt(kwargs: Dict[str, Any], provider: function posthog.ai.utils.merge_usage_stats(target: TokenUsage, source: TokenUsage, mode: str = 'incremental') -> None function posthog.ai.utils.serialize_raw_usage(raw_usage: Any) -> Optional[Dict[str, Any]] function posthog.ai.utils.with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any) -function posthog.alias(previous_id: str, distinct_id: str, timestamp: Optional[datetime.datetime] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] +function posthog.alias(previous_id: ID_TYPES, distinct_id: str, timestamp: Optional[datetime.datetime] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] function posthog.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] function posthog.capture_exception(exception: Optional[ExceptionArg] = None, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] function posthog.client.add_context_tags(properties) @@ -1237,7 +1237,7 @@ method posthog.ai.stream.AsyncStreamWrapper.aclose() -> None method posthog.ai.stream.AsyncStreamWrapper.close() -> None method posthog.bucketed_rate_limiter.BucketedRateLimiter.consume_rate_limit(key: Hashable) -> bool method posthog.bucketed_rate_limiter.BucketedRateLimiter.stop() -> None -method posthog.client.Client.alias(previous_id: str, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] +method posthog.client.Client.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] method posthog.client.Client.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.client.Client.capture_exception(exception: Optional[ExceptionArg], **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.client.Client.evaluate_flags(distinct_id: Optional[ID_TYPES] = None, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, flag_keys: Optional[List[str]] = None, device_id: Optional[str] = None) -> FeatureFlagEvaluations