From cd1db41f0c70311947aed2c47e7fc455c9407804 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 2 Jun 2026 13:50:42 -0400 Subject: [PATCH 1/7] feat: emit $is_server property on captured events --- posthog/client.py | 1 + posthog/test/test_client.py | 1 + 2 files changed, 2 insertions(+) diff --git a/posthog/client.py b/posthog/client.py index d2cccc26..76191d0a 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1318,6 +1318,7 @@ def _enqueue(self, msg, disable_geoip): msg["properties"] = {} msg["properties"]["$lib"] = "posthog-python" msg["properties"]["$lib_version"] = VERSION + msg["properties"]["$is_server"] = True if disable_geoip is None: disable_geoip = self.disable_geoip diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 4fac43c8..50e08aa0 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -159,6 +159,7 @@ def test_basic_capture(self): self.assertEqual(msg["distinct_id"], "distinct_id") self.assertEqual(msg["properties"]["$lib"], "posthog-python") self.assertEqual(msg["properties"]["$lib_version"], VERSION) + self.assertEqual(msg["properties"]["$is_server"], True) # these will change between platforms so just asssert on presence here assert msg["properties"]["$python_runtime"] == mock.ANY assert msg["properties"]["$python_version"] == mock.ANY From c2be91e3b08004491a93896643ae8c48c88fbd95 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 2 Jun 2026 15:49:35 -0400 Subject: [PATCH 2/7] chore: add changeset for $is_server property --- .sampo/changesets/emit-is-server-property.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .sampo/changesets/emit-is-server-property.md diff --git a/.sampo/changesets/emit-is-server-property.md b/.sampo/changesets/emit-is-server-property.md new file mode 100644 index 00000000..2ebdbad0 --- /dev/null +++ b/.sampo/changesets/emit-is-server-property.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Emit `$is_server` property on captured events so PostHog can identify server-side events. From 2f1a29d0cc70d1934d25034891668a874e22e532 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 2 Jun 2026 17:39:07 -0400 Subject: [PATCH 3/7] feat: make $is_server configurable (default true) --- .sampo/changesets/emit-is-server-property.md | 2 +- posthog/__init__.py | 2 ++ posthog/client.py | 5 ++++- posthog/test/test_client.py | 15 +++++++++++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.sampo/changesets/emit-is-server-property.md b/.sampo/changesets/emit-is-server-property.md index 2ebdbad0..25dc7dfd 100644 --- a/.sampo/changesets/emit-is-server-property.md +++ b/.sampo/changesets/emit-is-server-property.md @@ -2,4 +2,4 @@ pypi/posthog: patch --- -Emit `$is_server` property on captured events so PostHog can identify server-side events. +Add a configurable `$is_server` event property (default `true`) so PostHog can identify server-side events. Set `is_server=False` when using posthog-python as a client/CLI so the device OS is attributed normally. diff --git a/posthog/__init__.py b/posthog/__init__.py index 559aba88..d40f4bc7 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -313,6 +313,7 @@ def get_tags() -> Dict[str, Any]: project_api_key = None # type: Optional[str] poll_interval = 30 # type: int disable_geoip = True # type: bool +is_server = True # type: bool feature_flags_request_timeout_seconds = 3 # type: int super_properties = None # type: Optional[Dict] enable_exception_autocapture = False # type: bool @@ -1084,6 +1085,7 @@ def setup() -> Client: poll_interval=poll_interval, disabled=disabled, disable_geoip=disable_geoip, + is_server=is_server, feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds, super_properties=super_properties, # TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK) diff --git a/posthog/client.py b/posthog/client.py index 76191d0a..abe89e95 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -194,6 +194,7 @@ def __init__( personal_api_key=None, disabled=False, disable_geoip=True, + is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, super_properties=None, @@ -314,6 +315,7 @@ def __init__( self._flag_definition_cache_provider_async_runner_lock = threading.Lock() self.disabled = disabled or not self.api_key self.disable_geoip = disable_geoip + self.is_server = is_server self.historical_migration = historical_migration self.super_properties = super_properties self.enable_exception_autocapture = enable_exception_autocapture @@ -1318,7 +1320,8 @@ def _enqueue(self, msg, disable_geoip): msg["properties"] = {} msg["properties"]["$lib"] = "posthog-python" msg["properties"]["$lib_version"] = VERSION - msg["properties"]["$is_server"] = True + if self.is_server: + msg["properties"]["$is_server"] = True if disable_geoip is None: disable_geoip = self.disable_geoip diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 50e08aa0..8be15b5d 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -166,6 +166,21 @@ def test_basic_capture(self): assert msg["properties"]["$os"] == mock.ANY assert msg["properties"]["$os_version"] == mock.ANY + def test_capture_omits_is_server_when_disabled(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, + is_server=False, + ) + client.capture("python test event", distinct_id="distinct_id") + self.assertFalse(self.failed) + + msg = mock_post.call_args[1]["batch"][0] + self.assertEqual(msg["properties"]["$lib"], "posthog-python") + self.assertNotIn("$is_server", msg["properties"]) + def test_basic_capture_with_uuid(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) From 214b79fad845705483f2ad41939ddf057a1f2616 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 2 Jun 2026 18:45:08 -0400 Subject: [PATCH 4/7] fix: set $is_server after super_properties merge so it cannot be overridden --- posthog/client.py | 7 +++++-- posthog/test/test_client.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index abe89e95..50c88d26 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1320,8 +1320,6 @@ def _enqueue(self, msg, disable_geoip): msg["properties"] = {} msg["properties"]["$lib"] = "posthog-python" msg["properties"]["$lib_version"] = VERSION - if self.is_server: - msg["properties"]["$is_server"] = True if disable_geoip is None: disable_geoip = self.disable_geoip @@ -1332,6 +1330,11 @@ def _enqueue(self, msg, disable_geoip): if self.super_properties: msg["properties"] = {**msg["properties"], **self.super_properties} + # Set after the super_properties merge so this SDK's server classification + # can't be silently overridden by a user-provided super property. + if self.is_server: + msg["properties"]["$is_server"] = True + msg["distinct_id"] = stringify_id(msg.get("distinct_id", None)) msg = clean(msg) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 8be15b5d..9605776d 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -181,6 +181,20 @@ def test_capture_omits_is_server_when_disabled(self): self.assertEqual(msg["properties"]["$lib"], "posthog-python") self.assertNotIn("$is_server", msg["properties"]) + def test_is_server_not_overridden_by_super_properties(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, + super_properties={"$is_server": False}, + ) + client.capture("python test event", distinct_id="distinct_id") + self.assertFalse(self.failed) + + msg = mock_post.call_args[1]["batch"][0] + self.assertEqual(msg["properties"]["$is_server"], True) + def test_basic_capture_with_uuid(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) From 2037d02b313f0729cfffcb326675d20f37eb0090 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 2 Jun 2026 19:14:16 -0400 Subject: [PATCH 5/7] test: include $is_server in group_identify exact-match assertions --- posthog/test/test_client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 9605776d..e9d7054c 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -1421,6 +1421,7 @@ def test_basic_group_identify(self): "$lib": "posthog-python", "$lib_version": VERSION, "$geoip_disable": True, + "$is_server": True, }, ) self.assertTrue(isinstance(msg["timestamp"], str)) @@ -1450,6 +1451,7 @@ def test_basic_group_identify_with_distinct_id(self): "$lib": "posthog-python", "$lib_version": VERSION, "$geoip_disable": True, + "$is_server": True, }, ) self.assertTrue(isinstance(msg["timestamp"], str)) @@ -1483,6 +1485,7 @@ def test_advanced_group_identify(self): "$lib": "posthog-python", "$lib_version": VERSION, "$geoip_disable": True, + "$is_server": True, }, ) self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") @@ -1518,6 +1521,7 @@ def test_advanced_group_identify_with_distinct_id(self): "$lib": "posthog-python", "$lib_version": VERSION, "$geoip_disable": True, + "$is_server": True, }, ) self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00") From b9760e72a68f7c2f36bf74b07f9e22907f193859 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 3 Jun 2026 10:52:58 -0400 Subject: [PATCH 6/7] chore: bump changeset to minor (new is_server option) --- .sampo/changesets/emit-is-server-property.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sampo/changesets/emit-is-server-property.md b/.sampo/changesets/emit-is-server-property.md index 25dc7dfd..e3c05a24 100644 --- a/.sampo/changesets/emit-is-server-property.md +++ b/.sampo/changesets/emit-is-server-property.md @@ -1,5 +1,5 @@ --- -pypi/posthog: patch +pypi/posthog: minor --- Add a configurable `$is_server` event property (default `true`) so PostHog can identify server-side events. Set `is_server=False` when using posthog-python as a client/CLI so the device OS is attributed normally. From 4e9fc632758b0576cd893af6e717249fc31458dc Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 3 Jun 2026 11:05:12 -0400 Subject: [PATCH 7/7] docs: document is_server option --- posthog/__init__.py | 3 +++ posthog/client.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/posthog/__init__.py b/posthog/__init__.py index d40f4bc7..892a68c6 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -282,6 +282,9 @@ def get_tags() -> Dict[str, Any]: poll_interval: Seconds between local feature flag definition refreshes. disable_geoip: Whether to disable server-side GeoIP enrichment. Defaults to True. + is_server: Whether events are emitted from a server-side runtime. Defaults to + True; set to False when using the SDK as a client/CLI so the device OS is + attributed to the person normally. feature_flags_request_timeout_seconds: Timeout in seconds for feature flag and remote config requests. super_properties: Properties merged into every captured event. diff --git a/posthog/client.py b/posthog/client.py index 50c88d26..bf967449 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -240,6 +240,9 @@ def __init__( disabled: If True, disable captures and API requests. Useful in tests. disable_geoip: Whether to disable server-side GeoIP enrichment. Defaults to True. + is_server: Whether events are emitted from a server-side runtime. + Defaults to True; set to False when using the SDK as a client/CLI + so the device OS is attributed to the person normally. historical_migration: Mark events as historical migration imports. feature_flags_request_timeout_seconds: Timeout in seconds for feature flag and remote config requests.