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
8 changes: 7 additions & 1 deletion posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,7 @@ def group_identify(
uuid: Optional[str] = None,
disable_geoip: Optional[bool] = None,
distinct_id: Optional[ID_TYPES] = None,
options: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""
Set properties on a group.
Expand All @@ -653,6 +654,7 @@ def group_identify(
uuid: Optional UUID for the event
disable_geoip: Whether to disable GeoIP lookup
distinct_id: Optional distinct ID of the user performing the action
options: Optional capture options for the event, sent as given

Examples:
```python
Expand All @@ -676,6 +678,7 @@ def group_identify(
uuid=uuid,
disable_geoip=disable_geoip,
distinct_id=distinct_id,
options=options,
)


Expand All @@ -685,6 +688,7 @@ def alias(
timestamp: Optional[Union[datetime.datetime, str]] = None,
uuid: Optional[str] = None,
disable_geoip: Optional[bool] = None,
options: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""
Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.
Expand All @@ -696,6 +700,7 @@ def alias(
datetimes and parseable ISO timestamp strings are converted to UTC.
uuid: Optional UUID for the event
disable_geoip: Whether to disable GeoIP lookup
options: Optional capture options for the event, sent as given

Details:
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.
Expand All @@ -717,6 +722,7 @@ def alias(
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
options=options,
)


Expand All @@ -730,7 +736,7 @@ def capture_exception(
Args:
exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
**kwargs: Optional capture arguments including distinct_id, properties,
timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip.
timestamp, uuid, groups, flags, send_feature_flags, disable_geoip, and options.

Details:
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError("Some Error"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.
Expand Down
6 changes: 6 additions & 0 deletions posthog/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,16 @@ class OptionalCaptureArgs(TypedDict):
hidden ``/flags`` request on capture and may return different values than the ones
the code branched on.
disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False.
options: Capture options for this event, such as ``{"process_person_profile": False}``.
Sent as given, for PostHog to validate. An option wins over its legacy ``$`` property,
such as ``$process_person_profile``. A value that is not a dict is logged and ignored.
"""

distinct_id: NotRequired[Optional[ID_TYPES]]
properties: NotRequired[Optional[Dict[str, Any]]]
timestamp: NotRequired[Optional[Union[datetime, str]]]
uuid: NotRequired[Optional[Union[str, UUID]]]
options: NotRequired[Optional[Dict[str, Any]]]
groups: NotRequired[Optional[Dict[str, str]]]
flags: NotRequired[Optional["FeatureFlagEvaluations"]]
send_feature_flags: NotRequired[
Expand Down Expand Up @@ -83,13 +87,15 @@ class OptionalSetArgs(TypedDict):
it must be a valid UUID string or uuid.UUID instance; invalid values are ignored
and replaced with a newly generated UUID.
disable_geoip: Whether to disable GeoIP lookup for this operation. Defaults to False.
options: Capture options for this event, sent as given. See ``OptionalCaptureArgs``.
"""

distinct_id: NotRequired[Optional[ID_TYPES]]
properties: NotRequired[Optional[Dict[str, Any]]]
timestamp: NotRequired[Optional[Union[datetime, str]]]
uuid: NotRequired[Optional[Union[str, UUID]]]
disable_geoip: NotRequired[Optional[bool]]
options: NotRequired[Optional[Dict[str, Any]]]


ExcInfo = Union[
Expand Down
13 changes: 10 additions & 3 deletions posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
CaptureCompression,
_resolve_capture_compression,
)
from .capture_event import _canonical_event_uuid, _event_options
from .capture_send import _CAPTURE_V1_PATH
from .client import (
MAX_DICT_SIZE as _MAX_DICT_SIZE,
Expand Down Expand Up @@ -378,9 +379,8 @@ def enqueue_on_bound_loop() -> None:
def _normalize_uuid(self, msg: dict[str, Any]) -> str:
raw_uuid = msg.pop("uuid", None)
if raw_uuid is not None:
try:
normalized = str(UUID(str(raw_uuid)))
except (TypeError, ValueError, AttributeError):
normalized = _canonical_event_uuid(raw_uuid)
if normalized is None:
self.log.error(
"Invalid UUID %r. Falling back to a generated UUID.", raw_uuid
)
Expand Down Expand Up @@ -493,6 +493,7 @@ def _build_capture_event(
"distinct_id": distinct_id,
"event": event,
"uuid": kwargs.get("uuid"),
"options": _event_options(kwargs.get("options")),
},
kwargs.get("disable_geoip"),
kwargs.get("_property_allowlist"),
Expand Down Expand Up @@ -611,6 +612,7 @@ def _build_person_properties_event(
property_key: properties,
"event": event,
"uuid": kwargs.get("uuid"),
"options": _event_options(kwargs.get("options")),
}

def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
Expand Down Expand Up @@ -646,6 +648,7 @@ def group_identify(
uuid: Optional[Union[str, UUID]] = None,
disable_geoip: Optional[bool] = None,
distinct_id: Optional[ID_TYPES] = None,
options: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
try:
if not _stringify_id(group_type):
Expand All @@ -670,6 +673,7 @@ def group_identify(
"distinct_id": resolved_distinct_id,
"timestamp": timestamp,
"uuid": uuid,
"options": _event_options(options),
}
session_id = _get_context_session_id()
if session_id:
Expand All @@ -688,6 +692,7 @@ def alias(
timestamp: Optional[Union[datetime, str]] = None,
uuid: Optional[str] = None,
disable_geoip: Optional[bool] = None,
options: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
try:
resolved_previous_id = _stringify_id(previous_id)
Expand All @@ -707,6 +712,7 @@ def alias(
"event": "$create_alias",
"distinct_id": resolved_previous_id,
"uuid": uuid,
"options": _event_options(options),
}
session_id = _get_context_session_id()
if session_id:
Expand Down Expand Up @@ -809,6 +815,7 @@ def capture_exception(
groups=kwargs.get("groups"),
flags=kwargs.get("flags"),
disable_geoip=kwargs.get("disable_geoip"),
options=kwargs.get("options"),
)
if exception is not None and result is not None:
mark_exception_as_captured(exception, result)
Expand Down
108 changes: 59 additions & 49 deletions posthog/capture_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,27 @@
the legacy queued-message shape in a few load-bearing ways that this module
encodes:

- A typed ``options`` object carries a handful of sentinel properties, renamed
and strictly typed. Wrong JSON types fail deserialization of the *whole
batch*, so values are coerced to native types or omitted entirely.
- An ``options`` object carries per-event processing options. Options the
caller sets are sent as given, for PostHog to validate. Four legacy ``$``
properties fill the matching option when the caller left it unset, and are
always removed from ``properties``.
- ``$set``/``$set_once`` have no top-level form in v1; the server reads them
from ``properties``. The legacy ``set()``/``set_once()`` builders emit them at
the top level, so they are relocated into ``properties`` here.
- ``$lib``/``$lib_version`` are injected server-side from the required
``PostHog-Sdk-Info`` header and are stripped from v1 properties.
"""

from collections.abc import Callable
import logging
import re
from datetime import datetime, timezone
from typing import Any, Optional
from uuid import UUID

from posthog.utils import _normalize_timestamp

log = logging.getLogger("posthog")

# Sentinel properties lifted to top-level string fields on the event.
_TOPLEVEL_SENTINELS: tuple[tuple[str, str], ...] = (
("$session_id", "session_id"),
Expand All @@ -35,45 +40,49 @@
# Properties dropped from v1 events (server injects them from PostHog-Sdk-Info).
_STRIP_FROM_PROPERTIES = ("$lib", "$lib_version")

# Legacy properties and the option each one fills. The order matches posthog-rs
# and posthog-go.
_LEGACY_OPTION_PROPERTIES: tuple[tuple[str, str], ...] = (
("$cookieless_mode", "cookieless_mode"),
("$ignore_sent_at", "disable_skew_correction"),
("$product_tour_id", "product_tour_id"),
("$process_person_profile", "process_person_profile"),
)

# The uuid forms Go's uuid.Validate accepts. Python's UUID() also accepts
# misplaced hyphens and a bare "uuid:" prefix, which other SDKs reject.
_EVENT_UUID_PATTERN = re.compile(
r"(?:urn:uuid:)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
r"|\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}"
r"|[0-9a-f]{32}",
re.IGNORECASE,
)


def _coerce_bool(value: Any) -> Optional[bool]:
"""Coerce a sentinel value to ``bool`` using the backend's truthiness rules.
def _canonical_event_uuid(value: Any) -> Optional[str]:
"""Return the canonical form of a caller's event uuid, or None if invalid.

Native bool passes through; ``"true"``/``"1"`` and ``"false"``/``"0"``
(case-insensitive, trimmed) map to the obvious bool; any other numeric value
is nonzero-truthy. Anything else returns ``None`` so the option is omitted
rather than sent with a type the strict v1 schema would reject.
Capture keys per-event results by the canonical lowercase hyphenated form,
so a uuid sent in any other form would never match its result.
"""
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in ("true", "1"):
return True
if normalized in ("false", "0"):
return False
if isinstance(value, UUID):
return str(value)
if not isinstance(value, str) or not _EVENT_UUID_PATTERN.fullmatch(value):
return None
if isinstance(value, (int, float)):
return value != 0
return None


def _coerce_str(value: Any) -> Optional[str]:
"""Accept only ``str`` (the backend's ``product_tour_id`` is ``Option<String>``)."""
return value if isinstance(value, str) else None


# Sentinel properties lifted into the typed `options` object: legacy property
# key, the backend's field name, and the coercer enforcing its strict type
# (wrong JSON types fail deserialization of the whole batch, so a value that
# won't coerce is omitted). The coercer is stored directly to keep the dispatch
# type-checked rather than keyed by a stringly-typed name.
_OPTION_SENTINELS: tuple[tuple[str, str, Callable[[Any], Any]], ...] = (
("$cookieless_mode", "cookieless_mode", _coerce_bool),
("$ignore_sent_at", "disable_skew_correction", _coerce_bool),
("$product_tour_id", "product_tour_id", _coerce_str),
("$process_person_profile", "process_person_profile", _coerce_bool),
)
return str(UUID(value.lower()))


def _event_options(value: Any) -> dict[str, Any]:
"""Return a copy of a caller's ``options``, or ``{}`` when it is not a dict."""
if value is None:
return {}
if not isinstance(value, dict):
log.error(
"options must be a dict, got %s. Sending the event without them.",
type(value).__name__,
)
return {}
return dict(value)


def _v1_timestamp(timestamp: Any) -> str:
Expand Down Expand Up @@ -114,23 +123,24 @@ def _to_v1_event(msg: dict) -> dict:
for key in _STRIP_FROM_PROPERTIES:
properties.pop(key, None)

options: dict[str, Any] = {}
for prop_key, wire_key, coercer in _OPTION_SENTINELS:
options = _event_options(msg.get("options"))
for prop_key, option_key in _LEGACY_OPTION_PROPERTIES:
if prop_key not in properties:
continue
# Always removed from properties — these sentinels must never reach v1
# backend properties — but only emitted as an option when coercible.
coerced = coercer(properties.pop(prop_key))
if coerced is not None:
options[wire_key] = coerced
legacy = properties.pop(prop_key)
# A null option counts as unset, so the legacy value fills it.
if options.get(option_key) is None:
options[option_key] = legacy

top_level: dict[str, str] = {}
for prop_key, field_name in _TOPLEVEL_SENTINELS:
if prop_key not in properties:
continue
coerced_str = _coerce_str(properties.pop(prop_key))
if coerced_str is not None:
top_level[field_name] = coerced_str
# Always removed. A non-string value would fail the whole batch, so it
# is dropped.
value = properties.pop(prop_key)
if isinstance(value, str):
top_level[field_name] = value

event = {
"event": msg["event"],
Expand Down
Loading
Loading