Skip to content

Commit cb9b3c0

Browse files
committed
feat!: fill context and super values before before_send
Context tags, context options, super_properties, super_options, the derived personless option and the environment $release_id now fill before before_send runs, still only into keys the event leaves unset. before_send sees every value and has the final say, including removing a super property. $set, $set_once, $groups and $group_set fill one level deep when both values are dicts. The set() and set_once() values now win key by key over a $set or $set_once in properties, as ingestion merges them. Hoisting still runs once, after the hook.
1 parent bbfe7ee commit cb9b3c0

7 files changed

Lines changed: 124 additions & 76 deletions

File tree

‎posthog/__init__.py‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,9 @@ def set_context_option(key: str, value: Any) -> None:
309309
"""
310310
Set a capture option for every event captured within the current context.
311311
312-
Context options fill options an event leaves unset, after ``before_send``
313-
runs. They override ``super_options``. An event's own ``options`` and
314-
``before_send`` changes override them.
312+
Context options fill options an event leaves unset, before ``before_send``
313+
runs, so the hook sees them. They override ``super_options``. An event's
314+
own ``options`` override them, and ``before_send`` can change them.
315315
316316
Args:
317317
key: The option name, such as ``"process_person_profile"``
@@ -385,12 +385,14 @@ def get_context_options() -> Dict[str, Any]:
385385
requests after network, transport, or timeout failures. Defaults to 1.
386386
Set to 0 to disable retries.
387387
super_properties: Properties for every captured event. They fill only
388-
keys the event leaves unset, after ``before_send`` runs. An event's own
389-
properties, ``before_send`` changes and context tags override them.
388+
keys the event leaves unset, before ``before_send`` runs. ``$set``,
389+
``$set_once``, ``$groups`` and ``$group_set`` fill one level deep. An
390+
event's own properties and context tags override them, and
391+
``before_send`` can change or remove them.
390392
super_options: Capture options for every captured event, such as
391393
``{"cookieless_mode": True}``. They fill only options the event leaves
392-
unset, after ``before_send`` runs. An event's own ``options``,
393-
``before_send`` changes and context options override them. They also
394+
unset, before ``before_send`` runs. An event's own ``options`` and
395+
context options override them, and ``before_send`` can change them. They also
394396
win over an event's legacy property for the same key, such as
395397
``$cookieless_mode``, so pass per-event overrides of that key as
396398
``options``.
@@ -417,9 +419,9 @@ def get_context_options() -> Dict[str, Any]:
417419
project_root: Root path used to determine in-app exception stack frames.
418420
privacy_mode: Capture AI usage metadata without prompt inputs or outputs.
419421
before_send: Optional callback that can modify or drop events before upload.
420-
Return ``None`` to drop an event. It does not see context tags, context
421-
options, ``super_properties`` or ``super_options``. They fill in after
422-
it runs.
422+
Return ``None`` to drop an event. Context tags, context options,
423+
``super_properties`` and ``super_options`` fill in before it runs, so
424+
it can change or remove them.
423425
enable_local_evaluation: Whether to poll feature flag definitions for local
424426
evaluation when a personal API key is configured.
425427
flag_definition_cache_provider: Optional external cache provider for sharing

‎posthog/async_client.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,10 +453,8 @@ def _prepare_event(
453453
async def _process_event(
454454
self, msg: dict[str, Any], defaults: Optional[_EventDefaults]
455455
) -> Optional[dict[str, Any]]:
456-
processed = await self._run_before_send(msg)
457-
if processed is not None:
458-
_fill_event_defaults(processed, defaults)
459-
return processed
456+
_fill_event_defaults(msg, defaults)
457+
return await self._run_before_send(msg)
460458

461459
async def _run_before_send(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]:
462460
if self.before_send is None:

‎posthog/capture_event.py‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
# Top-level legacy keys relocated into properties (v1 has no top-level form).
4141
_RELOCATE_TO_PROPERTIES = ("$set", "$set_once")
4242

43+
# Properties that defaults fill one level deep when both values are dicts.
44+
_NESTED_FILL_PROPERTIES = frozenset({"$set", "$set_once", "$groups", "$group_set"})
45+
4346
# Properties dropped from v1 events (server injects them from PostHog-Sdk-Info).
4447
_STRIP_FROM_PROPERTIES = ("$lib", "$lib_version")
4548

@@ -92,8 +95,8 @@ def _event_options(value: Any) -> dict[str, Any]:
9295
class _EventDefaults:
9396
"""Context, global and SDK-derived values for one event, highest layer first.
9497
95-
They fill in after ``before_send``, so the hook never sees them, and the
96-
event's own values and the hook's changes always win.
98+
They fill in before ``before_send``, so the hook sees them and can change
99+
or remove them. The event's own values win over every default.
97100
"""
98101

99102
property_layers: tuple[Mapping[str, Any], ...] = ()
@@ -132,7 +135,9 @@ def _fill_event_defaults(
132135
"""Fill the keys an event left unset from ``defaults``, layer by layer.
133136
134137
A property is unset only when its key is missing. An option is unset when
135-
it is missing or ``None``, the same rule hoisting uses.
138+
it is missing or ``None``, the same rule hoisting uses. ``$set``,
139+
``$set_once``, ``$groups`` and ``$group_set`` fill one level deep when the
140+
event's value and the default are both dicts.
136141
"""
137142
if defaults is None:
138143
return
@@ -143,9 +148,18 @@ def _fill_event_defaults(
143148
msg["properties"] = properties
144149
for property_layer in defaults.property_layers:
145150
for key, value in property_layer.items():
146-
if key in properties or (allowlist is not None and key not in allowlist):
151+
if allowlist is not None and key not in allowlist:
152+
continue
153+
if key not in properties:
154+
properties[key] = _clean(value)
147155
continue
148-
properties[key] = _clean(value)
156+
existing = properties[key]
157+
if (
158+
key in _NESTED_FILL_PROPERTIES
159+
and isinstance(existing, dict)
160+
and isinstance(value, Mapping)
161+
):
162+
properties[key] = {**_clean(dict(value)), **existing}
149163
options = _event_options(msg.get("options"))
150164
for option_layer in defaults.option_layers:
151165
for key, value in option_layer.items():
@@ -177,16 +191,16 @@ def _to_v1_event(msg: dict) -> dict:
177191
properties = dict(msg.get("properties") or {})
178192

179193
# Relocate top-level $set/$set_once into properties; v1 has no top-level
180-
# form. On the unusual collision where properties already carries the key,
181-
# the properties value wins.
194+
# form. The top-level value comes from the set() or set_once() call, so it
195+
# wins key by key over a $set in properties, as ingestion merges them.
182196
for key in _RELOCATE_TO_PROPERTIES:
183197
top_val = msg.get(key)
184198
if top_val is None:
185199
continue
186200
existing = properties.get(key)
187201
if isinstance(top_val, dict) and isinstance(existing, dict):
188-
properties[key] = {**top_val, **existing}
189-
elif key not in properties:
202+
properties[key] = {**existing, **top_val}
203+
else:
190204
properties[key] = top_val
191205

192206
for key in _STRIP_FROM_PROPERTIES:

‎posthog/client.py‎

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -823,14 +823,15 @@ def __init__(
823823
requests after network, transport, or timeout failures. Defaults
824824
to 1. Set to 0 to disable retries.
825825
super_properties: Properties for every captured event. They fill
826-
only keys the event leaves unset, after ``before_send`` runs.
827-
An event's own properties, ``before_send`` changes and context
828-
tags override them.
826+
only keys the event leaves unset, before ``before_send`` runs.
827+
``$set``, ``$set_once``, ``$groups`` and ``$group_set`` fill
828+
one level deep. An event's own properties and context tags
829+
override them, and ``before_send`` can change or remove them.
829830
super_options: Capture options for every captured event, such as
830831
``{"cookieless_mode": True}``. They fill only options the event
831-
leaves unset, after ``before_send`` runs. An event's own
832-
``options``, ``before_send`` changes and context options
833-
override them. They also win over an event's legacy property
832+
leaves unset, before ``before_send`` runs. An event's own
833+
``options`` and context options override them, and
834+
``before_send`` can change them. They also win over an event's legacy property
834835
for the same key, such as ``$cookieless_mode``, so pass
835836
per-event overrides of that key as ``options``.
836837
enable_exception_autocapture: Automatically capture uncaught
@@ -847,9 +848,9 @@ def __init__(
847848
through unredacted. ``privacy_mode`` always wins. Defaults to
848849
False.
849850
before_send: Optional callback that can modify or drop events before
850-
upload. Return ``None`` to drop an event. It does not see
851-
context tags, context options, ``super_properties`` or
852-
``super_options``. They fill in after it runs.
851+
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.
853854
flag_fallback_cache_url: Optional feature flag fallback cache URL,
854855
such as ``memory://local/?ttl=300&size=10000`` or a Redis URL.
855856
enable_local_evaluation: Whether to poll feature flag definitions for
@@ -1357,9 +1358,10 @@ def set_context_option(self, key: str, value: Any) -> None:
13571358
"""
13581359
Set a capture option for every event captured within the current context.
13591360
1360-
Context options fill options an event leaves unset, after
1361-
``before_send`` runs. They override ``super_options``. An event's own
1362-
``options`` and ``before_send`` changes override them.
1361+
Context options fill options an event leaves unset, before
1362+
``before_send`` runs, so the hook sees them. They override
1363+
``super_options``. An event's own ``options`` override them, and
1364+
``before_send`` can change them.
13631365
13641366
Args:
13651367
key: The option name, such as ``"process_person_profile"``.
@@ -1703,7 +1705,7 @@ def capture(
17031705
options: Capture options for this event, such as
17041706
``{"process_person_profile": False}``. Sent as given, for
17051707
PostHog to validate. They override context options and
1706-
``super_options``, which fill in after ``before_send`` runs.
1708+
``super_options``, which fill in before ``before_send`` runs.
17071709
An option set at any layer wins over its
17081710
legacy ``$`` property, such as ``$process_person_profile``,
17091711
set at any layer. A ``None`` option counts as unset.
@@ -2540,10 +2542,22 @@ def _enqueue(
25402542
if self.is_server:
25412543
msg["properties"]["$is_server"] = True
25422544

2543-
# Applied after every enrichment step (system context, $lib/$lib_version)
2544-
# and again to the defaults filled in after before_send, so the final
2545-
# event shape is exactly the allowlist regardless of where a property
2546-
# came from.
2545+
_fill_event_defaults(
2546+
msg,
2547+
_build_event_defaults(
2548+
super_properties=self.super_properties,
2549+
super_options=self.super_options,
2550+
release_id=self._release_id,
2551+
context_properties=context_properties,
2552+
context_options=context_options,
2553+
derived_options=derived_options,
2554+
property_allowlist=property_allowlist,
2555+
),
2556+
)
2557+
2558+
# Applied after every enrichment step (system context, $lib/$lib_version,
2559+
# filled defaults), so the final event shape is exactly the allowlist
2560+
# regardless of where a property came from.
25472561
if property_allowlist is not None:
25482562
msg["properties"] = {
25492563
k: v for k, v in msg["properties"].items() if k in property_allowlist
@@ -2566,19 +2580,6 @@ def _enqueue(
25662580
self.log.exception(f"Error in before_send callback: {e}")
25672581
return None
25682582

2569-
_fill_event_defaults(
2570-
msg,
2571-
_build_event_defaults(
2572-
super_properties=self.super_properties,
2573-
super_options=self.super_options,
2574-
release_id=self._release_id,
2575-
context_properties=context_properties,
2576-
context_options=context_options,
2577-
derived_options=derived_options,
2578-
property_allowlist=property_allowlist,
2579-
),
2580-
)
2581-
25822583
# Re-normalized after before_send, which may have replaced or removed
25832584
# msg["uuid"], so the returned uuid always matches the wire event.
25842585
self._normalize_event_uuid(msg)

‎posthog/contexts.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,10 @@ def set_context_option(key: str, value: Any) -> None:
303303
"""
304304
Set a capture option for every event captured within the current context.
305305
306-
Context options fill options an event leaves unset, after ``before_send``
307-
runs. They override the client's ``super_options``. An event's own
308-
``options`` and ``before_send`` changes override them. Child contexts
309-
inherit them unless they are fresh.
306+
Context options fill options an event leaves unset, before ``before_send``
307+
runs, so the hook sees them. They override the client's ``super_options``.
308+
An event's own ``options`` override them, and ``before_send`` can change
309+
them. Child contexts inherit them unless they are fresh.
310310
311311
Args:
312312
key: The option name, such as ``"process_person_profile"``

‎posthog/test/test_capture_event.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,15 +204,14 @@ def test_top_level_set_relocated_into_properties(self, _name, key) -> None:
204204
self.assertNotIn(key, event) # not a top-level v1 field
205205

206206
def test_top_level_set_merges_with_existing_properties_set(self) -> None:
207-
# properties wins on key collision.
208207
msg = _legacy_msg(
209208
properties={"$set": {"a": "from_props", "b": "props_only"}},
210209
**{"$set": {"a": "from_top", "c": "top_only"}},
211210
)
212211
event = _to_v1_event(msg)
213212
self.assertEqual(
214213
event["properties"]["$set"],
215-
{"a": "from_props", "b": "props_only", "c": "top_only"},
214+
{"a": "from_top", "b": "props_only", "c": "top_only"},
216215
)
217216

218217
def test_groups_left_in_properties(self) -> None:

‎posthog/test/test_event_options.py‎

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,10 @@ async def test_async_event_and_context_properties_beat_super_properties():
252252
def _recording_hook(seen):
253253
def hook(msg):
254254
seen.append((dict(msg["properties"]), dict(msg["options"])))
255-
properties = {**msg["properties"], "shared": "hook"}
255+
properties = {
256+
**{k: v for k, v in msg["properties"].items() if k != "only_super"},
257+
"shared": "hook",
258+
}
256259
return {**msg, "properties": properties, "options": {"cookieless_mode": False}}
257260

258261
return hook
@@ -280,40 +283,71 @@ async def immediate():
280283
return call
281284

282285

283-
def _assert_defaults_fill_after_hook(seen, events):
286+
def _assert_hook_sees_defaults_and_has_final_say(seen, events):
284287
hook_properties, hook_options = seen[0]
285-
assert not {"shared", "only_super", "from_context"} & hook_properties.keys()
286-
assert hook_options == {}
287-
properties = events[0]["properties"]
288288
assert (
289-
properties["shared"],
290-
properties["only_super"],
291-
properties["from_context"],
292-
) == ("hook", 1, "context")
293-
assert events[0]["options"] == {
294-
"cookieless_mode": False,
295-
"product_tour_id": "context-tour",
296-
}
289+
hook_properties["shared"],
290+
hook_properties["only_super"],
291+
hook_properties["from_context"],
292+
) == ("super", 1, "context")
293+
assert hook_options == {"cookieless_mode": True, "product_tour_id": "context-tour"}
294+
properties = events[0]["properties"]
295+
assert (properties["shared"], properties["from_context"]) == ("hook", "context")
296+
assert "only_super" not in properties
297+
assert events[0]["options"] == {"cookieless_mode": False}
297298

298299

299-
def test_sync_defaults_fill_after_before_send():
300+
def test_sync_defaults_fill_before_before_send():
300301
seen: list = []
301302
events = _sync_wire_events(
302303
_context_capture("capture"),
303304
before_send=_recording_hook(seen),
304305
**DEFAULTS_CONFIG,
305306
)
306-
_assert_defaults_fill_after_hook(seen, events)
307+
_assert_hook_sees_defaults_and_has_final_say(seen, events)
307308

308309

309310
@pytest.mark.asyncio
310311
@pytest.mark.parametrize("method", ["capture", "capture_immediate"])
311-
async def test_async_defaults_fill_after_before_send(method):
312+
async def test_async_defaults_fill_before_before_send(method):
312313
seen: list = []
313314
events = await _async_wire_events(
314315
_context_capture(method), before_send=_recording_hook(seen), **DEFAULTS_CONFIG
315316
)
316-
_assert_defaults_fill_after_hook(seen, events)
317+
_assert_hook_sees_defaults_and_has_final_say(seen, events)
318+
319+
320+
NESTED_SUPER_PROPERTIES = {
321+
"$set": {"plan": "free", "source": "super"},
322+
"$groups": {"company": "acme", "team": "core"},
323+
"$unset": ["stale"],
324+
}
325+
326+
327+
def test_nested_properties_fill_one_level_deep():
328+
events = _sync_wire_events(
329+
lambda c: c.capture(
330+
"e",
331+
distinct_id="u",
332+
properties={"$set": {"plan": "pro"}, "$unset": ["other"]},
333+
groups={"company": "posthog"},
334+
),
335+
super_properties=NESTED_SUPER_PROPERTIES,
336+
)
337+
properties = events[0]["properties"]
338+
assert properties["$set"] == {"plan": "pro", "source": "super"}
339+
assert properties["$groups"] == {"company": "posthog", "team": "core"}
340+
assert properties["$unset"] == ["other"]
341+
342+
343+
@pytest.mark.parametrize("method", ["set", "set_once"])
344+
def test_set_call_wins_over_super_person_properties(method):
345+
key = f"${method}"
346+
events = _sync_wire_events(
347+
lambda c: getattr(c, method)(distinct_id="u", properties={"plan": "pro"}),
348+
super_properties={key: {"plan": "free", "source": "super"}},
349+
)
350+
assert events[0]["properties"][key] == {"plan": "pro", "source": "super"}
317351

318352

319353
def test_late_options_replace_and_remove_legacy_properties():

0 commit comments

Comments
 (0)