Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/omit-null-event-properties.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

Omit null-valued custom object properties recursively when serializing events, while preserving null array elements, caller inputs, and typed exception and feature-flag metadata.
5 changes: 4 additions & 1 deletion posthog/_async_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any, Optional

from ._async_request import async_batch_post, async_send_v1_batch
from ._event_properties import _clean_event_properties
from .capture_compression import CaptureCompression
from .capture_mode import CaptureMode
from .consumer import BATCH_SIZE_LIMIT, MAX_MSG_SIZE
Expand Down Expand Up @@ -49,7 +50,9 @@ async def _invoke_callback(callback, *args):


async def _serialized_event_size(event: dict[str, Any]) -> int:
serialized = await asyncio.to_thread(json.dumps, event, cls=DatetimeSerializer)
serialized = await asyncio.to_thread(
lambda: json.dumps(_clean_event_properties(event), cls=DatetimeSerializer)
)
return len(serialized.encode())


Expand Down
5 changes: 5 additions & 0 deletions posthog/_async_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any, Optional
from urllib.parse import quote, urljoin, urlsplit

from ._event_properties import _clean_event_properties
from .capture_compression import CaptureCompression
from .capture_v1 import _parse_retry_after, _send_v1_batch
from .request import (
Expand Down Expand Up @@ -49,6 +50,10 @@ def _serialize_v0_body(
"sent_at": datetime.now(tz=timezone.utc).isoformat(),
"api_key": api_key,
}
if "batch" in payload:
payload["batch"] = [
_clean_event_properties(event) for event in payload["batch"]
]
serialized = json.dumps(payload, cls=DatetimeSerializer)
data: str | bytes = serialized
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
Expand Down
51 changes: 51 additions & 0 deletions posthog/_event_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Event-only normalization at sizing and wire serialization boundaries."""

from typing import Any


def _omit_null_members(value: Any) -> Any:
if isinstance(value, dict):
return {
key: _omit_null_members(item)
for key, item in value.items()
if item is not None
}
if isinstance(value, (list, tuple)):
return [_omit_null_members(item) for item in value]
return value


def _clean_event_properties(event: Any) -> Any:
# Conversion of custom objects remains the responsibility of utils.clean.
# Do not change envelopes, non-event requests, or caller-owned dictionaries.
if not isinstance(event, dict):
return event
result = dict(event)
properties = event.get("properties")
if isinstance(properties, dict):
cleaned = {}
for key, value in properties.items():
# Typed SDK exception metadata keeps its field-specific null rules.
if key == "$exception_list" and event.get("event") == "$exception":
cleaned[key] = value
elif (
value is None
and event.get("event") == "$feature_flag_called"
and (
key == "$feature_flag_response"
or (
isinstance(properties.get("$feature_flag"), str)
and key == f"$feature/{properties['$feature_flag']}"
)
)
):
# Missing/error flag evaluations intentionally emit null. Only
# preserve the exact emitted flag key, not other custom $feature/ keys.
cleaned[key] = value
elif value is not None:
cleaned[key] = _omit_null_members(value)
result["properties"] = cleaned
for key in ("$set", "$set_once"):
if key in event:
result[key] = _omit_null_members(event[key])
return result
4 changes: 3 additions & 1 deletion posthog/capture_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
)
from posthog.utils import _normalize_timestamp, remove_trailing_slash

from ._event_properties import _clean_event_properties

if TYPE_CHECKING:
import requests

Expand Down Expand Up @@ -211,7 +213,7 @@ def _to_v1_event(msg: dict) -> dict:
"properties": properties,
}
event.update(top_level)
return event
return _clean_event_properties(event)


def _build_v1_batch_body(
Expand Down
6 changes: 5 additions & 1 deletion posthog/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

from queue import Empty

from ._event_properties import _clean_event_properties


MAX_MSG_SIZE = 900 * 1024 # 900KiB per event

Expand Down Expand Up @@ -244,7 +246,9 @@ def next(self):
pending_items += 1
try:
item_size = len(
json.dumps(item, cls=DatetimeSerializer).encode()
json.dumps(
_clean_event_properties(item), cls=DatetimeSerializer
).encode()
)
except Exception:
# Callback-modified events can still contain invalid mapping
Expand Down
4 changes: 4 additions & 0 deletions posthog/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from posthog.utils import remove_trailing_slash
from posthog.version import VERSION

from ._event_properties import _clean_event_properties

SocketOptions = List[Tuple[int, int, Union[int, bytes]]]

KEEPALIVE_IDLE_SECONDS = 60
Expand Down Expand Up @@ -395,6 +397,8 @@ def batch_post(
**kwargs,
) -> requests.Response:
"""Post the `kwargs` to the batch API endpoint for events"""
if "batch" in kwargs:
kwargs["batch"] = [_clean_event_properties(event) for event in kwargs["batch"]]
res = post(api_key, host, path, gzip, timeout, **kwargs)
return _process_response(
res, success_message="data uploaded successfully", return_json=False
Expand Down
Loading