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/identity-and-context-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: major
---

`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.
20 changes: 15 additions & 5 deletions docs/migration-7.x-to-8.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ You need to change code if your app does any of these:
- sets `$lib` or `$lib_version`, or filters on `$lib = "posthog-python-mcp"`
- passes a personal API key (`phx_...`) as the project API key
- reads event content from the SDK's debug log
- calls `alias()` without a `distinct_id` and relies on the context distinct ID

## Endpoints

Expand Down Expand Up @@ -130,6 +131,16 @@ The SDK moves them out of `properties` for you.
- `None` counts as unset and is removed.
- 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.

## Identity and request context

- 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.
- Generated personless distinct IDs are UUIDv7. 7.x generated UUIDv4.
- `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. If `$<group_type>_<group_key>` is over 200 bytes, pass `distinct_id`: PostHog drops an event whose distinct ID is over 200 bytes.
- PostHog drops an event whose `distinct_id` is over 200 UTF-8 bytes (`distinct_id_too_large`), and `on_error` reports it. 7.x sent events to legacy capture, which shortened the ID to 200 characters and kept the event.
- `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.
- `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.
- Context tags and context options now apply to `alias` and `group_identify`, below the call's own values, as they do for `capture`.
Comment thread
eli-r-ph marked this conversation as resolved.

## Exceptions

- `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.
Expand All @@ -148,16 +159,15 @@ Capture v1 sends processing options in an `options` object, next to `properties`

- `super_options`, a new `Client`, `AsyncPosthog` and module setting, sets options on every event, like `super_properties`.
- `set_context_option(key, value)` sets an option for the current context, like `tag()`. `get_context_options()` returns the options of the current context. Both exist on `Client` and as module functions. `AsyncPosthog` has no context methods, like it has no `tag()`, so call the module functions. They apply to every client in the context.
- Context tags and options reach `capture`, `capture_ai` and `capture_exception`. Context options also reach `set` and `set_once`. Neither reaches `alias` or `group_identify`. `super_properties` and `super_options` reach every method.
- Context tags and options reach `capture`, `capture_ai`, `capture_exception`, `alias` and `group_identify`. Context options also reach `set` and `set_once`. `super_properties` and `super_options` reach every method.
- Options are sent as given. PostHog validates them.
- An `options` value that is not a dict is logged as an error and ignored. The event is still sent.
- The legacy properties still work. They fill the matching option only when it is unset, and the SDK always removes them from `properties`. Their values are no longer converted, so pass `True` or `False`, not `"true"`.
- The legacy option properties, the `$`-prefixed properties legacy capture reads as settings, still work. They fill the matching option only when it is unset, and the SDK always removes them from `properties`. Their values are no longer converted, so pass `True` or `False`, not `"true"`.

| Legacy property | Option |
| Legacy option property | Option |
| --- | --- |
| `$cookieless_mode` | `cookieless_mode` |
| `$ignore_sent_at` | `disable_skew_correction` |
| `$product_tour_id` | `product_tour_id` |
| `$process_person_profile` | `process_person_profile` |

- An option set at any layer wins over its legacy property set at any layer. For example, `super_options={"cookieless_mode": True}` wins over an event's `$cookieless_mode: False`. When you move a default to options, move the per-event overrides of that key to options too.
Expand Down Expand Up @@ -186,7 +196,7 @@ These changes follow from this order:
- A `$set`, `$set_once`, `$groups` or `$group_set` in `super_properties` no longer replaces the whole value of the call. The two merge, and the call wins key by key.
- An event without a distinct ID gets `options.process_person_profile = false`. A `$process_person_profile: true` property no longer turns person processing back on. Set the option instead.
- To change an option in `before_send`, edit `options`. A legacy property that `before_send` adds does not replace an option that is already set.
- When the server enables minimal `$feature_flag_called` events, those events now keep `$window_id`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id`, so their options still apply.
- When the server enables minimal `$feature_flag_called` events, those events now keep `$window_id` and the legacy option properties, so their options still apply.

## AI capture

Expand Down
36 changes: 27 additions & 9 deletions posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
_parse_flag_payload,
_add_context_session_id,
_context_tag_defaults,
_explicit_distinct_id,
_group_identify_distinct_id,
_personless_options,
_positive_config_value,
add_context_tags as _add_context_tags,
Expand Down Expand Up @@ -803,7 +805,9 @@ def group_identify(
)
return None

