From 039d6caa9fb21882918a55db6282957cc3452494 Mon Sep 17 00:00:00 2001 From: Ethan Porcaro Date: Sat, 28 Feb 2026 07:50:31 -0700 Subject: [PATCH 1/5] fix(llma): use distinct_id from outer context if not provided --- posthog/ai/utils.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index d9177686..f6618ef6 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -2,7 +2,7 @@ import uuid from typing import Any, Callable, Dict, List, Optional, cast -from posthog import get_tags, identify_context, new_context, tag +from posthog import get_tags, identify_context, new_context, tag, contexts from posthog.ai.sanitization import ( sanitize_anthropic, sanitize_gemini, @@ -366,6 +366,10 @@ def call_llm_and_track_usage( if posthog_trace_id is None: posthog_trace_id = str(uuid.uuid4()) + if not contexts.get_context_distinct_id(): + # Use trace_id as district_id if it's not defined. + identify_context(posthog_trace_id) + if response and ( hasattr(response, "usage") or (provider == "gemini" and hasattr(response, "usage_metadata")) @@ -445,7 +449,6 @@ def call_llm_and_track_usage( sdk_tags, posthog_properties ) ph_client.capture( - distinct_id=posthog_distinct_id or posthog_trace_id, event="$ai_generation", properties=merged_properties, groups=posthog_groups, @@ -501,6 +504,10 @@ async def call_llm_and_track_usage_async( if posthog_trace_id is None: posthog_trace_id = str(uuid.uuid4()) + if not contexts.get_context_distinct_id(): + # Use trace_id as district_id if it's not defined. + identify_context(posthog_trace_id) + if response and ( hasattr(response, "usage") or (provider == "gemini" and hasattr(response, "usage_metadata")) @@ -580,7 +587,6 @@ async def call_llm_and_track_usage_async( sdk_tags, posthog_properties ) ph_client.capture( - distinct_id=posthog_distinct_id or posthog_trace_id, event="$ai_generation", properties=merged_properties, groups=posthog_groups, From ce2af792e89b4802fa7831fe30c1dc73e09667ca Mon Sep 17 00:00:00 2001 From: Ethan Porcaro Date: Tue, 3 Mar 2026 11:02:15 -0700 Subject: [PATCH 2/5] fix(llma): distinct_id from context is now explicitly passed to capture method --- posthog/ai/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index f6618ef6..fff047b9 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -449,6 +449,7 @@ def call_llm_and_track_usage( sdk_tags, posthog_properties ) ph_client.capture( + distinct_id=contexts.get_context_distinct_id(), event="$ai_generation", properties=merged_properties, groups=posthog_groups, @@ -587,6 +588,7 @@ async def call_llm_and_track_usage_async( sdk_tags, posthog_properties ) ph_client.capture( + distinct_id=contexts.get_context_distinct_id(), event="$ai_generation", properties=merged_properties, groups=posthog_groups, From 77c192896f556697b72bce9f70939a93ddb9da77 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 5 Mar 2026 12:34:30 +0000 Subject: [PATCH 3/5] fix(llma): fix $process_person_profile with outer context distinct_id, add tests - Fix personless check to consider outer context distinct_id (not just the explicit param), so events from users who set distinct_id via outer context are not incorrectly marked as personless. - Fix typo: "district_id" -> "distinct_id" in comments. - Add test coverage for distinct_id resolution: no id (personless), explicit param, outer context, and explicit overriding outer context. --- posthog/ai/utils.py | 18 +++-- posthog/test/ai/anthropic/test_anthropic.py | 86 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index fff047b9..842fc8a1 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -366,8 +366,11 @@ def call_llm_and_track_usage( if posthog_trace_id is None: posthog_trace_id = str(uuid.uuid4()) - if not contexts.get_context_distinct_id(): - # Use trace_id as district_id if it's not defined. + # Check if we have a real user distinct_id (from param or outer context) + has_person_distinct_id = posthog_distinct_id is not None or contexts.get_context_distinct_id() is not None + + if not has_person_distinct_id: + # Fall back to trace_id as distinct_id when no real user id is available. identify_context(posthog_trace_id) if response and ( @@ -425,7 +428,7 @@ def call_llm_and_track_usage( # Already serialized by converters tag("$ai_usage", raw_usage) - if posthog_distinct_id is None: + if not has_person_distinct_id: tag("$process_person_profile", False) # Process instructions for Responses API @@ -505,8 +508,11 @@ async def call_llm_and_track_usage_async( if posthog_trace_id is None: posthog_trace_id = str(uuid.uuid4()) - if not contexts.get_context_distinct_id(): - # Use trace_id as district_id if it's not defined. + # Check if we have a real user distinct_id (from param or outer context) + has_person_distinct_id = posthog_distinct_id is not None or contexts.get_context_distinct_id() is not None + + if not has_person_distinct_id: + # Fall back to trace_id as distinct_id when no real user id is available. identify_context(posthog_trace_id) if response and ( @@ -564,7 +570,7 @@ async def call_llm_and_track_usage_async( # Already serialized by converters tag("$ai_usage", raw_usage) - if posthog_distinct_id is None: + if not has_person_distinct_id: tag("$process_person_profile", False) # Process instructions for Responses API diff --git a/posthog/test/ai/anthropic/test_anthropic.py b/posthog/test/ai/anthropic/test_anthropic.py index bdf71c83..070fc31d 100644 --- a/posthog/test/ai/anthropic/test_anthropic.py +++ b/posthog/test/ai/anthropic/test_anthropic.py @@ -3,6 +3,8 @@ import pytest +from posthog import identify_context, new_context + try: from anthropic.types import Message, Usage @@ -1302,3 +1304,87 @@ async def run_test(): assert props["$ai_web_search_count"] == 2 assert props["$ai_input_tokens"] == 50 assert props["$ai_output_tokens"] == 25 + + +# ======================= +# Distinct ID Context Tests +# ======================= + + +def test_no_distinct_id_uses_trace_id_and_personless(mock_client, mock_anthropic_response): + """When no distinct_id is provided and no outer context, trace_id is used and event is personless.""" + with patch( + "anthropic.resources.Messages.create", return_value=mock_anthropic_response + ): + client = Anthropic(api_key="test-key", posthog_client=mock_client) + client.messages.create( + model="claude-3-opus-20240229", + messages=[{"role": "user", "content": "Hello"}], + posthog_trace_id="trace-123", + ) + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + + assert call_args["distinct_id"] == "trace-123" + assert props["$process_person_profile"] is False + + +def test_explicit_distinct_id_creates_person_profile(mock_client, mock_anthropic_response): + """When posthog_distinct_id is explicitly passed, it is used and event is not personless.""" + with patch( + "anthropic.resources.Messages.create", return_value=mock_anthropic_response + ): + client = Anthropic(api_key="test-key", posthog_client=mock_client) + client.messages.create( + model="claude-3-opus-20240229", + messages=[{"role": "user", "content": "Hello"}], + posthog_distinct_id="user-123", + posthog_trace_id="trace-123", + ) + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + + assert call_args["distinct_id"] == "user-123" + assert "$process_person_profile" not in props or props["$process_person_profile"] is not False + + +def test_outer_context_distinct_id_is_used(mock_client, mock_anthropic_response): + """When an outer context has a distinct_id, it should be used instead of trace_id.""" + with patch( + "anthropic.resources.Messages.create", return_value=mock_anthropic_response + ): + client = Anthropic(api_key="test-key", posthog_client=mock_client) + with new_context(): + identify_context("outer-user-456") + client.messages.create( + model="claude-3-opus-20240229", + messages=[{"role": "user", "content": "Hello"}], + posthog_trace_id="trace-123", + ) + + call_args = mock_client.capture.call_args[1] + props = call_args["properties"] + + assert call_args["distinct_id"] == "outer-user-456" + assert "$process_person_profile" not in props or props["$process_person_profile"] is not False + + +def test_explicit_distinct_id_overrides_outer_context(mock_client, mock_anthropic_response): + """When both outer context and explicit posthog_distinct_id are set, explicit wins.""" + with patch( + "anthropic.resources.Messages.create", return_value=mock_anthropic_response + ): + client = Anthropic(api_key="test-key", posthog_client=mock_client) + with new_context(): + identify_context("outer-user-456") + client.messages.create( + model="claude-3-opus-20240229", + messages=[{"role": "user", "content": "Hello"}], + posthog_distinct_id="explicit-user-789", + posthog_trace_id="trace-123", + ) + + call_args = mock_client.capture.call_args[1] + assert call_args["distinct_id"] == "explicit-user-789" From 0067cdfc6510619ec5f4094efa1981d04e2497f9 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 5 Mar 2026 12:37:49 +0000 Subject: [PATCH 4/5] chore: add sampo changeset for distinct_id context fix --- .sampo/changesets/steady-context-resolver.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .sampo/changesets/steady-context-resolver.md diff --git a/.sampo/changesets/steady-context-resolver.md b/.sampo/changesets/steady-context-resolver.md new file mode 100644 index 00000000..79b122fe --- /dev/null +++ b/.sampo/changesets/steady-context-resolver.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +fix(llma): use distinct_id from outer context if not provided, fix $process_person_profile for context-based identity From 1afefc25a1cf789451f9fff2fae696d5c2069efc Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 5 Mar 2026 12:38:57 +0000 Subject: [PATCH 5/5] style: ruff format --- posthog/ai/utils.py | 10 ++++++++-- posthog/test/ai/anthropic/test_anthropic.py | 22 ++++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index 842fc8a1..6e7a7037 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -367,7 +367,10 @@ def call_llm_and_track_usage( posthog_trace_id = str(uuid.uuid4()) # Check if we have a real user distinct_id (from param or outer context) - has_person_distinct_id = posthog_distinct_id is not None or contexts.get_context_distinct_id() is not None + has_person_distinct_id = ( + posthog_distinct_id is not None + or contexts.get_context_distinct_id() is not None + ) if not has_person_distinct_id: # Fall back to trace_id as distinct_id when no real user id is available. @@ -509,7 +512,10 @@ async def call_llm_and_track_usage_async( posthog_trace_id = str(uuid.uuid4()) # Check if we have a real user distinct_id (from param or outer context) - has_person_distinct_id = posthog_distinct_id is not None or contexts.get_context_distinct_id() is not None + has_person_distinct_id = ( + posthog_distinct_id is not None + or contexts.get_context_distinct_id() is not None + ) if not has_person_distinct_id: # Fall back to trace_id as distinct_id when no real user id is available. diff --git a/posthog/test/ai/anthropic/test_anthropic.py b/posthog/test/ai/anthropic/test_anthropic.py index 070fc31d..7900b82c 100644 --- a/posthog/test/ai/anthropic/test_anthropic.py +++ b/posthog/test/ai/anthropic/test_anthropic.py @@ -1311,7 +1311,9 @@ async def run_test(): # ======================= -def test_no_distinct_id_uses_trace_id_and_personless(mock_client, mock_anthropic_response): +def test_no_distinct_id_uses_trace_id_and_personless( + mock_client, mock_anthropic_response +): """When no distinct_id is provided and no outer context, trace_id is used and event is personless.""" with patch( "anthropic.resources.Messages.create", return_value=mock_anthropic_response @@ -1330,7 +1332,9 @@ def test_no_distinct_id_uses_trace_id_and_personless(mock_client, mock_anthropic assert props["$process_person_profile"] is False -def test_explicit_distinct_id_creates_person_profile(mock_client, mock_anthropic_response): +def test_explicit_distinct_id_creates_person_profile( + mock_client, mock_anthropic_response +): """When posthog_distinct_id is explicitly passed, it is used and event is not personless.""" with patch( "anthropic.resources.Messages.create", return_value=mock_anthropic_response @@ -1347,7 +1351,10 @@ def test_explicit_distinct_id_creates_person_profile(mock_client, mock_anthropic props = call_args["properties"] assert call_args["distinct_id"] == "user-123" - assert "$process_person_profile" not in props or props["$process_person_profile"] is not False + assert ( + "$process_person_profile" not in props + or props["$process_person_profile"] is not False + ) def test_outer_context_distinct_id_is_used(mock_client, mock_anthropic_response): @@ -1368,10 +1375,15 @@ def test_outer_context_distinct_id_is_used(mock_client, mock_anthropic_response) props = call_args["properties"] assert call_args["distinct_id"] == "outer-user-456" - assert "$process_person_profile" not in props or props["$process_person_profile"] is not False + assert ( + "$process_person_profile" not in props + or props["$process_person_profile"] is not False + ) -def test_explicit_distinct_id_overrides_outer_context(mock_client, mock_anthropic_response): +def test_explicit_distinct_id_overrides_outer_context( + mock_client, mock_anthropic_response +): """When both outer context and explicit posthog_distinct_id are set, explicit wins.""" with patch( "anthropic.resources.Messages.create", return_value=mock_anthropic_response