Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/alias-validates-identities.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 18 additions & 4 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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": {
Expand Down
37 changes: 37 additions & 0 deletions posthog/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions references/public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down