Skip to content

Commit 62ac4ab

Browse files
committed
feat!: SDK values are defaults that caller values override
$is_server, $geoip_disable and system context now fill last, only keys the event, context tags and super_properties left unset, before before_send. A super property $geoip_disable: False now wins over disable_geoip=True. The groups argument merges into a $groups property key by key and wins, in Client, AsyncClient and posthog.mcp events.
1 parent cb9b3c0 commit 62ac4ab

7 files changed

Lines changed: 176 additions & 76 deletions

File tree

‎posthog/async_client.py‎

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
_event_options,
4242
_EventDefaults,
4343
_fill_event_defaults,
44+
_merge_groups,
4445
)
4546
from .capture_send import _CAPTURE_V1_PATH
4647
from .client import (
@@ -409,7 +410,6 @@ def _normalize_uuid(self, msg: dict[str, Any]) -> str:
409410
def _prepare_event(
410411
self,
411412
msg: dict[str, Any],
412-
disable_geoip: Optional[bool],
413413
property_allowlist=None,
414414
) -> tuple[Optional[dict[str, Any]], Optional[str]]:
415415
if self.disabled or not self._accepting:
@@ -432,13 +432,7 @@ def _prepare_event(
432432
properties["$lib"] = "posthog-python"
433433
properties["$lib_version"] = VERSION
434434

435-
if disable_geoip is None:
436-
disable_geoip = self.disable_geoip
437-
if disable_geoip:
438-
properties["$geoip_disable"] = True
439435
msg["options"] = msg.get("options") or {}
440-
if self.is_server:
441-
msg["properties"]["$is_server"] = True
442436
if property_allowlist is not None:
443437
msg["properties"] = {
444438
key: value
@@ -481,7 +475,11 @@ def _event_defaults(
481475
context_options: Optional[dict[str, Any]] = None,
482476
derived_options: Optional[dict[str, Any]] = None,
483477
property_allowlist=None,
478+
disable_geoip: Optional[bool] = None,
479+
system_properties: Optional[dict[str, Any]] = None,
484480
) -> _EventDefaults:
481+
if disable_geoip is None:
482+
disable_geoip = self.disable_geoip
485483
return _build_event_defaults(
486484
super_properties=self.super_properties,
487485
super_options=self.super_options,
@@ -490,20 +488,23 @@ def _event_defaults(
490488
context_options=context_options,
491489
derived_options=derived_options,
492490
property_allowlist=property_allowlist,
491+
is_server=self.is_server,
492+
disable_geoip=disable_geoip,
493+
system_properties=system_properties,
493494
)
494495

495496
def _build_capture_event(
496497
self, event: str, kwargs: OptionalCaptureArgs
497-
) -> tuple[dict[str, Any], Optional[bool], Any, _EventDefaults]:
498-
properties = {**(kwargs.get("properties") or {}), **system_context()}
498+
) -> tuple[dict[str, Any], Any, _EventDefaults]:
499+
properties = dict(kwargs.get("properties") or {})
499500
if self.capture_trace_context:
500501
properties = {**_get_current_otel_span_properties(), **properties}
501502
properties = _add_context_session_id(properties)
502503

503504
distinct_id, personless = _get_identity_state(kwargs.get("distinct_id"))
504505
groups = kwargs.get("groups")
505506
if groups:
506-
properties["$groups"] = groups
507+
_merge_groups(properties, groups)
507508

508509
flags_snapshot = kwargs.get("flags")
509510
send_feature_flags = kwargs.get("send_feature_flags")
@@ -526,13 +527,14 @@ def _build_capture_event(
526527
"uuid": kwargs.get("uuid"),
527528
"options": _event_options(kwargs.get("options")),
528529
},
529-
kwargs.get("disable_geoip"),
530530
kwargs.get("_property_allowlist"),
531531
self._event_defaults(
532532
context_properties=_context_tag_defaults(),
533533
context_options=_get_context_options(),
534534
derived_options=_personless_options(personless),
535535
property_allowlist=kwargs.get("_property_allowlist"),
536+
disable_geoip=kwargs.get("disable_geoip"),
537+
system_properties=system_context(),
536538
),
537539
)
538540

@@ -541,12 +543,8 @@ def capture(
541543
) -> Optional[str]:
542544
"""Queue an event without blocking for network delivery."""
543545
try:
544-
msg, disable_geoip, property_allowlist, defaults = (
545-
self._build_capture_event(event, kwargs)
546-
)
547-
prepared, sent_uuid = self._prepare_event(
548-
msg, disable_geoip, property_allowlist
549-
)
546+
msg, property_allowlist, defaults = self._build_capture_event(event, kwargs)
547+
prepared, sent_uuid = self._prepare_event(msg, property_allowlist)
550548
if prepared is None or sent_uuid is None:
551549
return None
552550
if not self.send:
@@ -588,12 +586,8 @@ async def capture_immediate(
588586
self._immediate_callers[current] = self._immediate_callers.get(current, 0) + 1
589587
error_batch: list[dict[str, Any]] = []
590588
try:
591-
msg, disable_geoip, property_allowlist, defaults = (
592-
self._build_capture_event(event, kwargs)
593-
)
594-
prepared, sent_uuid = self._prepare_event(
595-
msg, disable_geoip, property_allowlist
596-
)
589+
msg, property_allowlist, defaults = self._build_capture_event(event, kwargs)
590+
prepared, sent_uuid = self._prepare_event(msg, property_allowlist)
597591
if prepared is None or sent_uuid is None:
598592
return None
599593
processed = await self._process_event(prepared, defaults)
@@ -771,12 +765,14 @@ def _enqueue_built_event(
771765
disable_geoip: Optional[bool],
772766
context_options: Optional[dict[str, Any]] = None,
773767
) -> Optional[str]:
774-
prepared, sent_uuid = self._prepare_event(msg, disable_geoip)
768+
prepared, sent_uuid = self._prepare_event(msg)
775769
if prepared is None or sent_uuid is None:
776770
return None
777771
if not self.send:
778772
return sent_uuid
779-
defaults = self._event_defaults(context_options=context_options)
773+
defaults = self._event_defaults(
774+
context_options=context_options, disable_geoip=disable_geoip
775+
)
780776
if not self._enqueue_prepared_event(prepared, defaults):
781777
return None
782778
return sent_uuid

‎posthog/capture_event.py‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,26 @@ def _build_event_defaults(
113113
context_options: Optional[Mapping[str, Any]] = None,
114114
derived_options: Optional[Mapping[str, Any]] = None,
115115
property_allowlist: Optional[Collection[str]] = None,
116+
is_server: bool = False,
117+
disable_geoip: bool = False,
118+
system_properties: Optional[Mapping[str, Any]] = None,
116119
) -> _EventDefaults:
117120
"""Order the layers: context, then global, then values the SDK derives."""
118-
# An explicit `$release_id` in the event or in super properties wins over
119-
# the environment value.
120-
release = {"$release_id": release_id} if release_id is not None else {}
121+
# A value in the event, the context or super properties wins over every
122+
# value the SDK adds, including `$is_server` and `$geoip_disable`.
123+
sdk_properties = dict(system_properties or {})
124+
if release_id is not None:
125+
sdk_properties["$release_id"] = release_id
126+
if is_server:
127+
sdk_properties["$is_server"] = True
128+
if disable_geoip:
129+
sdk_properties["$geoip_disable"] = True
121130
return _EventDefaults(
122-
property_layers=(context_properties or {}, super_properties or {}, release),
131+
property_layers=(
132+
context_properties or {},
133+
super_properties or {},
134+
sdk_properties,
135+
),
123136
option_layers=(
124137
context_options or {},
125138
_event_options(super_options),
@@ -168,6 +181,15 @@ def _fill_event_defaults(
168181
msg["options"] = options
169182

170183

184+
def _merge_groups(properties: dict[str, Any], groups: Mapping[str, Any]) -> None:
185+
"""Merge typed ``groups`` into the ``$groups`` property; ``groups`` wins key by key."""
186+
existing = properties.get("$groups")
187+
if isinstance(existing, dict):
188+
properties["$groups"] = {**existing, **groups}
189+
else:
190+
properties["$groups"] = dict(groups)
191+
192+
171193
def _v1_timestamp(timestamp: Any) -> str:
172194
"""Return a UTC RFC3339 timestamp string.
173195

‎posthog/client.py‎

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
_canonical_event_uuid,
4040
_event_options,
4141
_fill_event_defaults,
42+
_merge_groups,
4243
)
4344
from posthog.capture_send import (
4445
_CAPTURE_AI_V1_PATH,
@@ -812,10 +813,13 @@ def __init__(
812813
accepts a Project Secret API Key.
813814
disabled: If True, disable captures and API requests. Useful in tests.
814815
disable_geoip: Whether to disable server-side GeoIP enrichment.
815-
Defaults to True.
816+
Defaults to True. Events get ``$geoip_disable`` only when the
817+
event, context tags and ``super_properties`` leave it unset.
816818
is_server: Whether events are emitted from a server-side runtime.
817819
Defaults to True; set to False when using the SDK as a client/CLI
818-
so the device OS is attributed to the person normally.
820+
so the device OS is attributed to the person normally. Events
821+
get ``$is_server`` only when the event, context tags and
822+
``super_properties`` leave it unset.
819823
historical_migration: Mark events as historical migration imports.
820824
feature_flags_request_timeout_seconds: Timeout in seconds for feature
821825
flag and remote config requests.
@@ -849,8 +853,10 @@ def __init__(
849853
False.
850854
before_send: Optional callback that can modify or drop events before
851855
upload. Return ``None`` to drop an event. Context tags,
852-
context options, ``super_properties`` and ``super_options``
853-
fill in before it runs, so it can change or remove them.
856+
context options, ``super_properties``, ``super_options`` and
857+
the values the SDK adds, such as ``$is_server``,
858+
``$geoip_disable`` and ``$os``, fill in before it runs, so it
859+
can change or remove them.
854860
flag_fallback_cache_url: Optional feature flag fallback cache URL,
855861
such as ``memory://local/?ttl=300&size=10000`` or a Redis URL.
856862
enable_local_evaluation: Whether to poll feature flag definitions for
@@ -1694,14 +1700,17 @@ def capture(
16941700
uuid: A unique identifier for the event. If provided, it must be a
16951701
valid UUID string or uuid.UUID instance; invalid values are
16961702
ignored and replaced with a newly generated UUID.
1697-
groups: A dictionary of group information.
1703+
groups: A dictionary of group information. It merges into a
1704+
``$groups`` property and wins key by key.
16981705
flags: A FeatureFlagEvaluations snapshot from evaluate_flags(). The
16991706
exact values from the snapshot are attached with no extra /flags
17001707
request.
17011708
send_feature_flags: Deprecated. Prefer flags=... from
17021709
evaluate_flags(). When truthy, evaluates flags during capture and
17031710
attaches them to the event.
1704-
disable_geoip: Whether to disable GeoIP for this event.
1711+
disable_geoip: Whether to disable GeoIP for this event. A
1712+
``$geoip_disable`` property in the event, context tags or
1713+
``super_properties`` wins.
17051714
options: Capture options for this event, such as
17061715
``{"process_person_profile": False}``. Sent as given, for
17071716
PostHog to validate. They override context options and
@@ -1788,7 +1797,7 @@ def _capture(
17881797
# applied to the fully-enriched properties dict just before enqueueing.
17891798
property_allowlist = kwargs.get("_property_allowlist", None)
17901799

1791-
properties = {**(properties or {}), **system_context()}
1800+
properties = dict(properties or {})
17921801

17931802
if self.capture_trace_context:
17941803
properties = {**_get_current_otel_span_properties(), **properties}
@@ -1807,7 +1816,7 @@ def _capture(
18071816
}
18081817

18091818
if groups:
1810-
properties["$groups"] = groups
1819+
_merge_groups(properties, groups)
18111820

18121821
extra_properties: dict[str, Any] = {}
18131822

@@ -1908,6 +1917,7 @@ def _capture(
19081917
context_properties=_context_tag_defaults(),
19091918
context_options=_context_get_context_options(),
19101919
derived_options=_personless_options(personless),
1920+
system_properties=system_context(),
19111921
)
19121922

19131923
def _parse_send_feature_flags(self, send_feature_flags) -> SendFeatureFlagsOptions:
@@ -2499,6 +2509,7 @@ def _enqueue(
24992509
context_properties=None,
25002510
context_options=None,
25012511
derived_options=None,
2512+
system_properties=None,
25022513
):
25032514
# type: (...) -> Optional[str]
25042515
"""Push a new `msg` onto a lane's queue (analytics when unspecified), return the event uuid or None."""
@@ -2532,16 +2543,8 @@ def _enqueue(
25322543
if disable_geoip is None:
25332544
disable_geoip = self.disable_geoip
25342545

2535-
if disable_geoip:
2536-
msg["properties"]["$geoip_disable"] = True
2537-
25382546
msg["options"] = msg.get("options") or {}
25392547

2540-
# Super properties fill only keys the event left unset, so they cannot
2541-
# override this SDK's server classification.
2542-
if self.is_server:
2543-
msg["properties"]["$is_server"] = True
2544-
25452548
_fill_event_defaults(
25462549
msg,
25472550
_build_event_defaults(
@@ -2552,6 +2555,9 @@ def _enqueue(
25522555
context_options=context_options,
25532556
derived_options=derived_options,
25542557
property_allowlist=property_allowlist,
2558+
is_server=self.is_server,
2559+
disable_geoip=disable_geoip,
2560+
system_properties=system_properties,
25552561
),
25562562
)
25572563

‎posthog/mcp/_posthog_events.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from datetime import datetime, timezone
1111
from typing import Any, Dict, List
1212

13+
from posthog.capture_event import _merge_groups
14+
1315
from .constants import (
1416
POSTHOG_MCP_ANALYTICS_SOURCE,
1517
PostHogMCPAnalyticsEvent,
@@ -63,9 +65,9 @@ def _build_capture_event(event: Event) -> PostHogCaptureEvent:
6365
_add_session_id(event, properties)
6466
_add_conversation_id(event, properties)
6567
_add_person_processing(event, properties)
66-
_add_groups(event, properties)
6768
_add_common_properties(event, properties)
6869
_add_custom_properties(event, properties)
70+
_add_groups(event, properties)
6971
_add_server_build(event, properties)
7072

7173
event_name = (
@@ -92,9 +94,10 @@ def _add_conversation_id(event: Event, properties: Dict[str, Any]) -> None:
9294

9395

9496
def _add_groups(event: Event, properties: Dict[str, Any]) -> None:
97+
"""Merge the typed groups over a custom ``$groups`` property, key by key."""
9598
groups = event.get("groups")
9699
if groups:
97-
properties["$groups"] = groups
100+
_merge_groups(properties, groups)
98101

99102

100103
def _add_person_processing(event: Event, properties: Dict[str, Any]) -> None:

‎posthog/test/mcp/test_pipeline.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -925,14 +925,15 @@ def test_identity_enables_person_processing_and_set():
925925
"identify_actor_given_id": "user_1",
926926
"identify_actor_data": {"email": "a@b.com"},
927927
"groups": {"organization": "org_1"},
928+
"properties": {"$groups": {"organization": "custom", "project": "p1"}},
928929
"timestamp": datetime.now(timezone.utc),
929930
}
930931
[capture] = build_posthog_capture_events(event)
931932
props = capture["properties"]
932933
assert capture["distinct_id"] == "user_1"
933934
assert "$process_person_profile" not in props
934935
assert props["$set"] == {"email": "a@b.com"}
935-
assert props["$groups"] == {"organization": "org_1"}
936+
assert props["$groups"] == {"organization": "org_1", "project": "p1"}
936937

937938

938939
def test_listed_tool_names_only_on_tools_list():

‎posthog/test/test_client.py‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -420,20 +420,6 @@ def test_capture_omits_is_server_when_disabled(self):
420420
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
421421
self.assertNotIn("$is_server", msg["properties"])
422422

423-
def test_is_server_not_overridden_by_super_properties(self):
424-
with patch_capture_send("client") as mock_post:
425-
client = Client(
426-
FAKE_TEST_API_KEY,
427-
on_error=self.set_fail,
428-
sync_mode=True,
429-
super_properties={"$is_server": False},
430-
)
431-
client.capture("python test event", distinct_id="distinct_id")
432-
self.assertFalse(self.failed)
433-
434-
msg = sent_batch(mock_post)[0]
435-
self.assertEqual(msg["properties"]["$is_server"], True)
436-
437423
def test_basic_capture_with_uuid(self):
438424
with patch_capture_send("client") as mock_post:
439425
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)

0 commit comments

Comments
 (0)