Skip to content
Open
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/call-disable-geoip-event-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: major
---

A `disable_geoip` argument to a call now counts as a value of that event. It wins over a `$geoip_disable` in context tags or `super_properties`, and a `$geoip_disable` in the call's own `properties` still wins over it. `disable_geoip=False` now sends `$geoip_disable: false`, so it turns GeoIP lookup on for that event even when a context tag or `super_properties` turns it off. The client's `disable_geoip` setting keeps its place below every caller value. `AsyncPosthog` follows the same rules.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: major
---

`capture_exception` takes a `level=` argument on `Client`, `AsyncPosthog` and the module, which sets `$exception_level` (default `"error"`). Reserved exception properties passed in `properties`, such as `$exception_list` and `$exception_level`, are now ignored. In 7.x they overrode the SDK's values with a `DeprecationWarning`. `AsyncPosthog.capture_exception` now sends `$exception_level`.
5 changes: 5 additions & 0 deletions .sampo/changesets/invalid-uuid-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: major
---

An invalid event `uuid` is still replaced with a generated one, but the SDK now logs one warning instead of an error, and the warning no longer includes the value. An empty string `uuid` counts as unset, so the SDK generates one without logging. `AsyncPosthog` follows the same rules.
5 changes: 5 additions & 0 deletions .sampo/changesets/request-keyword-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: major
---

The parameters after `path` in `posthog.request.post` and after `host` in `posthog.request.flags` are keyword-only. With `gzip` removed, a 7.x call that passed them by position bound them to the wrong parameter. It now raises `TypeError`.
5 changes: 5 additions & 0 deletions .sampo/changesets/session-id-drop-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: major
---

A `$session_id` or `$window_id` that is not a string is not sent, because capture v1 would reject the whole batch, and the SDK logs a warning for each one. The warning names the key and the value's type, not the value. `None` counts as unset and drops without a warning. An empty string is sent. In 7.x these values were sent as properties.
19 changes: 14 additions & 5 deletions docs/migration-7.x-to-8.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Read the checklist first, then the sections that apply to you.
You need to change code if your app does any of these:

