Skip to content

Commit 4dbd65a

Browse files
eli-r-phclaude
andcommitted
fix: take group and alias ids from the caller, and apply request context by one rule
group_identify uses the caller's distinct id, then the context id, then $<group_type>_<group_key>, never a random id, and no longer adds the context session id. alias() no longer takes its target from the context distinct id. Context tags and options now reach alias and group_identify like capture. An empty or whitespace-only distinct id counts as missing, and generated personless distinct ids are UUIDv7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ff2fed1 commit 4dbd65a

6 files changed

Lines changed: 224 additions & 30 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: major
3+
---
4+
5+
`group_identify` falls back to the context distinct ID and then to `$<group_type>_<group_key>` instead of a random ID, and no longer adds the context session ID. `alias()` no longer takes its target from the context distinct ID. Context tags and options now apply to `alias` and `group_identify`. An empty or whitespace-only `distinct_id` counts as missing, and generated personless distinct IDs are UUIDv7. `AsyncPosthog` follows the same rules.

‎docs/migration-7.x-to-8.0.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ You need to change code if your app does any of these:
2525
- sets `$lib` or `$lib_version`, or filters on `$lib = "posthog-python-mcp"`
2626
- passes a personal API key (`phx_...`) as the project API key
2727
- reads event content from the SDK's debug log
28+
- calls `alias()` without a `distinct_id` and relies on the context distinct ID
2829

2930
## Endpoints
3031

@@ -127,6 +128,15 @@ The SDK moves them out of `properties` for you.
127128
- `None` counts as unset and is removed.
128129
- Any other value is removed and not sent, because capture would reject the whole batch. 7.x sent it as a property. The SDK logs a warning for each one, with the key and the value's type but not the value.
129130

131+
## Identity and request context
132+
133+
- An empty or whitespace-only `distinct_id` counts as missing, so the event gets a generated personless distinct ID. 7.x sent a whitespace-only ID, and capture dropped the event.
134+
- Generated personless distinct IDs are UUIDv7. 7.x generated UUIDv4.
135+
- `group_identify` uses the `distinct_id` you pass, then the context distinct ID, then `$<group_type>_<group_key>`. 7.x fell back to a random ID, which created a new person on every call.
136+
- `group_identify` no longer adds the context session ID. A session takes the person of its latest event, and this event's distinct ID is not the session's user.
137+
- `alias()` needs an explicit `distinct_id`. It no longer falls back to the context distinct ID, which can come from a request header the client controls. Without one, the call is dropped with a warning.
138+
- Context tags and context options now apply to `alias` and `group_identify`, below the call's own values, as they do for `capture`.
139+
130140
## Exceptions
131141

132142
- `capture_exception` takes a `level=` argument on `Client`, `AsyncPosthog` and the module. It sets `$exception_level`, for example `"warning"` or `"fatal"`. The default is `"error"`, and an unknown value counts as unset.

‎posthog/async_client.py‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555
_parse_flag_payload,
5656
_add_context_session_id,
5757
_context_tag_defaults,
58+
_explicit_distinct_id,
59+
_group_identify_distinct_id,
5860
_personless_options,
5961
_positive_config_value,
6062
add_context_tags as _add_context_tags,
@@ -798,7 +800,9 @@ def group_identify(
798800
)
799801
return None
800802

