Skip to content
11 changes: 11 additions & 0 deletions .sampo/changesets/minimal-feature-flag-called-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'pypi/posthog': minor
---

`$feature_flag_called` events are now minimized for non-experiment flags when the server enables it. When the `/flags` v2 response (`minimalFlagCalledEvents`) or the local-evaluation payload (`minimal_flag_called_events`) reports the gate as enabled and the evaluated flag has no linked experiment (`has_experiment` is `false`), the event's properties are reduced to a strict allowlist (`$feature_flag`, `$feature_flag_response`, `$feature_flag_has_experiment`, the `$feature_flag_*` debug scalars, `locally_evaluated`, `$groups`, `$process_person_profile`, `$session_id`, `$lib`, `$lib_version`, `$is_server`, `$geoip_disable`, `$os`, `$os_version`, `$os_distro`, `$python_runtime`, `$python_version`). Everything else — including super properties and custom event properties — is stripped from those events.

If the server does not report the gate, if the flag's `has_experiment` signal is missing, or if the flag is linked to an experiment, the full property set is sent unchanged. There is no SDK-side configuration; the gate is controlled per-team by the server. For `evaluate_flags()` snapshots, the gate is pinned when the snapshot is created, so deferred flag accesses are shaped by the evaluation that produced them.

Custom `flag_definition_cache` providers now receive an additional `minimal_flag_called_events` key in the definitions payload, so the gate survives external cache round-trips.

When the server reports `has_experiment` for a flag, every `$feature_flag_called` event also carries a `$feature_flag_has_experiment` boolean property.
15 changes: 14 additions & 1 deletion posthog/args.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
from typing import TYPE_CHECKING, TypedDict, Optional, Any, Dict, Union, Tuple, Type
from typing import (
TYPE_CHECKING,
TypedDict,
Optional,
Any,
Dict,
FrozenSet,
Union,
Tuple,
Type,
)
from types import TracebackType
from typing_extensions import NotRequired # For Python < 3.11 compatibility
from datetime import datetime
Expand Down Expand Up @@ -50,6 +60,9 @@ class OptionalCaptureArgs(TypedDict):
disable_geoip: NotRequired[
Optional[bool]
] # As above, optional so we can tell if the user is intentionally overriding a client setting or not
_property_allowlist: NotRequired[
Optional[FrozenSet[str]]
] # Internal: strict allowlist applied to the fully-enriched event properties. Used by minimal $feature_flag_called events.


class OptionalSetArgs(TypedDict):
Expand Down
195 changes: 162 additions & 33 deletions posthog/client.py

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions posthog/feature_flag_evaluations.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def __init__(
evaluated_at: Optional[int] = None,
errors_while_computing: bool = False,
quota_limited: bool = False,
minimal_flag_called_events: bool = False,
accessed: Optional[Set[str]] = None,
) -> None:
"""Internal — instances are created by the SDK via ``Client.evaluate_flags()``."""
Expand All @@ -83,6 +84,10 @@ def __init__(
self._evaluated_at = evaluated_at
self._errors_while_computing = errors_while_computing
self._quota_limited = quota_limited
# Pinned at snapshot creation: the gate value from the evaluation that produced
# these records. Deferred flag accesses fire events shaped by THIS evaluation's
# server response, not whatever the client-wide gate happens to be at send time.
self._minimal_flag_called_events = minimal_flag_called_events
self._accessed: Set[str] = set(accessed) if accessed is not None else set()

def is_enabled(self, key: str) -> bool:
Expand Down Expand Up @@ -196,6 +201,7 @@ def _clone_with(
evaluated_at=self._evaluated_at,
errors_while_computing=self._errors_while_computing,
quota_limited=self._quota_limited,
minimal_flag_called_events=self._minimal_flag_called_events,
# Copy the accessed set so the child tracks further access independently
# of the parent. Callers expect ``only_accessed()`` on the parent to reflect
# only what the parent saw, not what happened on filtered views.
Expand Down Expand Up @@ -262,4 +268,5 @@ def _record_access(self, key: str) -> None:
disable_geoip=self._disable_geoip,
properties=properties,
has_experiment=flag.has_experiment if flag else None,
minimal_flag_called_events=self._minimal_flag_called_events,
)
5 changes: 4 additions & 1 deletion posthog/flag_definition_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
runtime_checkable,
)

from typing_extensions import Required, TypedDict
from typing_extensions import NotRequired, Required, TypedDict


class FlagDefinitionCacheData(TypedDict):
Expand All @@ -40,11 +40,14 @@ class FlagDefinitionCacheData(TypedDict):
flags: List of feature flag definition dictionaries from the API.
group_type_mapping: Mapping of group type indices to group names.
cohorts: Dictionary of cohort definitions for local evaluation.
minimal_flag_called_events: Server-controlled gate for minimal
``$feature_flag_called`` events. Treated as False when absent.
"""

flags: Required[List[Dict[str, Any]]]
group_type_mapping: Required[Dict[str, str]]
cohorts: Required[Dict[str, Any]]
minimal_flag_called_events: NotRequired[bool]


@runtime_checkable
Expand Down
Loading
Loading