resolved_distinct_id = _get_identity_state(distinct_id)[0]
resolved_distinct_id = _group_identify_distinct_id(
distinct_id, group_type, group_key
)
msg: dict[str, Any] = {
"event": "$groupidentify",
"properties": {
Expand All @@ -816,10 +820,14 @@ def group_identify(
"uuid": uuid,
"options": _event_options(options),
}
session_id = _get_context_session_id()
if session_id:
msg["properties"]["$session_id"] = str(session_id)
return self._enqueue_built_event(msg, disable_geoip)
# No context session: this event's distinct id is not the session's
# user, and a session takes the person of its latest event.
return self._enqueue_built_event(
msg,
disable_geoip,
_get_context_options(),
context_properties=_context_tag_defaults(),
)
except Exception as error:
if self.debug:
raise
Expand All @@ -840,8 +848,10 @@ def alias(
if not resolved_previous_id:
self.log.warning("alias() called without a previous_id, dropping event")
return None
resolved_distinct_id, personless = _get_identity_state(distinct_id)
if personless:
# The alias target always comes from the caller: a context id can
# come from a client-controlled request header.
resolved_distinct_id = _explicit_distinct_id(distinct_id)
if not resolved_distinct_id:
self.log.warning("alias() called without a distinct_id, dropping event")
return None
msg: dict[str, Any] = {
Expand All @@ -858,7 +868,12 @@ def alias(
session_id = _get_context_session_id()
if session_id:
msg["properties"]["$session_id"] = str(session_id)
return self._enqueue_built_event(msg, disable_geoip)
return self._enqueue_built_event(
msg,
disable_geoip,
_get_context_options(),
context_properties=_context_tag_defaults(),
)
except Exception as error:
if self.debug:
raise
Expand All @@ -870,14 +885,17 @@ def _enqueue_built_event(
msg: dict[str, Any],
disable_geoip: Optional[bool],
context_options: Optional[dict[str, Any]] = None,
context_properties: Optional[dict[str, Any]] = None,
) -> Optional[str]:
prepared, sent_uuid = self._prepare_event(msg)
if prepared is None or sent_uuid is None:
return None
if not self.send:
return sent_uuid
defaults = self._event_defaults(
context_options=context_options, disable_geoip=disable_geoip
context_properties=context_properties,
context_options=context_options,
disable_geoip=disable_geoip,
)
if not self._enqueue_prepared_event(prepared, self._analytics_lane, defaults):
return None
Expand Down
68 changes: 49 additions & 19 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
to_values,
)
from posthog.utils import (
_uuid7,
FlagCache,
RedisFlagCache,
SizeLimitedDict,
Expand Down Expand Up @@ -263,17 +264,37 @@ def _positive_config_value(
return value


def _explicit_distinct_id(passed) -> Optional[str]:
"""The caller's distinct id, or None when it is missing, empty or whitespace."""
stringified = stringify_id(passed)
if stringified and stringified.strip():
return stringified
return None


def get_identity_state(passed) -> tuple[str, bool]:
"""Returns the distinct id to use, and whether this is a personless event or not"""
stringified = stringify_id(passed)
if stringified and len(stringified):
return (stringified, False)
explicit = _explicit_distinct_id(passed)
if explicit:
return (explicit, False)

context_id = get_context_distinct_id()
if context_id:
return (context_id, False)

return (str(uuid4()), True)
return (str(_uuid7()), True)


def _group_identify_distinct_id(passed, group_type, group_key) -> str:
"""The caller's id, else the context id, else one stable id per group.

Ingestion creates a person for each new distinct id, so a random id
would add a person on every call.
"""
distinct_id, personless = get_identity_state(passed)
if personless:
return f"${group_type}_{group_key}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Bound the synthetic group identity without rejecting valid group keys

With no explicit or contextual distinct ID, this supported call now generates a 202-byte root distinct_id:

with posthog.new_context(fresh=True):
    client.group_identify("company", "k" * 193, {"plan": "pro"})

$company_ contributes nine bytes. Capture v1 rejects distinct IDs over 200 UTF-8 bytes: at backend 172974f3fe4bedbf3a1c2d6c43761a2a78e33ff7, rust/capture/src/v1/analytics/constants.rs:96 defines the limit and process.rs:680–682 returns DistinctIdTooLarge. Validation failures become per-event drops before publication (process.rs:545–554). The group key itself fits the 400-character field in posthog/models/group/group.py:9; group processing/storage passes it through to the upsert.

This is introduced by this PR: at immediate base d205df87685fd792a4762e48875662a9ad3dd9a6, the omitted-ID path generates a 36-character UUID independent of the key length. Both clients now use this shared helper. The optional distinct_id parameter, group-identify specification’s identity-resolution flow, and migration guide’s stable-fallback promise support this omitted-ID operation. Supplying a short explicit/context ID avoids the problem, but neither is required. #1058 does not change this helper.

Runtime validation: I ran real public SDK calls, queue/flush, v1 serialization, and error handling from the exact head and immediate base. Only HTTP POST was replaced: the adapter fed the actual JSON payload into the compiled, verbatim pinned Rust validate_event function, then returned its decision as a v1-shaped response. This was an isolated validator test, not a running ingestion service, storage test, or production observation.

Key with group type company Base, both clients PR ID bytes PR native validation, both clients
191 ASCII characters Accepted 200 Accepted
192 ASCII characters Accepted 201 DistinctIdTooLarge
193 ASCII characters Accepted 202 DistinctIdTooLarge
96 é characters Accepted 201 DistinctIdTooLarge

The complete runtime suite produced 16 passed at base, versus 6 failed/10 passed at head; all six failures were the deliverability assertions above. With max_retries=2, the returned drop invoked on_error after one attempt, with reason distinct_id_too_large and no retry exhaustion. Explicit short IDs passed while preserving the full key. This is a terminal per-event rejection, not whole-batch loss.

Consider retaining the readable fallback when it fits the 200-byte UTF-8 limit. For oversized values, a fixed prefix such as $group_ plus SHA-256 of an unambiguous encoding of the (group_type, group_key) pair would provide a bounded deterministic identity, without changing $group_key or $group_set. The exact representation is an identity-format choice: truncation can collide, while random IDs lose the intended stability. Peer SDKs using the same readable convention do not make an oversized v1 identity deliverable.

Suggested repository regression (not added or run as written; the equivalent runtime harness above was executed): alongside the existing wire helpers in posthog/test/test_event_options.py:

@pytest.mark.asyncio
@pytest.mark.parametrize("key", ["k" * 191, "k" * 192, "k" * 193, "é" * 96])
async def test_group_fallback_fits_capture_limit(key):
    def call(client):
        with new_context(fresh=True):
            return client.group_identify("company", key, {"plan": "pro"})

    for events in [_sync_wire_events(call), await _async_wire_events(call)]:
        assert len(events) == 1
        event = events[0]
        assert event["event"] == "$groupidentify"
        assert event["properties"]["$group_type"] == "company"
        assert event["properties"]["$group_key"] == key
        assert event["properties"]["$group_set"] == {"plan": "pro"}
        assert len(event["distinct_id"].encode("utf-8")) <= 200

The byte-bound assertion passes at the immediate base and fails on the current head for the last three keys through each client. After correction, retain the existing ordinary-key stability test and assert repeatability for oversized fallbacks too. Suggested command: uv run --no-sync pytest posthog/test/test_event_options.py -k group_fallback_fits_capture_limit --timeout=30.

@eli-r-ph eli-r-ph Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm I think enforcing this is reasonable as long it's clearly documented for v1, but I'm open to alternatives.

We've had trouble with this before that we've had customers address, and there are storage-side limits already. I didn't see any events in yesterday's event traffic that would hit this issue, and only a few hundred instances across US and EU for the last 30 days in legacy capture (all scanners, not legit group events) EDIT: did more digging, the legacy counter is by batch so undercounts. pondering this...

Is the feedback loop acceptable for a v1 response from backend to issue a clearly marked drop result for this event only, and an ingestion warning to the team, and leave this as-is?

We could also truncate oversized generated distinct_ids on the SDK side, but this could group unrelated persons at times. Given instances of this issue are rare and generally not from legit event traffic

WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only to land if we stick with current behavior: drafted update to an SDK migration doc that wasn't explicit about this yet. public doc release draft PRs call this out too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR'd new capture v1 metric for this as well to track as legacy does. Also - legacy silently truncates long distinct IDs which feels more dangerous than calling it out to users prior to upgrade?

Anyway, worth discussing before we lock it in - might be easier to truncate and warn on capture v1 backend if you have concerns. A still saw very few long distinct IDs approaching this limit in prod data that were legit (not scanners/probes etc.)

return distinct_id


def _personless_options(personless: bool) -> dict[str, Any]:
Expand Down Expand Up @@ -2077,6 +2098,8 @@ def group_identify(
ignored and replaced with a newly generated UUID.
disable_geoip: Whether to disable GeoIP for this event.
distinct_id: The distinct ID of the user performing the action.
Falls back to the context distinct ID, then to
``$<group_type>_<group_key>``.
options: Capture options for this event, sent as given.

Examples:
Expand Down Expand Up @@ -2106,8 +2129,7 @@ def group_identify(

properties = properties or {}

# group_identify is purposefully always personful
distinct_id = get_identity_state(distinct_id)[0]
distinct_id = _group_identify_distinct_id(distinct_id, group_type, group_key)

msg: Dict[str, Any] = {
"event": "$groupidentify",
Expand All @@ -2122,11 +2144,14 @@ def group_identify(
"options": _event_options(options),
}

# NOTE - group_identify doesn't generally use context properties - should it?
if get_context_session_id():
msg["properties"]["$session_id"] = str(get_context_session_id())

return self._enqueue(msg, disable_geoip)
# No context session: this event's distinct id is not the session's
# user, and a session takes the person of its latest event.
return self._enqueue(
msg,
disable_geoip,
context_properties=_context_tag_defaults(),
context_options=_context_get_context_options(),
)

@no_throw()
def alias(
Expand All @@ -2144,9 +2169,9 @@ def alias(
Args:
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.
distinct_id: The new distinct ID to alias to. Required - the call is
dropped with a warning if it is missing or empty. The context
distinct ID is never used here.
timestamp: The timestamp of the event. UTC is preferred; non-UTC
datetimes and parseable ISO timestamp strings are converted to UTC.
uuid: A unique identifier for the event. If provided, it must be a
Expand All @@ -2172,10 +2197,10 @@ def alias(
)
return None

(distinct_id, personless) = get_identity_state(distinct_id)

if personless:
# No alias target was passed and none is available from context.
# The alias target always comes from the caller: a context id can come
# from a client-controlled request header.
distinct_id = _explicit_distinct_id(distinct_id)
if not distinct_id:
self.log.warning(
"alias() called without a distinct_id, dropping the $create_alias event"
)
Expand All @@ -2196,7 +2221,12 @@ def alias(
if get_context_session_id():
msg["properties"]["$session_id"] = str(get_context_session_id())

return self._enqueue(msg, disable_geoip)
return self._enqueue(
msg,
disable_geoip,
context_properties=_context_tag_defaults(),
context_options=_context_get_context_options(),
)

def capture_exception(
self,
Expand Down
48 changes: 48 additions & 0 deletions posthog/test/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
from posthog.capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH
from posthog.consumer import AI_MAX_MSG_SIZE, AI_MAX_PROPERTIES_SIZE, MAX_MSG_SIZE
from posthog.contexts import (
identify_context,
new_context,
set_context_option,
set_context_session,
tag,
set_capture_exception_code_variables_context,
set_code_variables_detect_secrets_context,
set_code_variables_ignore_patterns_context,
Expand Down Expand Up @@ -518,6 +522,50 @@ async def send_batch(api_key, host, batch, **kwargs):
assert batches[0][0]["event"] == expected_event


@pytest.mark.asyncio
@pytest.mark.parametrize(
("method_name", "method_kwargs", "expected_distinct_id", "expected_session"),
[
(
"group_identify",
{"group_type": "company", "group_key": "acme-1"},
"context-user",
None,
),
(
"alias",
{"previous_id": "anon-1", "distinct_id": "user-1"},
"anon-1",
"context-session",
),
],
)
async def test_identity_methods_take_context_values_by_the_context_rule(
method_name, method_kwargs, expected_distinct_id, expected_session
):
batches = []

async def send_batch(api_key, host, batch, **kwargs):
batches.append(batch)

with patch_async_capture_send(side_effect=send_batch):
async with AsyncPosthog("test-key", flush_interval=30) as client:
with new_context(fresh=True):
identify_context("context-user")
set_context_session("context-session")
tag("tenant", "acme")
set_context_option("product_tour_id", "tour-1")
assert getattr(client, method_name)(**method_kwargs) is not None
assert client.alias("anon-2", None) is None
await client.flush(timeout_seconds=1)

msg = batches[0][0]
assert msg["distinct_id"] == expected_distinct_id
assert msg["properties"].get("$session_id") == expected_session
assert msg["properties"]["tenant"] == "acme"
assert msg["options"]["product_tour_id"] == "tour-1"


@pytest.mark.asyncio
async def test_capture_after_shutdown_is_dropped_without_restarting_workers():
with mock.patch("posthog.async_client._build_client") as build_client:
Expand Down
Loading
Loading