- passes `Client` or `AsyncPosthog` constructor arguments by position after `host`
- calls `posthog.request.post` or `posthog.request.flags` with positional arguments after `path` or `host`
- sets `$exception_level` or another reserved exception property in `capture_exception(properties=...)`
- sets `capture_mode`, `POSTHOG_CAPTURE_MODE` or `gzip`
- imports `CaptureV1Error`, `posthog.capture_v1`, `request.batch_post`, `EVENTS_ENDPOINT` or `AI_EVENTS_ENDPOINT`
- sends events to a self-hosted PostHog that does not serve the capture v1 endpoints
Expand Down Expand Up @@ -58,7 +60,7 @@ To find MCP traffic, filter on the `$mcp_*` events and properties instead of `$l
| `CaptureV1Error` (`from posthog.capture_v1 import CaptureV1Error`) | `CaptureError` (`from posthog import CaptureError`). There is no alias. |
| `posthog.capture_v1` | `posthog.capture_event` and `posthog.capture_send` |
| `request.batch_post`, `async_batch_post`, `EVENTS_ENDPOINT`, `AI_EVENTS_ENDPOINT` | Nothing. Send events through a client. |
| The `gzip` parameter of `request.post` and `request.flags` | Nothing. |
| The `gzip` parameter of `request.post` and `request.flags` | Nothing. The parameters after `path` in `post` and after `host` in `flags` are keyword-only, so a 7.x call that passes them by position raises `TypeError`. |
| The `backoff` dependency | Add it to your own requirements if your code imports it. |

The `posthoganalytics` package has the same changes. For example, import `CaptureError` from `posthoganalytics`.
Expand Down Expand Up @@ -100,7 +102,7 @@ When the whole request is over the billing limit, capture returns a `402` with n

- Each event needs its own uuid. Capture rejects a whole batch that contains the same uuid twice, so the other events in that batch are lost too.
- The SDK accepts a uuid with hyphens, 32 hex digits, `{...}` braces or a `urn:uuid:` prefix, in any case. It sends the lowercase hyphenated form and returns that form from `capture`.
- Any other value is replaced with a generated uuid, and the SDK logs an error. This applies to `AsyncPosthog` too.
- Any other value is replaced with a generated uuid, and the SDK logs one warning that names the rule, not the value. An empty string counts as unset, so the SDK generates a uuid without a warning. This applies to `AsyncPosthog` too.
- Generated uuids are UUIDv7.

## Session and window IDs
Expand All @@ -110,7 +112,13 @@ The SDK moves them out of `properties` for you.

- A string is sent as given, including `""`.
- `None` counts as unset and is removed.
- Any other value is removed and not sent, because capture would reject the whole batch. 7.x sent it as a property.
- Any other value is removed and not sent, because capture would reject the whole batch. 7.x sent it as a property. The SDK logs a warning for each one, with the key and the value's type but not the value.

## Exceptions

- `capture_exception` takes a `level=` argument on `Client`, `AsyncPosthog` and the module. It sets `$exception_level`, for example `"warning"` or `"fatal"`. The default is `"error"`, and an unknown value counts as unset.
- `capture_exception` ignores reserved exception properties in `properties`, such as `$exception_list`, `$exception_level` and `$exception_source`. In 7.x they overrode the SDK's values, with a `DeprecationWarning`. Use `level=` instead of `$exception_level`.
- `AsyncPosthog.capture_exception` now sends `$exception_level`.

## Event options

Expand Down Expand Up @@ -141,10 +149,10 @@ Capture v1 sends processing options in an `options` object, next to `properties`

Values apply in this order. Steps 2 to 4 fill only the options and properties that the steps before them left unset:

1. the `options` and `properties` of the call
1. the `options` and `properties` of the call, then the call's `disable_geoip` argument, which fills `$geoip_disable`
2. context options and tags
3. `super_options` and `super_properties`
4. values the SDK sets: `$is_server` from `is_server`, `$geoip_disable` from `disable_geoip`, system properties such as `$os` and `$python_version`, `options.process_person_profile = false` for events without a distinct ID, and `$release_id` from `POSTHOG_RELEASE_ID`
4. values the SDK sets: `$is_server` from `is_server`, `$geoip_disable` from the client's `disable_geoip` setting, system properties such as `$os` and `$python_version`, `options.process_person_profile = false` for events without a distinct ID, and `$release_id` from `POSTHOG_RELEASE_ID`
5. `before_send`, which sees the result of steps 1 to 4 and can change or remove any of it
6. legacy properties, which fill unset options and are then removed

Expand All @@ -157,6 +165,7 @@ These changes follow from this order:

- Properties passed to a call now override `super_properties`. `super_properties` can no longer change `$lib` or `$lib_version`.
- A `$is_server`, `$geoip_disable` or system property such as `$os` that you set in a call or a context tag now wins over the SDK's value. In 7.x the SDK overwrote it. `super_properties` win over the SDK's value too, so `super_properties={"$geoip_disable": False}` turns GeoIP lookup on for events, even with `disable_geoip=True`.
- A `disable_geoip` argument to a call belongs to that event, so it wins over context tags and `super_properties`. A `$geoip_disable` in the call's own `properties` still wins over it. `disable_geoip=False` now sends `$geoip_disable: false`. In 7.x it sent no `$geoip_disable` property.
- The `groups` argument merges into a `$groups` property of the call, and wins key by key. In 7.x it replaced the property. MCP events merge their identity's groups into a custom `$groups` property the same way.
- A `$set`, `$set_once`, `$groups` or `$group_set` in `super_properties` no longer replaces the whole value of the call. The two merge, and the call wins key by key.
- An event without a distinct ID gets `options.process_person_profile = false`. A `$process_person_profile: true` property no longer turns person processing back on. Set the option instead.
Expand Down
10 changes: 7 additions & 3 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,17 +786,21 @@ def alias(

def capture_exception(
exception: Optional[ExceptionArg] = None,
*,
level: Optional[str] = None,
**kwargs: Unpack[OptionalCaptureArgs],
) -> Optional[str]:
"""
Capture exceptions that happen in your code.

Args:
exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
level: The ``$exception_level``, such as ``"warning"`` or ``"fatal"``.
Defaults to ``"error"``. An unknown value counts as unset.
**kwargs: Optional capture arguments including distinct_id, properties,
timestamp, uuid, groups, flags, send_feature_flags, disable_geoip, and options.
Overriding reserved exception properties through ``properties`` is
deprecated and will stop working in the next major version.
Reserved exception properties in ``properties``, such as
``$exception_list`` and ``$exception_level``, are ignored.

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 All @@ -814,7 +818,7 @@ def capture_exception(
Events
"""

return _proxy("capture_exception", exception=exception, **kwargs)
return _proxy("capture_exception", exception=exception, level=level, **kwargs)


def feature_enabled(
Expand Down
35 changes: 16 additions & 19 deletions posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@
)
from .capture_event import (
_build_event_defaults,
_canonical_event_uuid,
_event_options,
_EventDefaults,
_fill_event_defaults,
_merge_groups,
_resolve_event_uuid,
)
from .capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH
from .client import (
Expand Down Expand Up @@ -76,7 +76,9 @@
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS,
_exception_level,
_get_current_otel_span_properties,
_without_reserved_exception_properties,
exc_info_from_error,
exception_is_already_captured,
exceptions_from_error_tuple,
Expand All @@ -96,7 +98,6 @@
from .utils import (
SizeLimitedDict,
_normalize_timestamp,
_uuid7,
clean,
system_context,
)
Expand Down Expand Up @@ -445,18 +446,7 @@ def enqueue_on_bound_loop() -> None:
return admitted.result()

def _normalize_uuid(self, msg: dict[str, Any]) -> str:
raw_uuid = msg.pop("uuid", None)
if raw_uuid is not None:
normalized = _canonical_event_uuid(raw_uuid)
if normalized is None:
self.log.error(
"Invalid UUID %r. Falling back to a generated UUID.", raw_uuid
)
else:
msg["uuid"] = normalized
return normalized

normalized = str(_uuid7())
normalized = _resolve_event_uuid(msg.pop("uuid", None))
msg["uuid"] = normalized
return normalized

Expand Down Expand Up @@ -531,8 +521,6 @@ def _event_defaults(
disable_geoip: Optional[bool] = None,
system_properties: Optional[dict[str, Any]] = None,
) -> _EventDefaults:
if disable_geoip is None:
disable_geoip = self.disable_geoip
return _build_event_defaults(
super_properties=self.super_properties,
super_options=self.super_options,
Expand All @@ -542,7 +530,8 @@ def _event_defaults(
derived_options=derived_options,
property_allowlist=property_allowlist,
is_server=self.is_server,
disable_geoip=disable_geoip,
disable_geoip=self.disable_geoip,
call_disable_geoip=disable_geoip,
system_properties=system_properties,
)

Expand Down Expand Up @@ -873,9 +862,16 @@ def _enqueue_built_event(
def capture_exception(
self,
exception: Optional[ExceptionArg] = None,
*,
level: Optional[str] = None,
**kwargs: Unpack[OptionalCaptureArgs],
) -> Optional[str]:
"""Capture an exception. This method never raises, including in debug mode."""
"""Capture an exception. This method never raises, including in debug mode.

``level`` sets ``$exception_level`` and defaults to ``"error"``. An unknown
value counts as unset. Reserved exception properties in ``properties``,
such as ``$exception_list`` and ``$exception_level``, are ignored.
"""
try:
if exception is not None and exception_is_already_captured(exception):
self.log.debug("Exception already captured, skipping")
Expand All @@ -897,8 +893,9 @@ def capture_exception(
)
exceptions = event["exception"]["values"]
properties = {
**_without_reserved_exception_properties(kwargs.get("properties")),
"$exception_list": exceptions,
**(kwargs.get("properties") or {}),
"$exception_level": _exception_level(None, level),
}
context_enabled = get_capture_exception_code_variables_context()
context_mask = get_code_variables_mask_patterns_context()
Expand Down
55 changes: 51 additions & 4 deletions posthog/capture_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from typing import Any, Optional
from uuid import UUID

from posthog.utils import _normalize_timestamp
from posthog.utils import _normalize_timestamp, _uuid7
from posthog.utils import clean as _clean

log = logging.getLogger("posthog")
Expand Down Expand Up @@ -78,6 +78,37 @@ def _canonical_event_uuid(value: Any) -> Optional[str]:
return str(UUID(value.lower()))


def _resolve_event_uuid(value: Any) -> str:
"""Return the canonical form of a caller's event uuid, or a generated one.

A missing or empty uuid is generated silently. An invalid one is replaced
and logs one warning. The warning names the rule and not the value,
because callers can put their own data in the uuid field.
"""
if value is not None and value != "":
canonical = _canonical_event_uuid(value)
if canonical is not None:
return canonical
log.warning(
"Event uuid is not a valid UUID string or uuid.UUID. "
"Sending the event with a generated UUID."
)
return str(_uuid7())


def _json_type_name(value: Any) -> str:
"""Name a value's JSON type, the same names posthog-go and posthog-rs log."""
if isinstance(value, bool):
return "bool"
if isinstance(value, (int, float)):
return "number"
if isinstance(value, (list, tuple)):
return "array"
if isinstance(value, Mapping):
return "object"
return type(value).__name__


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:
Expand All @@ -93,7 +124,7 @@ def _event_options(value: Any) -> dict[str, Any]:

@dataclass(frozen=True)
class _EventDefaults:
"""Context, global and SDK-derived values for one event, highest layer first.
"""Per-call, context, global and SDK-derived values for one event, highest layer first.

They fill in before ``before_send``, so the hook sees them and can change
or remove them. The event's own values win over every default.
Expand All @@ -115,9 +146,18 @@ def _build_event_defaults(
property_allowlist: Optional[Collection[str]] = None,
is_server: bool = False,
disable_geoip: bool = False,
call_disable_geoip: Optional[bool] = None,
system_properties: Optional[Mapping[str, Any]] = None,
) -> _EventDefaults:
"""Order the layers: context, then global, then values the SDK derives."""
"""Order the layers: per-call arguments, context, global, then SDK values.

``disable_geoip`` is the client setting, an SDK value. ``call_disable_geoip``
is the argument of one call: it is a value of that event, so only the
event's own ``$geoip_disable`` property beats it.
"""
call_properties = (
{} if call_disable_geoip is None else {"$geoip_disable": call_disable_geoip}
)
# A value in the event, the context or super properties wins over every
# value the SDK adds, including `$is_server` and `$geoip_disable`.
sdk_properties = dict(system_properties or {})
Expand All @@ -129,6 +169,7 @@ def _build_event_defaults(
sdk_properties["$geoip_disable"] = True
return _EventDefaults(
property_layers=(
call_properties,
context_properties or {},
super_properties or {},
sdk_properties,
Expand Down Expand Up @@ -242,10 +283,16 @@ def _to_v1_event(msg: dict) -> dict:
if prop_key not in properties:
continue
# Always removed. A non-string value would fail the whole batch, so it
# is dropped.
# is dropped. None counts as unset and drops silently.
value = properties.pop(prop_key)
if isinstance(value, str):
top_level[field_name] = value
elif value is not None:
log.warning(
"dropping %s: a %s value is not a string",
prop_key,
_json_type_name(value),
)

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