From af50819d1330d85d708425dfcfce1be7a0691e38 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 30 Jul 2026 16:39:03 +0200 Subject: [PATCH 1/5] fix(contexts): reset context after fork --- .sampo/changesets/stalwart-bard-goulven.md | 5 ++ posthog/contexts.py | 33 ++++++++++- posthog/test/test_contexts.py | 66 ++++++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 .sampo/changesets/stalwart-bard-goulven.md diff --git a/.sampo/changesets/stalwart-bard-goulven.md b/.sampo/changesets/stalwart-bard-goulven.md new file mode 100644 index 000000000..8fa93fb46 --- /dev/null +++ b/.sampo/changesets/stalwart-bard-goulven.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Reset PostHog context after fork diff --git a/posthog/contexts.py b/posthog/contexts.py index a5ccae376..6df133a7c 100644 --- a/posthog/contexts.py +++ b/posthog/contexts.py @@ -1,4 +1,5 @@ import contextvars +import os from contextlib import contextmanager from typing import Optional, Any, Callable, Dict, TypeVar, cast, TYPE_CHECKING @@ -14,8 +15,10 @@ def __init__( fresh: bool = False, capture_exceptions: bool = True, client: Optional["Client"] = None, + generation: int = 0, ): self.client: Optional[Client] = client + self.generation = generation self.parent = parent self.fresh = fresh self.capture_exceptions = capture_exceptions @@ -128,10 +131,28 @@ def get_code_variables_detect_secrets(self) -> Optional[bool]: _context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar( "posthog_context_stack", default=None ) +_context_generation = 0 + + +def _reset_context_after_fork() -> None: + global _context_generation + + _context_generation += 1 + _context_stack.set(None) + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_reset_context_after_fork) def _get_current_context() -> Optional[ContextScope]: - return _context_stack.get() + current_context = _context_stack.get() + if ( + current_context is not None + and current_context.generation != _context_generation + ): + return None + return current_context def _default_capture_exceptions(client: Optional["Client"] = None) -> bool: @@ -199,13 +220,18 @@ def new_context( from . import capture_exception current_context = _get_current_context() + context_generation = _context_generation resolved_capture_exceptions = ( capture_exceptions if capture_exceptions is not None else _default_capture_exceptions(client) ) new_context = ContextScope( - current_context, fresh, resolved_capture_exceptions, client + current_context, + fresh, + resolved_capture_exceptions, + client, + context_generation, ) _context_stack.set(new_context) @@ -219,7 +245,8 @@ def new_context( capture_exception(e) raise finally: - _context_stack.set(new_context.get_parent()) + if context_generation == _context_generation: + _context_stack.set(new_context.get_parent()) def tag(key: str, value: Any) -> None: diff --git a/posthog/test/test_contexts.py b/posthog/test/test_contexts.py index 4c7aa5577..f0b274410 100644 --- a/posthog/test/test_contexts.py +++ b/posthog/test/test_contexts.py @@ -1,4 +1,6 @@ import asyncio +import contextvars +import os import unittest from unittest.mock import MagicMock, patch @@ -11,8 +13,10 @@ tag, identify_context, set_context_session, + set_context_device_id, get_context_session_id, get_context_distinct_id, + get_context_device_id, ) @@ -275,6 +279,68 @@ def test_context_inheritance_non_fresh_context(self): assert get_context_distinct_id() == "user123" assert get_context_session_id() == "session456" + @unittest.skipUnless( + hasattr(os, "fork") and hasattr(os, "register_at_fork"), + "requires os.fork and os.register_at_fork", + ) + def test_fork_clears_context_in_child_and_preserves_parent(self): + def context_state(): + return ( + get_context_distinct_id(), + get_context_session_id(), + get_context_device_id(), + get_tags(), + ) + + read_fd, write_fd = os.pipe() + with new_context(fresh=True): + identify_context("parent-user") + set_context_session("parent-session") + set_context_device_id("parent-device") + tag("parent-tag", "parent-value") + + with new_context(): + copied_context = contextvars.copy_context() + pid = os.fork() + if pid == 0: + os.close(read_fd) + else: + os.close(write_fd) + + if pid == 0: + child_state_after_inner_scope = context_state() + else: + parent_state = context_state() + + if pid == 0: + child_states = ( + child_state_after_inner_scope, + context_state(), + copied_context.run(context_state), + ) + os.write(write_fd, repr(child_states).encode()) + os.close(write_fd) + os._exit(0) + + child_states = os.read(read_fd, 4096) + os.close(read_fd) + _, status = os.waitpid(pid, 0) + + empty_state = (None, None, None, {}) + self.assertTrue(os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0) + self.assertEqual( + child_states, repr((empty_state, empty_state, empty_state)).encode() + ) + self.assertEqual( + parent_state, + ( + "parent-user", + "parent-session", + "parent-device", + {"parent-tag": "parent-value"}, + ), + ) + def test_child_tags_override_parent_tags_in_non_fresh_context(self): with new_context(fresh=True): tag("shared_key", "parent_value") From e2bcdd0b847db7e14ca98387dcd928b4c80d4f89 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 30 Jul 2026 18:41:42 +0200 Subject: [PATCH 2/5] chore: update public API snapshot --- references/public_api_snapshot.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index d83fbb742..e9d7482d4 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -612,6 +612,7 @@ attribute posthog.contexts.ContextScope.code_variables_mask_url_credentials: Opt attribute posthog.contexts.ContextScope.device_id: Optional[str] = None attribute posthog.contexts.ContextScope.distinct_id: Optional[str] = None attribute posthog.contexts.ContextScope.fresh = fresh +attribute posthog.contexts.ContextScope.generation = generation attribute posthog.contexts.ContextScope.parent = parent attribute posthog.contexts.ContextScope.session_id: Optional[str] = None attribute posthog.contexts.ContextScope.tags: Dict[str, Any] = {} @@ -910,7 +911,7 @@ class posthog.capture_mode.CaptureMode class posthog.capture_v1.CaptureV1Error(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None) class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, _use_ai_lane=False, _enable_multimodal_capture=False) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, endpoint=EVENTS_ENDPOINT, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE) -class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) +class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None, generation: int = 0) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) class posthog.exception_utils.AnnotatedValue(value, metadata) class posthog.exception_utils.VariableSizeLimiter(max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT) From 91069cd58b054eea28a1a524a73d91dccd306ad9 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:07:54 +0700 Subject: [PATCH 3/5] fix(contexts): keep fork generation private --- posthog/contexts.py | 10 +++++----- references/public_api_snapshot.txt | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/posthog/contexts.py b/posthog/contexts.py index 6df133a7c..f897d1d96 100644 --- a/posthog/contexts.py +++ b/posthog/contexts.py @@ -8,6 +8,9 @@ from posthog.client import Client +_context_generation = 0 + + class ContextScope: def __init__( self, @@ -15,10 +18,9 @@ def __init__( fresh: bool = False, capture_exceptions: bool = True, client: Optional["Client"] = None, - generation: int = 0, ): self.client: Optional[Client] = client - self.generation = generation + self._generation = _context_generation self.parent = parent self.fresh = fresh self.capture_exceptions = capture_exceptions @@ -131,7 +133,6 @@ def get_code_variables_detect_secrets(self) -> Optional[bool]: _context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar( "posthog_context_stack", default=None ) -_context_generation = 0 def _reset_context_after_fork() -> None: @@ -149,7 +150,7 @@ def _get_current_context() -> Optional[ContextScope]: current_context = _context_stack.get() if ( current_context is not None - and current_context.generation != _context_generation + and current_context._generation != _context_generation ): return None return current_context @@ -231,7 +232,6 @@ def new_context( fresh, resolved_capture_exceptions, client, - context_generation, ) _context_stack.set(new_context) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index e9d7482d4..d83fbb742 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -612,7 +612,6 @@ attribute posthog.contexts.ContextScope.code_variables_mask_url_credentials: Opt attribute posthog.contexts.ContextScope.device_id: Optional[str] = None attribute posthog.contexts.ContextScope.distinct_id: Optional[str] = None attribute posthog.contexts.ContextScope.fresh = fresh -attribute posthog.contexts.ContextScope.generation = generation attribute posthog.contexts.ContextScope.parent = parent attribute posthog.contexts.ContextScope.session_id: Optional[str] = None attribute posthog.contexts.ContextScope.tags: Dict[str, Any] = {} @@ -911,7 +910,7 @@ class posthog.capture_mode.CaptureMode class posthog.capture_v1.CaptureV1Error(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None) class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, _use_ai_lane=False, _enable_multimodal_capture=False) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, endpoint=EVENTS_ENDPOINT, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE) -class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None, generation: int = 0) +class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) class posthog.exception_utils.AnnotatedValue(value, metadata) class posthog.exception_utils.VariableSizeLimiter(max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT) From f0a62fc14fc2bb0c6bed867e340f3aef3ee85e80 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:20:11 +0700 Subject: [PATCH 4/5] test(contexts): document fork context policy --- .sampo/changesets/stalwart-bard-goulven.md | 2 +- posthog/test/test_contexts.py | 26 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.sampo/changesets/stalwart-bard-goulven.md b/.sampo/changesets/stalwart-bard-goulven.md index 8fa93fb46..b51a6e3a1 100644 --- a/.sampo/changesets/stalwart-bard-goulven.md +++ b/.sampo/changesets/stalwart-bard-goulven.md @@ -2,4 +2,4 @@ pypi/posthog: patch --- -Reset PostHog context after fork +Reset PostHog context after fork. Forked children no longer retain the parent process's active lexical context; they start without inherited context and can establish a new child-local context. diff --git a/posthog/test/test_contexts.py b/posthog/test/test_contexts.py index f0b274410..d43d96778 100644 --- a/posthog/test/test_contexts.py +++ b/posthog/test/test_contexts.py @@ -304,6 +304,13 @@ def context_state(): pid = os.fork() if pid == 0: os.close(read_fd) + with new_context(): + identify_context("child-user") + set_context_session("child-session") + set_context_device_id("child-device") + tag("child-tag", "child-value") + child_local_state = context_state() + child_state_after_local_scope = context_state() else: os.close(write_fd) @@ -314,6 +321,8 @@ def context_state(): if pid == 0: child_states = ( + child_local_state, + child_state_after_local_scope, child_state_after_inner_scope, context_state(), copied_context.run(context_state), @@ -328,8 +337,23 @@ def context_state(): empty_state = (None, None, None, {}) self.assertTrue(os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0) + child_local_state = ( + "child-user", + "child-session", + "child-device", + {"child-tag": "child-value"}, + ) self.assertEqual( - child_states, repr((empty_state, empty_state, empty_state)).encode() + child_states, + repr( + ( + child_local_state, + empty_state, + empty_state, + empty_state, + empty_state, + ) + ).encode(), ) self.assertEqual( parent_state, From 0177c0f918c1b58e3d1127c0654138687d730256 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 31 Jul 2026 09:52:05 +0200 Subject: [PATCH 5/5] test(contexts): guarantee fork probe cleanup --- posthog/test/test_contexts.py | 87 +++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/posthog/test/test_contexts.py b/posthog/test/test_contexts.py index d43d96778..e75a6610d 100644 --- a/posthog/test/test_contexts.py +++ b/posthog/test/test_contexts.py @@ -293,50 +293,69 @@ def context_state(): ) read_fd, write_fd = os.pipe() - with new_context(fresh=True): - identify_context("parent-user") - set_context_session("parent-session") - set_context_device_id("parent-device") - tag("parent-tag", "parent-value") - - with new_context(): - copied_context = contextvars.copy_context() - pid = os.fork() + pid = -1 + child_result = b"" + child_exit_code = 1 + try: + with new_context(fresh=True): + identify_context("parent-user") + set_context_session("parent-session") + set_context_device_id("parent-device") + tag("parent-tag", "parent-value") + + with new_context(): + copied_context = contextvars.copy_context() + pid = os.fork() + if pid == 0: + os.close(read_fd) + with new_context(): + identify_context("child-user") + set_context_session("child-session") + set_context_device_id("child-device") + tag("child-tag", "child-value") + child_local_state = context_state() + child_state_after_local_scope = context_state() + else: + os.close(write_fd) + if pid == 0: - os.close(read_fd) - with new_context(): - identify_context("child-user") - set_context_session("child-session") - set_context_device_id("child-device") - tag("child-tag", "child-value") - child_local_state = context_state() - child_state_after_local_scope = context_state() + child_state_after_inner_scope = context_state() else: - os.close(write_fd) + parent_state = context_state() if pid == 0: - child_state_after_inner_scope = context_state() - else: - parent_state = context_state() - - if pid == 0: - child_states = ( - child_local_state, - child_state_after_local_scope, - child_state_after_inner_scope, - context_state(), - copied_context.run(context_state), - ) - os.write(write_fd, repr(child_states).encode()) - os.close(write_fd) - os._exit(0) + child_states = ( + child_local_state, + child_state_after_local_scope, + child_state_after_inner_scope, + context_state(), + copied_context.run(context_state), + ) + child_result = repr(child_states).encode() + child_exit_code = 0 + except BaseException as error: + if pid != 0: + raise + child_result = f"{type(error).__name__}: {error}".encode() + finally: + if pid == 0: + try: + os.write(write_fd, child_result) + finally: + try: + os.close(write_fd) + finally: + os._exit(child_exit_code) child_states = os.read(read_fd, 4096) os.close(read_fd) _, status = os.waitpid(pid, 0) empty_state = (None, None, None, {}) - self.assertTrue(os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0) + self.assertTrue( + os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, + child_states.decode(errors="replace"), + ) child_local_state = ( "child-user", "child-session",