801-
resolved_distinct_id = _get_identity_state(distinct_id)[0]
803+
resolved_distinct_id = _group_identify_distinct_id(
804+
distinct_id, group_type, group_key
805+
)
802806
msg: dict[str, Any] = {
803807
"event": "$groupidentify",
804808
"properties": {
@@ -811,10 +815,14 @@ def group_identify(
811815
"uuid": uuid,
812816
"options": _event_options(options),
813817
}
814-
session_id = _get_context_session_id()
815-
if session_id:
816-
msg["properties"]["$session_id"] = str(session_id)
817-
return self._enqueue_built_event(msg, disable_geoip)
818+
# No context session: this event's distinct id is not the session's
819+
# user, and a session takes the person of its latest event.
820+
return self._enqueue_built_event(
821+
msg,
822+
disable_geoip,
823+
_get_context_options(),
824+
context_properties=_context_tag_defaults(),
825+
)
818826
except Exception as error:
819827
if self.debug:
820828
raise
@@ -835,8 +843,10 @@ def alias(
835843
if not resolved_previous_id:
836844
self.log.warning("alias() called without a previous_id, dropping event")
837845
return None
838-
resolved_distinct_id, personless = _get_identity_state(distinct_id)
839-
if personless:
846+
# The alias target always comes from the caller: a context id can
847+
# come from a client-controlled request header.
848+
resolved_distinct_id = _explicit_distinct_id(distinct_id)
849+
if not resolved_distinct_id:
840850
self.log.warning("alias() called without a distinct_id, dropping event")
841851
return None
842852
msg: dict[str, Any] = {
@@ -853,7 +863,12 @@ def alias(
853863
session_id = _get_context_session_id()
854864
if session_id:
855865
msg["properties"]["$session_id"] = str(session_id)
856-
return self._enqueue_built_event(msg, disable_geoip)
866+
return self._enqueue_built_event(
867+
msg,
868+
disable_geoip,
869+
_get_context_options(),
870+
context_properties=_context_tag_defaults(),
871+
)
857872
except Exception as error:
858873
if self.debug:
859874
raise
@@ -865,14 +880,17 @@ def _enqueue_built_event(
865880
msg: dict[str, Any],
866881
disable_geoip: Optional[bool],
867882
context_options: Optional[dict[str, Any]] = None,
883+
context_properties: Optional[dict[str, Any]] = None,
868884
) -> Optional[str]:
869885
prepared, sent_uuid = self._prepare_event(msg)
870886
if prepared is None or sent_uuid is None:
871887
return None
872888
if not self.send:
873889
return sent_uuid
874890
defaults = self._event_defaults(
875-
context_options=context_options, disable_geoip=disable_geoip
891+
context_properties=context_properties,
892+
context_options=context_options,
893+
disable_geoip=disable_geoip,
876894
)
877895
if not self._enqueue_prepared_event(prepared, self._analytics_lane, defaults):
878896
return None

‎posthog/client.py‎

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
to_values,
133133
)
134134
from posthog.utils import (
135+
_uuid7,
135136
FlagCache,
136137
RedisFlagCache,
137138
SizeLimitedDict,
@@ -262,17 +263,37 @@ def _positive_config_value(
262263
return value
263264

264265

266+
def _explicit_distinct_id(passed) -> Optional[str]:
267+
"""The caller's distinct id, or None when it is missing, empty or whitespace."""
268+
stringified = stringify_id(passed)
269+
if stringified and stringified.strip():
270+
return stringified
271+
return None
272+
273+
265274
def get_identity_state(passed) -> tuple[str, bool]:
266275
"""Returns the distinct id to use, and whether this is a personless event or not"""
267-
stringified = stringify_id(passed)
268-
if stringified and len(stringified):
269-
return (stringified, False)
276+
explicit = _explicit_distinct_id(passed)
277+
if explicit:
278+
return (explicit, False)
270279

271280
context_id = get_context_distinct_id()
272281
if context_id:
273282
return (context_id, False)
274283

275-
return (str(uuid4()), True)
284+
return (str(_uuid7()), True)
285+
286+
287+
def _group_identify_distinct_id(passed, group_type, group_key) -> str:
288+
"""The caller's id, else the context id, else one stable id per group.
289+
290+
Ingestion creates a person for each new distinct id, so a random id
291+
would add a person on every call.
292+
"""
293+
distinct_id, personless = get_identity_state(passed)
294+
if personless:
295+
return f"${group_type}_{group_key}"
296+
return distinct_id
276297

277298

278299
def _personless_options(personless: bool) -> dict[str, Any]:
@@ -2076,6 +2097,8 @@ def group_identify(
20762097
ignored and replaced with a newly generated UUID.
20772098
disable_geoip: Whether to disable GeoIP for this event.
20782099
distinct_id: The distinct ID of the user performing the action.
2100+
Falls back to the context distinct ID, then to
2101+
``$<group_type>_<group_key>``.
20792102
options: Capture options for this event, sent as given.
20802103
20812104
Examples:
@@ -2105,8 +2128,7 @@ def group_identify(
21052128

21062129
properties = properties or {}
21072130

2108-
# group_identify is purposefully always personful
2109-
distinct_id = get_identity_state(distinct_id)[0]
2131+
distinct_id = _group_identify_distinct_id(distinct_id, group_type, group_key)
21102132

21112133
msg: Dict[str, Any] = {
21122134
"event": "$groupidentify",
@@ -2121,11 +2143,14 @@ def group_identify(
21212143
"options": _event_options(options),
21222144
}
21232145

2124-
# NOTE - group_identify doesn't generally use context properties - should it?
2125-
if get_context_session_id():
2126-
msg["properties"]["$session_id"] = str(get_context_session_id())
2127-
2128-
return self._enqueue(msg, disable_geoip)
2146+
# No context session: this event's distinct id is not the session's
2147+
# user, and a session takes the person of its latest event.
2148+
return self._enqueue(
2149+
msg,
2150+
disable_geoip,
2151+
context_properties=_context_tag_defaults(),
2152+
context_options=_context_get_context_options(),
2153+
)
21292154

21302155
@no_throw()
21312156
def alias(
@@ -2143,9 +2168,9 @@ def alias(
21432168
Args:
21442169
previous_id: The previous distinct ID. Required - the call is dropped
21452170
with a warning if it is missing or empty.
2146-
distinct_id: The new distinct ID to alias to. Falls back to the
2147-
context distinct ID; the call is dropped with a warning if
2148-
neither is available.
2171+
distinct_id: The new distinct ID to alias to. Required - the call is
2172+
dropped with a warning if it is missing or empty. The context
2173+
distinct ID is never used here.
21492174
timestamp: The timestamp of the event. UTC is preferred; non-UTC
21502175
datetimes and parseable ISO timestamp strings are converted to UTC.
21512176
uuid: A unique identifier for the event. If provided, it must be a
@@ -2171,10 +2196,10 @@ def alias(
21712196
)
21722197
return None
21732198

2174-
(distinct_id, personless) = get_identity_state(distinct_id)
2175-
2176-
if personless:
2177-
# No alias target was passed and none is available from context.
2199+
# The alias target always comes from the caller: a context id can come
2200+
# from a client-controlled request header.
2201+
distinct_id = _explicit_distinct_id(distinct_id)
2202+
if not distinct_id:
21782203
self.log.warning(
21792204
"alias() called without a distinct_id, dropping the $create_alias event"
21802205
)
@@ -2195,7 +2220,12 @@ def alias(
21952220
if get_context_session_id():
21962221
msg["properties"]["$session_id"] = str(get_context_session_id())
21972222

2198-
return self._enqueue(msg, disable_geoip)
2223+
return self._enqueue(
2224+
msg,
2225+
disable_geoip,
2226+
context_properties=_context_tag_defaults(),
2227+
context_options=_context_get_context_options(),
2228+
)
21992229

22002230
def capture_exception(
22012231
self,

‎posthog/test/test_async_client.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH
1616
from posthog.consumer import AI_MAX_MSG_SIZE, AI_MAX_PROPERTIES_SIZE, MAX_MSG_SIZE
1717
from posthog.contexts import (
18+
identify_context,
1819
new_context,
20+
set_context_option,
21+
set_context_session,
22+
tag,
1923
set_capture_exception_code_variables_context,
2024
set_code_variables_detect_secrets_context,
2125
set_code_variables_ignore_patterns_context,
@@ -483,6 +487,50 @@ async def send_batch(api_key, host, batch, **kwargs):
483487
assert batches[0][0]["event"] == expected_event
484488

485489

490+
@pytest.mark.asyncio
491+
@pytest.mark.parametrize(
492+
("method_name", "method_kwargs", "expected_distinct_id", "expected_session"),
493+
[
494+
(
495+
"group_identify",
496+
{"group_type": "company", "group_key": "acme-1"},
497+
"context-user",
498+
None,
499+
),
500+
(
501+
"alias",
502+
{"previous_id": "anon-1", "distinct_id": "user-1"},
503+
"anon-1",
504+
"context-session",
505+
),
506+
],
507+
)
508+
async def test_identity_methods_take_context_values_by_the_context_rule(
509+
method_name, method_kwargs, expected_distinct_id, expected_session
510+
):
511+
batches = []
512+
513+
async def send_batch(api_key, host, batch, **kwargs):
514+
batches.append(batch)
515+
516+
with patch_async_capture_send(side_effect=send_batch):
517+
async with AsyncPosthog("test-key", flush_interval=30) as client:
518+
with new_context(fresh=True):
519+
identify_context("context-user")
520+
set_context_session("context-session")
521+
tag("tenant", "acme")
522+
set_context_option("product_tour_id", "tour-1")
523+
assert getattr(client, method_name)(**method_kwargs) is not None
524+
assert client.alias("anon-2", None) is None
525+
await client.flush(timeout_seconds=1)
526+
527+
msg = batches[0][0]
528+
assert msg["distinct_id"] == expected_distinct_id
529+
assert msg["properties"].get("$session_id") == expected_session
530+
assert msg["properties"]["tenant"] == "acme"
531+
assert msg["options"]["product_tour_id"] == "tour-1"
532+
533+
486534
@pytest.mark.asyncio
487535
async def test_capture_after_shutdown_is_dropped_without_restarting_workers():
488536
with mock.patch("posthog.async_client._build_client") as build_client:

0 commit comments

Comments
 (0)