Skip to content

Commit 3aed638

Browse files
feat: emit $is_server property on captured events (#643)
* feat: emit $is_server property on captured events * chore: add changeset for $is_server property * feat: make $is_server configurable (default true) * fix: set $is_server after super_properties merge so it cannot be overridden * test: include $is_server in group_identify exact-match assertions * chore: bump changeset to minor (new is_server option) * docs: document is_server option
1 parent a185fdb commit 3aed638

4 files changed

Lines changed: 54 additions & 0 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: minor
3+
---
4+
5+
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.

‎posthog/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,9 @@ def get_tags() -> Dict[str, Any]:
282282
poll_interval: Seconds between local feature flag definition refreshes.
283283
disable_geoip: Whether to disable server-side GeoIP enrichment. Defaults to
284284
True.
285+
is_server: Whether events are emitted from a server-side runtime. Defaults to
286+
True; set to False when using the SDK as a client/CLI so the device OS is
287+
attributed to the person normally.
285288
feature_flags_request_timeout_seconds: Timeout in seconds for feature flag
286289
and remote config requests.
287290
super_properties: Properties merged into every captured event.
@@ -313,6 +316,7 @@ def get_tags() -> Dict[str, Any]:
313316
project_api_key = None # type: Optional[str]
314317
poll_interval = 30 # type: int
315318
disable_geoip = True # type: bool
319+
is_server = True # type: bool
316320
feature_flags_request_timeout_seconds = 3 # type: int
317321
super_properties = None # type: Optional[Dict]
318322
enable_exception_autocapture = False # type: bool
@@ -1084,6 +1088,7 @@ def setup() -> Client:
10841088
poll_interval=poll_interval,
10851089
disabled=disabled,
10861090
disable_geoip=disable_geoip,
1091+
is_server=is_server,
10871092
feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds,
10881093
super_properties=super_properties,
10891094
# TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK)

‎posthog/client.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def __init__(
194194
personal_api_key=None,
195195
disabled=False,
196196
disable_geoip=True,
197+
is_server=True,
197198
historical_migration=False,
198199
feature_flags_request_timeout_seconds=3,
199200
super_properties=None,
@@ -239,6 +240,9 @@ def __init__(
239240
disabled: If True, disable captures and API requests. Useful in tests.
240241
disable_geoip: Whether to disable server-side GeoIP enrichment.
241242
Defaults to True.
243+
is_server: Whether events are emitted from a server-side runtime.
244+
Defaults to True; set to False when using the SDK as a client/CLI
245+
so the device OS is attributed to the person normally.
242246
historical_migration: Mark events as historical migration imports.
243247
feature_flags_request_timeout_seconds: Timeout in seconds for feature
244248
flag and remote config requests.
@@ -314,6 +318,7 @@ def __init__(
314318
self._flag_definition_cache_provider_async_runner_lock = threading.Lock()
315319
self.disabled = disabled or not self.api_key
316320
self.disable_geoip = disable_geoip
321+
self.is_server = is_server
317322
self.historical_migration = historical_migration
318323
self.super_properties = super_properties
319324
self.enable_exception_autocapture = enable_exception_autocapture
@@ -1328,6 +1333,11 @@ def _enqueue(self, msg, disable_geoip):
13281333
if self.super_properties:
13291334
msg["properties"] = {**msg["properties"], **self.super_properties}
13301335

1336+
# Set after the super_properties merge so this SDK's server classification
1337+
# can't be silently overridden by a user-provided super property.
1338+
if self.is_server:
1339+
msg["properties"]["$is_server"] = True
1340+
13311341
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
13321342

13331343
msg = clean(msg)

‎posthog/test/test_client.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,42 @@ def test_basic_capture(self):
159159
self.assertEqual(msg["distinct_id"], "distinct_id")
160160
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
161161
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
162+
self.assertEqual(msg["properties"]["$is_server"], True)
162163
# these will change between platforms so just asssert on presence here
163164
assert msg["properties"]["$python_runtime"] == mock.ANY
164165
assert msg["properties"]["$python_version"] == mock.ANY
165166
assert msg["properties"]["$os"] == mock.ANY
166167
assert msg["properties"]["$os_version"] == mock.ANY
167168

169+
def test_capture_omits_is_server_when_disabled(self):
170+
with mock.patch("posthog.client.batch_post") as mock_post:
171+
client = Client(
172+
FAKE_TEST_API_KEY,
173+
on_error=self.set_fail,
174+
sync_mode=True,
175+
is_server=False,
176+
)
177+
client.capture("python test event", distinct_id="distinct_id")
178+
self.assertFalse(self.failed)
179+
180+
msg = mock_post.call_args[1]["batch"][0]
181+
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
182+
self.assertNotIn("$is_server", msg["properties"])
183+
184+
def test_is_server_not_overridden_by_super_properties(self):
185+
with mock.patch("posthog.client.batch_post") as mock_post:
186+
client = Client(
187+
FAKE_TEST_API_KEY,
188+
on_error=self.set_fail,
189+
sync_mode=True,
190+
super_properties={"$is_server": False},
191+
)
192+
client.capture("python test event", distinct_id="distinct_id")
193+
self.assertFalse(self.failed)
194+
195+
msg = mock_post.call_args[1]["batch"][0]
196+
self.assertEqual(msg["properties"]["$is_server"], True)
197+
168198
def test_basic_capture_with_uuid(self):
169199
with mock.patch("posthog.client.batch_post") as mock_post:
170200
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
@@ -1391,6 +1421,7 @@ def test_basic_group_identify(self):
13911421
"$lib": "posthog-python",
13921422
"$lib_version": VERSION,
13931423
"$geoip_disable": True,
1424+
"$is_server": True,
13941425
},
13951426
)
13961427
self.assertTrue(isinstance(msg["timestamp"], str))
@@ -1420,6 +1451,7 @@ def test_basic_group_identify_with_distinct_id(self):
14201451
"$lib": "posthog-python",
14211452
"$lib_version": VERSION,
14221453
"$geoip_disable": True,
1454+
"$is_server": True,
14231455
},
14241456
)
14251457
self.assertTrue(isinstance(msg["timestamp"], str))
@@ -1453,6 +1485,7 @@ def test_advanced_group_identify(self):
14531485
"$lib": "posthog-python",
14541486
"$lib_version": VERSION,
14551487
"$geoip_disable": True,
1488+
"$is_server": True,
14561489
},
14571490
)
14581491
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
@@ -1488,6 +1521,7 @@ def test_advanced_group_identify_with_distinct_id(self):
14881521
"$lib": "posthog-python",
14891522
"$lib_version": VERSION,
14901523
"$geoip_disable": True,
1524+
"$is_server": True,
14911525
},
14921526
)
14931527
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")

0 commit comments

Comments
 (0)